September 5, 2026
Everyone Chains SSTI to RCE. I Chained It to Account Takeover.
Three attempts at code execution paid nothing. One template expression that just read a variable paid $1,000.

By HariHax
8 min read
Hello hackers,
Second write-up. Different target, different bug class, and a lesson that took me embarrassingly long to learn.
The target is a subscription-billing SaaS platform. Multi-tenant, and anyone can get a settings-level admin account by completing the public free-trial signup. No invite, no existing relationship, no guessing identifiers. That matters, because it's the entire precondition for what follows.
Here's the short version of what I got wrong: I found a template injection and immediately went hunting for RCE. Three separate attempts. All refuted. The thing that actually paid was a single expression that read a variable out of the render context.
Let's go.
Part 1: The engine announced itself
I wasn't fuzzing. I was reading.
The platform lets tenant admins customise their outgoing email templates โ receipts, invoices, dunning notices. Normal, documented, useful feature. So I opened the template editor and looked at what was already in there.
The stock templates contained this:
{% if %} โฆ {% elseif %} โฆ {% endif %}
{{invoice.balance|number_format(2)}}{% if %} โฆ {% elseif %} โฆ {% endif %}
{{invoice.balance|number_format(2)}}That's Twig. PHP's template engine, and that tag shape is unmistakable โ the {%โฆ%} block syntax and the |filter(args) pipe together are a fingerprint you only need to see once.
I recognised it because I'd read a disclosed report with exactly this shape before: a Critical SSTI in a tenant-editable template that chained to RCE. Same engine, same feature category.
So the idea was cheap to test: if the tenant controls template text, and the server runs that text through Twig, then the tenant may control a server-side expression evaluator.
Part 2: The dead end that nearly killed the finding
Now the part I want to spend real time on, because it's the most useful thing in this post.
There was a second place tenant text got rendered โ the invoice-number scheme, a small config field that builds your invoice reference format. It looked like a template sink. So I probed it:
A{{7*7}}ZA{{7*7}}ZSaved. HTTP 200. Next invoice rendered normally โ no 49 anywhere.
Three more variants. 200, 200, 200. Nothing rendered.
Four probes, four successful saves, zero evaluation. Read that at face value and the conclusion writes itself: the payload came back literally, so this isn't a template engine. Move on.
That conclusion would have been wrong, and not for the reason you'd think.
I re-read the stored value instead of trusting the response. The scheme field was unchanged. Not "changed and rendered literally" โ unchanged. The saves had been silently rejected. My payload never got anywhere near a renderer, so of course nothing evaluated.
The endpoint returns 200 for input it refuses to store.
Two very different situations that look identical from the attacker's side:
- The payload rendered literally โ not a template sink โ dead end
- The payload was never stored โ tells you nothing at all โ still open
A status code cannot tell those apart. Only reading the value back can.
As it turned out, that particular field really was safe โ a strict server-side whitelist of six documented tags. Genuinely not an SSTI sink. But I'd nearly used a false negative from one field to close the door on the feature.
So I wrote the email templates down as still open, with a note to myself:
A genuine Twig sink I have not managed to render yet. What's missing is a readable oracle โ somewhere the rendered output comes back to me.
Part 3: Finding an oracle
The blocker was never injection. It was observation. I could store expressions in the email template all day; I just couldn't see the rendered result without actually mailing something.
The way out was the invoice email composer. It pre-renders the stored template server-side and shows you the result in one request, before anything is sent:
GET /โฆ/customer/email?ref_context=โฆ&ref_id=1000GET /โฆ/customer/email?ref_context=โฆ&ref_id=1000That's the whole unlock. Store in one endpoint, read in another.
First probe:
ARITHMETIC[{{7*7}}]
ARITHMETIC[49]ARITHMETIC[{{7*7}}]
ARITHMETIC[49]Good โ but 49 is suspicion, not proof. Plenty of engines do arithmetic inside a sandbox. So I asked for something a sandbox is specifically supposed to deny:
PHP[{{constant("PHP_VERSION")}}]
PHP[8.2.28]PHP[{{constant("PHP_VERSION")}}]
PHP[8.2.28]That's certainty. constant() reaches straight into the PHP runtime, and it's exactly the sort of function a sandbox policy exists to deny. Getting a real version string back means the expression is being evaluated inside the application's own process, with that function reachable.
The vendor's reply later filled in the detail I couldn't see from outside โ and it's more interesting than I'd assumed. The sandbox was switched on. Its policy just didn't deny constant().
Part 4: The detour that paid nothing
Confirmed SSTI in PHP. So obviously: RCE. That's the reflex. That's what every write-up with this fingerprint does.
I spent three separate rounds on it. Here's what came back:
- Every value in the render context is a plain PHP array โ no objects, no methods. I probed roughly 200 method names across all 25 context values. Arrays have none, so there's nothing to call.
- The registered function set is stock Twig 1.x. No application-supplied function to abuse.
- Exactly one custom filter exists, and it ignores its argument entirely.
_selfresolves to the template name string, so the classic Twig 1.x environment gadget is inert.mapandfilterare Closure-typed โ they won't take a string callable.- The loader resolves nothing, so
source()andinclude()give no file read. - The Debug extension is absent.
That's not "I tried a payload list and gave up." That's a mechanical refutation: for code execution you need a callable, and in an array-only context no callable exists.
Clean negative. Zero dollars.
And the retrospective is blunt about the mistake: the higher-yield question โ what can this thing read? โ got asked far too late.
Part 5: One filter, $1,000
Eventually I asked the boring question. Instead of trying to do something with the expression evaluator, I asked what was sitting in scope:
{{_context|json_encode}}{{_context|json_encode}}About 20 KB of live internal objects came back.
And in there was users[0] โ not the tidy user object the rest of the product hands around. The raw database row.
[hash={{users[0].saas_password}}]
[salt={{users[0].saas_salt}}]
[tok={{users[0].login_token}}][hash={{users[0].saas_password}}]
[salt={{users[0].saas_salt}}]
[tok={{users[0].login_token}}]A portal password hash. Its salt. And a live single-use login token.
Part 6: From a leaked token to somebody's account
A hash is offline-crackable material, which is worth reporting on its own. The token is worth much more, because it's a live credential.
The chain:
- Unauthenticated, request a password reset for the victim's email. This mints a fresh
login_tokenin their database row. Critically, it does not change their password. - Render the template. The injected expression reads that token straight out of the raw row and hands it back to me.
- Unauthenticated, in a clean browser, redeem the token at the login-token endpoint.
- Land inside the victim's customer portal.
I verified it from the victim's side, not from my own status codes โ the profile page on the hijacked session showed the victim's real name and email address. Then I repeated the whole chain end to end against a second customer to prove it wasn't a one-off.
Two details that make this worse than it first looks:
- The token never reaches the victim's mailbox. I read it out of the database before it ever gets delivered.
- The victim's password keeps working. The reset doesn't rotate it โ I checked, the hash is byte-identical afterwards. Nothing breaks, so nothing prompts them to investigate.
And it generalises. Change which invoice you render against, and you change whose raw row lands in the context.
Why it happened
One line:
The product has a sanitised view of a user and a raw database row, and the render context got handed the wrong one.
Here's the part that makes this airtight rather than arguable. The platform's own REST API returns a user object with no password hash, no salt, no login token. Those fields simply aren't in its representation.
So the product already knows exactly which fields are secret. It encodes that knowledge in the API layer. The template context just never applies it.
Same story with the sandbox. It isn't missing โ it's incomplete. The engine is sandboxed, and the policy still leaves constant() reachable. A control that's switched on but not finished looks exactly like a control that works, right up until someone asks it for a runtime constant.
That's why the vector carries Scope: Changed: the vulnerable component is the tenant's own template editor, but the impact lands on a different customer's portal account.
CVSS 8.5 ยท High ยท CWE-1336 โ CWE-522 / CWE-640 A05:2025 Injection ยท A01:2025 Broken Access Control
Bounty: $1,000 โ the programme maximum.
The vendor's reply confirmed both halves independently: the sandbox is in use but constant() isn't disabled in it, and the users array is unscrubbed. Two separate fixes, one injection point.
The fix
Finish the sandbox policy. The sandbox is already there; it needs a whitelist that permits only the tags and filters the merge-tag feature actually documents. That removes constant() and most of the expression surface in one change.
Pass view models, not database rows. The render context should contain the same filtered representation the REST API already returns.
Make the save endpoint return a real error when it rejects input. A 200 on silent rejection isn't just confusing for attackers โ it's confusing for the customers using the feature.
What this is not
Being precise about the ceiling is part of the report, so it's part of the write-up:
- Not RCE. Refuted mechanically, as above.
- Not cross-tenant. Templates are per-tenant; every value I disclosed belonged to my own tenant's customers.
- Not a cracked password. I reported the hash and salt as offline-crackable material and left it there.
- Not undetectable. The victim still gets a password-reset email they didn't ask for. I never verified delivery, so I won't call it silent.
Three things I took from this
For an expression evaluator, reading beats executing. RCE is the reflex and it's often the hardest thing that primitive can do. Three rounds of gadget hunting returned nothing. One |keys dump returned password hashes. Ask what's in scope before you ask what's callable.
A 200 doesn't mean it worked โ it doesn't even mean it saved. Four probes came back 200 with nothing stored. Read the value back. Every time. The status code is the app's opinion; the stored value is the fact.
A negative from one field isn't a negative for the feature. The invoice-number scheme was genuinely safe. If I'd let that close the door, the email templates would still be sitting there with a half-finished sandbox around them.
How to spot this on your own target
- Read the stock content. The engine usually announces itself. {%โฆ%} plus
|filter(args)is Twig. {{โฆ}} with {%โฆ%} and no pipes is often Jinja. Django, Handlebars, Freemarker, Velocity all have their own tells. You don't need a payload to fingerprint an engine โ you need to look at what's already there. - Separate injection from observation. Storing an expression and seeing it render are two different problems. If you can store but not see, don't call it a dead end โ go find a preview, a composer, an export, an admin view. Anything that renders server-side and shows you the result.
- Prove the sandbox is incomplete, not just absent. Arithmetic isn't proof of anything โ plenty of sandboxes allow it. Ask for something a policy is supposed to deny: a runtime constant, an environment value, a version string. A sandbox that's on but permissive looks identical to one that works until you ask it the right question.
- Dump the context first.
{{_context|json_encode}}or the engine's equivalent, on your very first successful render. Type the context, list the keys, then decide what to spend your time on. I did this backwards and burned three rounds for nothing. - Look for the sanitised-vs-raw mismatch. When an app has an API that carefully strips credential fields and a render context that doesn't, the product has already told you which fields are secret. That gap is the bug.
If you found this useful, feel free to share it!
Thanks for reading. If you've chained an SSTI to something other than RCE, I'd like to hear about it in the comments โ I suspect "read the context first" is under-rated advice and I'd like more data points.
See you in the next one ๐
HariHax
- Medium โ https://medium.com/@HariHax
- LinkedIn โ https://www.linkedin.com/in/hari0msingh/
- X โ https://x.com/Hari0mSingh22
- YouTube โ https://www.youtube.com/@Hari_Hax
- Site โ https://hari0msingh.github.io/