0%

GadgetInspector源码分析

序言

好风凭借力,送我上青云。

今天来分析gadgetinspector源码。

简介

Ian Haken于2018年第26届DEFCON提出来的工具,主要是用来找出jar包内部的调用链。

这个工具有些地方很值得学习,总结一下。

关于这个工具

  • 这个工具不是用来寻找漏洞,而是利用已知的source->…->sink链或其相似特征发现分支利用链或新的利用链。
  • 这个工具是在整个应用的classpath中寻找利用链。
  • 这个工具进行了一些合理的预估风险判断(污点判断、污点传递等)。
  • 这个工具会产生误报不是漏报(其实这里还是会漏报,这是作者使用的策略决定的,在后面的分析中可以看到)。
  • 这个工具是基于字节码分析的,对于Java应用来说,很多时候我们并没有源码,而只有War包、Jar包或class文件。
  • 这个工具不会生成能直接利用的Payload,具体的利用构造还需要人工参与

在分析gadgetinspector源码的时候,大概会在以下几方面去讲解,并核心分析ASM部分,详细讲解如何进行污点分析:

  1. GadgetInspector:main方法,程序的入口,做一些配置以及数据的准备工作
  2. MethodDiscovery:类、方法数据以及父子类、超类关系数据的搜索
  3. PassthroughDiscovery:分析参数能影响到返回值的方法,并收集存储
  4. CallGraphDiscovery:记录调用者caller方法和被调用者target方法的参数关联
  5. SourceDiscovery:入口方法的搜索,只有具备某种特征的入口才会被标记收集
  6. GadgetChainDiscovery:整合以上数据,并通过判断调用链的最末端slink特征,从而判断出可利用的gadget chain

流程介绍

先写在这里:

ASM的方法描述符:

方法描述符使用小括号开始,小括号内部是方法入参的类型描述符按照顺序拼接的字符串,在加上返回值的类型描述符组成,返回值是void的时候,使用V。方法的描述符不包含方法名和参数名。

初始化

加载jar包内部类

1
2
//初始化jar包,获得一个类加载器,该类加载器已经将其加载
ClassLoader classLoader = initJar(args);

跟进initJar函数:

image-20201117095645316

可以看到initJar将启动参数参数内的jar都保存到path里面,然后继续调用getJarClassLoader函数:

getJarClassLoader:

  • 在项目路径下,创建临时文件夹exploded-jar,在jvm shutdown自动删除;

  • 接着将jar包内容提取到临时文件夹内部,这里面会有

image-20201117102333361

  • 接着使用URLClassLoader将jarpath加载到类加载器,并返回这个类加载器;

image-20201117102613592

包装类加载器

1
2
//获取类加载器
final ClassResourceEnumerator classResourceEnumerator = new ClassResourceEnumerator(classLoader);

这里就是将上一步加载我们jar的类加载器classLoader传入ClassResourceEnumerator。

跟进ClassResourceEnumerator:

image-20201117104353938

最关键的是这两个函数,分析一下:

image-20201117104440730

image-20201117104455288

可以看到getAllClasses()函数首先调用getRuntimeClasses()函数,那么跟进getRuntimeClasses()仔细看:

目的是将rt.jar加载到这里作者注释也写到他用了一个比较hacky的方式:

他先获取JDK内部的String类的路径,加载String类的同时,类加载器还会将rt.jar的全部类一起加载,那么最后就是将rt.jar的所有类都加入到ClassResource类型的result并且返回。

其实就是获取rt.jar的所有class。

回到getAllClasses()函数,其实拿到rt.jar之后,继续将传入的jar包内的类加入到result,并最后返回。

总结:

  • getRuntimeClasses获取rt.jar的所有class
  • getAllClasses获取rt.jar以及classLoader加载的class

方法探索 MethodDiscovery

image-20201117111114802

discover()

image-20201117111206892

该函数接收一个ClassResourceEnumerator类型的参数,这个参数主要将之前的类加载器(rt全部类+分析类)都包装并且传入。

获取每一个class类对象之后,进行ASM分析环节。

首先,看到在discover方法中获取了所有的类,并通过MethodDiscoveryClassVisitor去记录类和类方法信息。

跟进MethodDiscoveryClassVisitor去看看:

image-20201117115215131

这里其实会将我们所有类内部的元素进行存储,例如类名、父类名、接口、是否为接口、类的全部字段、classHandle把位。

image-20201117115411195

在ASM里,visit和visitEnd方法是一定会调用的,其他会按顺序来调用,那么这里就是:

  1. visit 访问类的头部
  2. visitField 访问类属性
  3. visitMethod 访问类方法
  4. visitEnd

save()

saveData方法中会通过调用factory的serialize对数据进行序列化,然后一行一行的输出

image-20201117144908462

这里重点看derive函数:

image-20201117160457359

这里说一下最后的翻转,跟进去:

image-20201117165701353

最后的save函数,保存格式为子类:父类:

image-20201117165758797

这里说一下InheritanceMap是一个类,有两个fields:

  1. inheritanceMap 存放是子->父集合
  2. subClassMap存放的是父->子集合

流程图探索 PassthroughDiscovery

在这环节中,需要将上一环节的成果也就是classes.dat,methods.dat和inheritanceMap.dat都利用起来。

这里的passthrough数据流指的是每个方法的返回结果方法参数的关系,这一步生成的数据会在生成passthrough调用图时用到。

主要用以发现函数返回值传进来参数之间污点关系,工作量最大的一个部分。

参考seebug,先看作者的一个demo:

image-20210316110702699

成员/this,都是0;参数为1;

FnConstant.invoke返回值与参数this(参数0,因为序列化时类的所有成员我们都能控制,所以所有成员变量都视为0参)、arg(参数1)的关系:

  • 与this的关系:返回了this.value,即与0参有关系
  • 与arg的关系:返回值与arg没有任何关系,即与1参没有关系
  • 结论就是FnConstant.invoke与参数0有关,表示为FnConstant.invoke()->0

