detection-engineering · threat-hunting · java · log4j

Detecting Risky Log4j Event Deserialization

We recently stood up an isolated lab to reproduce a Log4j deserialization issue, and what stuck with us was how little the victim showed for it. A legacy-style log receiver accepted a crafted serialized event, returned a normal success response, and kept running — even though attacker-controlled code had already executed inside its Java process. Nothing in the application's own behavior gave it away.

This post is about that gap between what the receiver appeared to do and what actually happened on the host. We'll walk through the conditions that have to line up before a server is genuinely at risk (fewer than you might guess), then four places to catch it: JVM process behavior on the endpoint, a YARA signature for the payload in memory, network inspection where the telemetry supports it, and a fleet hunt for real exposure.

This Isn't Log4Shell

The behavior drew public attention through Apache Log4j issue #4255. That issue is now closed and its original description is no longer available. The underlying design concern is documented more clearly in Apache's Log4j 2.x deserialization hardening discussion.

Apache does not classify this pattern as a Log4j vulnerability. Its security guidance for Java deserialization explains why: current Log4j Core does not take network, message-queue, or file input and pass it to ObjectInputStream during normal operation. An application has to create that unsafe data path itself.

Much of the upstream analysis we build on here — including a preserved copy of the original issue text — comes from Jeff McJunkin's teardown of the FOIS allowlist bypass. This post focuses on the SOC detection angle; his write-up covers the root cause and, notably, an exposure survey worth dwelling on. He tested 53 real-world products — Elasticsearch, Kafka, Jira, Confluence, Graylog, Solr, Jenkins, Minecraft among them — and found zero that were exploitable. Every one failed on the same condition: none of them ships code that receives a serialized LogEvent off the network. Apache removed its own socket-server implementation back in 2.9.0 (2017), and no published Maven artifact provides a serialized-event receiver, so in practice this path exists only in bespoke, custom wiring. That's also why 29 license-gated enterprise appliances — from vendors like VMware, Cisco, IBM, and SAP — fell outside the survey's scope. Their exposure is simply untested, and untested is not the same as clean.

That result reframes the whole hunt: finding Log4j on a server tells you almost nothing about whether that server is exposed. Several things have to be true at the same time:

  1. An application accepts or retrieves a Java-serialized object from a source an attacker can control or tamper with.
  2. The application deserializes a Log4j log event, including legacy integrations that rely on FilteredObjectInputStream.
  3. The serialized event reaches a nested deserialization path that the per-stream allowlist does not inspect.
  4. The application's classpath contains a usable deserialization gadget.

An internet-facing receiver is the obvious case, but far from the only one. A message queue, a cached session, or a shared file can carry the same risk any time something is trusted to have written those bytes and nothing actually verifies them.

Where the Allowlist Stops Looking

Log4j's FilteredObjectInputStream, often shortened to FOIS, extends Java's ObjectInputStream and restricts which classes the outer stream can resolve. It exists for a reason: it was the hardening added in response to CVE-2017-5645, an earlier socket-server deserialization RCE in Log4j. The wrinkle here is that the bypass defeats that fix using a class the allowlist itself permits. That's a useful reminder that FOIS is defense in depth — Apache explicitly states it is not a security boundary.

The hardening gap appears when the allowed outer object contains another serialized object:

  1. A serialized Log4j event is represented by Log4jLogEvent.LogEventProxy.
  2. Its message can be carried inside java.rmi.MarshalledObject, a class allowed by the Log4j filter.
  3. The marshalled payload is stored as opaque bytes, so the outer stream sees the wrapper but not every class inside it.
  4. When Log4j recovers the message, MarshalledObject.get() creates a separate deserialization stream for those inner bytes.

So you end up with a nested object graph that the outer allowlist never fully inspects. None of this makes MarshalledObject inherently malicious, and it doesn't hand anyone a reachable input path on its own. What it does show is that a deserialization filter is a way to lower risk, not a license to deserialize data you don't trust.

Why the Application Layer May Stay Quiet

The same message-recovery path can catch an exception and fall back to the event's string representation. In a receiver shaped like our lab application, the request therefore appeared successful after the dangerous inner operation had already happened.

