thumbnail
Java“幽灵比特位”(Ghost Bits):从字符截断到 WAF 绕过与路径穿越

Java“幽灵比特位”(Ghost Bits):从字符截断到 WAF 绕过与路径穿越

2026 年 4 月,安全研究员 Zhihui Chen(1ue)、Xinyu Bai(浅蓝 / B1u3r)与 Zongzheng Zheng 在 Black Hat Asia 2026 发表《Cast Attack: A New Threat Posed by Ghost Bits in Java》,公开讨论 Java 生态中一种长期被忽略的数据转换风险。

问题本身并不神秘:Java 的 char 是 16 位,byte 只有 8 位。某些代码把 char 当成字节写出时,高 8 位会被静默丢弃。于是,WAF 和业务代码看到的 Unicode 字符,可能在后端变成点号、斜杠、百分号、回车或换行。

这类被丢弃的高位被研究者称为 Ghost Bits,利用转换前后语义差异绕过安全边界的攻击方式则称为 Cast Attack。

先把两层关系讲清楚,避免两个常见误解:

  • Ghost Bits 是一类通用的转换风险,存在于任何执行 char → byte 收窄转换的 Java 代码里。它本身不是某个具体 CVE。
  • CVE-2025-41242 是 Ghost Bits 真实落地的一个案例,但它的完整攻击链不是“中文变成 ../”这么简单,而是 Spring 的 Ghost Bits 截断 与 Jetty 12 对非标准 %uXXXX 编码的解码 两者叠加才成立的“组件交互漏洞”。

它不是一个“影响所有 Java 程序的通杀 CVE”。要形成真实漏洞,必须同时存在外部可控输入、有损转换、转换前校验,以及把转换结果当成路径或协议数据解释的下游组件。本文先用具体 payload 解释原理,再使用 Vulhub 中的 CVE-2025-41242 环境完成一次可复现的路径穿越实验。

以下复现仅适用于本机 Vulhub 或明确授权的隔离环境。不要把 payload 发送到互联网上的未知目标。

一、Ghost Bits 的底层原理

一、char 转 byte 不是字符编码

Java 的 char 保存一个 16 位 UTF-16 代码单元,取值范围为 U+0000 至 U+FFFF;byte 则是 8 位有符号整数。

执行收窄转换时,Java 不检查字符能否被一个字节完整表示,也不会抛出异常,而是只保留最低 8 位:

1
截断结果 = UTF-16 代码单元 & 0xFF

例如,汉字“陪”是 U+966A:

1
2
3
4
U+966A = 0x96 0x6A
└─ 只保留低 8 位

(byte) '陪' = 0x6A = ASCII 字符 j

对应代码如下:

1
2
3
4
5
char input = '陪';
byte output = (byte) input;

System.out.printf("输入:%c U+%04X%n", input, (int) input);
System.out.printf("输出:0x%02X %c%n", output & 0xFF, output & 0xFF);

输出为:

1
2
输入:陪 U+966A
输出:0x6A j

标准 UTF-8 编码不会发生这种折叠:

1
byte[] bytes = String.valueOf(input).getBytes(StandardCharsets.UTF_8);

“陪”的 UTF-8 字节是 E9 99 AA,不是 6A。因此,(byte) ch 或 write(ch) 不能用来代替字符编码。

二、哪些 API 会丢弃高位

代码审计时应重点排查以下模式:

1
2
3
4
5
6
byte value = (byte) ch;
int value = ch & 0xFF;
outputStream.write(ch);
byteArrayOutputStream.write(ch);
dataOutputStream.writeBytes(text);
randomAccessFile.writeBytes(text);

其中最容易误判的是 OutputStream.write(int)。它虽然接收 int,但 API 契约只写入最低 8 位。DataOutputStream.writeBytes(String) 则逐字符执行相同的低位写入。

还应关注:

  • 已废弃的 String.getBytes(int, int, byte[], int);
  • 已废弃的 StringBufferInputStream;
  • 自定义 charToByte、toAscii、writeRaw 方法;
  • 遇到非法字符仍继续运行的 URL、Hex 或 Base64 解码器。

