-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"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.\n\nWhen 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.\n\n## The seven characters decode to `.%u002e`\n\nThe 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.\n\nA 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:\n\n| Character | Codepoint | High byte | Low byte | ASCII |\n|-----------|-----------|-----------|----------|-------|\n| 阮 | U+962E | 0x96 | 0x2E | `.` |\n| 严 | U+4E25 | 0x4E | 0x25 | `%` |\n| 灵 | U+7075 | 0x70 | 0x75 | `u` |\n| 丰 | U+4E30 | 0x4E | 0x30 | `0` |\n| 丰 | U+4E30 | 0x4E | 0x30 | `0` |\n| 甲 | U+7532 | 0x75 | 0x32 | `2` |\n| 来 | U+6765 | 0x67 | 0x65 | `e` |\n\n`阮严灵丰丰甲来` is seven characters of UTF-8 input. After the ghost-bits truncation it is seven bytes of ASCII: `.%u002e`.\n\nThe decoder's pre-patch loop is, in essence, the following twelve lines:\n\n```java\nfor (int i = 0; i < length; i++) {\n char ch = source.charAt(i);\n if (ch == '%') {\n int u = Character.digit(source.charAt(i + 1), 16);\n int l = Character.digit(source.charAt(i + 2), 16);\n bos.write((char) ((u << 4) + l));\n i += 2;\n changed = true;\n } else {\n bos.write(ch); // ghost bits\n }\n}\nreturn changed ? new String(bos.toByteArray(), charset) : source;\n```\n\n`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.\n\n## Spring's gate inspects the string Spring's decoder rewrote\n\nSpring 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.\n\nThe 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.\n\nA 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.\n\nThe 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:\n\n```python\nGHOST_BITS_SEG = '阮严灵丰丰甲来'.encode('utf-8')\n\ndef build_request(host, port, file_path):\n encoded_target = encode_target_path(file_path)\n path = b'/' + (GHOST_BITS_SEG + b'/') * 7 + encoded_target\n return (b'GET ' + path + b' HTTP/1.1\\r\\n'\n b'Host: ' + f'{host}:{port}'.encode() + b'\\r\\n'\n b'Connection: close\\r\\n\\r\\n')\n```\n\nYakit'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.\n\n## Jetty 12 knows what `%uXXXX` means. Jetty 11 did not.\n\nSpring'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 `..`.\n\nThe 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.\n\nThe `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.\n\n## Gate before canonicalize, again\n\nThe shape is one we have already named. Two days ago we covered [CVE-2026-26336](/posts/alfresco-share-blacklist-misses-the-semicolon), 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](/patterns/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.\n\nCVE-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`.\n\nThe 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.\n\n## The advisory names Jetty as a compliant container\n\nGitHub 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.\n\nThe 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.\n\n`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.\n\nBoth 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.\n\n## The patch swaps the sink\n\nThe 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.\n\nThe 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`.\n\nThe 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:\n\n```\n0. Manual custom\n1. Spring directory traversal (\".%u002e/\" * 7 + \"etc/passw%64\")\n2. Fastjson @type bypass ('{\"@type\":\"java.lang.Runtime\"}')\n3. Openfire access bypass (\"%2>/\" * 4 + \"log.jsp\")\n4. SMTP email smuggling (\"[CRLF]DATA[CRLF]Subject: PWNED...\")\n```\n\nFive 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.\n\nPoC: [HORKimhab/CVE-2025-41242](https://github.com/HORKimhab/CVE-2025-41242)","closing_line":"The fix replaces `bos.write(ch)` with `output.append(ch)`. The `write(int)` contract that produced the bug is unchanged in the JDK.","hook_md":"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.\n\nWhen 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.","post_id":272,"slug":"spring-uridecode-ghost-bits-jetty-finishes","title":"CVE-2025-41242: Spring's `uriDecode` Wrote 16-Bit Chars Into an 8-Bit Sink. Jetty 12 Finished the Traversal.","type":"initial","unreadable_sentence":"Spring's decoder fed sixteen-bit chars into an eight-bit sink, and Spring's gate then ran string-pattern checks on the truncated result. The decoder produced an encoded form the check could not name."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCasPZ3AAKCRDeZjl4jgkQ JlfaAQDWD2Fk7RuzUBjs+s8E222Q/IHzPKdMFwZxRZ5ZzKIYQwD+IRYTTRyU5KNl Qc8KmNk5k5k9e57qDess6Xb6oKxZQgI= =cHhq -----END PGP SIGNATURE-----