Ghost Bits:Java 编码转换里的幽灵攻击面
2026 年 4 月,BlackHat Asia 2026 上,研究员 Zhihui Chen(1ue)与 Xinyu Bai(浅蓝 / b1u3r)发表了议题《Cast Attack: A New Threat Posed by Ghost Bits in Java》,提出了 Ghost Bits(幽灵比特位)的概念。
这是一种长期潜伏在 Java 编码处理链里的攻击面,问题就藏在最普通的类型转换和编码处理里。因此研究者把其利用方式统称为 Cast Attack。
核心原理
char与byte的位宽不同
在 Java 中,char类型使用 16 位无符号整数表示,采用 UTF-16 编码,可容纳从0x0000到0xFFFF的Unicode字符。而byte类型仅使用 8 位,范围仅为 -128 到 127。
历史包袱:Java 发布之初,Unicode 标准早期版本认为世界上所有文字加起来不会超过 65536 个。很快生僻字、古汉字、emoji等加入后被打脸,只能使用 2 个 16 位(4 字节)的组合来表示增补字符。
从char到byte是一次窄化转换。按照 JLS 的规定,窄化转换会静默丢弃高位,只保留低位:

- 触发窄化的写法非常多
在实际代码里,下面这几种常见写法都会发生char -> byte的高位截断:
(byte) ch
ch & 0xFF
OutputStream.write(int)
DataOutputStream.writeBytes(String)
截断本身不是 bug,语言就是这么定义的。 真正的问题是,当这次截断恰好发生比如 WAF 检测之后、危险操作之前,就把一段看似无害的数据,悄悄还原成了攻击载荷。
Ghost Bits,指的就是在char -> byte窄化中被静默丢弃、却影响安全语义的那 8 个高位比特。
把一次请求的生命周期拆开看,通常经过两条链:
- 检测链:WAF / IDS、应用层输入校验(如黑名单、正则过滤器)、容器层的路径规范化(如 Tomcat / Jetty 判断用户是否有权访问某个 URL 时会对 URL 进行规范化)等,负责判断这个输入是否危险
- 执行链:后端框架真正解析、解码、落地这个输入的代码路径
只要这两条链对同一份字节的理解不一致,GAP 就出现了:检测链判定无害并放行,执行链解码后却还原出高危语义。
payload 的构造逻辑很简单,攻击者想让后端最终拿到某个危险 ASCII 字符(比如.或/),就去挑一个低 8 位与该字符相同的 Unicode 字符放进请求。WAF 看到的是一串无意义的 CJK 字符(如阮阮阯),规则不命中;后端代码一旦对它做了char -> byte窄化,高位被丢弃,低 8 位还原成原始字符,恶意 payload (如../)重新成形。
public class GhostBits {
public static void main(String[] args) {
// 统计整个汉字范围里,能映射成 '.' 的有多少个?
int count = 0;
System.out.println("能映射成 '.' 的汉字:");
/*
* 遍历 Unicode 中的 CJK 统一汉字基本区块:
* '一'(十进制 19968)是第一个汉字"一",'鿿'(十进制 40959)是该区块的最后一个字符。
* 这是 CJK 统一汉字的基本区(20992 个字符)。
* 实际还有扩展 A 区(㐀-䶿)、扩展 B 区等,攻击者也可能用。
*/
for (char c = '一'; c <= '鿿'; c++) {
byte b = (byte) c;
if ((b & 0xFF) == 0x2E) { // 0x2E = '.'
System.out.printf("'%c' (U+%04X) ", c, (int) c);
count++;
if (count % 10 == 0) System.out.println(); // 每 10 个换行
}
}
System.out.println("\n\n总共有 " + count + " 个汉字能映射成 '.'");
// 再统计能映射成 '/' 的
int count2 = 0;
System.out.println("\n能映射成 '/' 的汉字:");
for (char c = '一'; c <= '鿿'; c++) {
byte b = (byte) c;
if ((b & 0xFF) == 0x2F) { // 0x2F = '/'
System.out.printf("'%c' (U+%04X) ", c, (int) c);
count2++;
if (count2 % 10 == 0) System.out.println(); // 每 10 个换行
}
}
System.out.println("\n\n总共有 " + count2 + " 个汉字能映射成 '/'");
}
}

