Home Agile - HackTheBox
Post
Cancel

Agile - HackTheBox

Agile is a medium linux box by 0xdf featuring a simple web-based LFI that could be used to bypass PIN validation in the Werkzeug debug console. Once on the box, you’ll recover some creds from a MySQL database and gain access to a local user account. You’ll then be required to exploit a previously discovered vulnerability but this time using a local symlink to get through a filter, and then exploit sudoedit and a cron job that uses python’s virtualenv to gain code execution as root.


About




Recon


NMAP


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Nmap 7.70 scan initiated Wed Mar 15 06:47:26 2023 as: nmap -sC -sV -oN nmap.txt -v 10.10.11.203
Nmap scan report for agile (10.10.11.203)
Host is up (0.44s latency).
Not shown: 998 closed ports
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://superpass.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Mar 15 06:48:32 2023 -- 1 IP address (1 host up) scanned in 66.77 seconds


Web


Going to the web page redirected to superpass.htb, which I then added to my /etc/hosts file;

There is a getting started link at the bottom of the page, which redirect to a login form;

Clicking the register button, I was able to create an account, and login;

The application is a custom web-based password manager. Checking burp, the application issued us with a token following login;

1
session=.eJwtjkuKwzAQRK8ieh0GtSRLap8i-yGEltQdG5wPlrMKuftoMauiqOLxPnDVjfsiHebfD5hjBNyld74JnOC8CXcx2_Nm1oc5noZrHaM5lrWb1_j8wOV7OQ3ILn2B-djfMtraYAas2VEkLllTLmhLyMlHL7lZVGIqNgVN0-QImwRfGjYUVinqY_UanNQYAmkNwgOktobJZVsc-ZgjVXLosXkXlDG1zIVIHDvbkFOzqQz967vL_m-T4PsHJgRGWg.ZB1wLQ.O5sxCFDdi0TGl0bQOQvJrPSjISE

That looked a bit like a JWT, but the algorithm part is missing. Attempting to base64-decode the payload part of the token failed, likely due to the the - and _ symbols in the payload. Some encoders replace + and / with - and _, respectively. After replacing them, the payload decodes to a zlib-compressed data, which decompresses to a JSON object;

1
{"_flashes":[{" t":["message","Please log in to access this page."]}],"_fresh":true,"_id":"1c82969ab8f78b10b487363e8d01f9a9b074f755291de43bd1d1eafebf36c3f42ec6449fc4ea296f0c45280b2936869c92131d324fa17d8ab99e2a20d1a7d07b","_user_id":"17"}

So I wrote a simple script that, when given a token payload as an argument, decodes it, and when given a file, reads it as JSON and encode it;

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
#!/usr/bin/python3

import sys, os, zlib, base64

def token_decode(token):
  token = token.replace("-", "+")
  token = token.replace("_", "/")
  while (len(token) % 4) != 0:
    token += "="
  print(f"[+] Base64: {token}")
  data = base64.b64decode(token)
  data = zlib.decompress(data)
  wfo = open("token_data.json", "wb")
  wfo.write(data)
  wfo.close()
  print(f"[+] JSON: {data.decode()}")
  return data

def token_encode(data):
  data = base64.b64encode(zlib.compress(data.encode())).decode()
  while data.endswith("="): data = data[:-1]
  data = data.replace("+", "-")
  data = data.replace("/", "_")
  print(f"[+] Token: {data}")
  return data

if __name__ == '__main__':
  token = sys.argv[1]
  if os.path.isfile(token):
    data = open(token).read()
    token_encode(data)
  else:
    token_decode(token)

Multiple attempts to manipulate the token using this script failed, so I moved on.

The password manager has an option to export passwords in the vault, which generated a CSV file of passwords for download;

The fn parameter looks interesting. Testing it, it was found to be vulnerable to Local File Inclusion (LFI);

Giving the parameter an invalid file generated a detailed debug message that exposes the web app as a Flask/Werkzeug application. This indicate the server is running in debug mode. Going to the /console route (the default debug console route for Flask) however gave a 404 message;

