DEV Community

Cover image for Dissecting FOIS: The Log4j2 Filter That Only Does Half Its Job
Amartya Jha
Amartya Jha

Posted on Originally published at codeant.ai

Dissecting FOIS: The Log4j2 Filter That Only Does Half Its Job

__Log4j2's FilteredObjectInputStream checks which classes can be rebuilt, but not how large or deep the data can be. Here's how that gap enables an RCE and two gadget-free crashes on a serialized log receiver.

TL;DR

  • Log4j2's FilteredObjectInputStream (FOIS) checks the class names in a serialized stream and nothing else. It never limits object size or depth.
  • That one gap gives three problems on a single network log receiver: an unauthenticated RCE, a crash that pins a CPU core, and a 44-byte out-of-memory crash. Two of them need no exploit gadget.
  • The RCE (Log4j2 #4255) is prior work. CodeAnt's contribution is dissecting the two crashes, the contrast with logback, and the lab numbers.
  • Exposure is narrow. You need a serialized log receiver on the network, which is not a Log4j default. High severity, low prevalence.
  • Fix: send logs as text, or add the size and depth caps logback already ships.

At a Glance

A security filter is meant to do one job completely. FOIS does about half of one. It reads the name of every class arriving over the network and rejects anything not on an approved list.

That is a real check, but it is the only check. FOIS never asks how large an object is or how deeply nested, and it never turns on the size and depth limits Java already ships. Those two missing checks turn one log receiver into three problems.

The RCE below (Log4j2 #4255) is not our discovery; it is prior work, credited in full at the end.

What our team adds is the breakdown of the filter itself: the two crashes hiding in the limits FOIS forgot to set, the comparison with the library that got it right, and numbers from a real lab.

Read the full CodeAnt security research

Lab: Log4j 2.26.1 on JDK 17. Run it yourself with one Docker command.

What FOIS Was Built To Do

Log4j can ship log events between machines as Java objects, and the receiver rebuilds them from raw bytes. Rebuilding attacker-controlled bytes is the flaw behind a decade of Java RCE, so Apache wrapped that step in FOIS in Log4j 2.8.2 (the fix for CVE-2017-5645). FOIS keeps the class names it trusts and throws out the rest.

Two details of that trusted list matter. It allows a class called java.rmi.MarshalledObject, and it trusts whole Java packages by prefix, so everything under java.util and java.lang is waved through. Each one becomes a way in.

The One Mistake

FOIS checks which class is being rebuilt. It never checks how big or how deep. Java can cap array size, nesting depth, and reference count; FOIS turns none of that on. Everything below follows from that.

The Three Impacts

RCE — a java.rmi.MarshalledObject-wrapped gadget hits the FilteredObjectInputStream receiver; the receiver logs msg=null while whoami returns root, unauthenticated code execution as root.

DoS — the same receiver, no gadget: a 5.7 KB nested HashSet pins readObject at 100% CPU permanently, and a 44-byte Object array triggers OutOfMemoryError: Requested array size exceeds VM limit.

One: remote code execution, smuggled inside an allowed class. MarshalledObject is trusted, but it is a container: it carries a second batch of bytes inside itself, and when Log4j opens it, those inner bytes are rebuilt on a fresh stream that FOIS is not watching.

The outer check passes; the real payload rides in behind it. Log4j opens the container on its own during reconstruction, so if the server has a usable gadget library on its classpath, this is unauthenticated RCE.

In the lab against real Log4j 2.26.1 on JDK 17, a wrapped payload ran whoami and returned root, the server stayed up and logged a blank message, and the same payload sent without the container was correctly rejected. (On a modern JDK fewer gadgets survive, but the ones that run commands still work where the right library is present, so the requirement is just a usable gadget on the classpath.)

Two: a CPU bomb the size of a text message. HashSet is allowed, and rebuilding a set recomputes the hash of everything inside it.

A 2015 trick called SerialDOS (Wouter Coekaerts) builds a small nested structure where each layer is shared and referenced twice, so a shape a few dozen levels deep is reachable by an exponential number of paths, and the runtime walks every one. With no depth limit, the receiver runs it to completion:

A few kilobytes freezes the machine, one worker per packet, and it is silent: while the receiver spins there is no log line. The only sign is a thread stuck at 100% CPU.

Three: a 44-byte packet that crashes the process. A serialized array states its length before any data, and the runtime reserves that space immediately. With no size limit, that number can be anything.

A 44-byte packet can claim an array of billions of entries, and the process dies with an out-of-memory error before it even tries to allocate. No gadget, no library, just a lie about size.

The full research walks through the FOIS implementation, reproduces all three impact paths, and includes the lab setup and measurements.

Read the complete analysis

How logback Solved the Same Problem

logback, Spring Boot's default logger, had the identical socket receiver, and its guard shows what the finished job looks like. HardenedObjectInputStream does everything FOIS does, then the part FOIS skipped: an exact-name allowlist instead of trusting whole packages, a maximum array size and nesting depth (which kills both crashes), and a closed proxy path.

It is immune to all three. The difference between the two libraries is essentially one extra line of filter, the one that turns on the size and depth caps, which FOIS never wrote.

How Exposed Is This, Really?

Less than three impacts suggests, and this is the honest part. Every one needs a serialized log receiver exposed on the network, which is not how Log4j runs by default; Apache removed the built-in socket server years ago. When one researcher tested 53 product builds in default configuration, none were exploitable.

Call it high severity, low prevalence: trivial to hit where a receiver exists, uncommon to have one. Where they do appear, it tends to be logback's own socket receiver, an older Elastic Logstash log4j input, or legacy Log4j 1.x, which has no filter at all.

How To Fix It

If you cannot remove a serialized receiver right away, one runtime setting closes the gaps. It denies the gadget package and adds the size and depth caps at once. The catch: only one such filter applies per process and a later setting silently replaces an earlier one, so every rule lives in a single string.

-Djdk.serialFilter='!org.apache.commons.collections.**;maxarray=10000;maxdepth=16'
Enter fullscreen mode Exit fullscreen mode

That is a stopgap. The durable fix is to stop sending Java objects across a trust boundary: carry log events as JSON or plain text, or remove the serialized receiver. Log4j 3.x already dropped this pattern; the 2.x line still in production keeps it.

Are You Affected?

Only if you run a serialized log receiver reachable on the network, which is a deliberate setup, not a default. Look for a process listening on a log-ingest port (historically 4560) that rebuilds log events off the socket.

If you find one, treat it as high severity, apply the filter above, then move that transport to text or retire it. On Log4j 3.x the pattern is already gone.

  • Original research: U-Sec / Wujie Security, first credited with the MarshalledObject allowlist bypass behind #4255. The original report was later deleted.
  • Independent PoCs: joanbono's log4j2-4255-exploit and dinosn's log4j-4255.
  • CPU-crash technique: Wouter Coekaerts, "SerialDOS," 2015.
  • Prior writeup: Jeff McJunkin's post, whose 53-product test and detection guidance inform the exposure and fix sections.

Credit: Prior Work

The RCE (issue #4255), the property that any gadget works once the inner stream is unchecked, and the gadgets themselves are prior work. Our contribution is the breakdown of the filter around that RCE, the two crashes from its missing limits, the logback comparison, and the lab numbers.

The Takeaway

A class-name allowlist is not a deserialization boundary. Restricting which objects can be rebuilt may reduce the risk of code execution, but without limits on payload size, object depth, or resource consumption, the same endpoint can remain vulnerable to denial of service.

The bigger lesson is simple: a security control is only as strong as the attack surface it actually closes. An endpoint can look hardened against one exploit path while still being wide open to another.

If your application relies on deserialization, APIs, or other security-sensitive boundaries, automated checks alone may not expose these gaps. That is where adversarial testing matters.

Want to know what your application exposes before an attacker does?

CodeAnt AI helps teams identify security weaknesses across their codebase and infrastructure. For deeper coverage, CodeAnt's pentesting services put those controls to the test from an attacker's perspective, helping uncover exploitable paths that static analysis and automated scanners can miss.

If you want to see the kind of security research behind that approach, read the full Log4j2 FOIS analysis.

Find the weakness before someone else does.

References

  • Apache Log4j2 issue #4255
  • logback HardenedObjectInputStream
  • ysoserial · log4j2-4255-lab · Jeff McJunkin
  • CVEs: CVE-2017-5645, CVE-2017-5929, CVE-2019-17571, CVE-2023-6378

This breakdown is part of an ongoing effort by CodeAnt AI Security Research to analyse the trust boundaries in widely deployed infrastructure. The lab ran in isolated local Docker containers with synthetic payloads. The underlying issue and the gadgets are prior work, credited above.

Have a receiver like this, or want a deserialization boundary reviewed? Reach us at securityresearch@codeant.ai

Top comments (0)