这也说明,对 WAF 来说,要想通过黑名单的方式拦../,得把 82×82×82=551368 种组合全部加进黑名单......
回到前面所说的几种常见的触发窄化的写法:
(byte) ch
char ch = '阮';
byte badByte = (byte) ch;
System.out.println((char) (badByte & 0xFF)); // 打印'.'
P.S. 这里其实直接打印(char) badByte也是.,但是大家在处理字节时,几乎肌肉记忆一样非要加上& 0xFF,是防止badByte是负数。在byte要转成char时,JVM 底层会先把byte拓宽为int,高 24 位全部疯狂补1,窄化为char时,高 8 位被强行污染成了全1。所以badByte & 0xFF的唯一目的是确保了不管这个byte原本被当成正数还是负数,在向高位类型(int或char)转换时,都能强行把高位清零,从而原封不动地暴露出这个字节在底层的 8 位原始二进制形态。
具体转换过程如下:

ch & 0xFF
char ch = 'ļ';
int badInt = ch & 0xFF;
// 打印 (Char: '<', Unicode: U+003C, 十进制: 60)
System.out.printf("(Char: '%c', Unicode: U+%04X, 十进制: %d)%n", badInt, badInt, badInt);
具体转换过程如下:

OutputStream.write(int)
char ch = '阮';
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write(ch);
byte[] resultBytes = baos.toByteArray();
if (resultBytes.length > 0) {
byte badByte = resultBytes[0];
System.out.println((char) (badByte & 0xFF)); // 打印出 '.'
}
具体转换过程如下:

这里 Step 2 的方法内部窄化可以从 Java 官方文档对OutputStream.write(int b)的定义中看到:

DataOutputStream.writeBytes(String)
String s = "阮";
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
dos.writeBytes(s);
dos.flush();
byte[] resultBytes = baos.toByteArray();
if (resultBytes.length > 0) {
byte badByte = resultBytes[0];
System.out.println((char) (badByte & 0xFF)); // 依然打印出 '.'
}
具体转换过程如下:

这里 Step 1 的方法内部窄化可以从 Java 官方文档对DataOutputStream.writeBytes(String)的定义中看到:

DataOutputStream的核心作用,就是为了让你可以直接把 Java 的基础数据类型(如int、long、boolean、double)原封不动地写进字节流里,而不用自己手动去拆分二进制字节(如fos.write((num >>> 24) & 0xFF))
攻击面全景:Ghost Bits 能落到哪些 sink
Ghost Bits 放大了已有的攻击边界。按 sink 类型来看,受影响的场景大致可以分成五类:
- URL / 路径解码 -> 路径穿越、认证绕过......
Spring、Jetty 等框架的 URL 解码路径上存在char -> byte窄化,可用于绕过 WAF 实现目录穿越等。
- 文件名解析 -> 文件上传绕过。
Tomcat 的 RFC2231 文件名处理在解码时截断高位,可以把.jsp这类敏感后缀伪装成无害的 Unicode 字符,绕过 WAF 上传 Webshell。
- CRLF 类 -> SMTP 注入、HTTP 请求走私、CRLF 注入
邮件库(如 Angus Mail)、HTTP 客户端(Apache HttpClient ≤ 4.5.9)等组件中,隐写的 CRLF 序列会在窄化后还原成真实换行符。例如把 U+760D、U+760A 注入头部值,底层 char-to-byte writer 会输出真实的\r\n。
- 序列化与转义 -> 反序列化 RCE
Apache Commons BCEL 的 ClassLoader 解码、fastjson 的\u、\x 转义处理均存在 Ghost Bits,可绕过 WAF 触发反序列化远程代码执行
- 其它注入。
Jackson 的charToHex(ch & 255)截断可用于隐写 SQL 注入载荷;Lettuce(Redis 客户端)可导致 Redis 命令注入;XMLWriter 可导致 XML 标签注入......
因此,Ghost Bits 会让大量老洞的 WAF 防护重新失效。像 Spring4Shell(CVE-2022-22965)这种已有成熟防护规则的高危漏洞,用 Ghost Bits 变形后的 payload 都可能绕过现有规则,直接触发 RCE。
案例:Spring CVE-2025-41242
CVE-2025-41242 是一个由 Spring 框架与 Jetty 之间 URI 解码不一致导致的一个路径穿越漏洞,2025 年 8 月 14 日披露,由 1ue、b1u3r(即本议题的研究员)与 Webtide 的 Joakim Erdfelt 共同报告。
漏洞根源是 Spring 的StringUtils.uriDecode中的 Ghost Bits 缺陷:解码器遇到非%xy字符(即不需要被 URL 编码的普通字符)时,会直接调用我们前面提到的ByteArrayOutputStream.write(int):