This is odd since the 404 page returned above differs with the ones returned by the server when requesting an invalid route;

Hovering over a line in the debug error output showed a small terminal icon to the right;

Clicking it prompted us for a debug pin, which we don’t have;



Foothold


Enumeration using LFI


Taking advantage of the LFI bug, and the debug error output, I was able to extract a lot from the system;

  • 4 local users (/etc/passwd) - corum, runner, edwards, dev_admin
  • Hidden subdomain test.superpass.htb (/etc/hosts). Can’t access this domain from my box as it keeps redirecting to superpass.htb.
  • Process command (/proc/self/cmdline) - /app/venv/bin/python3/app/venv/bin/gunicorn--bind127.0.0.1:5000--threads=10--timeout600wsgi:app
  • Environment vars (/proc/self/environ) - LANG=C.UTF-8PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/binHOME=/var/wwwLOGNAME=www-dataUSER=www-dataINVOCATION_ID=9c2427940e444f78a89d7d86fde2dc58JOURNAL_STREAM=8:32855SYSTEMD_EXEC_PID=1090CONFIG_PATH=/app/config_prod.json
    • MySQL creds in /app/config_prod.json - superpassuser:dSA6l7q*yIVs$39Ml6ywvgK
  • Application entrypoint: /app/app/superpass/app.py
    • From there traced all different parts of the app, and downloaded them for analysis.

The code responsible for the export and the LFI was found in views/vault_views.py;

The generate_csv() function is defined in services/password_service.py;

I thought of finding a way to access other users password exports if I could figure out how the random string appended to the exported filename is generated. The get_random() function used is defined in services/utility_service.py;

1
2
3
4
5
import datetime
import hashlib

def get_random(chars=20):
    return hashlib.md5(str(datetime.datetime.now()).encode() + b"SeCReT?!").hexdigest()[:chars]

It looks pretty solid, so I moved on.


Werkzeug Debug Console PIN


Looking for possible ways to guess the console PIN, I found this guide. The README explains in great detail how to effectively guess the console PIN of a Werkzeug server provided you have an LFI on the host. I was able to get it to work after some tweaks.

The file werkzeug-pin-bypass.py has two arrays that need to be configured according to the target;

probably_public_bits;

  • The username is www-data in our case.
  • The third item set to Flask is wsgi_app in our case. I found this by running the given command getattr(app, '__name__', getattr(app.__class__, '__name__')) in a local instance of the web app. This seems to be the combination of the entrypoint file used by gunicorn (wsgi.py) and the web app entrypoint (app.py).
  • Full path of Flask library - /app/venv/lib/python3.10/site-packages/flask/app.py

private_bits;

  • Can’t run uuid.getnode() on the box obviously, and /sys/class/net/ens33/address does not exist. I read /proc/self/net/addr to get the interface the service is using, which was found to be eth0. Then I read /sys/class/net/eth0/address to get the address, which I converted to int. This value seems to have changed from when I first did the box, so you may need to regenerate.
  • Looking at the other code given for generating machine ID, for this box it will be contents of /etc/machine-id + superpass.service (from /proc/self/cgroup). This turn out to be ed5b159560f54721827644bc9b220d00superpass.service

Plugging all these parameters in, we got a PIN;

Running the script gave me a PIN, and it was accepted by the server. I then used the python console to spawn a BASH reverse shell;



User


With a shell on the box as www-data, I can now connect to the local MySQL server whose creds I got earlier;

The password in the users table are all hashed, but the ones in passwords table are plain;

The creds of corum are of interest as that’s a valid local user. Testing them, I got access to the local user account of corum over SSH using the password 5db7caa1d13cc37c9fc2;



PrivEsc


User has no special groups, nor sudo perms. Checking the nginx config files for the server at /etc/nginx/sites-enabled/, I understood why we couldn’t access /console directly;