Fndefault.invoke返回值与参数this(参数0)、arg(参数1)的关系:

  • 与this的关系:返回条件的第二个分支与this.f有关系,即与0参有关系
  • 与arg的关系:返回条件的第一个分支与arg有关系,即与1参有关系
  • 结论就是FnConstant.invoke与0参,1参都有关系,表示为Fndefault.invoke()->0、Fndefault.invoke()->1

回到gi,gadgetinspector是利用ASM来进行方法字节码的分析,主要逻辑是在类PassthroughDiscovery和TaintTrackingMethodVisitor中。特别是TaintTrackingMethodVisitor,它通过==标记追踪JVM虚拟机在执行方法时的stack和localvar==,并最终得到返回结果是否可以被参数标记污染。

不仅仅是信息搜集,还要做污点的信息判断,以及方法间的变量关联。

image-20201117170319531

discover()

这段很复杂 写个调用顺序在这:

discoverMethodCalls->MethodCallDiscoveryClassVisitor->MethodCallDiscoveryMethodVisitor->更新calledMethods和methodCalls集合;

topologicallySortMethodCalls->获得sortedMethods集合,存放着方法调用链逆拓扑结果

calculatePassthroughDataflow->PassthroughDataflowClassVisitor->PassthroughDataflowMethodVisitor

跟进discover方法,可以看到首先对之前的结果进行了加载:

image-20201117204635986

跟进discoverMethodCalls函数:

image-20201117204716978

可以看到又按照访问者模式,进行了ASM分析,跟进MethodCallDiscoveryClassVisitor :

image-20201117205029118

可以看到内部重写了下面的方法,并且会按照顺序进行执行:

  • visit:把当前观察的类名赋值到了this.name
  • visitMethod:继续进一步的对被观察类的每一个方法细节进行观察,传入了当前观察的类名和方法名
  • visitEnd:除了super,啥也没干

看到在visitMethod时候,进行了MethodCallDiscoveryMethodVisitor,跟进看看:

image-20201117205544372

MethodCallDiscoveryMethodVisitor继承了ASM的MethodVisitor的构造函数,

在MethodCallDiscoveryMethodVisitor构造方法执行的时候,会对this.calledMethods集合进行初始化,该集合的主要作用是在被观察方法对其他方法进行调用时(会执行visitMethodInsn方法),用于缓存记录被调用的方法,因此,我们可以看到visitMethodInsn方法中:

image-20201119092039034

理解:calledMethods存放的是被调用的方法,ASM对于正在visit的方法,在visit的时候,如果在方法内出现了调用其他方法的行为,那么就会执行visitMethodInsn方法,它会将被调用的方法记录在calledMethods这个hashset集合内。

并且在构造方法执行的时候,集合calledMethods也会被添加到gadgetinspector.PassthroughDiscovery#methodCalls中,做全局性的收集,因此,最后我们能通过discoverMethodCalls这一个方法,实现对这样一个数据的全量收集:

image-20201119093509631

接下来调用topologicallySortMethodCalls

image-20201119093735153

跟进去:

image-20201119095514058

这里就开始了重头戏,DFS+逆拓扑。

这里重点参考404的这篇文章,原理讲得很清楚。

在开始逆拓扑之前,可以看到作者首先准备了三个数据结构:

  • dfsStack:栈,用来分析方法调用顺序,也用于在在逆拓扑时候不会形成环
  • visitedNodes:访问过的结点,在一条调用链出现重合的时候,不会造成重复的排序
  • sortedMethods:最终逆拓扑排序出来的结果集合

跟进dfsTsort:

image-20201120092931396

dfsTsort是一个迭代函数:

判断条件:

  1. 对于待分析的方法,如果stack里面已经有了,那就不再入栈了
  2. 如果之前已经分析过某方法(visitedNodes已经存在),那么也不会再入栈了

接下来,将待分析方法所调用的所有方法集合都取出来,这里可以说是子方法集合outgoingReferences。

之后开始遍历子方法集合,取出每一个子方法作为参数,开始迭代

这其实就是DFS,当到达一个叶子结点的时候,由于没有子方法,就不会进入到循环,也就不会再次递归。

这时就会弹出栈顶元素,也就是叶子结点,加入到visitedNodes和sortedMethods里面。

image-20201120100545146

image-20201120100601500

生成passthrough数据流图

在calculatePassthroughDataflow中遍历了sortedmethods,并通过字节码分析,生成了方法返回值与参数关系的passthrough数据流。注意到下面的序列化决定器,作者内置了三种:JDK、Jackson、Xstream,会根据具体的序列化决定器判定决策过程中的类是否符合对应库的反序列化要求,不符合的就跳过:

  • 对于JDK(ObjectInputStream),类否继承了Serializable接口
  • 对于Jackson,类是否存在0参构造器
  • 对于Xstream,类名能否作为有效的XML标签

生成passthrough数据流代码:

image-20201120104511218

1
final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>();

首先passthroughDataflow主要负责的是存储参数污染结果,key对应方法名,value对应的是可以污染下去的参数索引集合。

image-20201130145525684

接下来,遍历经历过拓扑排序过的方法;

首先第一步会跳过静态初始化代码,因为静态代码块基本上是没法被污染的,直接在类加载阶段就会执行。

第二步在遍历的每个方法过程中,获取它所属的类,对其进行ASM访问者模式的分析:

跟进PassthroughDataflowClassVisitor中分析,重点还是在visitMethod方法中

image-20201130150835717

是这样的,对于每一个类中对的每一个方法都会经历PassthroughDataflowClassVisitor这一步。对于类中的每一个方法都会进行它的方法(例如visit,visitMethod),对于每一个方法都会在visitMethod里面走一遭。

