//nefariousplan

CVE-2025-41242: Spring's `uriDecode` Wrote 16-Bit Chars Into an 8-Bit Sink. Jetty 12 Finished the Traversal.

pattern

cve

proof of concept

Spring Framework's StringUtils.uriDecode has a single non-percent branch in its core loop. The branch calls bos.write(ch), where bos is a ByteArrayOutputStream and ch is a Java char. The write(int) method on a ByteArrayOutputStream keeps the eight low-order bits of its argument and discards the rest. Java chars are sixteen bits.

When the input is ASCII the truncation is invisible. When the input is a CJK Unified Ideograph it rewrites the character. 阮 (U+962E) becomes 0x2E, which is .. 严 (U+4E25) becomes 0x25, which is %. The sequence 阮严灵丰丰甲来 becomes the seven ASCII bytes .%u002e. CVE-2025-41242 lives between what Spring's path-traversal gate sees in that string and what Jetty 12 does with it next.

Spring Framework's StringUtils.uriDecode has a single non-percent branch in its core loop. The branch calls bos.write(ch), where bos is a ByteArrayOutputStream and ch is a Java char. The write(int) method on a ByteArrayOutputStream keeps the eight low-order bits of its argument and discards the rest. Java chars are sixteen bits.

When the input is ASCII the truncation is invisible. When the input is a CJK Unified Ideograph it rewrites the character. 阮 (U+962E) becomes 0x2E, which is .. 严 (U+4E25) becomes 0x25, which is %. The sequence 阮严灵丰丰甲来 becomes the seven ASCII bytes .%u002e. CVE-2025-41242 lives between what Spring's path-traversal gate sees in that string and what Jetty 12 does with it next.

The seven characters decode to .%u002e

The contract for ByteArrayOutputStream.write(int b) has been documented since the JDK 1.0 release: "The byte to be written is the eight low-order bits of the argument b. The 24 high-order bits of b are ignored." The same contract applies to every overload of OutputStream.write(int) in the standard library, and to every subclass that does not override it. Spring's StringUtils.uriDecode, on the pre-patch code path in spring-core/src/main/java/org/springframework/util/StringUtils.java, called this method directly with a Java char as the argument.

A Java char is a sixteen-bit unsigned integer. For any input character whose high byte is non-zero, the call discards the high byte and writes only the low byte into the stream. The CJK Unified Ideographs block (U+4E00 to U+9FFF) has a high byte in the range 0x4E to 0x9F and a low byte that varies. The PoC author picked seven ideographs whose low bytes spell a Unicode escape:

Character Codepoint High byte Low byte ASCII
阮 U+962E 0x96 0x2E .
严 U+4E25 0x4E 0x25 %
灵 U+7075 0x70 0x75 u
丰 U+4E30 0x4E 0x30 0
丰 U+4E30 0x4E 0x30 0
甲 U+7532 0x75 0x32 2
来 U+6765 0x67 0x65 e

阮严灵丰丰甲来 is seven characters of UTF-8 input. After the ghost-bits truncation it is seven bytes of ASCII: .%u002e.

The decoder's pre-patch loop is, in essence, the following twelve lines:

for (int i = 0; i < length; i++) {
    char ch = source.charAt(i);
    if (ch == '%') {
        int u = Character.digit(source.charAt(i + 1), 16);
        int l = Character.digit(source.charAt(i + 2), 16);
        bos.write((char) ((u << 4) + l));
        i += 2;
        changed = true;
    } else {
        bos.write(ch);   // ghost bits
    }
}
return changed ? new String(bos.toByteArray(), charset) : source;

bos.toByteArray() returns a byte array. new String(byte[], Charset) reinterprets the bytes as a string under the supplied charset. The seven ASCII bytes .%u002e are valid ASCII under every charset Spring supports. The decoder returns the literal string .%u002e to its caller, and the seven Chinese ideographs are gone from the path before any security check runs against it.

Spring's gate inspects the string Spring's decoder rewrote

Spring MVC's resource handling chain applies StringUtils.uriDecode to the request path and then runs isInvalidPath and isInvalidEncodedPath against the result. Both checks look for traversal indicators by string inspection: a literal .. segment, a ..\\ segment, a percent-encoded variant such as %2e%2e/ or %2e%2e\\. The pattern set is anchored to the alphabet RFC 3986 percent-encoding produces.