这些 API 不是一出现就等于漏洞。例如,在明确的二进制格式中写入低 8 位可能完全合理。真正需要追踪的是数据来源、校验位置和最终去向。

三、攻击链为什么能够绕过检查

典型数据流如下:

1
2
3
4
5
6
7
8
9
攻击者提交 Unicode 字符
↓
WAF / 黑名单按原始字符串检查
↓
Java API 丢弃每个字符的高 8 位
↓
低 8 位重新组成危险 ASCII 字节
↓
文件系统、HTTP、SMTP 或其他组件再次解析

漏洞的核心是“先检查,后变形”。检查阶段和执行阶段处理的不是同一份数据,安全判断自然可能失效。

二、从字符到攻击 payload

一、常见危险字节映射

下表使用 U+01xx 字符演示低位折叠。原始字符本身没有路径或协议语义,但截断后会变成 ASCII 控制符号。

输入字符 Unicode 低 8 位 截断结果 可能影响
Į U+012E 0x2E . 扩展名、路径穿越
į U+012F 0x2F / Unix 路径分隔符
Ŝ U+015C 0x5C \ Windows 路径分隔符
ĥ U+0125 0x25 % URL 编码起始符
ļ U+013C 0x3C < HTML/XML 标签
ľ U+013E 0x3E > HTML/XML 标签
Ľ U+013D 0x3D = 参数与表达式
č U+010D 0x0D CR 协议换行
Ċ U+010A 0x0A LF 协议换行

对任意目标字节 x,攻击者都可以寻找满足下面条件的 UTF-16 代码单元:

1
codeUnit & 0xFF == x

所以,把几个已经公开的 Unicode 字符加入 WAF 黑名单并不能解决问题——可生成的字符远不止公开的那几个。

手工逐字符计算低位折叠比较繁琐,实际构造 payload 通常借助现成工具:Tscan 轻武器库内置了 Ghost Bits 生成功能,输入目标 ASCII 字符串即可输出低 8 位等价的 Unicode payload。

image-20260630122131668

二、路径穿越 payload

输入:

1
ĮĮįetcįpasswd

截断过程:

1
2
Į  Į  į  e  t  c  į  p  a  s  s  w  d
. . / e t c / p a s s w d

实际字节:

1
2E 2E 2F 65 74 63 2F 70 61 73 73 77 64

最终结果:

1
../etc/passwd

原始字符串不包含字面量 ../,但截断后的结果包含。如果程序只在转换前调用 contains("../"),再把低位字节交给路径解析器,检查便失去意义。

Windows 形式同理:

1
2
输入:ĮĮŜWindowsŜwin.ini
输出:..\Windows\win.ini

三、文件上传 payload

原始文件名:

1
avatarĮjsp

下面的黑名单检查会放行它:

1
2
3
if (filename.endsWith(".jsp")) {
throw new SecurityException("禁止 JSP");
}

但如果后续组件错误地逐字符写入低 8 位,最终文件名会变成:

1
avatar.jsp

这条链必须存在真实的截断点。Java NIO 的 Path 不会无缘无故把 Į 变成点号。上传系统仍应由服务端生成随机文件名,并把文件存放在不可执行目录中,而不是依赖扩展名黑名单。

四、CRLF 注入 payload

原始值:

1
normalčĊX-Lab: injected

原始字符串不包含 Java 的 \r 或 \n,但 writeBytes() 产生的字节为:

1
6E 6F 72 6D 61 6C 0D 0A 58 2D 4C 61 62 3A 20 69 6E 6A 65 63 74 65 64

协议解析器最终看到:

1
normal\r\nX-Lab: injected

在 HTTP、SMTP 或自定义文本协议中,0D 0A 可能结束当前字段并开启新字段。是否能进一步造成响应拆分、邮件注入或请求走私,取决于具体协议库及调用方式。

五、ASCII 特征规则绕过

