247CTF // The Twig Injector - SSTI Through a Character Filter

247CTF // The Twig Injector - SSTI Through a Character Filter

Category: server-side template injection, sandbox escape by data flow

Stack: PHP, Symfony, Twig 2/3


STEPS
  1. Map the surviving character set - letters, _, ., |, {, } only
  2. Confirm template evaluation with a harmless probe like {{_self.templateName}}
  3. Reach Symfony's globals through the compiled-context bridge: _context.app
  4. Walk public properties: .request then .server - Request::$server is public
  5. Materialize the bag with .all (auto-calls ServerBag::all()) and render with |json_encode - both argument-free
  6. Read the flag from the $_SERVER dump - the process environment rides along in it

1. The target


$inject = preg_replace('/[^{\}a-z\|\_]/', '', $_GET['inject']);

$response = new Response(

    $this->get('twig')->createTemplate("Welcome to the twig injector!\n${inject}")->render()

);

The surviving alphabet: lowercase letters, _, ., |, {, }. No quotes, no

parentheses, no digits, no operators. Only {{ ... }} print statements survive

(the filter eats %, so block tags are impossible too).

It looks airtight. Two Twig behaviors open it.

2. Behavior one: dot-access calls zero-argument methods

In Twig, {{ a.b }} does not mean "property b". Compiled, it becomes

CoreExtension::getAttribute($a, 'b'), which tries, in order:

  1. 1. array key $a['b']
  2. 2. public property $a->b
  3. 3. method call $a->getB() / $a->isB() / $a->hasB() / $a->b() - with

*no arguments*, automatically, when the method exists with zero required

parameters.

Parentheses are not a prerequisite for invocation. The dot *is* the call. This

single fact turns a character set without parens into full method-walking ability.

3. Behavior two: globals live in the render context

createTemplate(...)->render() with no explicit variables still compiles with

the context seeded from Environment::getGlobals(). In Symfony that's the app

global (AppVariable). And _context - a special variable every compiled

template has - holds that merged context directly. So without touching

_self.env (whose env property is protected, a dead end) the bridge is:


{{ _context.app }}

reaching Symfony's AppVariable -> the current Request.

4. The data-flow walk


_context                array of render vars (array access works with .)

  .app                   AppVariable (global)

  .request               Request::getRequest()  -  wait, actually public property

Careful readers will trip here: Symfony's Request does not have getRequest();

it has the public properties $request, $query, $server, $headers,

$cookies, $files, $attributes. Twig's getAttribute checks properties too -

request.server resolves Request::$server, a ServerBag.


.request.server.all     ServerBag::all()  -  zero-arg, auto-called via getAttribute

|json_encode            filter, zero arguments, renders the array

And $_SERVER - which php-fpm populates with the process environment -

carries the flag. The full payload, every character inside the whitelist:


{{_context.app.request.server.all|json_encode}}

Charset audit: letters, dots, pipes, braces, underscores - all survive the

preg_replace. Length is irrelevant to the filter.

5. Why the obvious routes die (and what each teaches)

dead endwhylesson
{{_self.env}}env is a protected property of Template; getAttribute won't touch it, and there's no getEnv()check property visibility, not just existence
{{_self.templateName}}works, returns the template *name* - your own payload reflected backeven useless sinks confirm the injection
digits/operatorsstripped - but length, keys, first, join, json_encode take zero argsfilters are your operator library
{% %} block tags% strippedyou're confined to expressions - which turn out to be enough

6. Generalized discovery path for restricted SSTI

  1. 1. Confirm template execution. Inject something inert-but-evaluated

({{_self.templateName}} rendering proves expression evaluation; arithmetic

with allowed chars if any exist).

  1. 2. Find the context bridge. _context (Twig), cycler/range internals,

or any object the engine hands you for free. Globals > _self.

  1. 3. Walk by method-sugar. Enumerate zero-arg getters on each hop:

Environment::getGlobals, AppVariable::getRequest, Request::getContent

(parameterless with defaults also works).

  1. 4. Choose the sink that fits the charset. Here: an env-var dump via

$_SERVER needs no quotes, no digits, no functions - just dots and one

filter.

  1. 5. Verify the charset against the filter algebraically, not by eyeball:

regex-allowed set minus payload set must be empty.

7. Defense

  • createTemplate on user input is the bug; render fixed templates with data

passed as parameters.

  • If dynamic templates are truly required, run them in a sandbox with an

allowlisted set of tags/filters/functions/variables (Twig's SandboxExtension).

  • Don't store secrets in environment variables reachable from the web SAPI -

the app secret leak here was as much an infra choice as a code one.

Comments

Popular Posts