那么对于ASM在观察到每一个方法都会执行visitMethod方法,通过此处重新判断所观察的方法是不是我们想找、所关心的方法,只有我们关心的方法才能继续下去,进入PassthroughDataflowMethodVisitor继续观察。

image-20201130154509973

继续跟进PassthroughDataflowMethodVisitor,可以看到,它继承了TaintTrackingMethodVisitor,并有以下几个方法的实现:

  1. visitCode:在进入方法的第一时间,ASM会先调用这个方法
  2. visitInsn:在方法体中,每一个字节码操作指令的执行,ASM都会调用这个方法,return
  3. visitFieldInsn:对于字段的调用,ASM都会调用这个方法
  4. visitMethodInsn:方法体内,一旦调用了其他方法,都会触发这个方法的调用

image-20201130154944412

还有一个父类中的方法:

visitVarInsn:这个方法在方法体内进行字节码操作变量时,会被调用。

为了实现类似污点分析,去分析参数对方法的污染,其模仿了jvm,实现了两个集合,分别是本地变量表和操作数栈,通过其,实现具体的污点分析,那么具体是怎么进行的呢?

这里写一下具体细节:

visitCode->gadgetinspector.TaintTrackingMethodVisitor#visitVarInsn(在父类里面)->visitInsn更新污染表->gadgetinspector.PassthroughDiscovery#calculatePassthroughDataflow

这里需要跟一下gadgetinspector的逻辑:

首先:

本地变量表:List<Set> localVars;

操作数栈:List<Set> stackVars;

image-20210316195921976

这里借鉴一下threedr3am的例子:

image-20201130160432170

逆拓扑结果:

image-20201130160447220

按照这个例子来分析:

  • A.method1:

    第一步,ASM对A.method1进行观察,也就是PassthroughDataflowMethodVisitor进行观察,那么,在其方法被执行开始的时候,会触发PassthroughDataflowMethodVisitor.visitCode方法的调用,在这一步的代码中,会首先对方法是否是static方法等进行判断,接着做了一个操作,就是把入参放到了本地变量表中来,为什么要这样做呢?我们可以想象一下,一个方法内部,能用到的数据要不就是本地变量表的数据,要不就是通过字段调用的数据,那么,在分析调用其他方法,或者对返回值是否会被入参污染时的数据流动,都跟它紧密关联,为什么这样说?根据jvm字节码的操作,在调用方法前,肯定需要对相关参数进行入栈,那入栈的数据从哪里来,必然就是本地变量表或者其他字段(这里的其他字段估计是其他方法的返回值)。那么在形成这样的一个本地变量表之后,就能标识一个方法内部的数据流动,并最终确定污染结果。

    image-20210318193555532

    image-20210318193611196

上面的这步savedVariableState.localVars.set(index, values);其实就是在局部变量表里面更新索引。

第二步,这里三梦师傅写得很乱,仔细查了一下,visitVarInsn是访问局部变量指令。 局部变量指令是加载loads或存储stores局部变量值的指令。也就是说出现这些ILOAD, LLOAD, FLOAD, DLOAD, ALOAD, ISTORE, LSTORE, FSTORE, DSTORE, ASTORE or RET.指令的时候,可以触发这个方法。

image-20210318195425611