The string the decoder hands them is /.%u002e/.%u002e/.../etc/passwd. There is no .. substring in it. The %u002e sequence is a Microsoft IIS extension that JavaScript's escape() function emits and a small number of legacy parsers accept; it is not part of RFC 3986 and Java's standard URLDecoder does not handle it. To Spring's checks the seven-byte token .%u002e is an opaque substring. Both checks return false. Both gates pass.

A subtlety determines whether the decoder runs at all. Spring's path matcher has a fast path that skips uriDecode entirely when no character of the path string is percent-encoded. The PoC defeats the fast path by percent-encoding the final character of the target filename: the literal passwd is rewritten as passw%64, where %64 is the standard percent-escape for d. With one valid %xy somewhere in the path, the fast path is rejected, and the decoder runs over every character. The Chinese ideographs hit the ghost-bits branch.

The PoC also requires the Chinese characters to arrive on the wire as raw UTF-8 bytes, not pre-percent-encoded by the client. A standard browser, curl, or Burp Suite request normalises high-bit Unicode characters into ASCII percent-escapes (%E9%98%AE... for the UTF-8 encoding of 阮) before sending. Those encoded triplets take the percent branch of Spring's decoder, the one that correctly parses two hex digits to a byte; the ghost-bits branch never runs. The reproduction therefore uses a raw socket script that writes the literal UTF-8 bytes of the seven ideographs into the request line:

GHOST_BITS_SEG = '阮严灵丰丰甲来'.encode('utf-8')

def build_request(host, port, file_path):
    encoded_target = encode_target_path(file_path)
    path = b'/' + (GHOST_BITS_SEG + b'/') * 7 + encoded_target
    return (b'GET ' + path + b' HTTP/1.1\r\n'
            b'Host: ' + f'{host}:{port}'.encode() + b'\r\n'
            b'Connection: close\r\n\r\n')

Yakit's HTTP fuzzer transmits requests unmodified and is the second documented client. No mainstream tool reaches the bug without going around its own URL normalisation, which is part of why this CVE arrived with a custom socket script rather than a one-line curl.

Jetty 12 knows what %uXXXX means. Jetty 11 did not.

Spring's URI handling produces a path string. The path is then resolved against the filesystem by whichever servlet container hosts the application. On Jetty, that resolution runs through org.eclipse.jetty.util.URIUtil.encodePathSafeEncoding, a method that re-canonicalises path inputs before they hit the filesystem. The Jetty 12 implementation of that method recognises the IIS %uXXXX escape and translates it back to a single-byte %XX percent-escape: %u002e becomes %2e. The subsequent percent-decode then resolves %2e to ., and the input segment .%u002e becomes the segment ...

The PoC's path is /阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64. After Spring's decoder the seven repeats are /.%u002e/.%u002e/.%u002e/.%u002e/.%u002e/.%u002e/.%u002e/etc/passwd. After Jetty 12's canonicalisation the same path is /../../../../../.././etc/passwd. After filesystem resolution it is /etc/passwd. Seven .. steps are enough to reach the filesystem root from any reasonable web-app deployment directory, which is why the PoC repeats the ideographic segment seven times.

The URIUtil.encodePathSafeEncoding method that performs the %uXXXX translation does not exist in jetty-util 11. spring-boot-starter-parent pulled jetty-util 11 by default through Spring Boot 3.1.x; the 3.2.0 release bumped the default to jetty-util 12. The CVE is therefore reachable on Spring Boot 3.2.x with embedded Jetty defaults and is unreachable on 3.1.x with embedded Jetty defaults. The Spring framework code is the same on both. The container under it is what decides.

Gate before canonicalize, again

The shape is one we have already named. Two days ago we covered CVE-2026-26336, the Alfresco Share resource controller that ran a regex denylist against a URL and forwarded the URL to Tomcat. The regex saw ..;/; Tomcat saw ../. The defense was deployed in 2021 against a bypass published in 2018, and the CVE caught up in 2026. The pattern is Gate Before Canonicalize: a security check inspects an input for a forbidden form, and a downstream subsystem canonicalises the same input into the forbidden form, and the gate runs first.