字母、数字和空格也能被隐藏:

1
2
输入:ıĠŏŒĠıĽı
输出:1 OR 1=1

关键字符的低位分别为:

1
2
3
4
5
ı U+0131 → 0x31 → 1
Ġ U+0120 → 0x20 → 空格
ŏ U+014F → 0x4F → O
Œ U+0152 → 0x52 → R
Ľ U+013D → 0x3D → =

这只能说明 ASCII 黑名单可能看错数据,并不意味着应用必然存在 SQL 注入。后端使用参数化查询时,转换后的内容仍然只是参数值,不会自动变成 SQL 语法。

三、实战复现:CVE-2025-41242

一、这不是“中文变成 ../”,而是三段解码链

CVE-2025-41242 是 Spring Framework MVC 静态资源处理中的路径穿越问题。Spring 官方对它的定性很克制——部署在“不合规的 Servlet 容器(non-compliant Servlet container)”上时才可能受影响;并明确说明 Apache Tomcat 和 Eclipse Jetty 在默认安全功能未关闭时不受影响。

这就引出一个看似矛盾的问题:既然官方说 Jetty 默认不受影响,为什么 Vulhub 偏偏用 Jetty 把它复现出来了?答案藏在 Jetty 的版本 和 非标准 %uXXXX 编码 里。准确地说,这条链只在 Spring Framework + Jetty 12 的组合上跑通。

漏洞链的核心 payload 是七个汉字:

1
阮严灵丰丰甲来

逐字符保留低 8 位后得到:

字符 Unicode 低 8 位 ASCII
阮 U+962E 0x2E .
严 U+4E25 0x25 %
灵 U+7075 0x75 u
丰 U+4E30 0x30 0
丰 U+4E30 0x30 0
甲 U+7532 0x32 2
来 U+6765 0x65 e

七个字符折叠后的 ASCII 不是直接的 ..,而是:

1
.%u002e

真正完成目录跳转的,是这串 .%u002e 之后连续经过的三次解码:

1
2
3
4
5
6
7
原始路径片段:阮严灵丰丰甲来
↓ ① Spring StringUtils.uriDecode 中的低位截断(Ghost Bits)
中间结果:.%u002e
↓ ② Jetty 12 encodePathSafeEncoding 主动解码 %uXXXX
中间结果:.%2e
↓ ③ java.nio.file.Paths.get(URI) 的标准百分号解码
最终结果:..(与前导字面 . 配对,形成 ..)

三段分别落在不同组件里,每一段单独看都在自己的设计意图之内:

  • 第一段 · Spring 的 Ghost Bits。 org.springframework.util.StringUtils.uriDecode 在逐字符收集解码结果时,非 % 分支调用的是 ByteArrayOutputStream.write(int ch),而 OutputStream.write(int) 的契约是“只写低 8 位”。于是任何码点大于 0xFF 的字符进入这个分支,高位就被丢弃,七个汉字折叠成 .%u002e。
  • 第二段 · Jetty 12 的 %uXXXX 解码。 %uXXXX 是 IIS 和早期 JavaScript escape() 遗留的非标准编码,没有任何 RFC 收录过它。Jetty 自 2012 年起在入口解析时宽松地接受它;Jetty 12 在一次内部重构中,把用于“资源别名比较”的归一化函数 encodePathSafeEncoding 接到了资源路径构造路径上,该函数会主动把 %u002e 解码为码点 0x2E,再重新编码成标准形式 %2e。
  • 第三段 · 标准库解码。 最终构造出的 file:///.../.%2e/... 经 Paths.get(URI) 做 RFC 标准解码,%2e 变回 .,与前导的字面 . 配成 ..,文件系统层面就跳出了静态资源目录。

Spring 的前置检查既没看到字面量 ../,标准 URLDecoder 也不处理非标准 %uXXXX;Jetty 12 却会把 %u002e 再解释一遍。Ghost Bits 在这里负责制造解析差异,真正完成目录跳转的是后续组件对同一串字符的二次解释。