这里继续跟,该到new A().method1(args)调用,JVM会执行指令aload1(将局部变量表1号位置的元素入栈)对其参数args进行入栈,因为args是引用类型,那么操作代码就是Opcodes.ALOAD1,可以看到,代码中,从本地变量表获取了变量索引,并放入到操作数栈中来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Override
public void visitVarInsn(int opcode, int var) {
// Extend local variable state to make sure we include the variable index
for (int i = savedVariableState.localVars.size(); i <= var; i++) {
savedVariableState.localVars.add(new HashSet<T>());
}

Set<T> saved0;
switch(opcode) {
case Opcodes.ILOAD:
case Opcodes.FLOAD:
push();
break;
case Opcodes.LLOAD:
case Opcodes.DLOAD:
push();
push();
break;
case Opcodes.ALOAD:
push(savedVariableState.localVars.get(var));
break;
case Opcodes.ISTORE:
case Opcodes.FSTORE:
pop();
savedVariableState.localVars.set(var, new HashSet<T>());
break;
case Opcodes.DSTORE:
case Opcodes.LSTORE:
pop();
pop();
savedVariableState.localVars.set(var, new HashSet<T>());
break;
case Opcodes.ASTORE:
saved0 = pop();
savedVariableState.localVars.set(var, saved0);
break;
case Opcodes.RET:
// No effect on stack
break;
default:
throw new IllegalStateException("Unsupported opcode: " + opcode);
}

image-20210318195552582

第三步,这时则需要areturn,弹出操作数栈。执行了areturn方法,那么就会触发visitInsn这个方法,因为返回的是引用类型,那么相应的指令就是Opcodes.ARETURN,可以看到,在这个case中,会从栈顶,获取刚刚入栈(第二步中visitVarInsn从本地变量表获取的参数索引)的参数索引,并存储到returnTaint中,因此,即表示A.method1这个方法的调用,参数索引为1的参数param会污染返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void visitInsn(int opcode) {
switch(opcode) {
case Opcodes.IRETURN://从当前方法返回int
case Opcodes.FRETURN://从当前方法返回float
case Opcodes.ARETURN://从当前方法返回对象引用
returnTaint.addAll(getStackTaint(0));//栈空间从内存高位到低位分配空间
break;
case Opcodes.LRETURN://从当前方法返回long
case Opcodes.DRETURN://从当前方法返回double
returnTaint.addAll(getStackTaint(1));
break;
case Opcodes.RETURN://从当前方法返回void
break;
default:
break;
}

super.visitInsn(opcode);
}
1
2
3
4
5
6
protected Set<T> getStackTaint(int index) {
//出栈,index=0为栈顶
return savedVariableState.stackVars.get(savedVariableState.stackVars.size()-1-index);
}

private final Set<Integer> returnTaint;//被污染的返回数据

第四步,经过return之后,该方法的观察也就结束了,那么,回到gadgetinspector.PassthroughDiscovery#calculatePassthroughDataflow中,对于刚刚放到returnTaint污点分析结果,也会在其方法中,缓存到passthroughDataflow。

1
2
3
4
5
6
7
8
9
ClassReader cr = new ClassReader(inputStream);
try {
PassthroughDataflowClassVisitor cv = new PassthroughDataflowClassVisitor(classMap, inheritanceMap,
passthroughDataflow, serializableDecider, Opcodes.ASM6, method);
cr.accept(cv, ClassReader.EXPAND_FRAMES);
passthroughDataflow.put(method, cv.getReturnTaint());//缓存方法返回值与哪个参数有关系
} catch (Exception e) {
LOGGER.error("Exception analyzing " + method.getClassReference().getName(), e);
}
  • C.method3:

    同method1

  • B.method2:

    先看method2字节码:

    image-20210318203426098

    这个方法和前面连个都不一样,它内部调用了C.method3方法,因此,污点分析时,具体的细节就又不一样了:

    第一步,在其方法被执行开始的时候,同样会触发PassthroughDataflowMethodVisitor.visitCode方法的调用,在其中,也是做了相应的操作,把入参存到了本地变量表中来;

    第二步,因为方法内部即将调用C.method3,那么ASM调用visitVarInsn方法,对其参数param进行入栈,因为param是引用类型,那么操作代码就是Opcodes.ALOAD,因此,从第一步保存的本地变量表中获取变量入栈;

    这里仔细看,method2 准备调用method3时的状态:

    image-20210318210935086

    在ALOAD_1这一步之后,localvars=(0.0;1.1);stackVars=(0.0;1.1);

    第三步,方法内部调用了C.method3,那么,ASM就会触发visitMethodInsn方法的执行,在这一步,会先对被调用方法的入参进行处理,并把被调用方法的实例放到argTypes的第一个索引位置,后面依次放置其他参数,接着计算返回值大小。然后,因为方法调用,第二步已经把参数入栈stack了,而这些参数都是从本地变量表获取的,那么,可以从栈顶取到相关参数,并认为这些参数是可被控制,也就是被当前调用者caller方法污染的。

    这里进入visitMethodInsn方法具体看一下:

    image-20210318222907723

    执行到这里,argTypes存放的是(Cthis,String)

最后,也就是最重点的一步,从passthroughDataflow中获取了被调用方法的参数污染结果,也就是上一个方法C.method3方法被分析时候,return存储的数据,所以,这里就印证了前面为什么要使用逆拓扑排序,因为如果不这样做的话,C.method3可能在B.method2后被分析,那么,缓存就不可能存在污点分析的结果,那么就没办法对B.method2进行正确的污点分析。接着就是对从缓存取出的污染结果和入参对比,取出相应索引的污点参数,放入到resultTaint中。

gadgetinspector.TaintTrackingMethodVisitor#visitMethodInsn

  • main: 最后需要分析的是main方法的入参args是否会污染到其返回值

    第一步,执行visitCode存储入参到本地变量表

    第二步,执行visitVarInsn参数入栈

    第三步,执行visitMethodInsn调用A.method1,A.method1被污染的返回结果,也就是参数索引会被放在栈顶

    第四步,执行visitVarInsn把放在栈顶的污染参数索引,放入到本地变量表

    第五步,执行visitVarInsn参数入栈

    第六步,执行visitMethodInsn调用B.method2,被污染的返回结果会被放在栈顶

    第七步,执行visitInsn,返回栈顶数据,缓存到passthroughDataflow,也就是main方法的污点分析结果

    到此,ASM实现方法入参污染返回值的分析就到此为止了。

    接下来,passthroughDiscovery.save方法就被调用

    1
    2
    3
    4
    5
    6
    7
    public void save() throws IOException {
    if (passthroughDataflow == null) {
    throw new IllegalStateException("Save called before discover()");
    }

    DataLoader.saveData(Paths.get("passthrough.dat"), new PassThroughFactory(), passthroughDataflow.entrySet());
    }

    也是通过DataLoader.saveData把结果一行一行的保存到passthrough.dat文件中,而每行数据的序列化,是通过PassThroughFactory实现

    image-20201130172722782

    最终,这一阶段分析保存下来passthrough.dat文件的数据格式:

    1
    类名 方法名 方法描述 能污染返回值的参数索引1,能污染返回值的参数索引2,能污染返回值的参数索引3...

方法调用探索 CallGraphDiscovery

这一步和上一步类似,gadgetinspector 会再次扫描全部的Java方法,但检查的不再是参数与返回结果的关系,而是方法的参数与其所调用的子方法的关系,即子方法的参数是否可以被父方法的参数所影响

看下面这个例子

image-20210316211044098

1
2
调用者类名 调用者方法caller 调用者方法描述 被调用者类名 被调用者方法target 被调用者方法描述 调用者方法参数索引 调用者字段名 被调用者方法参数索引
Main (Ljava/lang/String;)V main A method1 (Ljava/lang/String;)Ljava/lang/String; 1 1

先看作者给出的例子:

image-20210316212942997

1/AbstractTableModel$ff19274a.hashcode与子方法IFn.invoke:

  • AbstractTableModel$ff19274a.hashcode的this(0参)传递给了IFn.invoke的1参,0->IFn.invoke()@1
  • 这里f是通过this._clojureFnMap(0参)获得的,并且f又作为IFn.invoke()的this(也是0参),换句话说:AbstractTableModel$ff19274a.hashcode的0参传递给了IFn.invoke的零参,表示为0->IFn.invoke()@0

2/FnCompose.invoke与子方法IFn.invoke:

  • 首先先看最里面,f1.invoke(arg)的参数arg为IFn.invoke(Object arg)的参数 都是1参 所以 1->IFn.invoke()@1
  • 第二层,f1.invoke(arg)里面的f1,是FnCompose的类属性 属于0参 在f1.invoke(arg)里面也是this,所以0->IFn.invoke()@0
  • 第三层,f2.invoke(f1.invoke(arg)),这里面 f1.invoke(arg)按道理是作为1参,但是对于f1反序列化来说,我们可以控制具体是IFn的哪个实现类,所以这里f1.invoke(arg)也可以看成是0参(一种合理的风险推测),所以是0->IFn.invoke()@1

具体看源码

discover->ModelGeneratorClassVisitor

直接跟gadgetinspector.CallGraphDiscovery#discover:

image-20201201093433260

1
2
3
4
5
6
7
8
//加载所有方法信息
Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
//加载所有类信息
Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();
//加载所有父子类、超类、实现类关系
InheritanceMap inheritanceMap = InheritanceMap.load();
//加载所有方法参数和返回值的污染关联
Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = PassthroughDiscovery.load();

接着遍历每一个class,并对其使用ASM进行观察:

image-20201201094123852

ModelGeneratorClassVisitor实现主要就是对每一个方法都进行了ASM的观察:

image-20201201095156174

ModelGeneratorMethodVisitor的实现,是这一步的重点逻辑所在,因为单单文字描述可能理解不太清楚,这边继续以一个例子进行讲解:

image-20210319000648951

可以看到上述例子中,Main的main方法中,调用了A.method1方法,并且入参是main的参数args以及Main的字段name

ASM的实现流程:

  • 在Main.main方法体被观察到的第一时间,ASM会调用ModelGeneratorMethodVisitor.visitCode,在这个方法中,根据参数的数量,一一形成名称arg0、arg1…,然后放入到本地变量表,本地变量表现在是0:arg0,1:args;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public void visitCode() {
super.visitCode();
int localIndex = 0;
int argIndex = 0;
//使用arg前缀来表示方法入参,后续用于判断是否为目标调用方法的入参
if ((this.access & Opcodes.ACC_STATIC) == 0) {
setLocalTaint(localIndex, "arg" + argIndex);
localIndex += 1;
argIndex += 1;
}
for (Type argType : Type.getArgumentTypes(desc)) {
setLocalTaint(localIndex, "arg" + argIndex);
localIndex += argType.getSize();
argIndex += 1;
}
}
  • 接着,因为即将要调用A.method1,ASM会调用visitVarInsn,先是aload_1将本地变量表中1号索引args入栈,然后aload_0将arg0入栈。
image-20201201101122436
  • 然后,ASM调用visitVarInsn把当前实例对应的参数入栈,上一步visitCode已经把实例命名为arg0存在本地变量表中,因此入栈的参数名称为arg0,接下来会执行getfield指令,对应调用ASM内部的visitFieldInsn获取字段name,并命名为arg0.name入栈。这里详细说一下,作者这里判断了字段是否是transient的。

    transient修饰符通常用于在那些实现了序列化接口的类中,不想被序列化的field。

    一旦被transient修饰过后的变量,该变量内容在序列化后无法获得访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
switch (opcode) {
case Opcodes.GETSTATIC:
break;
case Opcodes.PUTSTATIC:
break;
case Opcodes.GETFIELD:
Type type = Type.getType(desc);//获取字段类型
if (type.getSize() == 1) {
//size=1可能为引用类型
Boolean isTransient = null;

// If a field type could not possibly be serialized, it's effectively transient
//判断调用的字段类型是否可序列化
if (!couldBeSerialized(serializableDecider, inheritanceMap, new ClassReference.Handle(type.getInternalName()))) {
isTransient = Boolean.TRUE;
} else {
//若调用的字段可被序列化,则取当前类实例的所有字段,找出调用的字段,去判断是否被标识了transient
ClassReference clazz = classMap.get(new ClassReference.Handle(owner));
while (clazz != null) {
//遍历字段,判断是否是transient类型,以确定是否可被序列化
for (ClassReference.Member member : clazz.getMembers()) {
if (member.getName().equals(name)) {
isTransient = (member.getModifiers() & Opcodes.ACC_TRANSIENT) != 0;
break;
}
}
if (isTransient != null) {
break;
}
//若找不到字段,则向上父类查找,继续遍历
clazz = classMap.get(new ClassReference.Handle(clazz.getSuperClass()));
}
}

Set<Integer> taint;
if (!Boolean.TRUE.equals(isTransient)) {
//若不是Transient字段,则从栈顶取出它,取出的是this或某实例变量,即字段所属实例
taint = getStackTaint(0);
} else {
taint = new HashSet<>();
}

super.visitFieldInsn(opcode, owner, name, desc);
//在调用方法前,都会先入栈,作为参数
setStackTaint(0, taint);
return;
}
break;
image-20210319103128715
  • getfield之后,就是调用method1方法,对应的JVM指令是invokevirtual,所以ASM调用visitMethodInsn,因为Main.main调用了A.method1,在这里个环境,清楚的用代码解释了为什么前面需要把参数命名为arg0、arg1、arg0.name这样,因为需要通过这样的一个字符串名称,和被调用方法的入参进行关联,并最终形成调用者和被调用者直接的参数关联。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
//获取被调用method的参数和类型,非静态方法需要把实例类型放在第一个元素
Type[] argTypes = Type.getArgumentTypes(desc);
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++) {
//最右边的参数,就是最后入栈,即在栈顶
int argIndex = argTypes.length-1-i;
Type type = argTypes[argIndex];
//操作数栈出栈,调用方法前,参数都已入栈
Set<String> taint = getStackTaint(stackIndex);
if (taint.size() > 0) {
for (String argSrc : taint) {
//取出出栈的参数,判断是否为当前方法的入参,arg前缀
if (!argSrc.substring(0, 3).equals("arg")) {
throw new IllegalStateException("Invalid taint arg: " + argSrc);
}
int dotIndex = argSrc.indexOf('.');
int srcArgIndex;
String srcArgPath;
if (dotIndex == -1) {
srcArgIndex = Integer.parseInt(argSrc.substring(3));
srcArgPath = null;
} else {
srcArgIndex = Integer.parseInt(argSrc.substring(3, dotIndex));
srcArgPath = argSrc.substring(dotIndex+1);
}
//记录参数流动关系
//argIndex:当前方法参数索引,srcArgIndex:对应上一级方法的参数索引
discoveredCalls.add(new GraphCall(
new MethodReference.Handle(new ClassReference.Handle(this.owner), this.name, this.desc),
new MethodReference.Handle(new ClassReference.Handle(owner), name, desc),
srcArgIndex,
srcArgPath,
argIndex));
}
}

stackIndex += type.getSize();
}
break;
default:
throw new IllegalStateException("Unsupported opcode: " + opcode);
}

