笔者是大四学生,初涉安全的萌新,如果文章有错误之处还请大佬指出!

本文将介绍笔者探索出的一种比较完善的Java自动代码审计方式,实现了可控参数判断数据流分析

简介

在前两篇文章中做了一些基本的分析:

浅谈编写Java代码审计工具(1)使用AST实现入门案例

浅谈编写Java代码审计工具(2)SQL注入案例与AST的局限性

一开始尝试用AST做事情,遇到了较大的困难,于是考虑从字节码层面分析

在上一篇文章中,分析了反序列化漏洞链自动挖掘工具

之所以要深入学习GI这个工具,因为笔者是在此工具的基础上重写的一套代码

深入分析GadgetInspector核心代码

本文会有较多部分和GadgetInspector原理类似,尽量不重复写之前文章中的内容

流程分析

输入

工具最终的目标是:用户输入一个SpringBoot的Jar包,经过该工具的分析后直接得出漏洞报告

输入参考GI,读取SpringBoot和JDK的代码大致流程如下:

  1. 解压用户提供的Jar包
  2. 解压后BOOT-INF/classes为项目代码,创建InputStream
  3. 解压后BOOT-INF/lib为项目依赖库,包含各种普通Jar包
  4. 将依赖库全部普通Jar包解压,为其中所有class文件创建InputStream
  5. 根据String类找到rj.jar利用Guava的库得到JDK所有class文件,创建InputStream

基本信息

获得所有class文件的InputStream后,利用ASM技术分析所有文件,得出以下信息:

  1. 所有类和方法的信息(参考GI的MethodDiscoveryClassVisitor
  2. 所有方法内的方法调用信息(参考GI的MethodCallDiscoveryClassVisitor
  3. 分析所有类和方法的继承实现关系(参考GI的InheritanceDeriver

以上信息的分析代码较简单,将list传入ClassVisitorMethodVisitor,重写visitMethod等方法,每visit到一个新的类或方法,都会将当前信息加入集合中。最终得到的这个list即是我们需要的基本信息

Spring信息

不同于GI,笔者要实现的主要是针对于SpringBoot代码的审计,所以有针对性的对Spring信息进行收集是有必要的

而Spring生态中,需要重点关注的是SpringMVC相关,这里能够确认用户的输入,也就是整个分析流程的起点

@Controller
public class DemoController {
    @RequestMapping(path = "/demo")
    private String demo(@RequestParam(name = "demo") String demo) {
        ......
    }
}

参考上文代码,其中的参数demo是我们需要追踪的起点

通过一些简单的ASM代码,笔者实现了分析Controller层信息的代码,最终得到每个路径映射的参数,路径,方法等信息

DataFlow信息

DataFlow是数据流分析的关键,参考自GI的实现,该类模拟了JVM中的Operand StackLocal Varaibles。个人理解相当于把完全静态的代码做成了半动态,也只有这种情况下才能做到数据的“流动”

一句话:根据分析得到当前方法的返回值与哪些参数有关

原理在以前的文章中有过深入的分析,这里不再多说

CallGraph信息

CallGraph信息是指方法的调用图,这部分在之前的文章中没有写,所以会稍微多一些篇幅

例如这样的代码

public class Demo{
    int demo(int a){
        int b = A.test1(a);
        int c = new A().test2(a);
    }
}

那么应该有一个这样的调用关系:

Demo.demo(1)->A.test1(0)
Demo.demo(1)->A.test2(1)

由于test1方法是静态方法,而demotest2方法不是。需要考虑到正常情况下方法参数索引0为this

caller参数a的索引为1,target参数索引在静态情况下为0,正常情况下为1

这部分代码在GI中的实现不难:

ASM规定,在真正visit方法体之前会先调用visitCode,所以应该在这里做Operand StackLocal Variables的初始化

先看父类,进行清空然后根据方法传参清空重新对Local Variables赋值,这里是模拟JVM的真实操作

@Override
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<>());
        }
    }
}

子类给每一个方法设置上当前的参数索引

@Override
public void visitCode() {
    super.visitCode();
    int localIndex = 0;
    int argIndex = 0;
    if ((this.access & Opcodes.ACC_STATIC) == 0) {
        localVariables.set(localIndex, "arg" + argIndex);
        localIndex += 1;
        argIndex += 1;
    }
    for (Type argType : Type.getArgumentTypes(desc)) {
        localVariables.set(localIndex, "arg" + argIndex);
        localIndex += argType.getSize();
        argIndex += 1;
    }
}

