0x01 前言

在常规的Web安全攻防中,漏洞利用的各种编码绕过早就被常见WAF盘得包浆,最近也是看了浅蓝和1ue师傅在BHASIA的演讲,这里跟着原PPT对演讲中部分案例的原理进行分析

0x02 前置基础

首先从Java的基本数据类型开始理解,在Java中,为了完美支持Unicode,char类型被设计为16位(2字节)。而我们在进行网络传输或底层流处理时,面对的往往是byte,它是8位(1字节)的,当一个16位的char被强行塞进8位的空间时,高8位的数据会被抛弃,只保留低8位

PPT中给出的经典例子是burpsuite中处理编码时的一个问题,如图构造了一个包含中文的请求,结果发包时发现发出去的数据变成了乱码,这里以最新版(v2026.4)为例:

正好是舍弃高8位后的结果,根据师傅们的分析,罪魁祸首是DataOutputStream#writeBytes(String s),位置在java.io.DataOutputStream

从官方注释可以知道,该方法将字符串中的每个字符写入输出流,同时丢弃其高8位,从而导致非ASCII字符的数据损坏

而Cast Attack则是Ghost Bits引发的新型安全攻击技术

0x03 影响范围

在Java中有4种常见的写法都存在这个问题:

  • (byte) ch
  • ch & 0xFF
  • baos.write(ch)
  • DataOutputStream.writeBytes()

从影响范围来看,github上有潜在危害的仓库高达8.1k

0x04 案例

下面挑几个原PPT中的案例分析

BCEL Ghost Bits

原PPT中给出了一个BCEL的例子,漏洞点在ByteArrayOutputStream#write(ch),跟进jdk源码分析

ByteArrayOutputStream.write(ch)的内部正好就是前面提到的第一个写法,该写法会将所有字符的高 8 位抹除

我们知道BCEL字符串本质上就是gzip压缩的字节码,那么按照Ghost Bits的逻辑我们就需要将bcel字符串转为对应的unicode字符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.zip.GZIPOutputStream;

public class GhostBCELGenerator {

private static final Map<Integer, List<Character>> SUPER_DICT = new HashMap<>();
private static final Random RAND = new Random();

static {
for (int i = 0; i < 256; i++) {
SUPER_DICT.put(i, new ArrayList<>());
}

// 半角片假名、全角符号
for (char c = '\uFF00'; c <= '\uFFEF'; c++) {
SUPER_DICT.get(c & 0xFF).add(c);
}
// 汉字
for (char c = '\u4E00'; c <= '\u9FA5'; c++) {
SUPER_DICT.get(c & 0xFF).add(c);
}
}

public static String obfuscator(String classPath) throws Exception {
byte[] classBytes = Files.readAllBytes(Paths.get(classPath));

// 原生 GZIP 压缩
ByteArrayOutputStream baos = new ByteArrayOutputStream();
GZIPOutputStream gos = new GZIPOutputStream(baos);
gos.write(classBytes);
gos.close();
byte[] gzipBytes = baos.toByteArray();

StringBuilder ghostPayload = new StringBuilder("$$BCEL$$");
for (byte b : gzipBytes) {
int lowByte = b & 0xFF;
List<Character> candidates = SUPER_DICT.get(lowByte);

if (candidates != null && !candidates.isEmpty()) {
ghostPayload.append(candidates.get(RAND.nextInt(candidates.size())));
} else {
ghostPayload.append((char) ((0xFF << 8) | lowByte));
}
}

return ghostPayload.toString();
}
}

关键过程:

可以很直观的看到丢弃高8位的过程

fastjson \u escape

了解完BCEL Ghost Bits,原PPT中通过同样的思路发散到了Fastjson;

Fastjson中最重要的关键词就是@type,常规的waf基本也都对此进行了非常严格的限制,部分情况下可以通过简单的unicode编码绕过,但主流的WAF都会在拦截前做一次基础的Unicode解码,或者直接在正则里加上对\u0040type的匹配;

按照Ghost Bits的思路分析,原PPT中给出了问题所在点:com.alibaba.fastjson.parser.JSONLexerBase

其中的scanSymbol方法有个专门用于处理unicode的分支,当Fastjson解析JSON字符串遇到\u时,它会读取后面的4个字符(c1,c2,c3,c4),然后调用Integer.parseInt()

把后面4个字符当成16进制数字解析出来,问题就在于,Integer.parseInt()底层在将字符转换为数字时,调用的是 java.lang.Character.digit(char ch, int radix)

在Character.digit()中,只要这个字符在Unicode规范里被定义为十进制数字就会返回对应的数值,而꘠๐๔੦这四个Unicode字符的转换过程为:

  • ꘠ (U+A620) -> Java认为它的值是0
  • ๐ (U+0E50) -> Java认为它的值是0
  • ๔ (U+0E54) -> Java认为它的值是4
  • ੦ (U+0A66) -> Java认为它的值是0

