编码发展历程
“编码”这个词指将信息、数据等按照一定规则转换成特定的符号或代码,以便于传输、存储和处理。
早期古埃及的象形文字是一种编码形式。发展到了计算机时代,计算机相关数据的处理规则采用二进制编码形式,用0和1两个数字来代表。后来ASCII编码被引入,由于是美国人定制的,所以只表示了字母、数字和符号。
ASCII编码只有127个字符,27能代表128个字符。所以ASCII只用了七位。1字节(byte)=8位(bits),也就是ASCII编码只需要1字节。但是ASCII无法满足各类语言的需求,就比如中文,汉字超过几万个,7位肯定无法表示这么多的中文字符。后来我国制定了GB2312
编码,采用两字节来编码中文。两字节=16位,216能表示65536个字符。类似的,日本采用Shift_JIS
编码,韩国采用EUC-KR
编码。但是这样也带来了一个问题,就是各国编码之间不兼容,放一起会出现乱码。后来出现的Unicode编码为解决这个问题,定义了一个统一的字符集,把所有语言都整合到一套编码里。Unicode编码一般采用两字节。
但是,这样也有新的问题。ASCII编码的字母A
是十进制的65,二进制为01000001
,由于Unicode编码是两字节,那么需要在前面用0补位,即00000000 01000001
。如果数据都是英文字符,用Unicode编码比ASCII编码多用一倍的存储空间。所以出现了“可变长编码”的设计—UTF-8编码。
UTF-8编码
UTF-8编码可以参考维基百科:https://en.wikipedia.org/wiki/UTF-8#Overlong_encodings
UTF-8用一到四个字节表示字符。常用的英文编码是1字节。这样兼容了ASCII(Unicode前128个字符和ASCII一致),UTF-8也是如此。汉字通常是3字节。
wiki这个UTF-8对照表是将Unicode编码转为UTF-8编码的计算方式。
U+0000
到U+007F
意为0-127个字符,对应1字节,以0
开头,后面补入7个字符。以此类推2字节、3字节、4字节。
wiki官方给了个欧元符号从Unicode值U+20AC
转成UTF-8的计算例子。
Character | Binary code point | Binary UTF-8 | Hex UTF-8 |
---|---|---|---|
€(U+20AC) | 0010 0000 1010 1100 | 11100010 10000010 10101100 | E2 82 AC |
U+20AC
位于U+0800
和U+FFFF
之间,因此需要三个字节进行编码(对应上图Byte 3)。然后将十六进制转为二进制。
2(十六进制) = 0010(四位二进制)
0(十六进制) = 0000(四位二进制)
A(十六进制) = 1010(四位二进制)
C(十六进制) = 1100(四位二进制)
上图中Byte 3的格式是三个字节分别以1110
、10
、10
开头,然后分别补入4、6、6位。U+20AC
这四个四位的二进制数,按顺序补入即0010
(4位)、000010
(6位)、101100
(6位),然后结合三字节的固定头部,就成为了1110 0010
、10 000010
、10 101100
。然后再分别将这三个二进制转为16进制得到E2 82 AC
Overlong Encodings
编码发展历程中讲到,Unicode是固定的两字节,在使用英文字符时,会浪费一倍的存储空间。所以有了可变长编码设计的UTF-8。这种Overlong Encodings就是可变长度的一种形式,为了兼容各类编码系统设计的。同一个字符可以用不同数量的字节来表示。而所谓的Overlong就是用了比最少字节数更多的字节来编码。例如欧元符号€本身是三个字节,但我们用了四个字节编码。这就是超长编码。
假如例子中的欧元符号 € 要从3个字节编码成4个字节,可以先填充0。4字节编码需要填入的字节数分别是3、6、6、6,一共21位。那么需要在U+20AC
的二进制0010 0000 1010 1100
(16位)前面加入5个0,变成00000 0010 0000 1010 1100
,然后在按照4字节格式填入11110 000、10 000010、10 000010、10 101100
,转换成十六进制就是F0 82 82 AC
。这就是Overlong Encodings
但是Overlong Encodings是存在一定的安全隐患的,所以修订后的 UTF-8 标准(如 RFC 3629)规定,每个 Unicode 字符必须使用最短的字节序列来进行编码。例如python 3.7中对于无效的 UTF-8 字节流的每个字节都视为错误
路径中的Overlong Encodings安全问题
CVE-2000-0884就是Overlong Encodings的问题,参考链接:https://capec.mitre.org/data/definitions/80.html
IIS 4.0 and 5.0 allows remote attackers to read documents outside of the web root, and possibly execute arbitrary commands, via malformed URLs that contain UNICODE encoded characters, aka the “Web Server Folder Traversal” vulnerability.
路径中的%C0%AF
会被解码为/
,导致目录穿越。
/scripts/..%c0%af../winnt/system32/cmd.exe
/
本身的Unicode是U+002F
。url编码就是%2f
。但是%2f
一般不会被允许在路径中执行,防止被目录穿越。这里就通过Overlong Encodings进行绕过。
U+002F
本身是1字节的,二进制位00101111
,那么如果扩展成2字节,根据上面的图,需要11位,即补3个0,000 00101111
。然后根据两字节的格式,分成110 00000
和10 1011111
。UTF-8就是C0 AF
,即%C0%AF
,绕过了%2F
的限制
服务器解析时,将UTF-8编码形式转换成Unicode编码形式,即将上述转换过程进行倒推。按照2字节的110
和10
开头的格式,分别截取后面的二进制,得到00000
和101111
。然后拼接在一起的到00000101111
,此二进制对应的十进制是47,47对应的Unicode是/
,实现了跨目录。
同样的Glassfish 4.1之前的版本也受此攻击,参考:https://www.trustwave.com/hubfs/Web/Library/Advisories_txt/A_14137_twsl2015-016.txt?fid=6904
Java序列化绕WAF
Java生成序列化数据后,在网络流量侧其实是能看到关键类名的,如TiedMapEntry
等。
所以一般在防御时会对反序列化payload中常用的关键字进行提取,写成如下规则,来检测是否是攻击行为。
pcre:"/org\/apache\/commons\/collections\/Transformer|org\.apache\.commons\.collections(4)?\.functors|java\.lang\.reflect\.Proxy|java\/lang\/reflect\/InvocationHandler/i"
然后@1ue师傅提出了一个思路,常见的检测对类名进行检测,那能否让序列化后的类名不可见,从而绕过关键字检测。
ObjectStreamClass#readNonProxy(ObjectInputStream in)
-> ObjectInputStream#readUTF()
-> BlockDataInputStream#readUTF()
-> ObjectInputStream#readUTFBody(long utflen)
-> ObjectInputStream#readUTFSpan(StringBuilder sbuf, long utflen)
解析类名的核心方法定位到readUTFSpan
,它负责从缓冲区中读取一个 UTF-8 编码的片段,并将其解码为一个 Java 字符串。
可以看到这个方法中就根据上面图中UTF-8编码对应的字节格式进行解析的。整体的代码逻辑是,先读取第一个字节 b1,根据b1的前四位决定字符的格式(1字节、2字节或3字节)。如果是单字节,直接存储b1。如果是双字节,检查b2是否是10开头,如果是的话将b1和b2组合起来进行存储。如果是3字节,检查b2和b3是否前两位都是10,是的话将b1,b2,b3组合起来存储。
0xxxxxxx(0-127):单字节格式。
110xxxxx 10xxxxxx(128-2047):双字节格式。
1110xxxx 10xxxxxx 10xxxxxx(2048-65535):三字节格式。
那么思路同样是用Overlong Encodings来绕过。比如上面的类中一般都存在字符o
。o
的十六进制是0x6f
(二进制1101111),本身是单字节。如果用2字节进行绕过,先用0扩展成0000 1101111
,然后根据格式,变成110 00001
、10 101111
,对应的十六进制为0xC1
和0xAF
。这样将0x6F
修改为0xC10xAF
就可以绕过包含字符o
的关键字检测(字符不可见,如下图)。
因为默认的UTF-8解析是采用最小字节进行解析的。所以字节序列中无法解析出对应的字符,但由于反序列化的readUTFSpan
方法是按UTF-8实际的编码过程进行解析的,可以正确解析Overlong Encodings,并不影响实际的反序列化。
但是这种方式需要注意,在修改字节后,需要将字节最前面的长度也进行修改。例如0x6F
修改为0xC10xAF
多了一字节,那么就需要将长度0x10
修改为0x11
。Ps:org.example.Evil
类名长度16个字节,对应十六进制就是0x10
。