一、 Fastjson \x4_ 绕过技巧
熟悉 Fastjson 的人都接触过下面这种绕过 payload:
{"\x40type":"java.awt.Rectangle"}
它的核心解析机制是构造一个 new int[103] 数组,只在 0-9A-Fa-f 这些 ASCII 码对应的位置上存放对应的十六进制数值。

由于未占用的索引位置默认值都是 int 类型的 0,所以 \x40 的解析过程实际上等价于:
digits[4]*16 + digits[0] = 0x40
既然如此,第二个字符其实完全没必要规规矩矩地填 0——随便换一个非十六进制字符(比如 J)结果也一样:
digits[4]*16 + digits[(int)'J'] = 0x40
实际效果就像下图展示的那样。


二、 Fastjson \u0040 绕过方式
Fastjson 支持的另一种绕过 payload 长这样:
{"\u0040type":"java.awt.Rectangle"}
对应的解析步骤如下图所示。

继续向下跟进,能看到这里在尝试把字符转成合法的整数值。

Character.digit 顾名思义就是用来抓取十六进制字符的数值:
Character.digit('A', 16); // 10
Character.digit('f', 16); // 15
Character.digit('9', 16); // 9
Character.digit('G', 16); // -1(G不是16进制字符)
Character.digit('z', 16); // -1
但最终底层采用了一套非常“取巧”的算法。

因为传进来的参数是 char 类型,而 char 在 0-65535 范围内,存在大量字符刚好能“伪装”成合法的十六进制字符。

最终的绕过效果是这样。

三、 Jackson \u0040 绕过
Jackson 同样能处理 \u0040 形式的输入,但它的解析逻辑和 Fastjson 截然不同,属于非常“标准”的 Ghost Bits 场景。
{"\u006b\u0065\u0079":"\u0076\u0061\u006c\u0075\u0065"}
调用栈会走到 ReaderBasedJsonParser._decodeEscaped()。

继续跟进 CharTypes.charToHex(int) line: 272。

注意这里直接用 ch & 0xFF,这是典型的 Ghost Bits 写法:如果 ch 属于 char 范围(0-65535),高位会被直接砍掉,强行降级成 byte 范围。原理可以用 Python 的交互式运行结果来直观感受一下:

我们可以特意挑选中文范围的字符来构造 Ghost Bits,最终效果如下。

不过 Jackson 在实际利用时会受限于运行环境。ch 的值来自 _inputBuffer,本地测试走 ReaderBasedJsonParser 时,_inputBuffer 是 char[],完全可以利用。

可一旦打 Spring Boot,默认会使用 UTF8StreamJsonParser,此时 _inputBuffer 变成了 byte[],这个招数就失灵了。

不过,换个角度,也许你已经有别的思路了,不是吗?
四、 BCEL 利用 Ghost Bits
还记得 com.sun.org.apache.bcel.internal.util.ClassLoader 吗?它支持以 $$BCEL$$ 开头的字符串型 classloader。原理说白了就是把 gzip 压缩后的 class byte[] 用特定格式包裹起来,并在其中穿插大量 $ 符号。来看解析代码。

ByteArrayOutputStream.write 正是 Ghost Bits 的另一大常见来源——它在内部直接把 char 强转成 byte,高位就无声无息地“蒸发”了。

所以只要把 gzip 压缩后的 class byte[] 整体“翻译”成中文,就能得到下面这种效果。

五、 Tomcat 文件上传绕过
搞过 Tomcat 文件上传绕过的同学,对下面这种操作肯定不陌生:
POST /upload/img HTTP/1.1
Host: 127.0.0.1:9999
Content-Length: 185
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycCYhwj56XpogMIa0
------WebKitFormBoundarycCYhwj56XpogMIa0
Content-Disposition: form-data; name="file";filename*="UTF-8''1.jsp"
Content-Type: image/png
test
------WebKitFormBoundarycCYhwj56XpogMIa0--
跟进 org.apache.tomcat.util.http.fileupload.util.mime.RFC2231Utility.fromHex(String) line: 137。

这里同样使用了 ByteArrayOutputStream.write,更妙的是还支持 URL 编码(比如 1.%6asp)。其算法是 ch & 0x7F,相当于双重 Ghost Bits 叠加:
filename*="UTF-8''1.jsp"
filename*="UTF-8''1.%6asp"
filename*="UTF-8''1.%鸶繡sp"
filename*="UTF-8''1.汪癳絰"


同理,当 allowCasualMultipartParsing 开启时,可以利用这个思路隐藏参数名(值无法隐藏),在 CVE-2022-22965 中就能见到它的身影。

