0x01 前言
我们在使用ysoserial的时候,经常会用它生成序列化的payload,用于攻击具有反序列化功能的endpoint,而这些payload大部分都是比较长的一条执行链,在反序列化期间,由执行程序执行攻击者可控的source,然后通过依赖中存在的执行链,最终触发至slink,从而达到攻击的效果。
这些gadget chain有长有短,大部分可以通过类似Intellij idea这类工具去根据slink,查找调用者,以及各种调用者的实现,一路反向的跟踪,对于一些比较简单比较短的链,通常通过人工查找也能快速的找到,但是对于一些比较长的链,人工查找会耗费巨大的精力和时间,并且不一定能挖掘到gadget chain。
而有段时间,我苦恼于人工查找浪费巨大精力得不偿失时,忽然发现这样一款自动化挖掘gadget chain的工具,通过阅读分析它的源码,它给我带来了非常多的知识以及自动化挖掘的思路,其中就包括类似污点分析,如何去分析方法调用中,参数是否可以影响返回值,从而跟踪数据流动是否可以从source最终流动至slink,并影响至最终的slink点。
gadgetinspector:
https://github.com/JackOfMostTrades/gadgetinspector
slink:
- Runtime.exec():这种利用最为简单,但是实际生产情况基本不会遇到
- Method.invoke():这种方式通过反射执行方法,需要方法以及参数可控
- RMI/JRMP:通过反序列化使用RMI或者JRMP链接到我们的exp服务器,通过发送序列化payload至靶机实现
- URL.openStream:这种利用方式需要参数可控,实现SSRF
- Context.lookup:这种利用方式也是需要参数可控,最终通过rmi或ldap的server实现攻击
- …等等
在分析gadgetinspector源码的时候,大概会在以下几方面去讲解,并核心分析ASM部分,详细讲解如何进行污点分析:
- GadgetInspector:main方法,程序的入口,做一些配置以及数据的准备工作
- MethodDiscovery:类、方法数据以及父子类、超类关系数据的搜索
- PassthroughDiscovery:分析参数能影响到返回值的方法,并收集存储
- CallGraphDiscovery:记录调用者caller方法和被调用者target方法的参数关联
- SourceDiscovery:入口方法的搜索,只有具备某种特征的入口才会被标记收集
- GadgetChainDiscovery:整合以上数据,并通过判断调用链的最末端slink特征,从而判断出可利用的gadget chain
0x02 GadgetInspector:入口代码的分析
程序启动的入口,在该方法中,会做一些数据的准备工作,并一步步调用MethodDiscovery、PassthroughDiscovery、CallGraphDiscovery、SourceDiscovery、GadgetChainDiscovery,最终实现gadget chain的挖掘
参数合法判断:1
2
3
4if (args.length == 0) {
printUsage();
System.exit(1);
}
在程序的入口处,会先判断启动参数是否为空,若是空,则直接退出,因为程序对挖掘的gadget chain会有类型的区分,以及class所在位置的配置
日志、序列化类型配置:1
2
3
4
5
6//配置log4j用于输出日志
configureLogging();
boolean resume = false;
//挖掘的gadget chain序列化类型,默认java原生序列化
GIConfig config = ConfigRepository.getConfig("jserial");
日志配置是便于统一的输出管理,而序列化类型的配置,因为对链的挖掘前,我们需要确定挖掘的是哪种类型的链,它可以是jackson的json序列化,也可以是java原生的序列化等等
序列化配置接口:1
2
3
4
5
6
7
8
9
10public interface GIConfig {
String getName();
SerializableDecider getSerializableDecider(Map<MethodReference.Handle, MethodReference> methodMap, InheritanceMap inheritanceMap);
ImplementationFinder getImplementationFinder(Map<MethodReference.Handle, MethodReference> methodMap,
Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap,
InheritanceMap inheritanceMap);
SourceDiscovery getSourceDiscovery();
}
既然我们选择了不同的序列化形式,那么,相对来说,它们都会有自身特有的特征,因此我们需要实现jackson特有的SerializableDecider、ImplementationFinder、SourceDiscovery,从而能达到区分,并最终实现gadget chain的挖掘,
例jackson:
- SerializableDecider-JacksonSerializableDecider:
1 | public class JacksonSerializableDecider implements SerializableDecider { |
这一块代码,我们可以主要关心在apply方法中,可以看到,具体细节的意思就是,只要存在无参的构造方法,都表示可以被序列化。因为在java中,若没有显式的实现无参构造函数,而实现了有参构造函数,在这种情况下,该类是不具有无参构造方法的,而jackson对于json的反序列化,都是先通过无参构造方法进行实例化,因此,若无无参构造方法,则表示不能被jackson进行反序列化。所以,该决策类的存在意义,就是标识gadget chian中不可被反序列化的类,不可被反序列化就意味着数据流不可控,gadget chain无效。
- ImplementationFinder-JacksonImplementationFinder
1 | public class JacksonImplementationFinder implements ImplementationFinder { |
该实现类核心方法是getImplementations,因为java是一个多态性的语言,只有在运行时,程序才可知接口的具体实现类是哪一个,而gadgetinspector并不是一个运行时的gadget chain挖掘工具,因此,当遇到一些接口方法的调用时,需要通过查找该接口方法的所有实现类,并把它们组成链的一节形成实际调用的链,最后去进行污点分析。而该方法通过调用JacksonSerializableDecider的apply方法进行判断,因为对于接口或者子类的实现,我们是可控的,但是该json是否可被反序列化,需要通过JacksonSerializableDecider判断是否存在无参构造方法。
- SourceDiscovery-JacksonSourceDiscovery
1 | public class JacksonSourceDiscovery extends SourceDiscovery { |
该实现类,仅有discover这一个方法,不过,对于gadget chain的挖掘,它可以肯定是最重要的,因为一个gadget chain的执行链,我们必须要有一个可以触发的入口,而JacksonSourceDiscovery的作用就是找出具备这样特征的入口方法,对于jackson反序列化json时,它会执行无参构造方法以及setter、getter方法,若我们在数据字段可控的情况下,并由这些被执行的方法去触发,若存在gadget chain,那么就能触发source-slink整条链的执行。
1 | int argIndex = 0; |
此处是对于一些参数的一些解析配置:
1 | --resume:不删除dat文件 |
1 | final ClassLoader classLoader; |
这段代码,解析了程序启动参数最后一个“–参数”后的部分,这部分可以指定一个war包,也能指定多个jar包,并最终放到ClassResourceEnumerator,ClassResourceEnumerator通过guava的ClassPath,对配置加载的war、jar中的所有class进行读取或对jre的rt.jar中的所有class进行读取
1 | //删除所有的dat文件 |
这段代码,可以看到,如果没有配置–resume参数,那么在程序的每次启动后,都会先删除所有的dat文件
1 | //扫描java runtime所有的class(rt.jar)和指定的jar或war中的所有class |
最后这部分,就是核心的挖掘逻辑。
0x03 MethodDiscovery
这部分,主要进行了类数据、方法数据以及类继承关系数据的收集1
2
3
4
5
6
7
8if (!Files.exists(Paths.get("classes.dat")) || !Files.exists(Paths.get("methods.dat"))
|| !Files.exists(Paths.get("inheritanceMap.dat"))) {
LOGGER.info("Running method discovery...");
MethodDiscovery methodDiscovery = new MethodDiscovery();
methodDiscovery.discover(classResourceEnumerator);
//保存了类信息、方法信息、继承实现信息
methodDiscovery.save();
}
从上述代码可以看到,先判断了classes.dat、methods.dat、inheritanceMap.dat三个文件是否存在,若不存在则执行MethodDiscovery的实例化,并依次调用其discover、save方法
1 | public void discover(final ClassResourceEnumerator classResourceEnumerator) throws Exception { |
MethodDiscovery.discover方法中,通过调用classResourceEnumerator.getAllClasses()获取到rt.jar以及程序参数配置的jar、war中所有的class,然后遍历每一个class,接着通过ASM,对其每个类进行观察者模式的visit
跟进MethodDiscoveryClassVisitor,对于ClassVisitor,ASM对其每个方法的调用顺序是这样的:
visit顺序:1
2void visit(int version, int access, String name, String signature, String superName, String[] interfaces)
visit( 类版本 , 修饰符 , 类名 , 泛型信息 , 继承的父类 , 实现的接口)
->1
void visitSource(String source, String debug)
->1
void visitOuterClass(String owner, String name, String descriptor)
->1
void visitAttribute(Attribute attribute)
->1
2AnnotationVisitor visitAnnotation(String descriptor, boolean visible)
visitAnnotation(注解类型 , 注解是否可以在 JVM 中可见)
->1
void visit*()
->1
void visitEnd()
->1
2FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value)
visitField(修饰符 , 字段名 , 字段类型 , 泛型描述 , 默认值)
->1
2MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions)
visitMethod(修饰符 , 方法名 , 方法签名 , 泛型信息 , 抛出的异常)
那么,跟进这个调用顺序,我们跟进其实现代码:
1 | private class MethodDiscoveryClassVisitor extends ClassVisitor { |
visit()这个方法,会在类被观察的第一时间执行。可以看到在visit()这个方法执行时,保存了当前观察类的一些信息:
- this.name:类名
- this.superName:继承的父类名
- this.interfaces:实现的接口名
- this.isInterface:当前类是否接口
- this.members:类的字段集合
- this.classHandle:gadgetinspector中对于类名的封装
1 | public FieldVisitor visitField(int access, String name, String desc, |
第二步,被观察类若存在多少个field字段,那么visitField()这个方法,就会被调用多少次,每调用一次,就代表一个字段。看实现代码,visitField()方法在被调用时,会通过判断字段的类型去生成typeName类型名称,最后添加到visit()方法中初始化的this.members集合
1 | @Override |
而被观察类若存在多少个方法,那么visitMethod()这个方法,就会被调用多少次,每调用一次,就代表一个方法,看上述代码,可以清楚的看到,其对方法进行了收集,并缓存在this.discoveredMethods中
1 | @Override |
而在每一个visit*方法被执行后,最后一个执行的方法就是visitEnd(),在这段代码中,把当前的被观察的类信息缓存到了this.discoveredClasses,其中包括前面visitField阶段收集到的所有字段members
至此,MethodDiscovery.discover方法就执行完毕了,而下一步就是MethodDiscovery.save方法的执行
1 | public void save() throws IOException { |
通过DataLoader.saveData保存了收集到的discoveredClasses类信息以及discoveredMethods方法信息,对于这些信息的存储格式,通过了ClassReference.Factory()、MethodReference.Factory()进行实现
1 | public static <T> void saveData(Path filePath, DataFactory<T> factory, Collection<T> values) throws IOException { |
saveData方法中会通过调用factory的serialize对数据进行序列化,然后一行一行的输出
1 | public static class Factory implements DataFactory<ClassReference> { |
1 | public static class Factory implements DataFactory<MethodReference> { |
对于类信息的存储,最终形成classes.dat文件的数据格式是:
1 | 类名(例:java/lang/String) 父类 接口A,接口B,接口C 是否接口 字段1!字段1access!字段1类型!字段2!字段2access!字段1类型 |
对于方法信息的存储,最终形成methods.dat文件的数据格式是:
1 | 类名 方法名 方法描述 是否静态方法 |
在对类、方法信息存储后,会再进一步利用已得到的类信息,进行类继承、实现关系的整合分析:
1 | //形成 类名(ClassReference.Handle)->类(ClassReference) 的映射关系 |
核心实现位于InheritanceDeriver.derive方法
1 | public static InheritanceMap derive(Map<ClassReference.Handle, ClassReference> classMap) { |
前面类信息的收集保存,其得到的数据:
1 | 类名(例:java/lang/String) 父类 接口A,接口B,接口C 是否接口 字段1!字段1access!字段1类型!字段2!字段2access!字段1类型 |
通过这些信息,可以清楚的知道每个类继承的父类、实现的接口类,因此,通过遍历每一个类,并且通过递归的方式,从而一路向上查找收集,最终形成了父子、超类间的关系集合:
1 | 类名 -> 所有的父类、超类、接口类 |
并在实例化InheritanceMap返回时,在其构造方法中,对关系集合进行了逆向的整合,最终形成了:
1 | 类名 -> 所有的子孙类、实现类 |
构造方法细节:
1 | public class InheritanceMap { |
最后,对于收集到的继承、实现关系数据,通过调用InheritanceDeriver.save方法,在其内部调用DataLoader.saveData并通过InheritanceMapFactory的序列化方法,对数据进行保存
1 | public void save() throws IOException { |
1 | private static class InheritanceMapFactory implements DataFactory<Map.Entry<ClassReference.Handle, Set<ClassReference.Handle>>> { |
最终保存到inheritanceMap.dat文件中的数据格式:
1 | 类名 父类或超类或接口类1 父类或超类或接口类2 父类或超类或接口类3 ... |
0x04 方法入参和返回值污点分析-PassthroughDiscovery
在这一小节中,我主要讲解的是PassthroughDiscovery中的代码,该部分也是整个gadgetinspector中比较核心的部分,我在阅读相关代码的时候,通过查看网络上的一些资料、博文,他们对于大体原理的讲解,都分析得比较详细,其中有一篇https://paper.seebug.org/1034/,个人觉得讲得非常不错,其中就有关于逆拓扑结构等部分,在阅读本文章的时候,大家可以同时阅读这篇文章,相互结合着看,会有意向不到的效果,但该文章也有部分细节讲得不够透彻,其中就有ASM实现细节部分,而本篇文章,这一部分章节部分原因是为了弥补它的细节不足处而编写,还有就是主要为了阐述我对gadgetinspector的理解。
在讲这部分代码之前,我想要展示一个代码例子:
1 | public void main(String args) throws IOException { |
从上述代码,我们可以看到类A和方法method,方法method接收到参数后,通过return返回,接着赋值给main方法中的cmd变量,最后Runtime.exec执行命令。
所以,根据上面代码展示,我们只要能控制method这个方法的入参,就能控制其方法的返回值,并控制数据流最终流向Runtime.exec。这其实类似于污点分析,而在PassthroughDiscovery这个类的处理阶段中,最主要就是做这样的一件事,通过不断的分析所有的方法,它们是否会被入参所污染。
还有就是,方法数据流的传递,不仅仅是一层两层,可能在整个gadget chain中,会牵涉到非常之多的方法,那么,对于所有方法数据流的污点分析,其分析顺序将会是成功与否的前提条件。这边继续讲一个例子吧:
1 | public void main(String args) throws IOException { |
上述代码,可以看到source-slink之间的具体流程,经过数据流的污点分析,我们可以得到结果:
1 | A$method1-1 |
从代码上分析,因为A.method1的入参我们可以控制,并且其返回值间接的也被入参控制,接着赋值给了cmd变量,那么就表示cmd这个变量我们也是可以控制的,接着调用B.method2,cmd变量作为入参,并接着再把其入参作为C.method3的入参,最终走到Runtime.getRuntime().exec(param),那么,就意味着只要我们控制了A.method1的入参,最终我们可以通过这个数据,最终影响整个source->slink,并最终得到执行exec。
而从上面的代码流程,我们只要搞明白了A类的method1方法、B类的method2方法以及C类的method3方法能被哪个参数污染下去,那么,我们就能确定整个source至slink的污点传递,但是,这里有个问题,在得到B类的method2方法参数的污染结果之前,必须得先把C类的method3方法参数的污染结果得到,而具体怎么做到呢?在gadgetinspector中,通过了DTS,一种逆拓扑顺序的方式,先得到方法执行链的逆序排序的方法集合,然后由此,从最末端进行参数污点分析,倒着回来,也就是,我先确认C类的method3方法参数的污染结果,并存储起来,接着进行分析B类的method2方法的时候,就能根据前面得到的结果,继续分析下去,最后得到B类的method2方法的参数污染结果。
那么,逆拓扑顺序的具体代码实现是如何呢?
我们跟进passthroughDiscovery.discover方法1
2
3
4
5
6//加载文件记录的所有方法信息
Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
//加载文件记录的所有类信息
Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();
//加载文件记录的所有类继承、实现关联信息
InheritanceMap inheritanceMap = InheritanceMap.load();
可以看到前三个操作分别是加载前面MethodDiscovery收集到的类、方法、继承实现的信息
接着,调用discoverMethodCalls方法,整理出所有方法,调用者方法caller和被调用者target方法之间映射的集合1
2//搜索方法间的调用关系,缓存至methodCalls集合,返回 类名->类资源 映射集合
Map<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);
通过ASM Visitor的方式,使用MethodCallDiscoveryClassVisitor这个ClassVisitor实现类进行方法调用的收集1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16private Map<String, ClassResourceEnumerator.ClassResource> discoverMethodCalls(final ClassResourceEnumerator classResourceEnumerator) throws IOException {
Map<String, ClassResourceEnumerator.ClassResource> classResourcesByName = new HashMap<>();
for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {
try (InputStream in = classResource.getInputStream()) {
ClassReader cr = new ClassReader(in);
try {
MethodCallDiscoveryClassVisitor visitor = new MethodCallDiscoveryClassVisitor(Opcodes.ASM6);
cr.accept(visitor, ClassReader.EXPAND_FRAMES);
classResourcesByName.put(visitor.getName(), classResource);
} catch (Exception e) {
LOGGER.error("Error analyzing: " + classResource.getName(), e);
}
}
}
return classResourcesByName;
}
MethodCallDiscoveryClassVisitor中的运转流程:
1 | private class MethodCallDiscoveryClassVisitor extends ClassVisitor { |
方法的执行顺序是visit->visitMethod->visitEnd,前面也说过了,ASM对于观察者模式的具体表现。
- visit:在这个方法中,把当前观察的类名赋值到了this.name
- visitMethod:在这个方法中,继续进一步的对被观察类的每一个方法细节进行观察
继续进一步对方法的观察实现类是MethodCallDiscoveryMethodVisitor:
1 | private class MethodCallDiscoveryMethodVisitor extends MethodVisitor { |
具体的代码,我这里也做了比较详细的注释,在MethodCallDiscoveryMethodVisitor构造方法执行的时候,会对this.calledMethods集合进行初始化,该集合的主要作用是在被观察方法对其他方法进行调用时(会执行visitMethodInsn方法),用于缓存记录被调用的方法,因此,我们可以看到visitMethodInsn方法中,执行了
1 | calledMethods.add(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc)); |
并且在构造方法执行的时候,集合calledMethods也会被添加到gadgetinspector.PassthroughDiscovery#methodCalls中,做全局性的收集,因此,最后我们能通过discoverMethodCalls这一个方法,实现对这样一个数据的全量收集:
1 | {{sourceClass,sourceMethod}:[{targetClass,targetMethod}]} |
接着,在下一步,通过调用1
List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();
完成了对上述收集到的数据:
1 | {{sourceClass,sourceMethod}:[{targetClass,targetMethod}]} |
实现逆拓扑的排序,跟进topologicallySortMethodCalls方法
1 | Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences = new HashMap<>(); |
第一步,对methodCalls的数据进行了封装整理,形成了Map<MethodReference.Handle, Set<MethodReference.Handle>>这样结构的数据
1 | // Topological sort methods |
1 | private static void dfsTsort(Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences, |
接着,通过遍历每个方法,并调用dfsTsort实现逆拓扑排序,具体细节示意图,我前面推荐的那篇文章画得非常不错,建议此时去看看
- dfsStack用于在在逆拓扑时候不会形成环
- visitedNodes在一条调用链出现重合的时候,不会造成重复的排序
- sortedMethods最终逆拓扑排序出来的结果集合
最终,实现的效果如下:
1 | public void main(String args) throws IOException { |
调用链main->A.method1,main->B.method2->C.method3
排序后的结果:
1 | A.method1 |
通过这样的一个结果,就如我们前面所讲的,就能在污点分析方法参数的时候,根据这个排序后的集合顺序进行分析,从而在最末端开始进行,在上一层也能通过缓存取到下层方法已经过污点分析的结果,继而继续走下去。
这些,便是逆拓扑排序的实现以及意义。
接着,就到重头戏了,我这篇文章最想要描述的ASM怎么进行参数和返回结果之间的污点分析
1 | /** |
跟进calculatePassthroughDataflow这个方法
首先,会初始化一个集合,用于收集污染结果,key对应方法名,value对应可以污染下去的参数索引集合1
final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>();
紧接着,遍历被排序过后的方法,并跳过static静态初始化方法,因为静态代码块我们基本上是没办法污染的,其执行的时机在类加载的阶段1
2
3
4
5
6
7
8//遍历所有方法,然后asm观察所属类,经过前面DFS的排序,调用链最末端的方法在最前面
for (MethodReference.Handle method : sortedMethods) {
//跳过static静态初始化代码
if (method.getName().equals("<clinit>")) {
continue;
}
...
}
然后根据方法信息,获取到所属的类,接着通过ASM对其进行观察1
2
3
4
5
6
7
8
9
10
11
12
13
14
15//获取所属类进行观察
ClassResourceEnumerator.ClassResource classResource = classResourceByName.get(method.getClassReference().getName());
try (InputStream inputStream = classResource.getInputStream()) {
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);
}
} catch (IOException e) {
LOGGER.error("Unable to analyze " + method.getClassReference().getName(), e);
}
PassthroughDataflowClassVisitor实现中,重点在于visitMethod方法
1 | //不是目标观察的method需要跳过,上一步得到的method都是有调用关系的method才需要数据流分析 |
因为在上述构造PassthroughDataflowClassVisitor时,最后一个参数传入的便是需要观察的方法,因此,在ASM每观察到一个方法都会执行visitMethod的时候,通过此处重新判断是否我们关心的方法,只有我们关心的方法,最终才通过下一步构建PassthroughDataflowMethodVisitor对其进行方法级别的观察
1 | //对method进行观察 |
继续跟进PassthroughDataflowMethodVisitor,可以看到,它继承了TaintTrackingMethodVisitor,并有以下几个方法的实现:
- visitCode:在进入方法的第一时间,ASM会先调用这个方法
- visitInsn:在方法体重,每一个字节码操作指令的执行,ASM都会调用这个方法
- visitFieldInsn:对于字段的调用,ASM都会调用这个方法
- visitMethodInsn:方法体内,一旦调用了其他方法,都会触发这个方法的调用
在展示这四个方法的具体代码前,我还要说一下其父类中的一个方法:visitVarInsn,这个方法,会在方法体内字节码操作变量时,会被调用
为了实现类似污点分析,去分析参数对方法的污染,其模仿了jvm,实现了两个个集合,分别是本地变量表和操作数栈,通过其,实现具体的污点分析,那么具体是怎么进行的呢?
在分析前,我继续贴一个代码例子:
1 | public class Main { |
在这个例子中,通过逆拓扑排序后得到的列表为:
1 | A.method1 |
那么,分析也是根据这个顺序进行
- A.method1:
第一步,ASM对A.method1进行观察,也就是PassthroughDataflowMethodVisitor进行观察,那么,在其方法被执行开始的时候,会触发PassthroughDataflowMethodVisitor.visitCode方法的调用,在这一步的代码中,我们可以看到,会对方法是否是static方法等进行判断,接着做了一个操作,就是把入参放到了本地变量表中来,为什么要这样做呢?我们可以想象一下,一个方法内部,能用到的数据要不就是本地变量表的数据,要不就是通过字段调用的数据,那么,在分析调用其他方法,或者对返回值是否会被入参污染时的数据流动,都跟它紧密关联,为什么这样说?根据jvm字节码的操作,在调用方法前,肯定需要对相关参数进行入栈,那入栈的数据从哪里来,必然就是本地变量表或者其他字段。那么在形成这样的一个本地变量表之后,就能标识一个方法内部的数据流动,并最终确定污染结果。
1 | @Override |
第二步,在入参进入本地变量表之后,会执行return这个代码,并把param这个参数返回,在这个指令执行的时候会触发visitVarInsn方法,那么在进行return操作前,首先,会对其参数param进行入栈,因为param是引用类型,那么操作代码就是Opcodes.ALOAD,可以看到,代码中,从本地变量表获取了变量索引,并放入到操作数栈中来
1 | @Override |
第三步,执行return指令,也就触发visitInsn这个方法,因为返回的是引用类型,那么相应的指令就是Opcodes.ARETURN,可以看到,在这个case中,会从栈顶,获取刚刚入栈(第二步中visitVarInsn从本地变量表获取的参数索引)的参数索引,并存储到returnTaint中,因此,即表示A.method1这个方法的调用,参数索引为1的参数param会污染返回值。
1 | @Override |
第四步,经过return之后,该方法的观察也就结束了,那么,回到gadgetinspector.PassthroughDiscovery#calculatePassthroughDataflow中,对于刚刚放到returnTaint污点分析结果,也会在其方法中,缓存到passthroughDataflow
1 | ClassReader cr = new ClassReader(inputStream); |
C.method3:该方法和A.method1的污点分析流程是一样的
B.method2:这个方法和前面连个都不一样,它内部调用了C.method3方法,因此,污点分析时,具体的细节就又不一样了
第一步,在其方法被执行开始的时候,同样会触发PassthroughDataflowMethodVisitor.visitCode方法的调用,在其中,也是做了相应的操作,把入参存到了本地变量表中来
第二步,因为方法内部即将调用C.method3,那么ASM调用visitVarInsn方法,对其参数param进行入栈,因为param是引用类型,那么操作代码就是Opcodes.ALOAD,因此,从第一步保存的本地变量表中获取变量入栈
第三步,方法内部调用了C.method3,那么,ASM就会触发visitMethodInsn方法的执行,在这一步,会先对被调用方法的入参进行处理,并把被调用方法的实例放到argTypes的第一个索引位置,后面依次放置其他参数,接着计算返回值大小。然后,因为方法调用,第二步已经把参数入栈了,而这些参数都是从本地变量表获取的,那么,可以从栈顶取到相关参数,并认为这些参数是可被控制,也就是被当前调用者caller方法污染的,最后,也就是最重点的一步,从passthroughDataflow中获取了被调用方法的参数污染结果,也就是C.method3方法被分析时候,return存储的数据,所以,这里就印证了前面为什么要使用逆拓扑排序,因为如果不这样做的话,C.method3可能在B.method2后被分析,那么,缓存就不可能存在污点分析的结果,那么就没办法对B.method2进行正确的污点分析。接着就是对从缓存取出的污染结果和入参对比,取出相应索引的污点参数,放入到resultTaint中
1 | @Override |
第四步,接着执行return,跟前面一样,保存到passthroughDataflow
- main:最后需要分析的是main方法的入参args是否会污染到其返回值
1 | public String main(String args) throws IOException { |
按照上面A.method1、B.method2、C.method3的参数污染分析结果,很明显在观察main方法的时候
第一步,执行visitCode存储入参到本地变量表
第二步,执行visitVarInsn参数入栈
第三步,执行visitMethodInsn调用A.method1,A.method1被污染的返回结果,也就是参数索引会被放在栈顶
第四步,执行visitVarInsn把放在栈顶的污染参数索引,放入到本地变量表
第五步,执行visitVarInsn参数入
第六步,执行visitMethodInsn调用B.method2,被污染的返回结果会被放在栈顶
第七步,执行visitInsn,返回栈顶数据,缓存到passthroughDataflow,也就是main方法的污点分析结果
到此,ASM实现方法入参污染返回值的分析就到此为止了。
接下来,passthroughDiscovery.save方法就被调用
1 | public void save() throws IOException { |
也是通过DataLoader.saveData把结果一行一行的保存到passthrough.dat文件中,而每行数据的序列化,是通过PassThroughFactory实现
1 | public static class PassThroughFactory implements DataFactory<Map.Entry<MethodReference.Handle, Set<Integer>>> { |
最终,这一阶段分析保存下来passthrough.dat文件的数据格式:
1 | 类名 方法名 方法描述 能污染返回值的参数索引1,能污染返回值的参数索引2,能污染返回值的参数索引3... |
0x05 方法调用关联-CallGraphDiscovery
在这一阶段,会进行对方法调用关联的分析,也就是方法调用者caller和方法被调用者target直接的参数关联
举个例子描述:
1 | public class Main { |
在经过这个阶段,能得到的数据:
1 | 调用者类名 调用者方法caller 调用者方法描述 被调用者类名 被调用者方法target 被调用者方法描述 调用者方法参数索引 调用者字段名 被调用者方法参数索引 |
跟回代码,gadgetinspector.CallGraphDiscovery#discover:
加载了前面几个阶段分析处理的数据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进行观察
1 | SerializableDecider serializableDecider = config.getSerializableDecider(methodMap, inheritanceMap); |
ModelGeneratorClassVisitor的实现没什么重点的逻辑,主要就是对每一个方法都进行了ASM的观察
1 | private class ModelGeneratorClassVisitor extends ClassVisitor { |
ModelGeneratorMethodVisitor的实现,是这一步的重点逻辑所在,因为单单文字描述可能理解不太清楚,我这边继续以一个例子进行讲解:
1 | public class Main { |
可以看到上述例子中,Main的main方法中,调用了A.main1方法,并且入参是main的参数args以及Main的字段name
ASM的实现流程:
- 在Main.main方法体被观察到的第一时间,ASM会调用ModelGeneratorMethodVisitor.visitCode,在这个方法中,根据参数的数量,一一形成名称arg0、arg1…,然后放入到本地变量表
1 | @Override |
- 接着,因为即将要调用A.method1,ASM会调用visitVarInsn,把刚刚放入到本地变量表的arg0入栈
1 | @Override |
- 然后,ASM调用visitVarInsn把当前实例对应的参数入栈,上一步visitCode已经把实例命名为arg0存在本地变量表中,因此入栈的参数名称为arg0,截止调用visitFieldInsn获取字段name,并命名为arg0.name入栈
1 | @Override |
- 最后ASM调用visitMethodInsn,因为Main.main调用了A.method1,在这里个环境,清楚的用代码解释了为什么前面需要把参数命名为arg0、arg1、arg0.name这样,因为需要通过这样的一个字符串名称,和被调用方法的入参进行关联,并最终形成调用者和被调用者直接的参数关联
1 | @Override |
到此,gadgetinspector.CallGraphDiscovery#discover方法就结束了,然后执行gadgetinspector.CallGraphDiscovery#save对调用者-被调用者参数关系数据进行保存到callgraph.dat文件,其中数据的序列化输出格式,由GraphCall.Factory实现
1 | public static class Factory implements DataFactory<GraphCall> { |
数据格式:
1 | 调用者类名 调用者方法caller 调用者方法描述 被调用者类名 被调用者方法target 被调用者方法描述 调用者方法参数索引 调用者字段名 被调用者方法参数索引 |
0x06 利用链入口搜索-SourceDiscovery
在这一个阶段中,会扫描所有的class,把符合,也就是可被反序列化并且可以在反序列化执行的方法,全部查找出来,因为没有这样的入口,就算存在执行链,也没办法通过反序列化的时候进行触发。
因为入口的触发,不同的反序列化方式会存在不同是实现,因此,在gadgetinspector中,存在着多个SourceDiscovery的实现,有jackson的,java原生序列化的等等,我这里主要以jackson的SourceDiscovery实现开始分析。
先看SourceDiscovery抽象类:
1 | public abstract class SourceDiscovery { |
可以看到,它的discover实现中,加载了所以的类、方法、继承实现关系数据,接着调用抽象方法discover,然后,我们跟进jackson的具体实现中
1 | public class JacksonSourceDiscovery extends SourceDiscovery { |
从上述代码可以看出,实现非常之简单,只是判断了方法:
- 是否无参构造方法
- 是否getter方法
- 是否setter方法
为什么对于source会做这样的判断?因为对于jackson的反序列化,在其反序列化时,必须通过无参构造方法反序列化(没有则会反序列化失败),并且会根据一定情况调用其反序列化对象的getter、setter方法
在扫描所有的方法后,具备条件的method都会被添加到gadgetinspector.SourceDiscovery#discoveredSources中,并最后通过gadgetinspector.SourceDiscovery#save保存
1 | public void save() throws IOException { |
保存数据的序列化实现由Source.Factory实现
1 | public static class Factory implements DataFactory<Source> { |
最终输出到sources.dat文件的数据形式:
1 | 类名 方法名 方法描述 污染参数索引 |
0x07 最终挖掘阶段-GadgetChainDiscovery
这个阶段,是gadgetinspector自动化挖掘gadget chain的最终阶段,该阶段利用前面获取到的所有数据,从source到slink进行整合分析,最终判断slink,确定是否有效的gadget chain。
分析gadgetinspector.GadgetChainDiscovery#discover代码:
加载所有的方法数据以及继承实现关系数据1
2Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
InheritanceMap inheritanceMap = InheritanceMap.load();
重写方法的扫描
获取方法的所有实现,这是什么意思呢?因为java的继承特性,对于一个父类,它的方法实现,可以通过子孙类进行重写覆盖,为什么要这样做呢?因为多态特性,实现类只有运行时可确定,因此,需要对其所有重写实现都形成分析链,就能确保在非运行时,做到gadget chain的挖掘1
2Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver.getAllMethodImplementations(
inheritanceMap, methodMap);
分析InheritanceDeriver.getAllMethodImplementations代码:
- 获取类->方法集
1 | //遍历整合,得到每个类的所有方法实现,形成 类->实现的方法集 的映射 |
- 获取父类->子孙类集
1 | //遍历继承关系数据,形成 父类->子孙类集 的映射 |
- 遍历每个方法,并通过查询方法类的子孙类的方法实现,确定重写方法,最后整合成 方法->重写的方法集 的映射集合,静态方法跳过,因为静态方法是不可被重写的
1 | Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = new HashMap<>(); |
保存方法重写数据
回到gadgetinspector.GadgetChainDiscovery#discover中,接着,对扫描到的重写方法数据进行保存
1 | try (Writer writer = Files.newBufferedWriter(Paths.get("methodimpl.dat"))) { |
保存的数据格式:
1 | 类名 方法名 方法描述 |
整合方法调用关联数据
在前面阶段中,扫描出来的方法调用参数关联数据,都是独立的,也就是说,例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class Main {
private String name;
public void main(String args) throws IOException {
new A().method1(args, name);
new A().method2(args, name);
}
}
class A {
public String method1(String param, String param2) {
return param + param2;
}
public String method2(String param, String param2) {
return param + param2;
}
}
形成的方法调用参数关联数据:
1 | Main (Ljava/lang/String;)V main A method1 (Ljava/lang/String;)Ljava/lang/String; 1 1 |
上面形成的数据是分为了两条独立的数据,在统一的分析中,不太利于分析,因此,对其进行了整合,因为对于这两条记录来说,其都是Main.main发起的方法调用
整合代码:1
2
3
4
5
6
7
8
9
10
11Map<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);
}
}
gadget chain的初始化
1 | Set<GadgetChainLink> exploredMethods = new HashSet<>(); |
上述代码中,加载了sources.dat文件的数据,这些数据我们前面分析过,都是利用链入口,在被反序列化的时候可被触发执行的方法
1 | private static class GadgetChainLink { |
最后形成gadget chain的初始化工作
遍历初始化后的gadget chain集合
gadget chain取出,进行链可利用的判断
1 | GadgetChain chain = methodsToExplore.pop(); |
获取链的最后一个方法
1 | GadgetChainLink lastLink = chain.links.get(chain.links.size()-1); |
获取最后一个方法调用到的所有方法
1 | Set<GraphCall> methodCalls = graphCallMap.get(lastLink.method); |
遍历调用到的方法,若方法不能被污染传递,则跳过
1 | for (GraphCall graphCall : methodCalls) { |
获取被调用方法的所有重写方法
1 | Set<MethodReference.Handle> allImpls = implementationFinder.getImplementations(graphCall.getTargetMethod()); |
遍历所有重写方法,并加入链的最后一节,若已存在的链,为了避免死循环,因此会跳过
1 | for (MethodReference.Handle methodImpl : allImpls) { |
判断是否到了slink,若已到,则表示这条链可用,并缓存到discoveredGadgets中,若还没到slink,则把newChain加到集合中,随着下一次循环到的时候,再次分析下一层的调用
1 | if (isSink(methodImpl, graphCall.getTargetArgIndex(), inheritanceMap)) { |
slink的判断:
1 | private boolean isSink(MethodReference.Handle method, int argIndex, InheritanceMap inheritanceMap) { |
至此,整个gadgetinspector的源码浅析就结束,祝大家阅读愉快,新年将至,提前说声新年快乐!