CVE-2025-41242 is the same shape with a different bypass. In Alfresco the canonicaliser is Tomcat's path-parameter stripper, which discards ;params and exposes the .. underneath. In Spring it is Jetty 12's URIUtil.encodePathSafeEncoding, which translates %u002e and exposes the . underneath. The gate that fails to see traversal is different too. In Alfresco the gate is a regex denylist that hard-codes one spelling of ... In Spring the gate is a string-pattern check whose vocabulary covers RFC 3986 percent-encoding and does not cover %uXXXX.

The vocabulary mismatch is the part Spring contributed. Spring's decoder fed sixteen-bit chars into an eight-bit sink, manufactured .%u002e out of 阮严灵丰丰甲来, and then ran a check that understood only RFC 3986 percent-encoding against the result. The decoder produced an encoded form the check could not name. Two parsers disagreed about what the bytes said before the bytes ever reached Jetty.

The advisory names Jetty as a compliant container

GitHub advisory GHSA-r936-gwx5-v52f, the official record for CVE-2025-41242, states that "applications deployed on Apache Tomcat or Eclipse Jetty are not vulnerable" when those containers run with their default security settings. The advisory frames the vulnerability as conditional on a "non-compliant Servlet container," which it positions as something other than Tomcat or Jetty in default configuration.

The vulhub reproduction is a single Docker image: vulhub/spring-boot-jetty:3.2.4. The image is Spring Boot 3.2.4 with the spring-boot-starter-jetty dependency at its default version. The reproduction runs without configuration changes. The reproduction succeeds and dumps /etc/passwd over HTTP.

spring-boot-starter-jetty is one of the embedded servlet container starters maintained by the Spring team. Its purpose is to provide an embedded Jetty server with sensible defaults to applications that import it. Since Spring Boot 3.2.0 the BOM-pinned Jetty version under that starter has been jetty-util 12. A developer who follows the framework's getting-started guide for embedded Jetty on Spring Boot 3.2.x receives a runtime that the advisory's own published reproduction targets and exploits.

Both statements are public record. The advisory's "Jetty is not vulnerable" reading is doing work whose contours are visible only after reading the BOM and the reproduction together.

The patch swaps the sink

The Spring commit that fixes CVE-2025-41242, 24e66b63, refactors StringUtils.uriDecode. The non-percent branch changes from bos.write(ch) to output.append(ch), where output is a StringBuilder and append(char) preserves all sixteen bits of its argument. The percent branch changes from bos.write((char) ((u << 4) + l)) to a HexFormat.fromHexDigits call that writes into a byte array sized to the exact number of escape triplets in the input. The final return changes from new String(bos.toByteArray(), charset) to a string-builder concatenation that touches the byte array only when percent escapes are present.

The bug in Spring is fixed. The sink that takes a sixteen-bit integer and writes eight bits is the same sink it was on the day Spring's decoder was written. OutputStream.write(int) still keeps the eight low-order bits. ByteArrayOutputStream.write(int) inherits that contract unchanged. Every other consumer of write(int) in every other Java library that hands the method a char or any non-byte numeric type produces the same truncation, and the Java compiler will not warn on the implicit widening conversion from char to int.

The Black Hat Asia 2026 presentation that named this class, "Cast Attack: Ghost Bits," enumerates the bug in three additional places: Fastjson's @type parser, Openfire's request path handler, and SMTP smuggling under certain MTA configurations. The companion repository, shiyeshu/GBitsTools, ships a payload generator with presets for all four targets. Its README states the tool was "rapidly built by AI" and intended for "personal research convenience reproduction." The preset table is the kit's actual claim:

0. Manual custom
1. Spring directory traversal  (".%u002e/" * 7 + "etc/passw%64")
2. Fastjson @type bypass        ('{"@type":"java.lang.Runtime"}')
3. Openfire access bypass       ("%2>/" * 4 + "log.jsp")
4. SMTP email smuggling         ("[CRLF]DATA[CRLF]Subject: PWNED...")

Five named targets, four bug classes, one primitive. Spring's patch closes the one occurrence the CVE is for. The class is still in the JDK.

PoC: HORKimhab/CVE-2025-41242

The fix replaces bos.write(ch) with output.append(ch). The write(int) contract that produced the bug is unchanged in the JDK.