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
- Map the surviving character set - letters,
_,.,|,{,}only - Confirm template evaluation with a harmless probe like
{{_self.templateName}} - Reach Symfony's globals through the compiled-context bridge:
_context.app - Walk public properties:
.requestthen.server-Request::$serveris public - Materialize the bag with
.all(auto-callsServerBag::all()) and render with|json_encode- both argument-free - Read the flag from the
$_SERVERdump - 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. array key
$a['b'] - 2. public property
$a->b - 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 end | why | lesson |
|---|---|---|
{{_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 back | even useless sinks confirm the injection |
| digits/operators | stripped - but length, keys, first, join, json_encode take zero args | filters are your operator library |
{% %} block tags | % stripped | you're confined to expressions - which turn out to be enough |
6. Generalized discovery path for restricted SSTI
- 1. Confirm template execution. Inject something inert-but-evaluated
({{_self.templateName}} rendering proves expression evaluation; arithmetic
with allowed chars if any exist).
- 2. Find the context bridge.
_context(Twig),cycler/rangeinternals,
or any object the engine hands you for free. Globals > _self.
- 3. Walk by method-sugar. Enumerate zero-arg getters on each hop:
Environment::getGlobals, AppVariable::getRequest, Request::getContent
(parameterless with defaults also works).
- 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.
- 5. Verify the charset against the filter algebraically, not by eyeball:
regex-allowed set minus payload set must be empty.
7. Defense
createTemplateon 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
Post a Comment