在遇到方法内的方法调用时,会执行visitMethodInsn方法

@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    Type[] argTypes = Type.getArgumentTypes(desc);
    // 这里主要目的是判断是否STATIC决定第0位参数是否为this
    if (opcode != Opcodes.INVOKESTATIC) {
        Type[] extendedArgTypes = new Type[argTypes.length + 1];
        System.arraycopy(argTypes, 0, extendedArgTypes, 1, argTypes.length);
        extendedArgTypes[0] = Type.getObjectType(owner);
        argTypes = extendedArgTypes;
    }
    switch (opcode) {
        case Opcodes.INVOKESTATIC:
        case Opcodes.INVOKEVIRTUAL:
        case Opcodes.INVOKESPECIAL:
        case Opcodes.INVOKEINTERFACE:
            int stackIndex = 0;
            // 遍历调用方法的所有参数
            for (int i = 0; i < argTypes.length; i++) {
                // 这个argIndex是目标方法的参数索引
                int argIndex = argTypes.length - 1 - i;
                Type type = argTypes[argIndex];
                // 从Operand Stack中取出当前参数对应的值
                Set<String> taint = operandStack.get(stackIndex);
                if (taint.size() > 0) {
                    for (String argSrc : taint) {
                        // 由于这个值是visitCode时初始化的
                        // 所以会是arg1这样的格式需要切割
                        srcArgIndex = Integer.parseInt(argSrc.substring(3));
                        // 构造当前的CallGraph并保存结果
                        discoveredCalls.add(new CallGraph(
                            new MethodReference.Handle(
                                new ClassReference.Handle(this.owner), this.name, this.desc),
                            new MethodReference.Handle(
                                new ClassReference.Handle(owner), name, desc),
                            srcArgIndex,
                            argIndex));
                    }
                }
                stackIndex += type.getSize();
            }
            break;
        default:
            throw new IllegalStateException("unsupported opcode: " + opcode);
    }
    super.visitMethodInsn(opcode, owner, name, desc, itf);
}

漏洞分析

获得了以上信息,我们就可以对漏洞进行深入分析了,掌握DataFlowCallGraph的原理,那么数据流跟踪不是问题

SSRF实例

之所以用SSRF做实例,因为这是一种比较简单的审计,主要对几个请求相关的函数做监控即可

挑软柿子捏,也是一种抛砖引玉,根据这种简单的思路其实可以做大部分web漏洞的事情了

主要思路:确定请求参数是否可以到达关键函数

入口

// 遍历Spring中的所有路径映射
// 因为这里传入的参数是数据流分析的源头
for (SpringController controller : controllers) {
    for (SpringMapping mapping : controller.getMappings()) {
        // 映射mapping本身是绑定一个方法对象的
        MethodReference methodReference = mapping.getMethodReference();
        if (methodReference == null) {
            continue;
        }
        // 这里的目的将参数0腾出来,因为映射方法一定不是STATIC的
        Type[] argTypes = Type.getArgumentTypes(methodReference.getDesc());
        Type[] extendedArgTypes = new Type[argTypes.length + 1];
        System.arraycopy(argTypes, 0, extendedArgTypes, 1, argTypes.length);
        argTypes = extendedArgTypes;
        // 暂时认为只有String类型才会导致SSRF
        // bool数组记录每一个参数是否可以导致漏洞
        boolean[] vulnerableIndex = new boolean[argTypes.length];
        for (int i = 1; i < argTypes.length; i++) {
            if (argTypes[i].getClassName().equals("java.lang.String")) {
                vulnerableIndex[i] = true;
            }
        }
        // 根据mapping的方法找到它的所有调用
        Set<CallGraph> calls = allCalls.get(methodReference.getHandle());
        if (calls == null || calls.size() == 0) {
            continue;
        }
        // 遍历调用
        for (CallGraph callGraph : calls) {
            // 调用的参数索引
            int callerIndex = callGraph.getCallerArgIndex();
            if (callerIndex == -1) {
                continue;
            }
            // 只有caller的参数索引是String才进行数据流分析
            if (vulnerableIndex[callerIndex]) {
                // 防止循环的visited列表
                List<MethodReference.Handle> visited = new ArrayList<>();
                // 递归分析调用链
                doTask(callGraph.getTargetMethod(), callGraph.getTargetArgIndex(), visited);
            }
        }
    }
}