super.visitMethodInsn(opcode, owner, name, desc, itf);
}
}

到此,gadgetinspector.CallGraphDiscovery#discover方法就结束了,然后执行gadgetinspector.CallGraphDiscovery#save对调用者-被调用者参数关系数据进行保存到callgraph.dat文件,其中数据的序列化输出格式,由GraphCall.Factory实现:

image-20201201114033068

数据格式:

1
2
调用者类名 调用者方法caller 调用者方法描述 被调用者类名 被调用者方法target 被调用者方法描述 调用者方法参数索引 调用者字段名 被调用者方法参数索引
Main (Ljava/lang/String;)V main A method1 (Ljava/lang/String;)Ljava/lang/String; 1 1

利用链入口搜索 SourceDiscovery

在这一个阶段中,会扫描所有的class,把符合,也就是可被反序列化并且可以在反序列化执行的方法,全部查找出来,因为没有这样的入口,就算存在执行链,也没办法通过反序列化的时候进行触发。

因为入口的触发,不同的反序列化方式会存在不同是实现,因此,在gadgetinspector中,存在着多个SourceDiscovery的实现,有jackson的,java原生序列化的等等,这里主要以jackson的SourceDiscovery实现开始分析。

这一步会根据已知的反序列化漏洞的入口,检查所有可以被触发的方法。例如,在利用链中使用代理时,任何可序列化并且是java/lang/reflect/InvocationHandler子类的invoke方法都可以视为source。这里还会根据具体的反序列化库决定类是否能被序列化。

