本文将介绍笔者探索出的一种比较完善的Java自动代码审计方式,实现了可控参数判断和数据流分析
一开始尝试用AST做事情,遇到了较大的困难,于是考虑从字节码层面分析
在上一篇文章中,分析了反序列化漏洞链自动挖掘工具
之所以要深入学习GI这个工具,因为笔者是在此工具的基础上重写的一套代码
本文会有较多部分和GadgetInspector原理类似,尽量不重复写之前文章中的内容
工具最终的目标是:用户输入一个SpringBoot的Jar包,经过该工具的分析后直接得出漏洞报告

输入参考GI,读取SpringBoot和JDK的代码大致流程如下:
BOOT-INF/classes为项目代码,创建InputStreamBOOT-INF/lib为项目依赖库,包含各种普通Jar包InputStreamString类找到rj.jar利用Guava的库得到JDK所有class文件,创建InputStream获得所有class文件的InputStream后,利用ASM技术分析所有文件,得出以下信息:
MethodDiscoveryClassVisitor)MethodCallDiscoveryClassVisitor)InheritanceDeriver)以上信息的分析代码较简单,将list传入ClassVisitor和MethodVisitor,重写visitMethod等方法,每visit到一个新的类或方法,都会将当前信息加入集合中。最终得到的这个list即是我们需要的基本信息
不同于GI,笔者要实现的主要是针对于SpringBoot代码的审计,所以有针对性的对Spring信息进行收集是有必要的
而Spring生态中,需要重点关注的是SpringMVC相关,这里能够确认用户的输入,也就是整个分析流程的起点
例如下方代码,通过/demo传入的String型demo变量,这个是我们需要跟踪的起点
xxxxxxxxxxpublic class DemoController { (path = "/demo") private String demo((name = "demo") String demo) { ...... }}通过一些简单的ASM代码,笔者实现了分析Controller层信息的代码,最终得到每个路径映射的参数,路径,方法等信息
DataFlow是数据流分析的关键,参考自GI的实现,该类模拟了JVM中的Operand Stack和Local Varaibles。个人理解相当于把完全静态的代码做成了半动态,也只有这种情况下才能做到数据的“流动”
一句话:根据分析得到当前方法的返回值与哪些参数有关
原理在以前的文章中有过深入的分析,这里不再多说
CallGraph信息是指方法的调用图,这部分在之前的文章中没有写,所以会稍微多一些篇幅
例如这样的代码
xxxxxxxxxxpublic class Demo{ int demo(int a){ int b = A.test1(a); int c = new A().test2(a); }}那么应该有一个这样的调用关系:
xxxxxxxxxxDemo.demo(1)->A.test1(0)Demo.demo(1)->A.test2(1) 由于test1方法是静态方法,而demo和test2方法不是。需要考虑到正常情况下方法参数索引0为this
而caller参数a的索引为1,target参数索引在静态情况下为0,正常情况下为1
这部分代码在GI中的实现不难:
ASM规定,在真正visit方法体之前会先调用visitCode,所以应该在这里做Operand Stack和Local Variables的初始化
先看父类,进行清空然后根据方法传参清空重新对Local Variables赋值,这里是模拟JVM的真实操作
xxxxxxxxxxpublic 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<>()); } }}子类给每一个方法设置上当前的参数索引
xxxxxxxxxxpublic 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方法
xxxxxxxxxxpublic 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);}获得了以上信息,我们就可以对漏洞进行深入分析了,掌握DataFlow和CallGraph的原理,那么数据流跟踪不是问题
之所以用SSRF做实例,因为这是一种比较简单的审计,主要对几个请求相关的函数做监控即可
挑软柿子捏,也是一种抛砖引玉,根据这种简单的思路其实可以做大部分web漏洞的事情了
主要思路:确定请求参数是否可以到达关键函数
xxxxxxxxxx// 遍历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); } } }}用递归的方式实现完整调用链的分析
xxxxxxxxxxprivate 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); } }}这里是分析的核心部分
xxxxxxxxxxpublic 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; }
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; }
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; }}该类是全文的重点,最核心的类
笔者将GI的TaintTrackingMethodVisitor修改一部分重写为CoreMethodAdapter
并设置SSRFMethodAdapter继承自CoreMethodAdapter
暂时不解释目的,分析完自然可以看出
初始化
xxxxxxxxxx// 标识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 Stack和Local Variables的初始化状态
参考之前文章中画的图:理解方法是如何调用的

xxxxxxxxxxpublic 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调用
xxxxxxxxxxURL url = new URL(data);HttpURLConnection con = (HttpURLConnection) url.openConnection();con.getInputStream();字节码如下
xxxxxxxxxxNEW java/net/URLDUPALOAD 1INVOKESPECIAL java/net/URL.<init> (Ljava/lang/String;)VASTORE 3
ALOAD 3INVOKEVIRTUAL java/net/URL.openConnection ()Ljava/net/URLConnection;CHECKCAST java/net/HttpURLConnectionASTORE 4
ALOAD 4INVOKEVIRTUAL java/net/HttpURLConnection.getInputStream ()Ljava/io/InputStream;第一步分析:
NEW时候PUSH进去一个,然后DUP再PUSH进去一个相同的DUP的一份NEW后把初始化的url保存在局部变量表第3位第二步分析:
openConnection返回URLConnection,这个返回值此时位于栈顶CHECKCAST不影响栈和表的内容第三步:
HttpURLConnection.getInputStream最终分析:
如果第一步传入的data是可控参数,或者说是从请求中获取的参数,并且符合这三步规则,那么认为存在SSRF漏洞
这里是分析的重点部分,如果方法中出现方法调用,那么会执行到此方法
注意:可以看到在末尾执行的super.visitMethodInsn,这表示代码效果类似Debug,在Stack有变化之前做的分析
xxxxxxxxxxpublic 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);}注意:
自己写了个SpringBoot的项目,打了个Jar包(可以看到三步调用比较分开,而且引入了接口和实现)
xxxxxxxxxxpublic class DemoController { private final DemoService demoService;
public DemoController(DemoService demoService) { this.demoService = demoService; }
(path = "/ssrf1") private String ssrf1((name = "data") String data) { return demoService.ssrf1(data); }}
public interface DemoService { String ssrf1(String data);}
public class DemoServiceImpl implements DemoService { 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路径即可
xxxxxxxxxxjava -jar CodeInspector.jar --boot SpringBoot.jar --pack com.inspector.sbdemo
效果截图:见最后一行

图中可以看出,分析时间较长,需要5分钟左右
所以笔者每一次调试都是受罪,跑一次就是5分钟,本来有思路的情况等一会后就忘记了哈哈
项目开源了,大部分内容来自GI项目,但我做了一些优化和简化
已经做到分析SpringMVC传入的参数,做数据流的跟踪和分析,不会局限于SSRF
也许在目前的基础上,改写下,就可以实现其他的漏洞检测
距离最终的目标:输入一个jar直接出报告,其实不算很远了