而该方法只保留 16 位char的低 8 位。结果就是某些中文字符在解码过程中悄悄变成危险字符:
阮(U+962E) -> 0x2E = '.'
严(U+4E25) -> 0x25 = '%'
灵(U+7075) -> 0x75 = 'u'
丰(U+4E30) -> 0x30 = '0'
甲(U+7532) -> 0x32 = '2'
来(U+6765) -> 0x65 = 'e'
所以攻击者构造的字符串阮严灵丰丰甲来被静默地转换成.%u002e。
这个结果在 Spring 的isInvalidPath/isInvalidEncodedPath检查中既不含字面量../,标准的URLDecoder又不识别非标准的%uXXXX编码,所有安全检查全部放行。
但路径随后通过ServletContextResource交给底层 Jetty 的PathResource#resolve时,Jetty 在URIUtil.encodePathSafeEncoding中却主动把%u002e当作 Unicode 解码为.,使得/.%u002e/在文件系统层面变成/../,从而完成跨目录读取,最终读取/etc/passwd等任意文件。
官方修复方式是改用StringBuilder逐字符append,从根源上消除高位丢失。
下面用 Vulhub 的环境进行演示:

这里用的是 Yakit 发包,原因是:要触发这个漏洞,请求必须以阮严灵丰丰甲来的原始 UTF-8 字节直送服务端,不能预先做 URL 编码。不然就会走另一条不含截断逻辑的路径,漏洞无法触发:
ByteArrayOutputStream baos = new ByteArrayOutputStream(length);
boolean changed = false;
for (int i = 0; i < length; i++) {
int ch = source.charAt(i);
if (ch == '%') {
if (i + 2 < length) {
char hex1 = source.charAt(i + 1);
char hex2 = source.charAt(i + 2);
int u = Character.digit(hex1, 16);
int l = Character.digit(hex2, 16);
if (u == -1 || l == -1) {
throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
}
baos.write((char) ((u << 4) + l));
i += 2;
changed = true;
}
else {
throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
}
}
else {
baos.write(ch);
}
}
// 如果发生过解码(changed = true),就把 baos 里的字节转成字符串返回
// 如果没有发生过解码,直接返回原始字符串
// 注意:changed 只有遇到 % 才会变成 true,这意味着攻击者必须在路径中至少放一个正常的 URL 编码
return (changed ? StreamUtils.copyToString(baos, charset) : source);
}
这里还有一个细节:目标文件名中至少要有一个字符做 URL 编码(例如把passwd写成passw%64),否则 直接返回原始字符串。
这意味着浏览器、curl、BurpSuite 以及绝大多数代理工具都无法直接用来复现该漏洞,它们在发送请求前会对 URL 路径做规范化处理,把高位 Unicode 字符自动编码为 ASCII。如阮的 UTF-8 编码占用 3 个字节,物理值分别是0xE9、0x98、0xAE,浏览器随后会将这 3 个字节转成标准的 URL 编码格式%E9%98%AE发出:

