本文将介绍笔者探索出的一种比较完善的Java自动代码审计方式,实现了可控参数判断和数据流分析
一开始尝试用AST做事情,遇到了较大的困难,于是考虑从字节码层面分析
在上一篇文章中,分析了反序列化漏洞链自动挖掘工具
之所以要深入学习GI这个工具,因为笔者是在此工具的基础上重写的一套代码
本文会有较多部分和GadgetInspector
原理类似,尽量不重复写之前文章中的内容
工具最终的目标是:用户输入一个SpringBoot的Jar包,经过该工具的分析后直接得出漏洞报告
输入参考GI,读取SpringBoot和JDK的代码大致流程如下:
BOOT-INF/classes
为项目代码,创建InputStream
BOOT-INF/lib
为项目依赖库,包含各种普通Jar包InputStream
String
类找到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
变量,这个是我们需要跟踪的起点
xxxxxxxxxx
public class DemoController {
path = "/demo") (
private String demo( (name = "demo") String demo) {
......
}
}
通过一些简单的ASM代码,笔者实现了分析Controller层信息的代码,最终得到每个路径映射的参数,路径,方法等信息
DataFlow
是数据流分析的关键,参考自GI的实现,该类模拟了JVM中的Operand Stack
和Local Varaibles
。个人理解相当于把完全静态的代码做成了半动态,也只有这种情况下才能做到数据的“流动”
一句话:根据分析得到当前方法的返回值与哪些参数有关
原理在以前的文章中有过深入的分析,这里不再多说
CallGraph信息是指方法的调用图,这部分在之前的文章中没有写,所以会稍微多一些篇幅
例如这样的代码
xxxxxxxxxx
public class Demo{
int demo(int a){
int b = A.test1(a);
int c = new A().test2(a);
}
}
那么应该有一个这样的调用关系:
xxxxxxxxxx
Demo.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的真实操作
xxxxxxxxxx
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<>());
}
}
}
子类给每一个方法设置上当前的参数索引
xxxxxxxxxx
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
方法
xxxxxxxxxx
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);
}
获得了以上信息,我们就可以对漏洞进行深入分析了,掌握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);
}
}
}
}
用递归的方式实现完整调用链的分析
xxxxxxxxxx
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);
}
}
}
这里是分析的核心部分
xxxxxxxxxx
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;
}
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
的初始化状态
参考之前文章中画的图:理解方法是如何调用的
xxxxxxxxxx
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调用
xxxxxxxxxx
URL url = new URL(data);
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.getInputStream();
字节码如下
xxxxxxxxxx
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进去一个相同的DUP
的一份NEW
后把初始化的url保存在局部变量表第3位第二步分析:
openConnection
返回URLConnection
,这个返回值此时位于栈顶CHECKCAST
不影响栈和表的内容第三步:
HttpURLConnection.getInputStream
最终分析:
如果第一步传入的data是可控参数,或者说是从请求中获取的参数,并且符合这三步规则,那么认为存在SSRF漏洞
这里是分析的重点部分,如果方法中出现方法调用,那么会执行到此方法
注意:可以看到在末尾执行的super.visitMethodInsn
,这表示代码效果类似Debug,在Stack有变化之前做的分析
xxxxxxxxxx
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);
}
注意:
自己写了个SpringBoot的项目,打了个Jar包(可以看到三步调用比较分开,而且引入了接口和实现)
xxxxxxxxxx
public 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路径即可
xxxxxxxxxx
java -jar CodeInspector.jar --boot SpringBoot.jar --pack com.inspector.sbdemo
效果截图:见最后一行
图中可以看出,分析时间较长,需要5分钟左右
所以笔者每一次调试都是受罪,跑一次就是5分钟,本来有思路的情况等一会后就忘记了哈哈
项目开源了,大部分内容来自GI项目,但我做了一些优化和简化
已经做到分析SpringMVC
传入的参数,做数据流的跟踪和分析,不会局限于SSRF
也许在目前的基础上,改写下,就可以实现其他的漏洞检测
距离最终的目标:输入一个jar直接出报告,其实不算很远了