递归分析

用递归的方式实现完整调用链的分析

private static void doTask(MethodReference.Handle targetMethod, int targetIndex,
                           List<MethodReference.Handle> visited) {
    // 如果该方法已经被visit过那么加入列表
    // 后续递归遇到后直接退出(防止死循环)
    if (visited.contains(targetMethod)) {
        return;
    } else {
        visited.add(targetMethod);
    }
    // 拿到目标方法对应的class文件的InputStream才能进行分析
    ClassFile file = classFileMap.get(targetMethod.getClassReference().getName());
    try {
        InputStream ins = file.getInputStream();
        ClassReader cr = new ClassReader(ins);
        ins.close();
        // 自己实现的ClassVisitor
        SSRFClassVisitor cv = new SSRFClassVisitor(
            targetMethod, targetIndex, localInheritanceMap, localDataFlow);
        cr.accept(cv, ClassReader.EXPAND_FRAMES);
        // 这是成功发现ssrf的条件
        // 在后文中分析
        if (cv.getPass().size() == 3 && !cv.getPass().contains(false)) {
            String message = targetMethod.getClassReference().getName() + "." + targetMethod.getName();
            logger.info("detect ssrf: " + message);
        }
    } catch (IOException e) {
        e.printStackTrace();
        return;
    }
    // 找到下一个方法的调用图
    Set<CallGraph> calls = allCalls.get(targetMethod);
    if (calls == null || calls.size() == 0) {
        return;
    }
    // 每个方法都有多个调用
    // 所以要用集合(用Set为了去重)
    for (CallGraph callGraph : calls) {
        // targetIndex在构造方法里规定-1是特殊情况
        // 这一步可以将调用时传入的参数跟踪下去(实现可控参数追踪)
        if (callGraph.getCallerArgIndex() == targetIndex && targetIndex != -1) {
            // 再次验证防止被visit过
            if (visited.contains(callGraph.getTargetMethod())) {
                return;
            }
            // 递归下一个
            doTask(callGraph.getTargetMethod(), callGraph.getTargetArgIndex(), visited);
        }
    }
}

SSRFClassVisitor

这里是分析的核心部分

public class SSRFClassVisitor extends ClassVisitor {
    // 继承实现关系
    private final InheritanceMap inheritanceMap;
    // 已经分析得到的DataFlow信息(返回值与哪些参数有关)
    // 这里并没有用到DataFow,提供给核心类使用
    private final Map<MethodReference.Handle, Set<Integer>> dataFlow;

    // 当前类名
    private String name;
    // 当前类泛型(保留)
    private String signature;
    // 当前类父类(保留)
    private String superName;
    // 当前类的接口(保留)
    private String[] interfaces;

    // 目标方法名
    private MethodReference.Handle methodHandle;
    // 目标方法的参数索引
    private int methodArgIndex;
    // 成功的标识
    private List<Boolean> pass;

    public SSRFClassVisitor(MethodReference.Handle targetMethod,
                            int targetIndex, InheritanceMap inheritanceMap,
                            Map<MethodReference.Handle, Set<Integer>> dataFlow) {
        // 构造里面对属性赋值
        super(Opcodes.ASM6);
        this.inheritanceMap = inheritanceMap;
        this.dataFlow = dataFlow;
        this.methodHandle = targetMethod;
        this.methodArgIndex = targetIndex;
        this.pass = new ArrayList<>();
    }

    // 对外提供一个获取接口
    public List<Boolean> getPass() {
        return pass;
    }

    @Override
    public void visit(int version, int access, String name, String signature,
                      String superName, String[] interfaces) {
        // visit到任何类的时候对属性赋值
        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
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        // 如果调用的方法是我们希望调用的方法(目标方法)
        if (name.equals(this.methodHandle.getName())) {
            // 核心类
            SSRFMethodAdapter ssrfMethodAdapter = new SSRFMethodAdapter(
                    this.methodArgIndex, this.pass,
                    inheritanceMap, dataFlow, Opcodes.ASM6, mv,
                    this.name, access, name, descriptor, signature, exceptions
            );
            // 主要为了兼容性
            return new JSRInlinerAdapter(ssrfMethodAdapter,
                    access, name, descriptor, signature, exceptions);
        }
        return mv;
    }
}