image-20201202104444974

image-20201202110221901

这里就是首先去查看想要找哪一种类型的反序列化漏洞入口点,

image-20201202110252070

跟进config.getSourceDiscovery看一眼:

image-20201202110107440

先看SourceDiscovery抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class SourceDiscovery {

private final List<Source> discoveredSources = new ArrayList<>();

protected final void addDiscoveredSource(Source source) {
discoveredSources.add(source);
}

public void discover() throws IOException {
Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();
Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
InheritanceMap inheritanceMap = InheritanceMap.load();

discover(classMap, methodMap, inheritanceMap);
}

public abstract void discover(Map<ClassReference.Handle, ClassReference> classMap,
Map<MethodReference.Handle, MethodReference> methodMap,
InheritanceMap inheritanceMap);

public void save() throws IOException {
DataLoader.saveData(Paths.get("sources.dat"), new Source.Factory(), discoveredSources);
}
}

可以看到它的discover实现中,加载了所有的类、方法、继承实现关系数据,接着调用抽象方法discover,然后,我们跟进jackson的具体实现中:

image-20201202104037072

从上述代码可以看出,实现非常之简单,只是判断了方法:

  1. 是否无参构造方法
  2. 是否getter方法
  3. 是否setter方法

为什么对于source会做这样的判断?因为对于jackson的反序列化,在其反序列化时,必须通过无参构造方法反序列化(没有则会反序列化失败),并且会根据一定情况调用其反序列化对象的getter、setter方法。

