UTF-8 Overlong Encodeing 绕过WAF
Unicode 到 UTF-8
引用一段来自dicksuck的回答
Unicode 字符到 UTF-8 编码的转换遵循一套清晰但精妙的规则。其核心思想是:根据 Unicode 码点(Code Point)所处的数值范围,决定使用多少个字节(1 到 4 个)进行编码,并按照特定模式填充这些字节的位。 UTF-8 的设计巧妙地实现了对 ASCII 的完全兼容,并支持表示整个 Unicode 码空间(U+0000 到 U+10FFFF)。
以下是详细的转换步骤和规则:
确定 Unicode 码点:
- 每个字符在 Unicode 标准中都有一个唯一的编号,称为码点。
- 码点通常用十六进制表示,前缀
U+。例如:'A'(拉丁字母 A) 的码点是U+0041'€'(欧元符号) 的码点是U+20AC'中'(中文“中”) 的码点是U+4E2D'😂'(笑哭表情) 的码点是U+1F602
根据码点范围确定所需字节数:
UTF-8 根据码点值的大小将其编码为 1 到 4 个字节。范围划分如下:Unicode 码点范围 (十六进制) Unicode 码点范围 (十进制) UTF-8 字节序列 (二进制) 所需字节数 U+0000-U+007F0-1270xxxxxxx1 U+0080-U+07FF128-2047110xxxxx 10xxxxxx2 U+0800-U+FFFF2048-655351110xxxx 10xxxxxx 10xxxxxx3 U+10000-U+10FFFF65536-111411111110xxx 10xxxxxx 10xxxxxx 10xxxxxx4 - 第一列: Unicode 码点所处的范围。
- 第二列: 码点的十进制表示(帮助理解范围大小)。
- 第三列: UTF-8 编码的模板。
x表示用于存放码点二进制位的比特位。 - 第四列: 编码这个范围内的码点所需的字节数量。
将码点转换为二进制:
- 将码点的十六进制值转换为二进制形式。
- 根据上一步确定的字节数,从低位向高位(从右向左)提取出所需的比特位。如果二进制位数不足模板中
x的总数,则在高位(左边)补零。
填充 UTF-8 字节序列模板:
- 将上一步提取出的二进制位(包括补的零),从低位到高位(从右向左),依次填入模板中
x的位置。 - 重要规则:
- 首字节: 模板中第一个字节开头的
1的个数(110...,1110...,11110...)明确指示了这个字符使用了多少个字节。0...表示单字节。 - 后续字节: 所有后续字节必须以
10开头。这是 UTF-8 的一个重要特征,用于区分一个字节是某个字符编码序列的起始字节还是后续字节。
- 首字节: 模板中第一个字节开头的
- 将上一步提取出的二进制位(包括补的零),从低位到高位(从右向左),依次填入模板中
得到最终的 UTF-8 字节序列:
- 填充完成后,你就得到了代表该 Unicode 字符的 UTF-8 编码字节序列。这个序列可以存储在文件、网络传输或在内存中表示该字符串。
详细示例:
- 示例 1:
'A'(U+0041) - 1 字节- 码点:
U+0041(十六进制) =65(十进制) - 范围:
0 - 127-> 1 字节 - 模板:
0xxxxxxx - 码点二进制:
65=1000001(二进制,7位) - 填充模板: 模板要求 7 个
x(0xxxxxxx)。将1000001填入x的位置 ->01000001 - UTF-8 字节序列 (十六进制):
0x41(等同于 ASCII'A') - UTF-8 字节序列 (二进制):
01000001
- 码点:
- 示例 2:
'¢'(分币符号, U+00A2) - 2 字节- 码点:
U+00A2(十六进制) =162(十进制) - 范围:
128 - 2047-> 2 字节 - 模板:
110xxxxx 10xxxxxx - 码点二进制:
162=10100010(二进制,8位) - 填充模板:
- 模板
x总位数: 5 (第一个字节) + 6 (第二个字节) = 11 位。 - 码点二进制
10100010只有 8 位,需要在高位补 3 个零:000 10100010-> 补零后为00010100010(11位)。 - 将这 11 位 从低位到高位 填入模板的
x:- 低 6 位 (
100010) 填入第二个字节的10xxxxxx->10100010 - 剩下的高 5 位 (
00010) 填入第一个字节的110xxxxx->11000010
- 低 6 位 (
- 模板
- UTF-8 字节序列 (十六进制):
0xC2 0xA2 - UTF-8 字节序列 (二进制):
11000010 10100010
- 码点:
示例 3: '中' (U+4E2D) - 3 字节
- 码点:
U+4E2D(十六进制) =20013(十进制) - 范围:
2048 - 65535-> 3 字节 - 模板:
1110xxxx 10xxxxxx 10xxxxxx - 码点二进制:
20013=0100111000101101(二进制,16位。十六进制4E2D=0100 1110 0010 1101) - 填充模板:
- 模板
x总位数: 4 + 6 + 6 = 16 位。码点正好 16 位,无需补零。 - 将这 16 位 从低位到高位 填入模板的
x:- 低 6 位 (
101101) 填入第三个字节的10xxxxxx->10101101 - 中间 6 位 (
111000) 填入第二个字节的10xxxxxx->10111000 - 高 4 位 (
0100) 填入第一个字节的1110xxxx->11100100
- 低 6 位 (
- 模板
- UTF-8 字节序列 (十六进制):
0xE4 0xB8 0xAD - UTF-8 字节序列 (二进制):
11100100 10111000 10101101
模板是固定给出的
Overlong Encoding是什么?
Overlong Encoding就是将1个字节的字符,按照UTF-8编码方式强行编码成2位以上UTF-8字符的方法。
比如点号.,其unicode编码和ascii编码一致,均为0x2E,按照上表,它只能被编码成单字节的UTF-8字符,按照单字节编码方案:
U+002E的二进制是101110- 模板
0xxxxxxx,x 总位数7,高位补0成为0101110 - 填入1字节模板:
0xxxxxxx→00101110(十六进制0x2E)。
如果强行将U+002E(本应用1字节编码)当作一个更大码点(在U+0080-U+07FF范围内)来处理呢?并使用双字节UTF-8模板编码。
步骤:
- 码点值不变:仍是
U+002E(二进制00101110,但双字节编码需要11位有效负载)。 - 高位补0:为了“适配”双字节范围,在码点高位补0,扩展成11位:
00000101110(相当于十进制46,但二进制表示为11位:00000+101110)。 - 填入双字节模板:
- UTF-8双字节模板:
110xxxxx 10xxxxxx(用于码点U+0080-U+07FF)。 - 高5位(
xxxxx)取自扩展后的高5位:00000。 - 低6位(
xxxxxx)取自扩展后的低6位:101110。 - 结果:
- 第一个字节:
110+00000=11000000(十六进制0xC0)。 - 第二个字节:
10+101110=10101110(十六进制0xAE)。
- 第一个字节:
- UTF-8双字节模板:
- 最终序列:
0xC0 0xAE。
- 码点值不变:仍是
关键问题:这个序列是否有效?
- 绝对无效,且违反UTF-8标准。
- 原因1:违反“最短形式”规则(Shortest Form Requirement):
- UTF-8标准强制要求字符必须使用最小可能字节数编码。
U+002E完全可以用1字节(0x2E)表示,使用双字节(0xC0 0xAE)是“过长编码”(Overlong Encoding)。 - 正规UTF-8解码器(如Python、Java、现代浏览器)会将其视为非法序列,并抛出错误或替换为占位符(如
�)。
- UTF-8标准强制要求字符必须使用最小可能字节数编码。
- 原因2:码点范围不匹配:
- 双字节UTF-8序列仅适用于码点
U+0080及以上(≥128)。U+002E(46)小于128,不属于该范围。
- 双字节UTF-8序列仅适用于码点
- 原因3:安全性风险:
- 这种序列可能被恶意利用(如注入攻击)。历史上,某些旧系统(如早期Internet Explorer)错误地将
0xC0 0xAE解释为点号,允许攻击者绕过安全检查(例如,在URL中编码../为%C0%AE%C0%AE%2F实现目录遍历)。但现代系统已修复此问题。
- 这种序列可能被恶意利用(如注入攻击)。历史上,某些旧系统(如早期Internet Explorer)错误地将
- 原因1:违反“最短形式”规则(Shortest Form Requirement):
现代UTF-8解码器(如Python的decode('utf-8')、JavaScript的TextDecoder)会检测到0xC0 0xAE是过长序列,并报错:
1 | b'\xC0\xAE'.decode('utf-8') # 抛出 UnicodeDecodeError: invalid start byte |
但是在Java中,就可能存在问题
场景
比如随便找个rome链,我们对其序列化
1 | package org.exploit.third.rome; |
序列化后如下

很显然WAF不会对乱码进行进行匹配,而是对可见字符进行匹配
如果我们能利用解析问题去让我们的可见字符也变成乱码,使WAF不能识别,而程序能顺利进行,就顺利的达到了目标
实现
1ue1uekin8找到了readObject中对类名的解析
上面的demo readObject有很多嵌套,不太适合调试,我们用一个简单的Evil demo调试
1 | package org.example.TestCode; |
跟进readObject,经过如下栈,来到readUTFSpan
1 | readUTFSpan:3625, ObjectInputStream$BlockDataInputStream (java.io) |
readUTFSpan完成了从字节缓冲区中读取一段UTF-8编码的字符串,转换为字符并追加到StringBuilder中,返回实际读取的字节数。
从代码注释我们可以看出,取出当前字节的高4位去判定属于哪个模板。

buf[pos++]:从缓冲区中取出当前 pos 位置的字节,并将 pos 后移一位;
& 0xFF:将字节值无符号扩展为 int,确保结果为 0~255 的非负整数。
>>代表高4位
看代码可以知道,只对模板进行了判断就继续解析了。比如双字节的判定,先判断第一个字节是否为1110xxxxx模板,然后判断第二个字节是否为10xxxxxx。可以看到并没有去判断我们上面所说的像python一样验证码点范围是否处于对应的区间。
构造
我们试着把org.example.Evil的第一个字符o进行双字节UTF-8混淆,其16进制为0x6f
如果是单字节的情况,o会以0xxxxxxx的模板去解析

我们改造成用双字节的模板去解析:

我们把对应的6F(01101111)


双字节模板110xxxxx 10xxxxxx
填充11101111后,得到o的双字节 11100001 101101111,也就是16进制的C1 AF
(本插件按Insert后开启插入模式修改数据)
修改后可以看到已经变成了乱码

我们保存后继续反序列化。
控制台进行了报错:

因为我们把一个字节改成了两个字节的原因,utflen却没变,导致提前结束了对类名的读取

修改后即顺利读取


这样就能实现反序列化的同时字节码不可被WAF读取
工程化利用
我们跟进writeObject到writeNonProxy
1 | writeNonProxy:818, ObjectStreamClass (java.io) |
可以看到这里调用的writeUTF去写入的类名

把writeUTF修改如下:
1 | String name = desc.getName(); |
因为writeNonProxy是privite方法,可以直接重载ObjectOutputStream.writeClassDescriptor,除了修改开始的writeUTF,剩下的部分继续粘贴进去就行了
1 | package org.exploit.misc; |
1 | public static void serialize(Object obj) throws Exception |
不过这里只修改了各个类名为不可读,接口还没修改,不过WAF大概率也是拦截类名。
我们还可以进一步把接口也给做相同的修改,只需要在writeUTF打上断点,看哪些地方调用了它写入数据
找到了writeNonProxy中写入字段UTF的代码,把这里也修改(额外添加下划线的map)

1 | package org.example.TestCode; |
测试
可以看到Evil类序列化出来已经完全成为了依托乱码

以rome链做一个测试:
可以看到其中用到的类和字段(比如val,_beanClass)都变成了乱码,但仍然可以反序列化


这种方式不会影响作用在java层阻断,比如resolveClass、RASP,是一种针对WAF的特定绕过。但是很有效,膜烧麦✌
参考:
https://www.leavesongs.com/PENETRATION/utf-8-overlong-encoding.html
https://vidar-team.feishu.cn/docx/LJN4dzu1QoEHt4x3SQncYagpnGd