这里其实已经能看出来和BCEL思路的区别了,其中并未涉及到前文提到的4种危险写法仍然可以利用

fastjson \x escape

同理还有\x绕过,一般waf同样会对标准的十六进制进行校验,问题所在点仍然是JSONLexerBase中用于处理十六进制的分支

当Fastjson遇到\x时会向后读两个字符,分别赋值给x1和x2,通过digits数组来快速映射成对应的数值,最后通过digits[x1] * 16 + digits[x2]来计算再转换为char

然而这个过程中没有做任何的范围越界检查或非法字符校验,对于非法的十六进制字符默认填充的值为0,也就是说传入任何非法字符最终结果都为x_val = 4 * 16 + 0 = 64(@)

“Ghost Bits” Read Arbitrary File: Spring CVE-2025-41242

前面几个例子主要从绕过waf层面进行了分析,这个例子则是从应用框架本身出发来进行分析

https://github.com/spring-projects/spring-framework/pull/34673/changes

从官方pr#34673可以看到对CVE-2025-41242的修复方案,直接去除了ByteArrayOutputStream相关处理流程,改为了StringBuilder,而原本的写法正是标准的Ghost Bits

这里以p牛的靶场为例,先看poc

在SpringBoot里,如果请求一个没有被@Controller拦截的路径,Spring默认会把它当成静态资源(比如图片、JS文件)来处理,这就触发了图上的前4步:

  • HTTPRequest:攻击者发送特定请求 /阮严灵…/etc/host%73。
  • DispatcherServlet:Spring的前端控制器接管请求。
  • ResourceHttpRequestHandler:发现没有对应的路由,转交给静态资源处理器。
  • CallDecode:准备解析这个路径到底指向服务器上的哪个文件,调用PathResourceResolver,最终进入StringUtils.uriDecode()

经过转换后的内容如上

当uri中存在%时才会进入解码的分支,否则会按原样返回中文字符串;

接着看,spring本身对静态资源的解析还是做了不少限制,但这里存在一个容器适配的问题;

图中的第6步,Spring拿着被截断还原的恶意路径../../etc/hosts,去生成一个Resource对象;

位置:org.springframework.web.servlet.resource.PathResourceResolver#getResource(java.lang.String, jakarta.servlet.http.HttpServletRequest, java.util.List<? extends org.springframework.core.io.Resource>)

第7步则是遍历所有的locations,按照locations集合的顺序进行查找,最后短路返回

前4个classLoader列都有值(指向了AppClassLoader),说明它们是ClassPathResource,第5个是则ServletContextResource,代表整个Web容器,在这里就是Jetty

然后注意这里还通过encodeOrDecodeIfNecessary进行了一次解码,而其内部最终还是通过UriUtils.decode()调用了StringUtils.uriDecode()

随后进入重载方法:org.springframework.web.servlet.resource.PathResourceResolver#getResource(java.lang.String, org.springframework.core.io.Resource)

通过resource.isReadable()和this.checkResource(resource, location)判断resource是否可读以及检查是否合法

当遍历到这前4个目录时,会尝试在Classpath下进行相对路径解析,Java的ClassLoader机制非常严格,无法通过 ../ 轻易跳出Classpath去读取操作系统的绝对路径文件,所以在这前4步,漏洞是触发不了的,Spring会返回null,然后继续往下循环,直到第5个,如果是Tomcat就会拦截,但Jetty就会直接拼接得到路径

此时的资源实现类是Jetty提供的,位置:org.eclipse.jetty.util.resource.PathResource

通过org.eclipse.jetty.util.resource.PathResource#resolve方法进行路径拼接,最终获取到了文件内容

0x05 总结

Cast Attack本质上是由于字符视图和字节视图的不一致导致的,当一个Payload跨越这两种视图时,一旦发生类型强转、算术折叠或规范化差异就会出现问题,这种基于视图不一致和截断特性的攻击模式,在安全发展史上其实一直如影随形,例如一些经典的绕过姿势:

  • SQL宽字节注入:PHP眼中的%df%5c和MySQL眼中的字符产生了认知偏差,吃掉了转义符,这是字符集解析的视图不一致
  • 文件上传%00截断:业务层看到的是完整的字符串长度,而底层的C/C++API看到\0就认为字符串已结束,这是高低层语言对内存边界的认知不一致
  • 各种URL多重编码绕过:同样是利用WAF与后端中间件在解码深度和字符折叠规则上的差异

本文只是挑了几个案例进行分析,原PPT中还有很多更深更广的内容,这里就不挨着分析了,感兴趣可以阅读原PPT:

https://i.blackhat.com/Asia-26/Presentations/Asia-26-Bai-Cast-Attack-Ghost-Bits-4.23.pdf