这里SourceDiscovery是一个抽象类,具体情况具体继承分析,看一下它的的一个简单继承SimpleSourceDiscovery:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class SimpleSourceDiscovery extends SourceDiscovery {

@Override
public void discover(Map<ClassReference.Handle, ClassReference> classMap,
Map<MethodReference.Handle, MethodReference> methodMap,
InheritanceMap inheritanceMap, Map<MethodReference.Handle, Set<GraphCall>> graphCallMap) {

final SerializableDecider serializableDecider = new SimpleSerializableDecider(inheritanceMap);

for (MethodReference.Handle method : methodMap.keySet()) {
if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {
if (method.getName().equals("finalize") && method.getDesc().equals("()V")) {
addDiscoveredSource(new Source(method, 0));
}
}
}

// If a class implements readObject, the ObjectInputStream passed in is considered tainted
// 如果类实现了readObject,则传入的ObjectInputStream被认为是污染的
for (MethodReference.Handle method : methodMap.keySet()) {
if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {
if (method.getName().equals("readObject") && method.getDesc().equals("(Ljava/io/ObjectInputStream;)V")) {
addDiscoveredSource(new Source(method, 1));
}
}
}

// Using the proxy trick, anything extending serializable and invocation handler is tainted.
// 使用动态代理trick时,任何扩展了serializable and InvocationHandler的类会受到污染。
for (ClassReference.Handle clazz : classMap.keySet()) {
if (Boolean.TRUE.equals(serializableDecider.apply(clazz))
&& inheritanceMap.isSubclassOf(clazz, new ClassReference.Handle("java/lang/reflect/InvocationHandler"))) {
MethodReference.Handle method = new MethodReference.Handle(
clazz, "invoke", "(Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;)Ljava/lang/Object;");

addDiscoveredSource(new Source(method, 0));
}
}

// hashCode() or equals() are accessible entry points using standard tricks of putting those objects into a HashMap.
// hashCode()或equals()是将对象放入HashMap的标准技巧的可访问入口点
for (MethodReference.Handle method : methodMap.keySet()) {
if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {
if (method.getName().equals("hashCode") && method.getDesc().equals("()I")) {
addDiscoveredSource(new Source(method, 0));
}
if (method.getName().equals("equals") && method.getDesc().equals("(Ljava/lang/Object;)Z")) {
addDiscoveredSource(new Source(method, 0));
addDiscoveredSource(new Source(method, 1));
}
}
}

// Using a comparator proxy, we can jump into the call() / doCall() method of any groovy Closure and all the
// args are tainted.
// 使用比较器代理,可以跳转到任何groovy Closure的call()/doCall()方法,所有的args都被污染
// https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/Groovy1.java
for (MethodReference.Handle method : methodMap.keySet()) {
if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))
&& inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/Closure"))
&& (method.getName().equals("call") || method.getName().equals("doCall"))) {

addDiscoveredSource(new Source(method, 0));
Type[] methodArgs = Type.getArgumentTypes(method.getDesc());
for (int i = 0; i < methodArgs.length; i++) {
addDiscoveredSource(new Source(method, i + 1));
}
}
}
}

在扫描所有的方法后,具备条件的method都会被添加到gadgetinspector.SourceDiscovery#discoveredSources中,并最后通过gadgetinspector.SourceDiscovery#save保存

最终输出到sources.dat文件的数据形式:

1
类名 方法名 方法描述 污染参数索引

gadgets挖掘阶段-GadgetChainDiscovery

这一步会遍历全部的source,并在callgraph.dat中递归查找所有可以继续传递污点参数的子方法调用,直至遇到sink中的方法。

分析gadgetinspector.GadgetChainDiscovery#discover代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
public void discover() throws Exception {
Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
InheritanceMap inheritanceMap = InheritanceMap.load();
Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver.getAllMethodImplementations(
inheritanceMap, methodMap);//得到方法的所有子类方法实现(被子类重写的方法)

final ImplementationFinder implementationFinder = config.getImplementationFinder(
methodMap, methodImplMap, inheritanceMap);

//将方法的所有子类方法实现保存到methodimpl.dat
try (Writer writer = Files.newBufferedWriter(Paths.get("methodimpl.dat"))) {
for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodImplMap.entrySet()) {
writer.write(entry.getKey().getClassReference().getName());
writer.write("\t");
writer.write(entry.getKey().getName());
writer.write("\t");
writer.write(entry.getKey().getDesc());
writer.write("\n");
for (MethodReference.Handle method : entry.getValue()) {
writer.write("\t");
writer.write(method.getClassReference().getName());
writer.write("\t");
writer.write(method.getName());
writer.write("\t");
writer.write(method.getDesc());
writer.write("\n");
}
}
}

//方法调用map,key为父方法,value为子方法与父方法参数传递关系
Map<MethodReference.Handle, Set<GraphCall>> graphCallMap = new HashMap<>();
for (GraphCall graphCall : DataLoader.loadData(Paths.get("callgraph.dat"), new GraphCall.Factory())) {
MethodReference.Handle caller = graphCall.getCallerMethod();
if (!graphCallMap.containsKey(caller)) {
Set<GraphCall> graphCalls = new HashSet<>();
graphCalls.add(graphCall);
graphCallMap.put(caller, graphCalls);
} else {
graphCallMap.get(caller).add(graphCall);
}
}

//exploredMethods保存在调用链从查找过程中已经访问过的方法节点,methodsToExplore保存调用链
Set<GadgetChainLink> exploredMethods = new HashSet<>();
LinkedList<GadgetChain> methodsToExplore = new LinkedList<>();
//加载所有sources,并将每个source作为每条链的第一个节点
for (Source source : DataLoader.loadData(Paths.get("sources.dat"), new Source.Factory())) {
GadgetChainLink srcLink = new GadgetChainLink(source.getSourceMethod(), source.getTaintedArgIndex());
if (exploredMethods.contains(srcLink)) {
continue;
}
methodsToExplore.add(new GadgetChain(Arrays.asList(srcLink)));
exploredMethods.add(srcLink);
}

long iteration = 0;
Set<GadgetChain> discoveredGadgets = new HashSet<>();
//使用广度优先搜索所有从source到sink的调用链
while (methodsToExplore.size() > 0) {
if ((iteration % 1000) == 0) {
LOGGER.info("Iteration " + iteration + ", Search space: " + methodsToExplore.size());
}
iteration += 1;

GadgetChain chain = methodsToExplore.pop();//从队首弹出一条链
GadgetChainLink lastLink = chain.links.get(chain.links.size()-1);//取这条链最后一个节点

Set<GraphCall> methodCalls = graphCallMap.get(lastLink.method);//获取当前节点方法所有子方法与当前节点方法参数传递关系
if (methodCalls != null) {
for (GraphCall graphCall : methodCalls) {
if (graphCall.getCallerArgIndex() != lastLink.taintedArgIndex) {
//如果当前节点方法的污染参数与当前子方法受父方法参数影响的Index不一致则跳过
continue;
}

Set<MethodReference.Handle> allImpls = implementationFinder.getImplementations(graphCall.getTargetMethod());//获取子方法所在类的所有子类重写方法

for (MethodReference.Handle methodImpl : allImpls) {
GadgetChainLink newLink = new GadgetChainLink(methodImpl, graphCall.getTargetArgIndex());//新方法节点
if (exploredMethods.contains(newLink)) {
//如果新方法已近被访问过了,则跳过,这里能减少开销。但是这一步跳过会使其他链/分支链经过此节点时,由于已经此节点被访问过了,链会在这里断掉。那么如果这个条件去掉就能实现找到所有链了吗?这里去掉会遇到环状问题,造成路径无限增加...
continue;
}

GadgetChain newChain = new GadgetChain(chain, newLink);//新节点与之前的链组成新链
if (isSink(methodImpl, graphCall.getTargetArgIndex(), inheritanceMap)) {//如果到达了sink,则加入discoveredGadgets
discoveredGadgets.add(newChain);
} else {
//新链加入队列
methodsToExplore.add(newChain);
//新节点加入已访问集合
exploredMethods.add(newLink);
}
}
}
}
}