二、为什么必须是 %uXXXX,而不是更短的写法

这是理解整条链最关键的一点,也是大多数人复现失败的原因。

Ghost Bits 既然能把汉字投射成任意 ASCII,那为什么不直接用更短的形态?下表列出三种看起来都能得到 .. 的写法:

策略 汉字 payload 长度 Spring uriDecode 输出
A(CVE 官方) 阮严灵丰丰甲来 7 字 .%u002e
B 严甲来严甲来 6 字 %2e%2e
D 阮阮 2 字 ..(字面量)

在 Spring Boot 3.2.4 + Jetty 12.0.7 上实测(数据引自 Vulhub Discussion #776),只有策略 A 成功(HTTP 200),B 和 D 都返回 404。原因是 Spring 在静态资源路径上布置了两道防御,都在拦截“解码后出现 ..”的形态:

  • 第一道(ResourceHttpRequestHandler.isInvalidEncodedPath)检查的是原始请求路径。汉字不是百分号编码,URLDecoder 原样保留,看不到 ..,三种策略在这里都能通过。
  • 第二道(PathResourceResolver.isInvalidEncodedPath)检查的是经过 Ghost Bits 之后的资源路径字符串,同样用 URLDecoder.decode 解码后再找 ../ 或 ..\。区别就在这里:
    • 策略 D 的字面 .. 更早被 Jetty 自身的 isNotNormalWithinSelf 拦下;
    • 策略 B 的 %2e%2e 是合法百分号编码,URLDecoder 干净地解成 ..,被第二道检查拦下;
    • 策略 A 的 %u002e 里 u 不是十六进制数字,URLDecoder.decode 直接抛 IllegalArgumentException,而 Spring 的 catch 块把这个异常静默吞掉,判定“路径合法”予以放行。

换句话说,%uXXXX 恰好落在了一个唯一的夹缝里:Spring 的标准库解码器不认识它(抛异常被吞),Jetty 12 的归一化逻辑却认识它(主动解码)。两个解码器对同一串字符的语义不一致,正是这条链能走通的唯一原因。这不是风格选择,而是结构上唯一的钥匙。

三、为什么 Jetty 11 复现不了

这也解释了官方那句“Jetty 默认不受影响”和 Vulhub 用 Jetty 复现成功之间的张力——区别在于 Jetty 的大版本。

Spring 这一侧的 Ghost Bits bug 在所有受影响的 Spring 版本里都存在;变量在 Jetty:

  • Jetty 11 的 URIUtil.addPath 调用的是 encodePath,这是一个纯编码器——遇到 % 一律转义成 %25,根本没有 %uXXXX 分支。Spring 输出的 .%u002e 会被它原样转义成 .%25u002e,Paths.get(URI) 解码后 %25 变回 %,整串成了一个名为 %u002e 的字面文件名,文件不存在,返回 404。
  • Jetty 12(12.0.0+)把 encodePathSafeEncoding 接进了路径构造,它是个归一化器,会主动识别并解码 %uXXXX,链条才在第二段被点燃。

所以版本组合上:

Spring Boot Spring Framework Jetty 是否可复现
3.1.x 6.0.x Jetty 11 否(Jetty 11 不解码 %uXXXX)
3.2.x – 3.3.x 6.1.x Jetty 12 是(需 Spring < 6.1.22)
3.4.x 6.2.x Jetty 12 是(需 Spring < 6.2.10)
任意 任意 Tomcat 否(Tomcat 不识别 %uXXXX)

四、搭建 Vulhub 隔离环境

Vulhub 提供的实验链就是 Spring Framework + Jetty 12 组合,刚好满足上述条件。环境要求:

  • Docker 与 Docker Compose;
  • 仅监听本机或隔离测试网段。

获取 Vulhub 后进入对应目录:

1
2
3
git clone --depth 1 https://github.com/vulhub/vulhub.git
cd vulhub/spring/CVE-2025-41242
docker compose up -d

检查容器状态:

1
2
docker compose ps
docker compose logs --tail=50

环境启动后访问:

1
http://127.0.0.1:8080/

image-20260630113518943

Vulhub 目录也自带 poc.py,会自动计算 docbase 深度。为看清请求行里的原始字节,下面改用 Yakit 手动发送原始请求来复现。

实验完成后销毁环境:

1
docker compose down -v

五、使用 Yakit 发送原始请求

也可以在 Yakit HTTP Fuzzer 中向本机靶场发送下面的原始请求(路径段数按 docbase 实际深度调整,下面以 7 段为例):

1
2
3
GET /阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64 HTTP/1.1
Host: 127.0.0.1:8080
Connection: close

这里有两个容易导致复现失败的细节,都和前面讲的机制直接相关。

第一,阮严灵丰丰甲来 必须以原始 UTF-8 字节进入请求行。浏览器、curl、Burp Suite 及部分代理会“礼貌地”把高位字节百分号编码成 %E9%98%AE...。一旦被编码,进入 Spring uriDecode 时就会走 if (ch == '%') 分支——那是标准百分号解码,会把 %E9%98%AE 还原回汉字 阮,根本不经过 Ghost Bits 的 else 分支,高位不会丢失,链条第一段就断了。所以必须用能原样发送请求行的工具(Yakit Raw 模式或 Python socket)触发。

第二,目标文件名中至少需要一个百分号编码,例如把 passwd 最后的 d 写成 %64。否则 Spring 会走一条不触发 uriDecode 的快速路径,Ghost Bits 同样不会发生。

这也是为什么“把 payload 粘贴到浏览器地址栏”通常无法复现,而能够原样发送请求行的工具可以触发。

六、实测结果

在本机 Vulhub 靶场(Spring Framework + Jetty 12)上原样发送上一节的请求,服务端返回 200,并回显了目标文件内容:

image-20260630114130073

七、影响版本与适用边界

Spring 官方公告列出的受影响分支如下(注意“可用性”一列,它决定了你能不能拿到补丁):

Spring Framework 分支 受影响版本 官方修复版本 可用性
6.2.x 6.2.0—6.2.9 6.2.10 开源(OSS)
6.1.x 6.1.0—6.1.21 6.1.22 仅商业版
6.0.x 6.0.0—6.0.29 无 已停止支持
5.3.x 5.3.0—5.3.43 5.3.44 仅商业版

也就是说,开源用户实际只有 6.2.10 一条升级路径;停留在 6.1.x 或 5.3.x 的开源版本拿不到官方补丁,需要靠换容器或加反向代理过滤来止损。

即便 Spring 版本命中,整条链还要同时满足:

  1. Servlet 容器是 Jetty 12(12.0.0+),而非 Jetty 11 或 Tomcat——这是链条第二段的必要条件;
  2. 应用以 WAR 或嵌入式 Servlet 容器方式部署,且容器没有拒绝可疑序列;
  3. 应用通过 Spring Resource Handling 对外提供文件系统后端的静态资源。注意:以 fat JAR(java -jar app.jar,静态资源打包在 jar 内)运行时,资源解析走不到 Jetty 的 PathResource.resolve,链条会断;mvn spring-boot:run 开发模式或解压部署才会触发;
  4. 目标文件名中至少包含一个百分号编码(如 passw%64);
  5. 请求以原始 UTF-8 字节送达(见上一节)。

官方公告说 Tomcat 和 Jetty 在默认安全功能未关闭时不受影响,与此处“Vulhub 用 Jetty 12 复现成功”并不矛盾:Jetty 默认配置下的某些 URI 合规检查会拦住可疑序列,而 Vulhub 靶场是为了稳定呈现漏洞链准备的特定组合,不能用它反推“所有默认 Jetty 部署都能被同一请求利用”。

同样,BCEL、Jackson、Fastjson、Tomcat、Jetty、Undertow、HttpClient、Lettuce 等名称出现在研究案例中,不代表它们的所有版本都存在同一个 Ghost Bits 漏洞。必须具体到版本、转换函数和完整数据流,才能下结论。

四、代码审计与修复

一、先找截断点,再追踪数据流

可以先使用 rg 搜索最明确的危险 API:

1
2
3
rg -n "writeBytes\(|StringBufferInputStream|RandomAccessFile" src
rg -n "&\s*(0[xX][fF]{2}|255)" src
rg -n "OutputStream|ByteArrayOutputStream" src

显式 (byte) 强转形式较多,建议使用 IDE 结构化搜索、Semgrep 或 CodeQL。对每个命中点继续回答三个问题:

  1. 输入是否来自 HTTP、文件名、压缩包、邮件、消息队列或其他不可信来源?
  2. 安全检查是在截断前还是截断后进行?
  3. 截断结果是否进入路径、协议、查询、模板或权限判断?

风险排序可以简化为:

1
2
外部可控 + 截断前校验 + 截断后进入路径/协议 = 高风险
内部数据 + 明确二进制格式 + 不改变安全语义 = 低风险

二、禁止用截断代替编码

错误代码:

1
2
3
for (char ch : text.toCharArray()) {
output.write(ch);
}

正确代码:

1
output.write(text.getBytes(StandardCharsets.UTF_8));

安全敏感场景可以要求编码器遇到异常输入时直接失败:

1
2
3
CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);