How much of that you see depends on the integration, but the takeaway holds either way: a 200, a tidy log line, or a missing stack trace proves nothing about whether an attempt was harmless. You have to look past the application's own response, at process activity, memory, and the network.

Our lab's most useful signal was the process lineage. A Java service unexpectedly started a command interpreter, followed by suspicious outbound activity. The application log was nearly silent; the endpoint was not.

Detection 1: JVM Child Processes

In our testing, the surface that paid off was what the JVM did next. Watch server-side Java processes for child processes that don't belong, especially:

  • A java or javaw parent starting a Unix shell, Windows command shell, PowerShell, or another scripting interpreter.
  • A child command line containing download, remote-shell, encoded-command, or named-pipe behavior.
  • A newly created child process making an outbound connection the parent application does not normally require.
  • Execution under a service identity, from a temporary directory, or on a server where interactive shells are unexpected.

Don't put your top severity on java spawning a shell by itself. Plenty of build systems, orchestration platforms, and ordinary enterprise apps do exactly that all day. What earns a high-severity alert is the combination: the parentage plus a real execution primitive, unexpected egress, or both.

In practice we run two rules side by side. A broad, lower-severity one covers unusual JVM-to-interpreter execution generally; a narrow, high-severity one fires when that execution is immediately followed by remote-access behavior. This maps to MITRE ATT&CK T1059, and where the vulnerable receiver is reachable from outside, T1190 as well.

Here's the Sigma version of the high-confidence Linux rule from our lab. It's deliberately behavioral, so it will also catch similar command execution after other Java exploits, not just this deserialization path.

title: Java Process Spawning a Remote Unix Shell
id: f016ffe5-5b74-4434-8215-4525c04fb3a5
status: experimental
description: |
  Detects a Java process spawning a Unix shell with command-line behavior
  commonly associated with a remote shell. This may identify post-exploitation
  activity following unsafe Java deserialization but is not specific to one vulnerability.
references:
  - https://github.com/apache/logging-log4j2/discussions/4168
author: BHIS ActiveSOC
date: 2026-08-26
tags:
  - attack.initial-access
  - attack.t1190
  - attack.execution
  - attack.t1059.004
logsource:
  category: process_creation
  product: linux
detection:
  selection_parent:
    ParentImage|endswith: '/java'
  selection_shell:
    Image|endswith:
      - '/bash'
      - '/sh'
      - '/dash'
      - '/zsh'
      - '/ksh'
      - '/csh'
      - '/ash'
      - '/busybox'
  # bash/zsh pseudo-device reverse shell — excluding loopback readiness probes
  selection_dev_socket:
    CommandLine|contains:
      - '/dev/tcp/'
      - '/dev/udp/'
  filter_loopback_socket:
    CommandLine|re: '/dev/(tcp|udp)/(127\.|0\.0\.0\.0|localhost|\[?::1)'
  # netcat invoked as a command (word-anchored so it can't match rsync/sync/etc.)
  selection_netcat_word:
    CommandLine|re: '(?i)(?:^|[\s;|&/(''"])(?:nc|ncat|netcat)\s'
  # ...paired with an exec flag close by (kills the free-floating `echo -e` match)
  selection_netcat_exec:
    CommandLine|re: '(?i)(?:^|[\s;|&/(''"])(?:nc|ncat|netcat)\s+(?:\S+\s+){0,2}?(?:-e\b|-c\b|--exec\b|--sh-exec\b)'
  selection_named_pipe:
    CommandLine|contains: 'mkfifo'
  selection_interactive_shell:
    CommandLine|re: '(?i)(?:^|[\s;|&/(''"])(?:bash|sh)\s+-i\b'
  condition: >-
    selection_parent and selection_shell and
    (
      (selection_dev_socket and not filter_loopback_socket) or
      selection_netcat_exec or
      (selection_netcat_word and selection_named_pipe) or
      (selection_interactive_shell and (selection_dev_socket or selection_netcat_word))
    )
fields:
  - ParentImage
  - Image
  - CommandLine
  - User
falsepositives:
  - Java applications that intentionally launch remote administration tooling
  - Non-loopback readiness/health probes that dial a real host with /dev/tcp
