SpEL and EL injection that can call a method is critical remote code execution, not a reflected-input curiosity. Under-rating it is as wrong as inventing it.
Server-side template injection is the bug that two different scanners get wrong in two opposite directions on the same day. One tool sees a reflected expression and screams critical at every echo of user input. Another finds a genuine SpEL injection, decides it 'just renders text', and files it as a low that nobody reads. Both readings are wrong, and they are wrong for the same reason: they grade on what the input looks like instead of what the engine can be made to do. The only fact that sets the severity is whether the expression context can invoke a method. The moment it can, you are one payload away from remote code execution.
This post is about how apPosture proves that distinction with arithmetic and a string method call, and never with a real gadget. We escalate from proof-of-evaluation to proof-of-capability without ever firing an exploit. The blast radius is a multiplication.
Over-claiming and under-rating are not opposite mistakes; they are the same mistake wearing different clothes. Over-claiming treats reflection as execution: input appears in the response, therefore critical. Under-rating treats execution as reflection: the page rendered, therefore cosmetic. The cure for both is to stop reading the surface and start measuring capability. A finding has to answer one question with evidence: did the server evaluate my expression, and if so, how far does that evaluation reach?
Severity then follows capability, not vibes. A sandboxed evaluator that adds two numbers is graded by what it can actually do. An evaluator that hands you method invocation is graded as the remote code execution it is.
The first rung is reflection-immune by construction. We inject the bare expression 7919*7907 (and its delimited variants such as {{7919*7907}}, ${7919*7907} and #{7919*7907}). The literal payload never contains the digits 62615533. If that product comes back in the response, no string in our request carried it - the only thing that could have produced it is an engine that evaluated the multiplication. That is server-side evaluation, demonstrated with arithmetic, not a shell command, a JNDI lookup, or a deserialization gadget.
There is a trap on rung one, and it is the difference between a careful tool and a noisy one. A pure arithmetic calculator evaluates 7919*7907 too. So does a hardened expression engine like QLExpress in its locked-down mode, which permits math but forbids method calls. Both return 62615533. Neither is remote code execution. A scanner that stops at the multiplication and stamps critical has just manufactured a false positive on every calculator endpoint on the internet.
So a bare-arithmetic hit alone is necessary but not sufficient. To separate a calculator from a code-execution-capable engine, we send a second, reflection-immune probe: a string method call, "apvz".concat("Z9q"). The marker apvz and the suffix Z9q are never a contiguous substring of any payload we send. If the response contains the joined string apvzZ9q, the engine did not just compute - it invoked a method. A calculator cannot do that. A method-hardened sandbox refuses to do that. Only a real evaluator (QLExpress, MVEL, JEXL, BeanShell, plain SpEL, OGNL) joins those two strings.
Method invocation is the bright line because from String.concat the engine can reach arbitrary Java: type accessors, class loading, T(java.lang.Runtime). We do not walk that path. We do not call Runtime. We do not exec anything. We prove the door is unlocked with a string concatenation and stop there. That is proof of capability with zero blast radius, and it is exactly enough to grade the finding honestly.
Every rung maps to a single, defensible severity. The table below is the grading rule the engine actually applies - each row is an observation about the response, and the severity is read straight off it, not inferred from how scary the payload looks.
| Observation | What it proves | Severity |
|---|---|---|
| Payload echoed verbatim | The 7919*7907 string returns unchanged. The input was reflected as text; nothing evaluated it. | None - no finding raised. |
| Arithmetic evaluated | 62615533 returns. Server-side evaluation is proven, but a calculator or method-hardened engine does the same. | Graded by capability, not auto-critical. |
| Closing-token differential | A valid template block renders 2xx while the same block with its end tag removed throws a 5xx parse error. Input reaches a template sink; method access is not shown. | High - reachability, exploit_status reachable. |
| Method invocation reachable | "apvz".concat("Z9q") returns apvzZ9q. The engine reaches arbitrary Java - this is the RCE signal. | Critical RCE - exploit_status confirmed. |
Notice the third row. When the rendered output is discarded (a void Velocity handler, for instance) the arithmetic oracle goes silent because there is nothing to reflect. We fall back to a differential: a structurally valid block returns 2xx, the same block missing its closing token raises a 5xx parse exception, and the broken payload is a strict prefix of the valid one so a length cap or input filter cannot explain it. That proves the input reaches a template parser. It does not prove method access, so it is graded high and reported as reachable, never confirmed. We say which fact we have.
The confirming payload for Java SpEL is not the one for Python Jinja2, Ruby ERB, or a Go text/template. Go is the sharpest example: its {{}} syntax does not evaluate arithmetic at all, so the 7919*7907 oracle is completely inert against a major backend language. For Go we use a different reflection-immune trick - the print builtin concatenates, so {{print "X" "Y"}} returns XY only if the template rendered. A single-language regex would miss every engine it was not written for, and those misses are silent false negatives - the worst kind, because nobody sees the bug that was never reported. We probe per engine and grade per capability.
It would be trivial to make the proof louder. A real Runtime.exec callback would be flashier in a demo. We refuse it on purpose. The arithmetic-plus-method oracle gives a security team everything that matters - confirmation that code ran, confirmation that method invocation is reachable, the exact payload, the oracle result, and the engine family - while running nothing more dangerous than a multiplication and a string join against an authorized target. The finding is reproducible by hand, it survives an audit line by line, and it is safe to run against production. Template injection that can invoke a method is remote code execution, and apPosture rates it that way - not because the payload looked dangerous, but because the engine proved it could be. Under-rating that is as wrong as inventing it. The honest severity is the one the evidence forces, and the evidence here is arithmetic.