September 25, 2026
$17,000 for Walking a Class Hierarchy: Escaping a Template Sandbox to Full RCE
Sandboxes exist because someone already knew this feature was dangerous. That’s what made this target interesting from the start — Solmark…

By T4nv1
7 min read
Sandboxes exist because someone already knew this feature was dangerous. That's what made this target interesting from the start — Solmark hadn't made the beginner's mistake of handing raw template syntax to a full-privilege engine. They'd actually sandboxed it. The bug wasn't that they forgot to think about this risk. It was that the sandbox they built stopped at the wrong layer.
The target, which I'll call Solmark here per their program's disclosure requirements, is a B2B reporting platform that lets customers define custom report templates — inserting merge fields for things like {{ customer.name }} or {{ invoice.total }} into a document that gets rendered and emailed out on a schedule. Template-driven document generation is common enough that most security-conscious teams building it are at least aware server-side template injection exists as a category. Solmark clearly was. What they'd built was a genuinely restricted execution environment for user-supplied templates — not the raw engine.
Confirming the Engine, Then Confirming the Restriction
The template syntax in the custom report builder used double-curly-brace expressions, immediately recognizable as one of the Jinja2-family engines common across Python web backends:
{{ customer.name }}{{ customer.name }}The classic first test for this class of vulnerability is arithmetic — if {{ 7*7 }} renders as 49 instead of the literal text 7*7, the input is being evaluated as an expression by the engine rather than treated as inert template data. That confirmed immediately. What came next is where most SSTI hunting either stalls out or gets genuinely interesting, because Jinja2 and its close relatives are commonly deployed inside a SandboxedEnvironment specifically to prevent exactly what a naive tester tries next — reaching Python's object model to get at anything resembling code execution.
I tried the standard, well-documented Jinja2 sandbox-escape starting point:
{{ ''.__class__.__mro__[1].__subclasses__() }}{{ ''.__class__.__mro__[1].__subclasses__() }}This is a common first move because Python objects are deeply introspectable by design — every string instance carries a reference to its class, every class carries its method resolution order up through its base classes, and from object itself you can enumerate every subclass currently loaded in the process, which on a typical Python web app includes dozens of classes with genuinely dangerous capabilities (file handles, subprocess wrappers, import machinery) sitting somewhere in that list. This particular payload returned an error rather than a class list. Solmark's sandbox was specifically blocking access to dunder attributes like __class__ and __mro__ — the exact attributes this classic escape depends on. That's a real, meaningfully effective mitigation. A large fraction of publicly circulated SSTI payloads stop working the moment dunder-attribute access is filtered, which is presumably why whoever built this sandbox considered the risk handled.
Where the Sandbox's Assumption Broke Down
A sandbox that filters dunder attribute access by name is making a specific, narrower assumption than it might appear to be making: that the only path to dangerous introspection runs through attributes literally starting and ending with double underscores. That assumption isn't quite true, because Jinja2 exposes built-in filters — functions the template author is explicitly meant to be able to call, like formatting or string manipulation helpers — and the sandboxing layer's job is to restrict what those filters and the surrounding expression syntax can reach, not necessarily every single built-in Python mechanism that might expose similar information through a different route.
I started probing which specific attribute names were actually being blocked versus which were simply never tested, because a blocklist and a genuine sandbox are different things — a blocklist only stops what someone thought to list.
{{ ''|attr('__class__') }}{{ ''|attr('__class__') }}Using Jinja2's attr() filter — a legitimate, documented way to access an object's attribute by name as a string, intended for cases where a template needs dynamic attribute access — instead of the direct dot-dunder syntax the sandbox was pattern-matching against. This came back successfully, returning the string class object. The sandbox's dunder-attribute filtering was implemented against the literal . syntax path, checking attribute names at the point of direct access, but hadn't been applied consistently to the attr() filter route reaching the exact same attributes through a different mechanism. Same destination, different road, and only one of the two roads had a checkpoint on it.
From there, the rest of the classic chain reassembled itself using the same filter-based indirection at every step:
{{ ''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1)|attr('__subclasses__')() }}{{ ''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1)|attr('__subclasses__')() }}This returned the full list of loaded subclasses of object — hundreds of entries, among them the usual targets for this kind of escape: classes wrapping file I/O, subprocess execution, and various import-related machinery. From there it was a matter of locating the right subclass index (these shift between Python versions and installed packages, so I iterated through the list looking for something matching subprocess.Popen or an equivalent) and instantiating it through the same attr()-based indirection to avoid re-triggering the dunder filter at any point in the chain.
Confirming Execution Without Actually Running Anything Destructive
Once the chain reached a class capable of spawning a subprocess, the responsible next step was the same one I'd use for any blind or semi-blind RCE confirmation: prove code executes using an inert, observable side effect rather than anything that touches the filesystem, reads data, or persists.
I constructed the final payload to invoke a harmless outbound network request — a curl to a unique subdomain under infrastructure I controlled — rather than anything resembling file access, data exfiltration, or persistence. Within a few seconds of triggering a report render containing the payload, that request landed on my listener, originating from IP ranges consistent with Solmark's application infrastructure rather than any proxy or scanner. That was sufficient, complete confirmation that arbitrary command execution was achievable from a template field a customer account was permitted to define — full stop, no further exploitation needed.
Where I Drew the Line
RCE through a genuinely sandboxed engine is a serious enough finding that being disciplined about scope matters even more than usual. I ran this against a report template on my own test account exclusively, using an outbound network callback as the sole confirmation mechanism. I didn't attempt to read any file on the server, didn't try to enumerate environment variables or credentials, didn't attempt any form of persistence, and didn't test the chain against any other endpoint that might share the same templating backend. A single confirmed callback proves the entire chain works; anything past that point stops being proof of concept and starts being actual system compromise, which isn't something a bug bounty engagement should ever require.
The Report
Sandbox-escape findings need to make clear, explicitly, that a real mitigation existed and precisely where its coverage gap was — that distinction matters enormously for how a team prioritizes the fix, because "you had no sandbox" and "your sandbox had an incomplete filter" call for very different remediation approaches.
Title: Jinja2 sandbox escape via attr() filter bypasses dunder-attribute blocklist, enabling remote code execution in custom report templates
Root cause: The sandboxing layer filters direct dunder-attribute access (object.__class__) at the syntax level but does not apply the same restriction to Jinja2's attr() filter, which provides equivalent attribute access through a different code path. This allows the full classic Python object-introspection escape chain to be reconstructed using filter-based indirection at each step, ultimately reaching a subprocess-capable class and achieving command execution.
Reproduction: The full chain of attr()-based payloads in sequence, the specific point where direct dunder access failed versus where the filter-based equivalent succeeded, and the out-of-band callback confirmation with timestamps — explicitly noting that only an inert network callback was used to confirm execution, with no filesystem or data access attempted.
Impact: Full remote code execution on the report-rendering infrastructure, achievable by any customer account with permission to define a custom report template — a capability available to a large fraction of the platform's paying customer base by design, not a privileged or administrative feature.
Fix recommendations:
- Extend the dunder-attribute filter to cover Jinja2's
attr()filter and any other built-in mechanism providing equivalent dynamic attribute access, ideally by filtering at the point attributes are actually resolved rather than pattern-matching against specific syntax forms - Consider migrating to a stricter sandboxing approach that allowlists specific permitted operations rather than attempting to blocklist dangerous ones, since blocklist-based sandboxes for a language as introspectable as Python have a long history of exactly this kind of bypass being found through an alternate access path
- Run rendering for user-supplied templates in a genuinely isolated execution context (a separate, minimally-privileged process or container with no network egress and no filesystem access beyond what rendering strictly requires), so that even a successful sandbox escape has a dramatically smaller blast radius
What Happened After
Solmark's engineering team engaged directly with me during triage to understand the exact bypass mechanism, which made for one of the more collaborative disclosure processes I've had — they clearly wanted to understand the class of gap, not just patch the one payload. They shipped an emergency fix extending the filter to cover attr()-based access within 48 hours, followed by a move to a genuinely isolated rendering process for all custom templates over the following month, which addresses the underlying blast-radius problem regardless of whether some other bypass surfaces later. The report closed at $17,000, rated critical, reflecting both the severity of full RCE and the fact that the vulnerable feature was available to ordinary paying customers rather than requiring any elevated access to begin with.
Why Sandbox Escapes Reward Patience Over Cleverness
Finding a working escape from a genuinely restricted environment isn't usually about discovering some novel technique nobody's thought of. It's almost always about carefully mapping what a specific implementation actually restricts, versus what it merely intends to restrict, and looking for the gap between those two things. A few habits worth carrying into your own testing:
When the obvious payload gets blocked, that's information, not a dead end. A rejected {{ 7*7 }} variant or a blocked dunder-access attempt tells you real filtering exists — the next step is mapping its actual boundary, not giving up or assuming the target is unexploitable.
Look for alternate paths to the same destination. Template engines, and Python specifically, tend to offer more than one way to reach the same underlying object or attribute. A filter blocking syntax form A is worth testing against every equivalent syntax form B, C, and D the engine happens to support.
Confirm execution with the least invasive technique that still proves the point completely. An out-of-band network callback is, in nearly every case, sufficient proof of arbitrary code execution — there's essentially never a good reason to escalate further in a legitimate bug bounty proof of concept once that confirmation lands.
Report the specific coverage gap, not just the working payload. A team that understands exactly which code path their filter missed can fix the actual underlying assumption. A team that only sees a working exploit is more likely to patch that one payload and leave the door open for the next variation someone else finds.
What I keep coming back to with this one is that Solmark's engineers had clearly done real security thinking here — they built a sandbox specifically because they understood the risk, which is more than a lot of teams manage. The gap wasn't a lack of caution. It was that a blocklist built against one syntax form quietly assumed that syntax form was the only door, in a language that's spent decades making almost everything reachable from almost everything else if you're willing to walk the object graph carefully enough.