Spring Boot 是否也能沿用这个思路呢?答案是不行,这里有额外的校验逻辑。
org.springframework.http.ContentDisposition.decodeFilename(String, Charset) line: 475

六、 URLDecoder 绕过
JDK 自带的 URLDecoder.decode 同样存在任由 char 替代 hex 字符的问题,原理和 Fastjson 的 \u0040 绕过如出一辙。

最终效果。

七、 Jetty URLDecoder 绕过
在一些 Jetty 组件的历史绕过漏洞中,为什么可以用 %2> 代替 %2e 呢?
根源就藏在 org.eclipse.jetty.util.URIUtil.decodePath 方法里。跟进 TypeUtil.parseInt(String, int, int, int) line: 281。

char c 继续传入 TypeUtil.convertHexDigit(int) line: 373。

又是一副“取巧”算法的老面孔。能被用来顶替的字符如下所示。

在可见字符范围内,特征非常明显:
9=@
9=`
a=:
b=;
c=<
d==
e=>
f=?
八、 Jetty + Spring Boot 实战绕过
可以去看看 P 牛第一时间搭建的靶场,非常贴近真实环境。如果直接访问类似 /阮严灵丰丰甲来/ 的路径,会发生什么?
StringUtils.uriDecode(String, Charset) line: 804

它会对路径中 /a/b/c 的 a、b、c 依次进行解码,而 baos.write(ch) 就存在 Ghost Bits 问题。不过要生效,changed 标志位必须为 true,否则直接原样返回 source。理论上真正的 payload 应该是这样的:
/%2e严灵丰丰甲来/
它等同于:
/.%u002e/
如果单独使用下面这种形式,是不会触发 Ghost Bits 的。
/阮严灵丰丰甲来/
但注意后面还有一次针对整个 /a/b/c 的总解码。

因此 URL 编码只要有一处就够了,这也是原本 payload 结尾必须用 %64 的原因——当然,加密在别的位置同样可行。
/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64
解码完成后进入 PathResource.resolve(String) line: 286。

这里存在 normalizePath 检测,直接构造 ../ 是行不通的。这也是非要利用“严灵丰丰甲来”产生 %u002e 的原因。
addPath 执行时会走到 URIUtil.encodePathSafeEncoding(String) line: 1481,在这一步把 %u002e 处理成 %2e。


额外提一句:常规情况下 new File("./.%2E/windows/win.ini") 是无法正常工作的,但这里偏偏用了 URI,而它恰好能正确识别 %2E。

整个完整流转大致可归纳为:
/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64
/.%u002e/.%u002e/.%u002e/etc/passwd
/.%2e/.%2e/.%2e/etc/passwd
/../../../etc/passwd
那么,URIUtil.encodePathSafeEncoding 在处理 %u002e 时,自身有没有 Ghost Bits 风险呢?遗憾的是,前面有 TypeUtil.isHex 的检查,导致无法进行二次变形。

九、 HttpClient CRLF 注入
还记得 JSP 挑战 吗?HttpClient 的 CRLF 漏洞同样离不开 Ghost Bits 的“帮忙”。

原理直截了当。
ByteArrayBuffer.append(char[], int, int) line: 139

最典型的 char 强转 byte,高位直接丢失,为注入埋下了伏笔。
十、 更多延伸与验证
Ghost Bits 最容易出现在类似 \u0040 这类 Unicode 解码的地方。顺着这个思路,还能不能找到其他目标呢?
Nashorn? 看起来像是能搞:
payload1 = "\\u006aava.lang.Runtime.getRuntime().exec('calc')";
但是发现 jdk.nashorn.internal.parser.Lexer.convertDigit() 内部做了严格限制,堵死了这条路。

JSP 方面又如何?
<%u0052u0075u006e\u0074\u0069\u006d\u0065\u002e\u0067\u0065\u0074\u0052\u0075\u006e\u0074\u0069\u006d\u0065\u0028\u0029\u002e\u0065\u0078\u0065\u0063\u0028\u0022\u0063\u0061\u006c\u0063\u0022\u0029\u003b%>
org.eclipse.jdt.internal.compiler.parser.Scanner.getHexadecimalValue() 里面同样有校验,想粗暴绕过也不容易。

以上这些实战案例和源码级分析,在云栈社区的安全板块里也经常被反复讨论,毕竟不同的解析器在字符处理上的一点点偷懒,往往就是攻击者眼中最美的“后门”。
当然,安全性从来不是某一个框架的问题,而是整个 Java 生态里需要共同面对的话题。如果感兴趣,也可以去云栈社区看看更多相关讨论。