CVE-2025-41242 的官方修复正是这一思路的直接体现:Spring 在 StringUtils.uriDecode 中把 baos.write(ch)(丢高位)改为 output.append(ch)(保留完整 16 位 char),汉字进来还是汉字,Ghost Bits 不再发生。修复随 6.2.10、6.1.22、6.0.30、5.3.44 发布。

三、ASCII 字段使用白名单

协议命令、HTTP 头名称、文件扩展名和内部标识符如果只需要 ASCII,应直接拒绝非 ASCII 和控制字符:

1
2
3
4
5
6
7
8
static void requirePrintableAscii(String value) {
boolean valid = value.codePoints()
.allMatch(c -> c >= 0x20 && c <= 0x7E);

if (!valid) {
throw new IllegalArgumentException("字段包含非 ASCII 或控制字符");
}
}

四、校验最终执行表示

推荐处理顺序:

1
2
3
4
5
6
按协议解码一次
→ 必要时执行 Unicode 规范化
→ 执行业务白名单
→ 使用明确字符集编码
→ 校验最终路径或协议结构
→ 执行敏感操作

路径场景必须检查规范化后的最终路径:

1
2
3
4
5
6
Path root = uploadRoot.toRealPath();
Path target = root.resolve(serverGeneratedName).normalize();

