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+007F
0
-127
0xxxxxxx
1 U+0080
-U+07FF
128
-2047
110xxxxx 10xxxxxx
2 U+0800
-U+FFFF
2048
-65535
1110xxxx 10xxxxxx 10xxxxxx
3 U+10000
-U+10FFFF
65536
-1114111
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
4 - 第一列: 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