SSRFMethodAdapter

该类是全文的重点,最核心的类

笔者将GI的TaintTrackingMethodVisitor修改一部分重写为CoreMethodAdapter

并设置SSRFMethodAdapter继承自CoreMethodAdapter

暂时不解释目的,分析完自然可以看出

初始化

// 标识
private final int access;
// 描述
private final String desc;
// 参数索引
private final int methodArgIndex;
// 成功的标识
private final List<Boolean> pass;

public SSRFMethodAdapter(int methodArgIndex, List<Boolean> pass, InheritanceMap inheritanceMap,
                         Map<MethodReference.Handle, Set<Integer>> passthroughDataflow,
                         int api, MethodVisitor mv, String owner, int access, String name,
                         String desc, String signature, String[] exceptions) {
    super(inheritanceMap, passthroughDataflow, api, mv, owner, access, name, desc, signature, exceptions);
    // 对以上属性赋值
    this.access = access;
    this.desc = desc;
    this.methodArgIndex = methodArgIndex;
    this.pass = pass;
}

visitCode

之前有提到过visitCode方法是在进入方法体之前调用的,优先度很高

所以这里主要的目的是设置Operand StackLocal Variables的初始化状态

参考之前文章中画的图:理解方法是如何调用的

@Override
public void visitCode() {
    super.visitCode();
    int localIndex = 0;
    int argIndex = 0;
    // 非STATIC情况Local Variables Array[0] = this
    if ((this.access & Opcodes.ACC_STATIC) == 0) {
        localIndex += 1;
        argIndex += 1;
    }
    // 遍历每个参数
    for (Type argType : Type.getArgumentTypes(desc)) {
        // 如果参数索引和传入的索引一致
        // 表示获取到了可控的参数位置
        if (argIndex == this.methodArgIndex) {
            // 参数是保存在Local Variables里的
            // 方法调用时会压栈执行
            localVariables.set(localIndex, true);
        }
        localIndex += argType.getSize();
        argIndex += 1;
    }
}

visitMethodInsn

笔者抛砖引玉,给出最简单的SSRF调用

URL url = new URL(data);
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.getInputStream();

字节码如下

NEW java/net/URL
DUP
ALOAD 1
INVOKESPECIAL java/net/URL.<init> (Ljava/lang/String;)V
ASTORE 3

ALOAD 3
INVOKEVIRTUAL java/net/URL.openConnection ()Ljava/net/URLConnection;
CHECKCAST java/net/HttpURLConnection
ASTORE 4

ALOAD 4
INVOKEVIRTUAL java/net/HttpURLConnection.getInputStream ()Ljava/io/InputStream;

第一步分析:

  • NEW时候PUSH进去一个,然后DUP再PUSH进去一个相同的

  • 调用URL构造方法时,从局部变量表取出第1位压栈,实际消耗了第一步DUP的一份

  • 构造方法没有返回值,但第一步复制了俩,未消耗的这时被初始化了
  • 将此时栈顶NEW后把初始化的url保存在局部变量表第3位

第二步分析:

  • 把第一步保存到局部变量表第3位的取出来压栈
  • 调用openConnection返回URLConnection,这个返回值此时位于栈顶
  • CHECKCAST不影响栈和表的内容
  • 最后将方法返回值保存在局部变量表第4位

第三步:

  • 从局部变量表第4位取出压栈,调用方法HttpURLConnection.getInputStream
  • 当这三步全部完成的时候,一个HTTP请求就被成功发送了

最终分析:

如果第一步传入的data是可控参数,或者说是从请求中获取的参数,并且符合这三步规则,那么认为存在SSRF漏洞

这里是分析的重点部分,如果方法中出现方法调用,那么会执行到此方法

注意:可以看到在末尾执行的super.visitMethodInsn,这表示代码效果类似Debug,在Stack有变化之前做的分析

