RC RANDOM CHAOS

Log4Shell executed exactly as written

Log4Shell, xz-utils, and Spring4Shell weren't isolated bugs. They were composition failures in systems too deep for anyone to fully read. The disease is complexity.

· 7 min read
Log4Shell executed exactly as written

Log4Shell was not a bug. CVE-2021-44228, CVSS 10.0. It was a feature executing exactly as written. A logging library resolved JNDI lookups on attacker-controlled strings because years earlier someone decided log messages should support variable substitution, and variable substitution grew to include remote object resolution over LDAP and RMI. Nobody running Log4j in production knew that. Most engineers shipping applications that bundled it did not know a single log line could reach out to an attacker’s LDAP server and load a remote class. The string interpolation was not the disease. The disease was that the behaviour lived inside a dependency four layers deep that no human on the team had ever read.

That is the actual condition. CVEs are the symptoms. The disease is that modern software is assembled from components nobody fully understands, layered deliberately so that understanding is discouraged, and shipped with the assumption that abstraction equals safety. Attackers do not need to be smarter than the defenders. They need to understand one narrow path through a system that the defenders were never expected to trace. The asymmetry is not skill. It is comprehension.

Consider the mechanism at the code level. Log4j’s ${jndi:ldap://...} reached a lookup handler that instantiated a Context and performed a naming lookup. CWE-502, deserialization of untrusted data, sitting downstream of what looked like a formatting string. The exploit primitive was remote class loading followed by gadget execution. T1190, exploitation of a public-facing application, chained to T1059 once code ran. The attacker controlled the string. The string reached a code path that trusted it completely. Between the log call and the JNDI context there were message-pattern parsers, lookup resolvers, and substitution passes - each one a component that behaved correctly in isolation and catastrophically in composition. No single author wrote a vulnerability. The vulnerability emerged from the layering.

This is composition failure, and it is the defining property of the systems in question. Each layer is verified against its own contract. The renderer trusts the parser. The parser trusts the loader. The loader trusts the input because the parser was supposed to sanitise it, and the parser trusted the input because the caller was supposed to. The trust boundary dissolves across the stack because no layer owns it. Every layer assumes the layer beneath handled the case. The gap is not in any component. The gap is in the space between them, and that space is exactly where nobody is looking because looking there requires holding the entire chain in your head at once.

Java deserialization is the clean example of the pattern. ObjectInputStream.readObject() reconstructs an arbitrary object graph from a byte stream. On its own, harmless. The danger is the gadget chain - a sequence of readObject, finalize, and property-setter calls across unrelated library classes that, when strung together, reach Runtime.exec. Commons-Collections InvokerTransformer. Spring MethodInvokeTypeProvider. The classes were never designed to interoperate. The attacker composes them. Each gadget is a legitimate method doing its documented job. The chain is the exploit. No CVE fully captures it because the vulnerability is distributed across a dozen libraries that each look correct. CWE-502 again, but the root cause is that the platform made object graphs trivially reconstructable and left the security of that reconstruction to whatever happened to be on the classpath.

The classpath is the point. Transitive dependency depth is where comprehension dies. A mid-sized Java service resolves several hundred transitive dependencies. A Node project routinely exceeds a thousand. Nobody reads them. Nobody can. The tooling optimises for pulling more code in, not for understanding what was pulled. Dependency confusion - T1195.001 - works because the resolution logic that decides which package to fetch is itself a layer nobody audits. Register the internal package name on a public registry with a higher version number, and the resolver picks yours. The build succeeds. The pipeline runs your code with pipeline credentials. The engineer never saw a decision get made because the decision was buried in a resolver’s version-precedence rule.

xz-utils is the mature form of this. CVE-2024-3094, CVSS 10.0. A backdoor introduced into liblzma over months by a maintainer who had earned commit trust through legitimate contribution. The payload was not in the source anyone reviewed. It was staged in the build system - obfuscated inside test fixtures, extracted by a modified configure step, injected into the compiled object during the build, and hooked into sshd through the linker’s symbol resolution to intercept RSA_public_decrypt. T1195.002, compromise of the software supply chain. The design deliberately exploited the fact that no reviewer reads generated build artifacts, that binary test blobs are opaque by convention, and that the distance between source and shipped object is a place defenders have been trained to ignore. The obfuscation was the exploit. The complexity was not incidental. It was the weapon.

This is what makes the systemic version worse than any single CVE. The xz backdoor was found by an engineer investigating a 500-millisecond latency anomaly in SSH logins. Not a scanner. Not an EDR alert. A human who understood the expected performance of a system precisely enough to notice it was wrong, and who had the rare freedom to chase the anomaly to its root. That skillset is thinning. The people who can hold a full trust chain in their head - from the syscall to the substitution parser to the linker hook - are aging out, and the systems are getting deeper faster than the comprehension can be replaced.

What this produces in telemetry is the second half of the failure. Log4Shell exploitation generates a Sysmon Event ID 3 - an outbound network connection from a Java process to an LDAP port. It generates Event ID 1 when the loaded class spawns a child. EDR flags java.exe spawning cmd.exe or /bin/sh, a parent-child anomaly most correlation rules catch. But the JNDI lookup itself - the actual vulnerability firing - produces nothing. No log. The logging library does not log that it just performed a network lookup on a log string. The telemetry sees the second-order effect and is blind to the mechanism. Defenders detect the exploit only at the point where it becomes indistinguishable from ordinary process behaviour, which means detection depends entirely on the payload being noisy. A payload that stays in-process, that never spawns a child, that only reads memory or exfiltrates over an already-open channel, fires nothing. The blindness is a direct product of the complexity. The instrumentation was built for the layer, not for the composition, so the composition is invisible.

The deserialization case is the same. A gadget chain reaching Runtime.exec is loud - process creation, Event ID 1, an EDR parent-child alert. A gadget chain that instead writes a file, sets a JVM property, or opens a socket the application already uses is quiet. The SIEM has a correlation rule for the noisy variant because someone wrote it after the noisy variant burned an organisation. The quiet variant has no rule because nobody traced the chain far enough to know it was reachable. The detection gap mirrors the comprehension gap exactly. Defenders instrument what they understand. What the complexity hides, the telemetry also hides.

Spring4Shell, CVE-2022-22965, CVSS 9.8, closed the same way it opened. The bug was a class-loader access path reachable through Spring’s data-binding, which populated object properties from request parameters and - through a JDK 9 change to getClass().getModule() - exposed a route to manipulate the Tomcat AccessLogValve and write a webshell. Two frameworks and a JDK version interacting in a way none of the three teams modelled. The patch added a denylist for the dangerous property paths. It did not remove the data-binding-to-class-loader reachability. It fenced the known route. The mechanism that made the route possible - automatic binding of untrusted input into object graphs across a trust boundary nobody owned - remained.

That is the residual exposure after every patch of this class. The CVE gets a fix. The fix addresses the reachable path. The condition that made the path reachable - deep composition, unread dependencies, trust boundaries owned by no layer, telemetry scoped to components rather than chains - is untouched, because it is not a bug. It is the architecture. Patching CVE-2021-44228 removed JNDI-from-logs. It did not remove the reality that most production systems contain reachable behaviour their operators cannot enumerate.

For operators of critical infrastructure under SOCI obligations, the practical consequence is specific. A vulnerability register built from CVE feeds tracks the symptoms. It does not measure the depth of unreviewed composition in a build, and that depth is the actual exposure. The controls that break this class are the ones that reduce trust distance - build provenance and artifact attestation so the gap between source and shipped object is verifiable, dependency pinning and internal-registry precedence so resolution is not attacker-influenced, and instrumentation scoped to trust boundaries rather than individual processes so composition becomes observable. Where an active compromise is suspected in that chain, it goes to the incident response team with the build and dependency evidence intact, not into a ticket queue.

The phrase - we are the last people who know how it works - is not nostalgia. It is a threat model. The exploitable condition is the distance between how a system behaves and how many people understand that behaviour. That distance is widening by design. Attackers are not defeating comprehension. They are exploiting its absence. The next CVE-2021-44228 is already in a dependency four layers down, behaving exactly as written, waiting for someone who reads that far.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.