The config for the vhost test.superpass.nginx is defined in superpass-test.nginx. The service is listening on port 5555 locally;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
    listen 127.0.0.1:80;
    server_name test.superpass.htb;

    location /static {
        alias /app/app-testing/superpass/static;
        expires 365d;
    }
    location / {
        include uwsgi_params;
        proxy_pass http://127.0.0.1:5555;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Protocol $scheme;
    }
}

Running ps aux showed the process running as the user runner;

So I setup an SSH tunnel using -L 5555:test.superpass.htb:5555 and edited my /etc/hosts file to map test.superpass.htb to debian.local (my localhost). It worked, and I can now access the server;

This looks very similar to the public facing website. The sources are in /app/app-testing, and are owned by runner. The file /app/config_test.json is interesting, and is readable by runner, which the server is running as. Testing the LFI bug discovered earlier on the public instance, the local instance seems to be not vulnerable as it simply logs out the user;

Checking the source at /app/app-testing/superpass/views/vault_views.py, I realised why;

The application is checking to make sure the requested file’s extension ends with .csv. This would have complicated things, but since we already have a shell on the box, we could exploit this by creating a symbolic link whose name ends with .csv and point it to any file we wish.

My first attempt at doing this was to create the symlink in /tmp (/var/tmp). It didn’t work. I realized that all symlinks created in this directory by a user cannot be used by another user even if the other user has the required permissions on the destination file of the symlink. More on this here

Since I am working as the user corum, I made my home directory readble and executable to others on the box. This is to allow runner to access files in corum’s home directory. I then created a symlink to the file /app/config_test.json. It worked, and I got a MySQL cred;

A credential was found in the database for the user edwards, and the password d07867c6267dcb5df0af gave me access to his local account over SSH;

The user edwards has permissions to use sudoedit to edit a file with privileges of user and group dev_admin. You can trigger this with the command;

1
sudoedit -u dev_admin /app/config_test.json

sudoedit uses the $EDITOR environment variable to decide which editor to use when viewing a file. By setting it to vim <file>, we could use this to read/write to any file that has permissions for user or group dev_admin.

Further enumeration showed that /app/test_and_update.sh is run as cron task by 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
#!/bin/bash

# update prod with latest from testing constantly assuming tests are passing

echo "Starting test_and_update"
date

# if already running, exit
ps auxww | grep -v "grep" | grep -q "pytest" && exit

echo "Not already running. Starting..."

# start in dev folder
cd /app/app-testing

# system-wide source doesn't seem to happen in cron jobs
source /app/venv/bin/activate

# run tests, exit if failure
pytest -x 2>&1 >/dev/null || exit

# tests good, update prod (flask debug mode will load it instantly)
cp -r superpass /app/app/
echo "Complete!"

The script simply activates a python virtual environment by sourcing /app/venv/bin/activate, run some tests using pytest, and copy all the contents of /app/app-testing/superpass/ to /app/app/. Checking the permissions of the files involved, /app/venv/bin/activate was found to be writable to the group dev_admin;

This is very interesting since the file is executed as a script. Setting my $EDITOR variable to vim /app/venv/bin/activate;

Strangely, it opened the file in read-only mode. I had to add -- between vim and the target file before it worked;

1
export EDITOR='vim -- /app/venv/bin/activate'

Few seconds later, I got a shell;



Summary


  • Web app has open registration.
    • It is a web-based password manager.
    • Export feature found to be vulnerable to LFI.
    • Exploited it to generate Werkzeug console PIN, and gained RCE.
  • www-data;
    • Recovered creds of user corum from local MySQL using creds in /app/config_prod.json.
  • corum;
    • Got user.txt
    • Setup an SSH tunnel to access test.superpass.htb:5555.
    • Bypassed LFI filter using local symlinks to dump /app/config_test.json.
    • Used it access local MySQL and found creds for edwards.
  • edwards;
    • sudoedit perms to /app/config_test.json as user/group dev_admin.
    • Identified a cron task running /app/test_and_update.sh which uses a python virtual env.
    • Virtual env setup file is writable to group dev_admin.
    • Exploited it to inject a BASH reverse shell, and got a shell as root.
This post is licensed under CC BY 4.0 by the author.
Contents