从而破坏漏洞触发条件。要复现,必须使用允许逐字节构造并发送原始 HTTP 请求的工具,如 Yakit。(其实在 BurpSuite 的 Repeater 中,将 Payload Encoding 的 URL-encode these characters 选项取消勾选也可以发送原始中文字符)
P.S. 整个漏洞的请求链路如下:
客户端发请求
↓
Jetty收到请求(第一层,负责网络通信)
↓
Jetty把请求交给Spring(第二层,负责业务逻辑)
↓
Spring调用uriDecode处理URL
↓
uriDecode里 baos.write(ch) 触发截断
注意这里原payload里面有 %64,这样才能使 changed 为真,才能传入解码过后的字符
阮严灵丰丰甲来 >> .%u002e 每个汉字被截断后转换成了一个字符
原payload被解析成了:
/.%u002e/.%u002e/.%u002e/.%u002e/.%u002e/.%u002e/.%u002e/etc/passwd
↓
Spring安全检查,检测黑名单: (这也是不能直接写 /阮阮/阮阮/ 的原因)
../ ← 标准路径穿越
..\ ← Windows风格
%2e%2e ← ../的URL编码
.%u002e ← 没有检测到!
↓
路径交回给Jetty处理静态资源
↓
Jetty扩展支持了 %uXXXX 这种非标准写法
认出这是Unicode编码,把 %u002e 解码成 .
%u002e 中的 002e 等于十六进制 0x002E,对应的 Unicode 字符就是 U+002E,即英文句号 .
因此最终payload变成了:
/../../../../../../../etc/passwd
Payload 构造实操:从手工推演到工具化
前面有说到,构造 payload 的核心只有一步:给定一个目标字节,找出所有低 8 位与它相同的 Unicode 字符。 由于高 8 位有 256 种取值(去掉与原 ASCII 字符相同的那个,还剩 255 种),理论上每一个危险字节都有约 255 个 Unicode 候选。攻击面的宽度由此而来。
挑选候选字符时还要注意:
- 目标字符集要对得上。如果后端解码用的不是会触发窄化的路径,候选字符不一定能还原成预期字节。
- 中间环节是否会过滤 / 归一化。如果链路上存在一次 Unicode 归一化(NFC/NFKC)或非 ASCII 过滤,精心挑的候选字符可能被提前消解掉。
- 候选字符是否会被其它组件改写。多组件串联时,某个候选字符可能在到达目标 sink 之前就被另一段逻辑处理掉。
除了自己构造,社区已经有现成的知识库 / 工具把这件事自动化,如 yaklang/hack-skills 中的ghost-bits-cast-attack这个 Skill:

其系统整理了 Java char -> byte 窄化导致的 WAF 绕过,覆盖 SQLi、反序列化、文件上传、路径穿越、CRLF、请求走私等场景,涉及 Tomcat、Spring、Jetty、Jackson、Fastjson、BCEL、HttpClient、Angus Mail 等组件。
代码审计思路
Ghost Bits 类问题的难点在于危险点不是某个函数,而是某种错位,纯靠函数黑名单是抓不全的。也许可以按下面五步:
- 找模式
先用 grep 把前面的 4 种常见写法捞出来:
(byte) ch
ch & 0xFF
OutputStream.write(int)
DataOutputStream.writeBytes(String)
不过显而易见这样局限性很大:研究者在 GitHub 上搜索这类写法,命中超过 8100 条,其中绝大多数是误报。
- 找边界
对每个候选点确认三个位置:
- 解码点:输入在哪里被解码 / 转换(窄化发生处);
- 校验点:安全校验 / WAF 检测 / 路径规范化在哪里发生;
- 执行点(sink):解码后的数据最终落到哪个危险操作。如果校验点和执行点之间夹着一次窄化转换,且校验看到的是窄化前的形态,这就是一个真实的 Ghost Bits 风险点。
- 数据流追踪
沿着 source -> sink 把数据流走通,确认链路中确实存在char -> byte窄化,并且安全校验发生在窄化之前。
- 重点审计区域清单
Ghost Bits 高发于以下几类代码,审计时优先看:
- URL / 路径解码器(`uriDecode`、自研的 path 解析);
- 字符集 / 编码转换逻辑;
- 文件名、HTTP 头、MIME 参数的解析(如 RFC2231 处理);
- 自研的序列化器 / 反序列化器;
- hex、`\u`、`\x` 等转义编解码逻辑。- 可利用性判断
定位到风险点后,还要判断是不是真的能打:
- 输入的低 8 位是否可控
- 目标字符集下,是否存在低 8 位匹配的 Unicode 字符可用
- 窄化之后、sink 之前,是否还有一次归一化或过滤会把变形字符消解掉三个都是,才是一个可利用的洞。
防御与修复
黑名单这条路基本是死路,但 WAF 可以转换思路:
- 在请求解析层统一规范化
WAF 先模拟 Spring 的行为:对 URL 路径中的每个字符,如果它是非 ASCII(如汉字),就计算(byte)c & 0xFF,看低 8 位是否是危险字符。如果是,就拦截。这种方法不需要枚举汉字,只要判断低8位。
- 检测
%u这种非标准编码
直接拦截 URL 中出现%u(无论后面是什么),因为 RFC 3986 规定 URL 编码必须是%xx两位,四位的%uXXXX是非标准的,正常业务不应该出现。
- 在 WAF 层做路径规范化
将/%u002e/%u002e/先解码成/../../,再进行路径穿越检测。这样 WAF 在数学上赢回一局。
总结