@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    // 三步的条件
    boolean urlCondition = owner.equals("java/net/URL") && name.equals("<init>") &&
            desc.equals("(Ljava/lang/String;)V");
    boolean urlOpenCondition = owner.equals("java/net/URL") && name.equals("openConnection") &&
            desc.equals("()Ljava/net/URLConnection;");
    boolean urlInputCondition = owner.equals("java/net/HttpURLConnection") &&
            name.equals("getInputStream") && desc.equals("()Ljava/io/InputStream;");
    // 当前这一步是否能够传递下去
    boolean isTaint = false;
    Type[] argTypes = Type.getArgumentTypes(desc);
    // 第一步
    if (urlCondition) {
        int stackIndex = 0;
        // 遍历拿到传String的参数
        for (int i = 0; i < argTypes.length; i++) {
            int argIndex = argTypes.length - 1 - i;
            Type type = argTypes[argIndex];
            // 现在是调用之前,模拟Stack还没有操作
            // 所以此时可以直接从Operand Stack中获取参数
            Set<Boolean> taint = operandStack.get(stackIndex);
            // 如果这个参数在visitCode时被设置位true
            // 表示这个参数是可控的,由请求传递过来的
            if (taint.size() > 0 && taint.contains(true)) {
                // 设置能够继续传递
                isTaint = true;
                // 结果列表加入一个true
                // 最终结果列表如果是3个true说明一切顺利
                pass.add(true);
                break;
            }
            stackIndex += type.getSize();
        }
        // 调用父类模拟Stack操作
        super.visitMethodInsn(opcode, owner, name, desc, itf);
        // 参考分析第1步,为了继续传递下去
        // 这也是为什么没有返回值但还需要设置栈顶的原因
        if (isTaint) {
            operandStack.set(0, true);
        }
        // 别漏了,不return就出了大错
        return;
    }
    // 第二步
    if (urlOpenCondition) {
        // 参考分析第2步
        // 这个压栈的参数如果被设置位true认为在继续传递
        if (operandStack.get(0).contains(true)) {
            // 成功标识
            pass.add(true);
            // 父类模拟操作
            super.visitMethodInsn(opcode, owner, name, desc, itf);
            // 这里有返回值,继续传递
            operandStack.set(0, true);
            // 别漏了
            return;
        }
    }
    // 第三步
    if (urlInputCondition) {
        // 第三步就比较简单了
        if (operandStack.get(0).contains(true)) {
            // 成功标识
            pass.add(true);
            // 别漏了
            return;
        }
    }
    // 不符合条件的继续调用就可以
    super.visitMethodInsn(opcode, owner, name, desc, itf);
}

注意:

  1. 实例是三步在一起,连续执行。实际如果分散开来,情况和上文的三步分析一致,照样可以检测
  2. 这里做到的事是跟踪请求参数能到达的每一处,所以不用担心检测的深度

实践

自己写了个SpringBoot的项目,打了个Jar包(可以看到三步调用比较分开,而且引入了接口和实现)

@Controller
public class DemoController {
    private final DemoService demoService;

    public DemoController(DemoService demoService) {
        this.demoService = demoService;
    }

    @RequestMapping(path = "/ssrf1")
    private String ssrf1(@RequestParam(name = "data") String data) {
        return demoService.ssrf1(data);
    }
}

public interface DemoService {
    String ssrf1(String data);
}

@Service
public class DemoServiceImpl implements DemoService {
    @Override
    public String ssrf1(String data) {
        try {
            StringBuilder response = new StringBuilder();
            URL url = new URL(data);
            HttpURLConnection con = (HttpURLConnection) url.openConnection();
            con.setRequestMethod("GET");
            con.setRequestProperty("User-Agent", "Mozilla/5.0");
            BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                response.append(inputLine);
            }
            in.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return demoDao.doSomething(data);
    }
}

打包调用:指定一下项目的package路径即可

java -jar CodeInspector.jar --boot SpringBoot.jar --pack com.inspector.sbdemo

效果截图:见最后一行

图中可以看出,分析时间较长,需要5分钟左右

所以笔者每一次调试都是受罪,跑一次就是5分钟,本来有思路的情况等一会后就忘记了哈哈

总结

项目开源了,大部分内容来自GI项目,但我做了一些优化和简化

https://github.com/EmYiQing/CodeInspector

其实项目的核心还是GI的驱动类

https://github.com/EmYiQing/CodeInspector/blob/master/src/main/java/org/sec/core/CoreMethodAdapter.java

后续:

已经做到分析SpringMVC传入的参数,做数据流的跟踪和分析,不会局限于SSRF

也许在目前的基础上,改写下,就可以实现其他的漏洞检测

距离最终的目标:输入一个jar直接出报告,其实不算很远了

点击收藏 | 1 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