//保存搜索到的利用链到gadget-chains.txt
try (OutputStream outputStream = Files.newOutputStream(Paths.get("gadget-chains.txt"));
Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {
for (GadgetChain chain : discoveredGadgets) {
printGadgetChain(writer, chain);
}
}

这里先看一下:

1
Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver.getAllMethodImplementations(inheritanceMap, methodMap);

这里是为了得到父类方法的所有子类方法实现(被子类重写的方法)

因为Java的继承特性,对于一个父类,它的方法实现,可以通过子孙类进行重写覆盖。

因为多态特性,实现类只有运行时可确定,因此,需要对其所有重写实现都形成分析链,就能确保在非运行时,做到gadget chain的挖掘。

分析InheritanceDeriver.getAllMethodImplementations代码:

首先获取类对应的方法集合,就是每个里面有哪些方法。

methodByClass:类名-类拥有的方法名

image-20210322115045568

subClassMap:父类-继承的子孙类

image-20210322115453899

接下来遍历methodMap中的每个方法,并通过查询继承了方法所属类的子孙类的方法实现,确定方法是否被重写,最后整合成 方法->重写的方法集 的映射集合,静态方法跳过,是不可被重写的。

image-20210322131051058

保存的数据格式:

image-20210322132035155

接下来整合:

image-20210322132556641

太晕了。。有时间整理。

作者给出的判断sink方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
private boolean isSink(MethodReference.Handle method, int argIndex, InheritanceMap inheritanceMap) {
if (method.getClassReference().getName().equals("java/io/FileInputStream")
&& method.getName().equals("<init>")) {
return true;
}
if (method.getClassReference().getName().equals("java/io/FileOutputStream")
&& method.getName().equals("<init>")) {
return true;
}
if (method.getClassReference().getName().equals("java/nio/file/Files")
&& (method.getName().equals("newInputStream")
|| method.getName().equals("newOutputStream")
|| method.getName().equals("newBufferedReader")
|| method.getName().equals("newBufferedWriter"))) {
return true;
}
if (method.getClassReference().getName().equals("java/lang/Runtime")
&& method.getName().equals("exec")) {
return true;
}
/*
if (method.getClassReference().getName().equals("java/lang/Class")
&& method.getName().equals("forName")) {
return true;
}
if (method.getClassReference().getName().equals("java/lang/Class")
&& method.getName().equals("getMethod")) {
return true;
}
*/
// If we can invoke an arbitrary method, that's probably interesting (though this doesn't assert that we
// can control its arguments). Conversely, if we can control the arguments to an invocation but not what
// method is being invoked, we don't mark that as interesting.
if (method.getClassReference().getName().equals("java/lang/reflect/Method")
&& method.getName().equals("invoke") && argIndex == 0) {
return true;
}
if (method.getClassReference().getName().equals("java/net/URLClassLoader")
&& method.getName().equals("newInstance")) {
return true;
}
if (method.getClassReference().getName().equals("java/lang/System")
&& method.getName().equals("exit")) {
return true;
}
if (method.getClassReference().getName().equals("java/lang/Shutdown")
&& method.getName().equals("exit")) {
return true;
}
if (method.getClassReference().getName().equals("java/lang/Runtime")
&& method.getName().equals("exit")) {
return true;
}
if (method.getClassReference().getName().equals("java/nio/file/Files")
&& method.getName().equals("newOutputStream")) {
return true;
}
if (method.getClassReference().getName().equals("java/lang/ProcessBuilder")
&& method.getName().equals("<init>") && argIndex > 0) {
return true;
}
if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("java/lang/ClassLoader"))
&& method.getName().equals("<init>")) {
return true;
}
if (method.getClassReference().getName().equals("java/net/URL") && method.getName().equals("openStream")) {
return true;
}
// Some groovy-specific sinks
if (method.getClassReference().getName().equals("org/codehaus/groovy/runtime/InvokerHelper")
&& method.getName().equals("invokeMethod") && argIndex == 1) {
return true;
}
if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/MetaClass"))
&& Arrays.asList("invokeMethod", "invokeConstructor", "invokeStaticMethod").contains(method.getName())) {
return true;
}
return false;
}

对于每个入口节点来说,其全部子方法调用、孙子方法调用等等递归下去,就构成了一棵树。之前的步骤所做的,就相当于生成了这颗树,而这一步所做的,就是从根节点出发,找到一条通往叶子节点的道路,使得这个叶子节点正好是我们所期望的sink方法。gadgetinspector对树的遍历采用的是广度优先(BFS),而且对于已经检查过的节点会直接跳过,这样减少了运行开销,避免了环路,但是丢掉了很多其他链。

GI的缺点

  1. 对于运行时确定的实现,也就是多态性,没办法做到污点分析:
  2. 还是因为多态的原因,还是没法做到完整的调用链搜索