在 Java 安全研究里,反序列化、表达式注入、模板注入和类加载问题通常更容易得到关注。Black Hat Asia 2026 提出的 Ghost Bits 讨论的则是另一类问题:它不对应某一个具体 CVE,而是一种可能长期潜伏在 Java 处理链里的底层失配。
它讨论的核心现象很简单:
上层安全检查看到的字符串,和底层最终执行的字节,未必是同一份数据。
一旦这种失配出现在关键边界位置,后果就不再局限于单点绕过,而可能扩展为:
- WAF 绕过
- 文件上传绕过
- 路径穿越和任意文件读取
- 认证绕过
- SMTP 注入与官方邮件劫持
- 企业邮箱限制绕过
- HTTP Request Smuggling
- Header 注入、XSS、Redis / XML 协议污染
研究者把这类由类型转换引出的利用链命名为 Cast Attack。问题并不出在少见的危险函数上,反而常常藏在最普通的类型转换和编码处理里。
本文基于 Black Hat Asia 2026《Cast Attack: A New Threat Posed by Ghost Bits in Java》以及相关补充资料整理,重点讨论它在 Java 生态里的成因、利用路径和工程防御思路。
一、Ghost Bits 到底是什么
先看一个最普通的 Java 类型转换:
java
char ch = '陪';
byte b = (byte) ch;
System.out.println((int) b);
这里发生的事情是:
text
陪 = U+966A
十六进制 = 0x966A
(byte) 陪 = 0x6A
0x6A = ASCII 'j'
应用层看到的是 "陪",某些底层处理链最终执行的却可能是 "j"。
这就是 Ghost Bits 的核心含义:char -> byte 过程中,高 8 位被静默丢弃,只保留低 8 位;被丢掉的那部分高位,就是研究者所说的 "Ghost Bits"。
二、Java 为什么会出现这个问题
2.1 char 是 16 位,byte 只有 8 位
Java 的 char 是 16 位 Unicode 代码单元,而 byte 只有 8 位:
| 类型 | 位宽 | 说明 |
|---|---|---|
char |
16 bit | 保存 Unicode 字符 |
byte |
8 bit | 只能容纳一个字节 |
当代码写成 (byte) ch 时,本质是在做一次从 16 位到 8 位的截断。
例如下面这些字符的码位都明显超过了 0xFF:
| 字符 | Unicode | 十六进制 |
|---|---|---|
陪 |
U+966A |
0x966A |
阮 |
U+962E |
0x962E |
严 |
U+4E25 |
0x4E25 |
瘍 |
U+760D |
0x760D |
瘊 |
U+760A |
0x760A |
2.2 截断不是编码,而是"只保留低 8 位"
以 陪 为例:
text
陪 = 0x966A
高 8 位 = 0x96
低 8 位 = 0x6A
转换后:
text
(byte) 陪 = 0x6A
这里没有发生 UTF-8、UTF-16 之类的字符编码转换,只是单纯把高 8 位扔掉了。
2.3 风险不在"中文",而在语义分离
Ghost Bits 很容易让人误解成"中文字符有风险"。更准确的说法是:
- 上层按字符语义做校验
- 下层按字节语义继续执行
- 检查和执行落在两个不同的语义空间里
问题不在字符本身,而在 字符语义和字节语义分离。
换个更直观的比喻:人工核验看到的是完整证件信息,闸机却只拿到了编号的最后两位。输入还是同一张证件,但系统两端处理的内容已经不一致。
三、漏洞为什么能成立
Ghost Bits 的攻击链通常可以概括为:
text
攻击者输入 Unicode 字符
↓
WAF / 业务校验看到的是安全字符串
↓
检查通过
↓
底层 Java 代码发生 char -> byte 截断
↓
低 8 位折叠成危险 ASCII 字节
↓
真实漏洞被触发
一个非常直观的例子是文件上传:
text
1.陪sp
如果某一层校验只看到了 陪,它会判断"这不是 .jsp";但在底层保存时,如果发生强制 char -> byte 截断,陪 可能折叠成 j,最终文件名就会落成:
text
1.jsp
SMTP 注入同理。字符 瘍 和 瘊 的低 8 位分别是 0x0D、0x0A,也就是:
text
瘍瘊 -> \r\n
一旦这对字符进入协议边界相关字段,输入的作用就不再是普通文本,而是协议边界本身。
四、哪些 Java 写法风险最高
Ghost Bits 相关漏洞并不总是出现在显眼的危险函数里,很多时候恰恰来自这些"看起来很普通"的写法:
java
(byte) ch
ch & 0xff
ch & 255
0xff & ch
baos.write(ch)
DataOutputStream.writeBytes(String s)
OutputStream.write(int)
StringBufferInputStream.read()
String.getBytes(int, int, byte[], int)
RandomAccessFile.writeBytes()
URLDecoder.decode()
它们的共同点通常有两类:
- 把字符直接当字节处理。
- 只保留低 8 位,或者依赖默认编码继续处理。
如果这些写法恰好出现在文件名、URL、Header、SMTP 地址、Redis 指令、XML/HTML 输出等边界位置,风险会迅速扩大。
五、常见的 Ghost Bits 映射
攻击者不会随便选 Unicode 字符,而会专门挑那些低 8 位刚好等于目标 ASCII 的字符。
| Unicode 字符 | 低 8 位 | 折叠后字符 |
|---|---|---|
陪 (U+966A) |
0x6A |
j |
阮 (U+962E) |
0x2E |
. |
严 (U+4E25) |
0x25 |
% |
灵 (U+7075) |
0x75 |
u |
丰 (U+4E30) |
0x30 |
0 |
甲 (U+7532) |
0x32 |
2 |
来 (U+6765) |
0x65 |
e |
瘍 (U+760D) |
0x0D |
\r |
瘊 (U+760A) |
0x0A |
\n |
因此:
阮严灵丰丰甲来会折叠成.%u002e瘍瘊会折叠成\r\n
前者可以继续参与路径穿越,后者则直接变成协议注入的关键边界。
六、代表性攻击案例
Ghost Bits 不是停留在概念层面的研究问题。类似现象已经在多类生产级组件里出现过,例如:
- Openfire
- Spring Framework
- Jetty
- Tomcat
- Fastjson
- Jackson
- GeoServer
- Jira
- Confluence
- Apache HttpClient
- Angus Mail / Jakarta Mail
6.1 Tomcat 文件上传绕过
在上传场景里,如果攻击者提交:
text
filename*=UTF-8''1.陪sp
WAF 或业务层看到的可能只是 1.陪sp,因此不会把它识别为 .jsp。但 Tomcat 某些处理链会继续进入 RFC2231Utility.fromHex() 一类逻辑,其中关键代码类似:
java
out.write((byte) c);
这里不是正常的字符编码过程,而是直接做 char -> byte 截断。结果 陪 被折叠成 j,最终文件落地为:
text
1.jsp
这类问题的本质是:检查阶段看的是字符,保存阶段执行的是字节。
6.2 Fastjson / Jackson 的 JSON 绕过
Ghost Bits 不只影响文件名,也会影响 JSON 相关场景。
一类典型问题来自 WAF 与解析器对同一段输入理解不一致。例如:
- Fastjson 某些
\uXXXX解析路径会使用Character.digit() - 这个 API 不只识别 ASCII 数字,也会识别部分 Unicode 数字字符
- 于是攻击者可以把敏感字段伪装成"看起来不像
@type的输入"
更具体地说,Character.digit() 并不只识别 ASCII 0-9,还会识别部分 Unicode 数字字符。例如研究里就提到过:
text
๐
੦
꘠
这些字符在某些上下文里仍可能被当成十六进制或数字字符继续处理。于是攻击者可以构造表面上不像 @type 的输入,例如:
json
{
"\u꘠๐๔੦type": "..."
}
WAF 看到的并不是字面上的 @type,但解析器在后续解码链里可能仍恢复出敏感键名。
类似地,Jackson 某些路径如果使用了 ch & 255 一类低字节折叠逻辑,就可能让 WAF 看不到真实 SQL 关键字,而后端执行时恢复出真正的危险内容。比如 丰、耳、失 的低字节分别可以对应 0、3、1,从而把表面上看不出意义的字符串逐步还原成真正的数字片段,再参与后续 SQL 语义拼接。它和文件上传绕过的底层逻辑是一致的,都是上层按字符检查、下层按字节恢复。
这些案例说明:Ghost Bits 并不是只能制造"乱码",它同样能成为 JSON、反序列化和注入场景中的隐藏通道。
6.3 URL 解码、认证绕过与路径穿越
Ghost Bits 在 URL 和路径处理链里尤其麻烦,因为这类链路往往天然包含多次解码和多层解析。
研究中一个很典型的技巧是利用 Jetty TypeUtil.convertHexDigit() 的位运算行为,让:
text
%2> -> %2E -> .
它背后的关键细节是,Jetty 为了性能使用了类似下面的位运算:
java
int d = ((c & 0x1f) + ((c >> 6) * 0x19) - 0x10);
对字符 > 来说:
text
'>' = 0x3E
0x3E & 0x1F = 30
30 - 16 = 14
14 = 十六进制 E
于是 > 在这个计算路径里会被当成 E,也就让 %2> 最终等价于 %2E。
这样一来,原本不显眼的:
text
%2>%2>
就可能被后端继续恢复成:
text
..
这条思路被用于多个实际案例中:
- Openfire 认证绕过(
/setup/setup-s/%2>%2>/%2>%2>/log.jsp) - Spring 静态资源路径穿越与任意文件读取(研究中提到的 CVE-2025-41242)
Spring 这个案例很能说明问题。上层检查认为输入路径不包含 ../,但下层 Jetty 在后续解析中却能把构造字符恢复为真正的 ..。关键不在于某一层"完全失效",而在于 Spring 在检查字符串,Jetty 在执行字节和解码后的结果。
这个案例里,一个代表性 payload 是:
text
阮严灵丰丰甲来
它不是随机乱码,而是精确构造出来的低字节映射:
| 字符 | 低 8 位 | 折叠后 |
|---|---|---|
阮 |
0x2E |
. |
严 |
0x25 |
% |
灵 |
0x75 |
u |
丰 |
0x30 |
0 |
丰 |
0x30 |
0 |
甲 |
0x32 |
2 |
来 |
0x65 |
e |
所以它会先折叠成:
text
.%u002e
再进一步恢复成:
text
..
研究里提到的 Spring 利用链大致可以概括成两步:
- Spring 侧会经过
StringUtils.uriDecode()、ResourceHttpRequestHandler#getResource(),并在isInvalidPath(path)这类检查中判断当前字符串是否包含../或..\\。 - 请求继续进入 Jetty 侧的
PathResource#resolve()和 URL Decode 链路后,Ghost Bits 才开始把构造字符还原成真正的路径穿越片段。
换句话说,检查阶段看到的是 阮严灵丰丰甲来,执行阶段走到的却是 .%u002e -> ..。从工程视角看,这就是字符层面的 TOCTOU。
6.4 SMTP 注入、官方邮件劫持与域限制绕过
Ghost Bits 在邮件场景里通常比普通 Web 漏洞更难处理,因为它借用的是系统自身的可信身份。
以 Angus Mail / Jakarta Mail 场景为例,如果代码中存在类似:
java
bytes[i] = (byte) chars[i++];
攻击者就可以构造把 瘍瘊 折叠成 \r\n 的输入,从一个普通收件人地址开始,向底层 SMTP 会话里插入新的命令边界。这样带来的后果包括:
- SMTP 命令注入
- 借官方系统发出伪造内容邮件
- 让收件人看到"发件人真实、域名真实、链路真实"的钓鱼邮件
- 绕过企业邮箱注册限制,把外部地址伪装成内部地址处理
因此,Jira 邮件劫持、Confluence 域名限制绕过这类问题会显得格外棘手。攻击者并不是伪造一个假系统,而是在借用你自己的真实系统完成攻击。
这里最关键的协议事实是:SMTP 是纯文本协议,命令之间就是靠 \r\n 分隔的。例如:
text
RCPT TO:
DATA
QUIT
一旦应用把攻击者输入写进 SMTP 层,而底层又出现了:
java
bytes[i] = (byte) chars[i++];
这样的强制截断,那么:
text
瘍 -> 0x0D -> \r
瘊 -> 0x0A -> \n
这意味着攻击者有机会把一个"看起来像邮箱地址的字符串"改写成一整段新的 SMTP 会话。例如:
smtp
attacker@qq.com
RCPT TO:<attacker@qq.com>
DATA
Subject: PWNED
I LOVE YOU!
.
QUIT
Jira 这个案例的麻烦之处在于:这封邮件不是伪造出来的,而是系统自己发出去的,因此 SPF、DKIM、DMARC 这类传统邮件信任机制也会一起为它背书。
Confluence 场景的问题则在于:应用层可能只检查邮箱字符串是否以 @company.com 结尾,但 SMTP 层在遇到被恢复出的 \r\n 后,可能早就把真正收件人截断到了攻击者自己的地址上。于是上层看起来"是企业邮箱",底层实际投递的却不是同一个收件人。
七、为什么它会演变成协议级漏洞
Ghost Bits 的影响不只体现在"字符变了",更麻烦的是它可能恢复出协议边界字符。
最典型的一组就是:
text
\r\n
一旦攻击者能够通过 Unicode 输入恢复出 \r、\n、.、/、%、: 这些边界相关字节,攻击面就会迅速从业务逻辑问题升级为协议级问题。
7.1 Header Injection
假设代码写了:
java
response.setHeader("X-User", username);
开发者以为 username 只是普通文本;如果它在底层被折叠成:
text
admin\r\nSet-Cookie: admin=true
这时攻击者做的就不再是"输入一个用户名",而是在插入一条新的 HTTP Header。
7.2 HTTP Request Smuggling
在代理、WAF、网关和后端服务器之间,请求边界本来就可能存在理解差异;Ghost Bits 会把这种差异进一步放大。
典型表现是:
- 前置组件只看到了表面字符串
- 后端组件在继续解码、折叠或重建字节流后恢复出新的 Header 或新的请求边界
- 最终把额外请求偷偷拼接进后续流量中
Ghost Bits 与 Request Smuggling 的关系,本质上是同一个问题:前后层对"请求到底长什么样"没有形成统一语义。
研究里还提到过 Apache HttpClient 相关链路。问题不一定发生在最显眼的业务代码里,也可能藏在 Header 拼接、writeBytes()、OutputStream.write() 这类基础设施代码里。它们本身看起来不像漏洞入口,但一旦参与了边界重建,最终影响的就是整个请求的语义。
7.3 Redis / XML / XSS
这类问题并不局限于 HTTP。
- Redis 的 RESP 协议依赖
\r\n作为边界,注入后可以开启新的命令 - XML / HTML 如果先做了转义、后面又发生一次错误的
char -> byte截断,转义就可能被"重新打穿" - 最终攻击面可以落到 Redis 指令注入、XML 污染甚至 XSS
以 Redis 为例,它的 RESP 协议天然依赖换行边界:
text
*2\r\n
$3\r\n
GET\r\n
$3\r\n
key\r\n
如果攻击者能够额外恢复出一组 \r\n,那就不只是污染一个参数,而是有机会"新开一条命令"。
XML / HTML 场景则说明了另一个审计难点:你明明在前面看到了 escapeXml() 一类转义动作,但如果后面又发生一次错误截断,前面的转义可能就白做了。真正需要追问的,不只是"有没有 escape",而是 "escape 之后又发生了什么"。
换句话说,Ghost Bits 攻击的不是某一个业务规则,而是所有依赖"文本边界"的解析器。
八、为什么这类问题难发现
8.1 它通常藏在多层解析链里
一个真实请求往往会经过:
text
浏览器 -> CDN -> WAF -> Nginx -> Spring MVC -> Tomcat / Jetty -> 业务代码 -> Mail / Redis / DB
这条链路里每一层都可能做解码、规范化、转义、校验或重建。最麻烦的地方不在于"某一层完全错误",而在于 每一层理解的对象都不完全相同。
从工程上看,这类问题很像 TOCTOU:
- 检查时看到的是一种语义
- 使用时执行的是另一种语义
这也是很多 Ghost Bits 问题难排查的原因:不是某一行代码单独写错了,而是整条处理链各自都"看起来合理",最后组合出了漏洞。
8.2 老 API 带来了历史债务
很多高风险 API 诞生于默认 ASCII 或单字节思维还很强的时代,例如:
java
DataOutputStream.writeBytes()
RandomAccessFile.writeBytes()
StringBufferInputStream
String.getBytes(int, int, ...)
当年的"char 大致等于 byte"在今天的 Unicode、国际化和多协议环境里已经不成立了,但这些 API 仍然活在大量历史系统里。
8.3 WAF 很难完整模拟真实后端
WAF 看到的通常只是原始请求,而真正决定安全的是最终执行结果。中间如果还叠加了:
- URL Decode
- Unicode Decode
- Charset Convert
- Path Normalize
- Framework Re-Parse
- 协议边界重建
那么 WAF 做的往往不是精确复现,而是在猜后端最终会怎么理解这段输入。这决定了它天然会有盲区。
8.4 日志和监控经常记录不到最终态
很多系统日志只会记原始输入,例如:
- 看到的是
1.陪sp - 实际落地的是
1.jsp
或者:
- 看到的是
瘍瘊 - 实际执行的是
\r\n
这样一来,攻击已经发生,审计与蓝队复盘却未必能从日志中直接看出原因。
九、如何在工程上规避 Ghost Bits
9.1 先统一语义,再做安全判断
错误顺序通常是:
text
先校验 -> 再解码 -> 再规范化 -> 再执行
正确顺序应该是:
text
Decode -> Canonicalize -> Normalize -> Validate -> Execute
先把输入还原成最终会被执行的那份语义,再决定它是否安全;否则,检查和执行很容易错位。
9.2 禁止隐式 char -> byte 截断
对安全边界最直接的工程要求就是:不要再让代码偷偷发生低 8 位折叠。
高危写法:
java
out.write((byte) c);
更安全的做法是显式指定编码,把整个字符串按统一字符集编码成字节:
java
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
out.write(bytes);
这里的关键差别在于:UTF-8 是编码,(byte) ch 是截断,两者不是一回事。
9.3 禁用历史危险 API
建议在代码规范或静态扫描规则里直接限制以下 API:
java
DataOutputStream.writeBytes()
RandomAccessFile.writeBytes()
StringBufferInputStream
String.getBytes(int, int, ...)
OutputStream.write(int)
URLDecoder.decode(String) // 依赖默认编码的旧用法
String(byte[]) // 默认 Charset
如果必须做字符与字节转换,就显式指定 StandardCharsets.UTF_8 之类的固定编码,不要依赖默认值。
9.4 对协议边界字段做严格白名单
不是所有字段都必须接受全量 Unicode。对于协议边界相关字段,更现实的做法通常是明确限制字符集,例如:
- 文件名
- Header 名和值
- Email 本地部分
- URL Path 片段
- Redis Key
- SMTP Address
- XML Tag
- Multipart Boundary
这类字段最怕"尽量兼容"的容错解析,因为容错本身经常就是问题的起点。
9.5 安全设备和审计要尽量贴近真实解析链
如果安全产品只做关键字匹配,它看到的永远只是表层输入。更可靠的做法是:
- 尽量复现后端的真实解码顺序
- 在规范化之后再做检测
- 检查框架真正看到的 Header、Path 和协议字节流
同样,代码审计也不能只盯着 Runtime.exec() 这种显眼 Sink,像 out.write((byte) c)、writeBytes() 这类看起来普通的写法,也应该进入高风险视野。
9.6 日志要记录"最终态"而不只是"原始态"
如果只记录原始输入,很多 Ghost Bits 攻击在复盘时会变成"现象存在,但链路看不懂"。
更有价值的记录方式包括:
- 原始输入
- Canonical Form
- Decode 后结果
- Normalize 后结果
- 最终发往协议层或落盘的字节内容
例如日志不能只写 1.陪sp,最好还能看到它在最终链路里是否已经变成 1.jsp;也不能只记录 瘍瘊 这样的原始字符,而应该知道协议层最终执行的是不是 \r\n。
这部分可以收敛成一句工程原则:不要在尚未完成最终解析的数据上做安全判断。
十、总结
Ghost Bits 值得关注,不是因为它提供了某个新奇技巧,而是因为它把一类更普遍的问题摆到了台面上:当检查语义和执行语义不一致时,再严密的规则也可能失效。
从文件上传、路径穿越、认证绕过,到 SMTP 注入、Request Smuggling、Redis 协议污染,这些看起来完全不同的漏洞,背后其实可能是同一种底层失配:
- 上层检查的是字符
- 下层执行的是字节
- 多层组件对同一份输入的理解并不一致
Cast Attack 值得重视,也正是因为这一点。它不只对应某一个具体漏洞点,而是会在类似条件反复出现时不断生成新变种:隐式 char -> byte 截断、重复解码、协议边界重建,再加上检查与执行分属不同层,组合起来就足以形成新的攻击面。
虽然这次研究聚焦 Java,但它反映的并不是 "Java 特有漏洞",而是编码边界、语义边界和协议边界失配共同导致的问题。Java 只是因为历史 API、框架生态和容器链路更复杂,更容易把这类问题系统性地放大。
如果把全文压缩成一句话,可以这样概括:安全判断应当建立在最终会被执行的那份数据上,而不是某一层暂时看到的字符串。