if (!target.startsWith(root)) {
throw new SecurityException("目标路径越界");
}

五、针对 CVE-2025-41242 的具体处置

除了升级 Spring,还有几条针对这条特定链的止损手段,按代价从低到高:

  1. 升级 Spring Framework 到含修复的版本(开源用户升到 6.2.10;6.1.x / 5.3.x 开源用户见下条)。这是根治。
  2. 把 Servlet 容器从 Jetty 换成 Tomcat。Tomcat 不识别 %uXXXX,链条第二段直接断开——适合短期无法升级 Spring 的场景。
  3. 生产环境以 fat JAR 部署,让 classpath:/static/ 留在 jar 内而非真实文件系统,资源解析走不到 Jetty 的 PathResource.resolve。
  4. 在反向代理层(Nginx / Apache)拒绝路径中的裸高位字节,即不是 %XX 形式的非 ASCII 字节。这破坏了“必须以原始 UTF-8 字节送达”这一前提。

数据库使用参数化查询;HTTP、SMTP、Redis 与 XML 使用结构化 API,不要手工拼接协议文本。

Ghost Bits 的单次转换很简单,难点在于它通常藏在多层组件之间。排查时不要停留在“输入里有没有危险字符”,而要记录入口代码点、中间字符串、最终十六进制字节和下游解析结果。只有安全检查面对的数据与执行端真正处理的数据完全一致,这条攻击链才会失效。

上一篇
下一篇