level: critical

Tune the parent path and expected service behavior for each environment. If network telemetry is available, correlate the process event with an outbound connection from the shell or one of its descendants.

Detection 2: The Payload in Memory

A YARA scan complements the behavioral rule from a different angle: it can find the serialized payload sitting in the receiver's memory or staged on disk, whether or not it has detonated yet. The hard part is keeping the false positives under control.

Class names associated with Log4j and common gadget libraries also exist inside legitimate JAR files and loaded JVM metadata. Matching those names alone creates noise: they live inside log4j-core.jar and commons-collections.jar on every host that runs the software, and a rule that greps for the bare names fires on the vulnerable JARs themselves and on every JVM that has those libraries loaded.

The trick is to match each class name in its serialized class-descriptor form: a two-byte, big-endian length immediately followed by the dotted, fully-qualified class name — exactly how ObjectStreamClass writes a class name into a java.io serialization stream, and a shape you won't find in a compiled .class file, whose constant pool stores the slash-separated internal name (org/apache/...) instead. Two caveats keep this honest. A JAR is a ZIP, so it carries no AC ED stream header at all — the header is what separates a live payload from the classes on disk. And a couple of the atoms below, like the field descriptor Ljava/rmi/MarshalledObject;, are themselves slash-separated strings that do occur inside class files; those are corroborating strings, not standalone tells, and they only count because the rule requires them alongside the stream header and the serialized LogEventProxy descriptor. A higher-confidence signature should require a combination such as:

  • The Java serialization stream header (AC ED 00 05).
  • A serialized class descriptor for the Log4j event proxy.
  • Evidence of the MarshalledObject wrapper.
  • Multiple serialized descriptors associated with the same gadget family.

Here are the two rules we shipped. The first is specific to this hardening gap. The second is a generic Commons Collections gadget signature that catches ysoserial-style CC1/CC6 payloads however they arrive, which is useful in its own right beyond this one issue. Both are go-yara / RE2 compatible (plain-text strings plus one hex atom, no regex, no module imports), so they run cleanly against both files and process memory.

rule BHIS_Exploit_Log4j2_FOIS_4255_Deserialization
{
    meta:
        author = "BHIS ActiveSOC"
        description = "Serialized Java payload exploiting the Log4j2 FOIS/MarshalledObject allowlist bypass (log4j2 #4255), carrying a commons-collections gadget chain to RCE; matches the serialized stream in memory/on staging, not the vulnerable JAR on disk"
        date = "2026-08-26"
        mitre_attack = "T1190,T1059"

    strings:
        // Java serialized object stream header: STREAM_MAGIC 0xACED + STREAM_VERSION 0x0005
        $ser_magic = { AC ED 00 05 }

        // #4255 wrapper: Log4jLogEvent$LogEventProxy carrying a java.rmi.MarshalledObject,
        // in serialized class-descriptor form (0x00 <len> <dotted FQCN>)
        $fois_logeventproxy    = "\x00\x3eorg.apache.logging.log4j.core.impl.Log4jLogEvent$LogEventProxy"
        $fois_marshalled_field = "\x00\x1bLjava/rmi/MarshalledObject;"
        $fois_marshalled_class = "\x00\x19java.rmi.MarshalledObject"

        // commons-collections gadget-chain class descriptors, serialized form
        $cc_invoker  = "\x00\x3aorg.apache.commons.collections.functors.InvokerTransformer"
        $cc_chained  = "\x00\x3aorg.apache.commons.collections.functors.ChainedTransformer"
        $cc_constant = "\x00\x3borg.apache.commons.collections.functors.ConstantTransformer"
        $cc_lazymap  = "\x00\x2aorg.apache.commons.collections.map.LazyMap"
        $cc_tiedmap  = "\x00\x34org.apache.commons.collections.keyvalue.TiedMapEntry"

    condition:
        $ser_magic and
        $fois_logeventproxy and
        ($fois_marshalled_field or $fois_marshalled_class) and
        2 of ($cc_*)
}

