Java vulnerabilities of the week: 17 to 23 September 2026

Most security feeds hand you a CVE id, a CVSS number, and one sentence saying an attacker may do something bad. As a developer that tells me almost nothing I can use. The part worth knowing is the mechanism: the exact buffer with no ceiling, the exact class filter that checks one path and forgets another. See the shape once and you recognise it in your own code, which is worth far more then the score. That is the whole reason for this series, and I wrote about the mindset in the CVE stigma post.

After last week this one is calm. Last week was coordinated mega-batches: Netty fifteen advisories, Storm fifteen CVEs, Syncope twenty-two. This week is five items, and every single one comes from the Apache Software Foundation, and every one is a library or framework that sits underneath other software rather then something you wrote. MyFaces under your JSF pages, MINA under your directory and messaging services, Karaf as the container your bundles run in, Neethi under any SOAP stack still speaking WS-Policy.

Two of the five are the same project, Apache MINA, with two unrelated bugs. The one to patch today is MyFaces, a critical that landed right as last week’s window closed. The one worth studying is a MINA advisory that exists only because an earlier fix was announced for three release branches and reached one of them.

The week in five

Apache Karaf: a ThreadLocal on a pooled thread pins the ClassLoader

Karaf is an OSGi runtime, and in OSGi bundles come and go while the process keeps running, each bundle with its own ClassLoader. Hot redeploy is the whole point of the model: install a feature, update a bundle, refresh it, all without a restart. So anything that keeps an old ClassLoader alive after its bundle is gone is a leak, and it shows up in Metaspace, the region that holds class metadata, not in the ordinary heap. That is part of why these leaks hide: a heap dump under load can look healthy while Metaspace climbs on every redeploy.

The mechanism here starts from a reasonable performance choice. DocumentBuilderFactory and TransformerFactory are not cheap to create, so XmlUtils cached them and reused them per thread through a static ThreadLocal. That part is sensible. The trouble is lifetime. A ThreadLocal entry lives as long as the Thread it is set on, and Karaf’s worker threads are pooled and long-lived, so in practice they never end. The cached factory was loaded by, and holds a reference back to, the ClassLoader of the bundle that first reached XmlUtils. When that bundle is updated, OSGi builds a fresh ClassLoader for the new revision and tries to drop the old one, and it cannot, because a pooled thread still holds a ThreadLocal whose value chains back to it. Do that on every redeploy and you leak one ClassLoader per cycle, and every class that loader ever defined is pinned with it. The fix in 4.4.11 stops caching those factories in a static ThreadLocal.

Why I find it interesting

This is the archetypal Java memory leak and the pattern is worth carrying in your head. A ThreadLocal set on a thread you did not create and will not end, a pool thread or a container thread, lives until that thread dies, which is never. If its value references a class, directly or down a chain of fields, it pins that class’s ClassLoader and everything the loader ever defined. In any environment with hot redeploy this is a ClassLoader per cycle: OSGi here, but a servlet container reloading a webapp or an application server hot-deploying an EAR is the same story, and Metaspace is where it lands. The bug belongs to the unbounded-lifetime family, the same reason ThreadLocal.remove() exists and the same reason a library that stashes something in a ThreadLocal on a borrowed thread has to clear it in a finally. The tell is a static ThreadLocal whose value is anything richer than a primitive or a plain JDK type. If you see one living on a pooled thread, assume a leak until you have proven otherwise.

Apache MINA: a fix is a claim until you can see it in the artifact you run

MINA is a network application framework, the transport layer under Apache Directory Server, Qpid and a handful of other services, and one of the things it can do is read a serialized Java object straight off the wire. Java deserialization is the most reliable remote code execution primitive the platform has, because readObject runs logic chosen by the incoming byte stream, so any framework that deserializes untrusted input guards it with a filter: an allowlist of the classes it is willing to reconstruct.

The earlier bug, CVE-2026-47065, was a hole in that guard. MINA’s filter overrode resolveClass, the ObjectInputStream hook called for ordinary classes, and checked each name against its allowlist. But ObjectInputStream has a second hook, resolveProxyClass, called when the stream rebuilds a dynamic java.lang.reflect.Proxy. If you only guard resolveClass, an attacker serializes a proxy that implements an allowed interface but is backed by a malicious InvocationHandler, and the proxy path never consults your allowlist at all. So a filter that looked complete had a door in the side, and the fix was to override resolveProxyClass too and apply the same check. This is a known class of bypass, and it is exactly why hardening deserialization means covering both hooks rather than one.

The new bug, CVE-2026-94301, is that this resolveProxyClass override reached the 2.2.x branch and not the 2.0.x and 2.1.x branches, which kept shipping without it. A user who upgraded to 2.1.13 on the strength of the earlier advisory was therefore still exposed to the same bypass. tonghuaroot found the gap, MINA issued a new CVE and backported the override, and the fix for the older branches is in 2.0.31 and 2.1.15.

Why I find it interesting

There are two lessons stacked here, one small and one large. The small one is the resolveProxyClass gap: an allowlist that checks class names has to cover the proxy path as well, or dynamic proxies walk straight through it, and that is a concrete thing to grep for in any custom ObjectInputStream you own. The large one is that a fixed-version number in an advisory is a claim you can verify, not a guarantee to assume. A backport can miss a maintenance branch, and a release can be tagged before it reaches Maven Central. The way to know a fix is in the artifact you run is to look at it: the commit on the branch you build from, or the class in the jar you actually resolved. That is the same instinct behind this whole series, a number tells you a bug exists, it does not tell you the mechanism, and it does not by itself tell you the code in front of you carries the fix. If you deserialize anything off a network in a MINA-based service, check that resolveProxyClass is overridden in exact version on your classpath, and move to 2.0.31, 2.1.15 or 2.2.8 and up.

What I take away from this week

Where the fix reaches you splits the list in two. MyFaces and Karaf are things you run and patch directly: bump myfaces-impl in your JSF application to 4.1.4 or the matching fix on your line, and move Karaf to 4.4.11. MINA and Neethi usually arrive transitively, MINA under directory and messaging services, Neethi under a CXF or Axis2 SOAP stack, so a framework or BOM bump can carry them, but the resolved version is what counts and pins lag, so check org.apache.mina:mina-core and org.apache.neethi:neethi in your dependency tree instead of assuming. And the MINA pair has a trap worth repeating: on the 2.1 line the decompression fix is in 2.1.13 while the deserialization fix is only in 2.1.15, so aim for 2.0.31, 2.1.15 or 2.2.8 and later to be clear of both.

The MINA backport is the story I will keep, because it is a plain reminder that a version number in an advisory is worth verifying. The earlier advisory listed three fixed versions, and at that point the override had reached one branch. That is not special to MINA: backports get sequenced across branches, and releases sometimes get tagged before they land on Maven Central. I lean on the workflow behind this post partly because I have been caught before by a patched version that was not downloadable yet. The habit that protects you is to confirm the fix in the artifact, the commit on your branch or the code in your jar, and treat the version string in the notes as a pointer to check rather than proof.

Third, the unbounded-work shape is everywhere again. Neethi’s five are all variations on “some input makes us allocate or recurse with no cap”, the decompression bomb in MINA’s CompressionFilter is the same idea with a compression ratio as the lever, and the identical decompression shape hit async-http-client back in August (CVE-2026-85721, unbounded response decompression). Any buffer, queue or recursion sized by data the other side controls needs an explicit ceiling and a sane failure when it is hit, because “the input will be reasonable” is a hope, not a bound, and a hostile peer’s entire job is to break that hope.

References