rule BHIS_Exploit_Java_CommonsCollections_Deserialization_Gadget
{
    meta:
        author = "BHIS ActiveSOC"
        description = "Serialized Java payload carrying a commons-collections (CC1/CC6-style) gadget chain to command execution; generic ysoserial-class deserialization RCE signature, applicable well beyond log4j2 #4255"
        date = "2026-08-26"
        mitre_attack = "T1190,T1059"

    strings:
        $ser_magic = { AC ED 00 05 }

        $cc_invoker  = "\x00\x3aorg.apache.commons.collections.functors.InvokerTransformer"
        $cc_chained  = "\x00\x3aorg.apache.commons.collections.functors.ChainedTransformer"
        $cc_constant = "\x00\x3borg.apache.commons.collections.functors.ConstantTransformer"
        $cc_lazymap  = "\x00\x2aorg.apache.commons.collections.map.LazyMap"
        $cc_tiedmap  = "\x00\x34org.apache.commons.collections.keyvalue.TiedMapEntry"

        // command-execution sink, serialized-descriptor form + method name
        $sink_runtime    = "\x00\x11java.lang.Runtime"
        $sink_getruntime = "getRuntime"

    condition:
        $ser_magic and
        3 of ($cc_*) and
        1 of ($sink_*)
}

Whatever signature you write, test it against more than the one payload that made it. Our corpus included the actual Log4j and gadget-library JARs, benign serialized objects, a pile of unrelated binaries, and the stream header dropped into random data. Both rules matched the captured payload, including the case that matters most in practice, where it was buried mid-buffer behind a random prefix and suffix. They stayed silent on the vulnerable log4j-core and commons-collections JARs on disk, on directories full of benign system binaries, and on the serialization header sitting in front of noise.

Test input Expected Result
Captured serialized payload match both rules match
Payload embedded mid-buffer (random prefix + suffix) match both rules match
Vulnerable log4j-core JAR on disk no match silent
Vulnerable commons-collections JAR on disk no match silent
Directories of benign system binaries no match silent
Serialization header alone + random data no match silent

These rules are built for memory and staged-file scans. Catching the payload on the wire needs the request body itself, which is where the next section comes in.

Detection 3: Network, If You Can See It

Network detection only works when the sensor actually captures useful application data. The serialized-object content type and the Java serialization header would make a good, targeted signature, but only when request headers and body bytes are available, and TLS inspection, privacy policy, and sensor placement all decide whether they are.

A lot of HTTP telemetry keeps only method, URI, host, response code, and body length. Once you're down to that, the bytes you'd key on are already gone. A rule that fires on a POST with a non-empty body isn't detecting deserialization; it's flagging ordinary web traffic and dressing it up with a scary name.

So before writing anything for the network, confirm the headers or payload you need are really being collected. If they aren't, write down the visibility gap and put the effort into the endpoint rule instead. A detection that can't see its own evidence buys you false confidence and extra noise, nothing more.

Detection 4: Hunting Real Exposure

A fleet hunt should answer whether an unsafe deserialization path is actually reachable, not just whether Log4j is installed somewhere. That means gathering evidence for each link in the chain:

Question Evidence to Collect
Does the application consume Java-serialized log events? Receiver code, startup arguments, open ports, queue consumers, and serialization-related classes
Can an attacker influence the bytes? Network reachability, authentication, producer trust, and integrity controls on stored objects
Is a nested object graph constrained? JVM-wide serialization filter, stream-specific filter, and filter-factory configuration
Are gadget dependencies present? Runtime classpath and dependency inventory, not filenames alone
Would execution be visible? Process creation, command-line, memory-scan, and outbound network telemetry

Velociraptor (or any fleet-response platform) is well suited to the first and last rows of that table — walking each host for the JARs that matter. If you've used Velociraptor's Generic.Detection.Log4jRCE for Log4Shell the shape is familiar, but the target is different: instead of grepping JARs for JndiLookup.class, you're looking for two ingredients on the same host.

  • A vulnerable Log4j JAR — log4j-core 2.8.0 or later (ships the Log4jLogEvent$LogEventProxy MarshalledObject field) and/or log4j-api 2.11.0 or later (ships FilteredObjectInputStream). There is no upper bound: Apache declined to treat this as a vulnerability, so current releases are still affected. McJunkin verified the defect through 2.27.0-SNAPSHOT and confirmed exploitation against 2.24.3 and the current 2.26.1 release, so a hunt that stops at some "latest patched" version quietly reports up-to-date hosts as clean, and the blind spot grows with every Log4j release.
  • A deserialization gadget library on the same classpath — Commons Collections ≤ 3.2.1 (the unguarded InvokerTransformer), commons-collections4 < 4.1, or one of the other classic gadget libraries.

Be honest about what a quick inventory can and cannot see, because the gaps are exactly where a real attacker's host hides:

  • Filenames lie by omission. A shaded or fat JAR, or a Spring Boot executable JAR, carries log4j-core inside another archive (BOOT-INF/lib/, relocated classes) where a top-level file scan never looks. Distribution packages and container images also strip the version out of the filename (/usr/share/java/log4j-core.jar), so a name-and-version match reports them as absent rather than unknown. Opening each candidate archive and confirming the indicator classes (FilteredObjectInputStream.class, Log4jLogEvent$LogEventProxy.class) are really inside is what turns a filename guess into evidence.
  • Gadget presence is not gadget reachability. Spring, SnakeYAML, Hibernate and friends sit on nearly every Java host, often at versions whose chains were patched years ago. Their presence raises priority; it does not by itself prove an exploitable sink. Confirm the version, and above all whether an attacker-controlled deserialization path actually reaches it.

We built a Velociraptor artifact along these lines for our own fleet, but the logic matters more than any one implementation: enumerate JARs, identify the vulnerable Log4j and gadget-library ingredients, open the candidates to confirm the classes are physically present, and rank the hosts that have both. Treat the result as a prioritization list, not a verdict. A vulnerable-looking library combination with no reachable deserialization sink is not an exploitable service; a custom receiver taking in untrusted serialized objects is, and it belongs at the top of the review queue.

Reducing the Risk

The best fix is to get native Java serialization out of your log transport entirely. Apache points people at structured formats like JSON or RFC 5424 over mutually authenticated TLS. When a legacy serialized receiver can't be retired right away, work down this list:

  1. Remove it from untrusted networks and require strong authentication between producers and consumers.
  2. Configure a context-appropriate JVM-wide serialization filter and test how it combines with stream-specific filters. Oracle's serialization filtering guidance notes that filtering is not active unless it is explicitly configured. McJunkin's analysis confirms a blocklist like -Djdk.serialFilter=!org.apache.commons.collections.**;* stops the tested gadget chain — though a filter tuned to your own environment's gadgets is stronger, and note that a blocked attempt still logs an identical, innocuous-looking line.
  3. Remove unused gadget-capable dependencies and keep Log4j, the JDK, and application libraries on supported releases.
  4. Run the service with least privilege and restrict outbound network access.
  5. Alert on unexpected child processes and preserve enough telemetry to reconstruct parent-child execution and egress.

If one of the correlated endpoint alerts does fire, grab the process tree and the relevant memory before you restart the service. Then isolate the host if that's warranted, trace back where the input came from, and review whatever credentials the Java process had access to. Finish by hunting for the same receiver configuration and child-process pattern elsewhere in the fleet.

Wrapping Up

The point here isn't that every Log4j box is a Log4Shell waiting to happen; most of them aren't. It's that an allowlist in front of unsafe deserialization is a mitigation and not a guarantee, and nested streams are exactly where that difference starts to bite.

Surface Best Use Key Limitation
Endpoint behavior Detect command execution and follow-on activity Needs process and command-line telemetry
Memory or payload signature Find a serialized gadget graph Bare class names create false positives
Network inspection Detect delivery when headers or body bytes are visible Metadata-only HTTP logs are insufficient
Fleet exposure hunt Prioritize reachable unsafe receivers JAR presence alone does not prove exposure

Start by tracking down the applications that deserialize Java objects from data they don't fully control, then build your endpoint, memory, and hunting coverage around those. It's an approach that outlives this particular bug, because you're keying on the behavior rather than chasing a single issue number.

This work was done in an isolated lab for authorized defensive testing. We've deliberately left out exploit construction steps and any working payloads.