gadgetInspector分析
0x01 Intro
工具基于ASM技术来对控制字节码,从而达到对传入jar及war包的classpath下的类进行读取,并依次记录类信息、类方法信息、调用关系信息。最后基于以上收集的信息来进行反序列化链的挖掘,分别对应如下几个类:
-
GadgetInspector:main方法,程序的入口,做一些配置以及数据的准备工作
-
MethodDiscovery:类、方法数据以及父子类、超类关系数据的搜索
-
PassthroughDiscovery:分析参数能影响到返回值的方法,并收集存储
-
CallGraphDiscovery:记录调用者caller方法和被调用者target方法的参数关联
-
SourceDiscovery:入口方法的搜索,只有具备某种特征的入口才会被标记收集
-
GadgetChainDiscovery:整合以上数据,并通过判断调用链的最末端slink特征,从而判断出可利用的gadget chain
0x02 主入口-GadgetInspetcor
该类为整个工具的入口类,基本上是对于相关配置做出初始化处理,静态代码块中创建准备写入相关结果的文件。main中首先验证是否存在参数,若为空退出。工具在挖掘时需要我们指定不同的gadget-chain,如jdk原生反序列化、jackson等,以及指定classpath的路径。
接下来会对日志进行配置,之后是对历史dat文件(上面提到的类、方法等相关数据的本地化存储)的管理,以及反序列化链类型的指定。我们主要看这一部分是如何指定反序列化链类型的:
else if (arg.equals("--config")) { //--config参数指定fuzz类型 config = ConfigRepository.getConfig(args[++argIndex]); if (config == null) { throw new IllegalArgumentException("Invalid config name: " + args[argIndex]); }
跟进到getConfig方法中,并且也可以看到所有的gadget-chain是通过不同的Config来实现的,并且都实现了GIConfig接口:
public interface GIConfig { String getName(); SerializableDecider getSerializableDecider(Map<MethodReference.Handle, MethodReference> methodMap, InheritanceMap inheritanceMap); ImplementationFinder getImplementationFinder( Map<Handle, MethodReference> methodMap, Map<Handle, Set<Handle>> methodImplMap, InheritanceMap inheritanceMap, Map<ClassReference.Handle, Set<Handle>> methodsByClass); SourceDiscovery getSourceDiscovery(); SlinkDiscovery getSlinkDiscovery();}
我们以Jackson的实现来看,这些被实现的方法都会在后面用到,他们都是用来对指定gadget-chain进行区分的方法,不同的gadget-chain的特征不同,因此我们可以通过这些方法来确认对应的chain。
package gadgetinspector.config;import gadgetinspector.ImplementationFinder;import gadgetinspector.SerializableDecider;import gadgetinspector.SlinkDiscovery;import gadgetinspector.SourceDiscovery;import gadgetinspector.data.ClassReference;import gadgetinspector.data.InheritanceMap;import gadgetinspector.data.MethodReference;import gadgetinspector.data.MethodReference.Handle;import gadgetinspector.jackson.JacksonImplementationFinder;import gadgetinspector.jackson.JacksonSerializableDecider;import gadgetinspector.jackson.JacksonSourceDiscovery;import java.util.Map;import java.util.Set;public class JacksonDeserializationConfig implements GIConfig { @Override public String getName() { return "jackson"; } @Override public SerializableDecider getSerializableDecider(Map<MethodReference.Handle, MethodReference> methodMap, InheritanceMap inheritanceMap) { return new JacksonSerializableDecider(methodMap); } @Override public ImplementationFinder getImplementationFinder( Map<Handle, MethodReference> methodMap, Map<Handle, Set<Handle>> methodImplMap, InheritanceMap inheritanceMap, Map<ClassReference.Handle, Set<Handle>> methodsByClass) { return new JacksonImplementationFinder(getSerializableDecider(methodMap, inheritanceMap)); } @Override public SourceDiscovery getSourceDiscovery() { return new JacksonSourceDiscovery(); } @Override public SlinkDiscovery getSlinkDiscovery() { return null; }}
跟进JacksonSerializableDecider,两个map中记录的是可以通过Jackson决策的类和方法:
//类是否通过决策的缓存集合 private final Map<ClassReference.Handle, Boolean> cache = new HashMap<>(); //类名-方法集合 映射集合 private final Map<ClassReference.Handle, Set<MethodReference.Handle>> methodsByClassMap;
具体的决策判断逻辑在apply中,在后面的分析中我们也可以看到会调用apply方法来判断类和方法是否通过决策。以jackson的apply来举例,由于jackson的json反序列化是需要以类的无参构造为起始,在java中如果没有显式声明无参构造器,但是显式声明了一个有参构造,那么该类是没有无参构造的,因此代表着该类不可进行jackson反序列化。
@Override public Boolean apply(ClassReference.Handle handle) { if (isNoGadgetClass(handle)) { return false; } Boolean cached = cache.get(handle); if (cached != null) { return cached; } Set<MethodReference.Handle> classMethods = methodsByClassMap.get(handle); if (classMethods != null) { for (MethodReference.Handle method : classMethods) { //该类,只要有无参构造方法,就通过决策 if (method.getName().equals("<init>") && method.getDesc().equals("()V")) { cache.put(handle, Boolean.TRUE); return Boolean.TRUE; } } } cache.put(handle, Boolean.FALSE); return Boolean.FALSE; }
接下来回到Config中,继续看InplementationFinder,在决策时由于Java的多态性,并且gadgetinspector无法在要被检测的jar运行时进行判断,因此当调用到某一接口的方法时,需要查找接口所有的实现类中的该方法,并将这些方法组成实际的调用链去进行污点分析。这些方法是否可进行当前指定的gadget-chain反序列化,还是需要通过apply方法来进行判断:
public class JacksonImplementationFinder implements ImplementationFinder { private final SerializableDecider serializableDecider; public JacksonImplementationFinder(SerializableDecider serializableDecider) { this.serializableDecider = serializableDecider; } @Override public Set<MethodReference.Handle> getImplementations(MethodReference.Handle target) { Set<MethodReference.Handle> allImpls = new HashSet<>(); // For jackson search, we don't get to specify the class; it uses reflection to instantiate the // class itself. So just add the target method if the target class is serializable. if (Boolean.TRUE.equals(serializableDecider.apply(target.getClassReference()))) { allImpls.add(target); } return allImpls; }}
继续看JacksonSourceDiscovery,内部只有一个discover方法,这个方法的作用就是帮我们找到可进行Jackson反序列化的入口方法,对于jackson反序列化来说,会以无参构造为入口,并依次执行setter以及getter。因此discover会查找出通过了apply决策后的类的无参构造(()V代表无参,返回值为viod),以及getter和setter。
@Override public void discover(Map<ClassReference.Handle, ClassReference> classMap, Map<MethodReference.Handle, MethodReference> methodMap, InheritanceMap inheritanceMap, Map<MethodReference.Handle, Set<GraphCall>> graphCallMap) { final JacksonSerializableDecider serializableDecider = new JacksonSerializableDecider(methodMap); for (MethodReference.Handle method : methodMap.keySet()) { if (skipList.contains(method.getClassReference().getName())) { continue; } if (serializableDecider.apply(method.getClassReference())) { if (method.getName().equals("<init>") && method.getDesc().equals("()V")) { addDiscoveredSource(new Source(method, 0)); } if (method.getName().startsWith("get") && method.getDesc().startsWith("()")) { addDiscoveredSource(new Source(method, 0)); } if (method.getName().startsWith("set") && method.getDesc().matches("\\(L[^;]*;\\)V")) { addDiscoveredSource(new Source(method, 0)); } }
继续向下看GadgetInspector,进入到initJarData方法中,通过for循环读取最后面的参数,从而指定多个jar或war包,通过URLClassLoader,根据绝对路径将这些jar或war包进行加载,并通过ClassResourceEnumerator将jar或war包中的class进行加载:
ClassLoader classLoader = initJarData(args, boot, argIndex, haveNewJar, pathList);
for (int i = 0; i < args.length - argIndex; i++) { String pathStr = args[argIndex + i]; if (!pathStr.endsWith(".jar")) { //todo 主要用于大批量的挖掘链 //非.jar结尾,即目录,需要遍历目录找出所有jar文件 File file = Paths.get(pathStr).toFile(); if (file == null || !file.exists()) continue; Files.walkFileTree(file.toPath(), new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { if (!file.getFileName().toString().endsWith(".jar")) return FileVisitResult.CONTINUE; File readFile = file.toFile(); Path path = Paths.get(readFile.getAbsolutePath()); if (Files.exists(path)) { if (ConfigHelper.history) { if (!scanJarHistory.contains(path.getFileName().toString())) { if (jarCount.incrementAndGet() <= ConfigHelper.maxJarCount) { pathList.add(path); } } } else { if (jarCount.incrementAndGet() <= ConfigHelper.maxJarCount) { pathList.add(path); } } } return FileVisitResult.CONTINUE; } }); continue; } Path path = Paths.get(pathStr).toAbsolutePath(); if (!Files.exists(path)) { throw new IllegalArgumentException("Invalid jar path: " + path); } pathList.add(path);
//类枚举加载器,具有两个方法 //getRuntimeClasses获取rt.jar的所有class //getAllClasses获取rt.jar以及classLoader加载的class final ClassResourceEnumerator classResourceEnumerator = new ClassResourceEnumerator( classLoader);
接下来进入beginDiscovery方法中,接下来我们开始分析具体的挖掘逻辑。
0x03 类、方法、继承关系数据收集-MethodDiscovery
首先进入methodDiscovery当中,可以看到如果不存在,会生成classes.dat、methods .dat、inheritanceMap.dat,分别对类数据、方法数据以及继承关系数据进行收集:
if (!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(); }
跟进MethodDiscovery.discover,传入了上面保存了类信息的classResourceRnumerator,并且调用了getAllClasses方法,获取到了包括rt.jar和指定jar、war包中的所有类,并调用ClassReader的accept方法进行下一步,这里所用到的就是ASM。
public void discover(final ClassResourceEnumerator classResourceEnumerator) throws Exception { for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) { try (InputStream in = classResource.getInputStream()) { ClassReader cr = new ClassReader(in); try { //使用asm的ClassVisitor、MethodVisitor,利用观察模式去扫描所有的class和method并记录 cr.accept(new MethodDiscoveryClassVisitor(), ClassReader.EXPAND_FRAMES); } catch (Exception e) { LOGGER.error("Exception analyzing: " + classResource.getName(), e); } } catch (Exception e) { e.printStackTrace(); } } }
ASM及访问者模式
ASM的设计原理基于访问者模式,常用于类的属性无改变,在不侵入类的情况下并对属性的操作做出扩充的场景(类似于AOP)。用生活中的例子我们可以这么理解,想象你是一个导游,要带游客参观一个由多个景点(类、方法、字段等)组成的旅游区(Java类)。访问者模式的工作方式是这样的:
-
景点清单:旅游区有一份固定的景点清单(类的结构,比如方法、字段,并且这些不会变动)。
-
游客自由行动:游客(XXXVisitor)可以自由选择在每个景点做什么(比如拍照、记录日志、修改行为)。
-
导游协调:导游(ASM-ClassReader)负责按顺序带游客访问每个景点,并让游客在每个景点执行自己的操作。
ASM的关键思想:字节码(景点)的结构是固定的,但你可以通过"游客"灵活地定义在每个"景点"做什么,使用时我们需要先通过字节流等方式读入要控制的类,之后传入给ClassReader的accept方法,accept方法会按照JVM规定好的类文件结构来依次调用对应的方法,我们可以通过重写ClassVisitor的各个visit方法,在调用accept时传入,从而实现自己的visitXXX的逻辑。因为ASM是基于责任链的调用,并且支持visiter的嵌套包装来进行遍历调用,调用顺序为从最外层的子visitor开始调用,直到最内层的ClassVisitor,因此需要在我们的visit逻辑中处理下一层的visitor逻辑,直到将所有嵌套的visitor逻辑处理完毕(最外层也就是ASM中的ClassVisitor)否则有可能会造成visiter的逻辑断链。
1. visit() → 访问类的基础信息(版本、类名等)2. visitSource() → 源码信息(可选)3. visitModule() → 模块信息(Java 9+,可选)4. visitNestHost() → 嵌套类宿主(Java 11+,可选)5. visitPermittedSubtype() → sealed类的许可子类(Java 17+,可选)6. visitOuterClass() → 外部类信息(如果是内部类)7. visitAnnotation() → 类上的注解(可能有多个)8. visitTypeAnnotation() → 类上的类型注解(可能有多个)9. visitAttribute() → 类的自定义属性(可能有多个)10. visitField() → 类的字段(按字节码中的顺序访问)11. visitMethod() → 类的方法(按字节码中的顺序访问)12. visitEnd() → 类访问结束
我们回到MethodDiscovery.discover,在通过cr.accept后,cr先调用visit方法,因此我们跟进传入cr的MethodDiscoveryClassVisitor的visit方法,MDCV的visit方法保存了当前观察类的信息
-
this.name:类名
-
this.superName:继承的父类名
-
this.interfaces:实现的接口名
-
this.isInterface:当前类是否接口
-
this.members:类的字段集合
-
this.classHandle:gadgetinspector中对于类名的封装,可以通过类名来操作类中相关属性
public void visit ( int version, int access, String name, String signature, String superName, String[]interfaces) { this.name = name; this.superName = superName; this.interfaces = interfaces; this.isInterface = (access & Opcodes.ACC_INTERFACE) != 0; this.members = new ArrayList<>(); this.classHandle = new ClassReference.Handle(name);//类名 annotations = new HashSet<>(); super.visit(version, access, name, signature, superName, interfaces); }
接下来我们跳过几个不太重要的visit,来到visitField,在cr的控制下,被观察的类有多少个字段,visitField就会被调用多少次,来对字段进行处理。参数列表分别代表属性访问限定符,属性名,属性类型,泛型,属性的初始值(只有静态字段生效)该方法调用时,会先判断该字段是否是静态if ((access & Opcodes.ACC_STATIC) == 0),之后会通过判断字段的类型,如果是Object或者数组类型,就获取其具体内部类型,如果是基本类型,就获取类型的原始描述符。
比如String类型是Object,String[]是Array,那么最后保存的是java/lang/String,Int类型保留原始描述符后为I。获取到类型后将数据保存到visit中初始化好的列表member中。
@Override public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) { if ((access & Opcodes.ACC_STATIC) == 0) { Type type = Type.getType(desc); String typeName; if (type.getSort() == Type.OBJECT || type.getSort() == Type.ARRAY) { typeName = type.getInternalName(); } else { typeName = type.getDescriptor(); } members.add(new ClassReference.Member(name, access, new ClassReference.Handle(typeName))); } return super.visitField(access, name, desc, signature, value); }
可以看到传入的是ClassReference的内部类Member的构造函数,我们跟进ClassReference及Member的结构,可以发现在ClassReference中通过member数组来存储字段信息,内部类Member存储了字段的名字,访问限定修饰符,以及一个Handle类型的type,用来存储属性类型。Handle也是ClassReference中的一个内部类,只有一个字段,用来存储类名。大概访问流程是每个被观测的类对应一个MethodDiscoveryClassVisitor及ClassReference,当ASM观测到一个字段时调用visitField,此时visitField会new一个ClassReference.Member来存储字段信息,并将其添加到MDCV的List<ClassReference.Member> members中。当类中所有字段都被add进去之后,会调用到后续的visit,在最后调用visitEnd时,可以发现members.toArray(new ClassReference.Member[members.size()]),将member中所有被创建的ClassReference.Member转成了数组,并且初始化了一个ClassReference,将所有的字段合并到了ClassReference的Member[]数组中。
private final Member[] members;
public static class Member { private final String name; private final int modifiers; private final ClassReference.Handle type; public Member(String name, int modifiers, Handle type) { this.name = name; this.modifiers = modifiers; this.type = type; }
public static class Handle { private final String name; public Handle(String name) { this.name = name; } public String getName() { return name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Handle handle = (Handle) o; return name != null ? name.equals(handle.name) : handle.name == null; } @Override public int hashCode() { return name != null ? name.hashCode() : 0; } }
接下来进行 visitMethod,依旧是观察到多少个方法就会调用多少次,初始化一个MethodReference,传入类名,方法名,方法描述(方法的返回值类型以及参数类型,需要使用Type类来进行解析),并且将方法添加到列表discoveredMethods中。
@Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { boolean isStatic = (access & Opcodes.ACC_STATIC) != 0; //找到一个方法,添加到缓存 discoveredMethods.add(new MethodReference( classHandle,//类名 name, desc, isStatic)); return super.visitMethod(access, name, desc, signature, exceptions); }
最后进入到visitEnd,刚才也说过了会将所有字段整合到一个ClassReference中,并且将整合好的ClassReference添加到discoveredClasses中
@Override public void visitEnd() { ClassReference classReference = new ClassReference( name, superName, interfaces, isInterface, members.toArray(new ClassReference.Member[members.size()]), annotations);//把所有找到的字段封装 //找到一个方法遍历完成后,添加类到缓存 discoveredClasses.add(classReference); super.visitEnd(); }
整个methodDiscovery.discovr执行完成,继续到下一步methodDiscovery.save();中,通过DataLoader.saveData完成。其中对于classes.dat和methods.dat分别通过ClassReference.Factory()和MethodReference.Factory()创建的factory进行序列化存储
public static <T> void saveData(Path filePath, DataFactory<T> factory, Collection<T> values) throws IOException { try (BufferedWriter writer = Files.newWriter(filePath.toFile(), StandardCharsets.UTF_8)) { for (T value : values) { final String[] fields = factory.serialize(value); if (fields == null) { continue; } StringBuilder sb = new StringBuilder(); for (String field : fields) { if (field == null) { sb.append("\t"); } else { sb.append("\t").append(field); } } writer.write(sb.substring(1)); writer.write("\n"); } }
最终形成的文件格式如下:
classes.dat:
类名(例:java/lang/String) 父类 接口A,接口B,接口C 是否接口 字段1!字段1access!字段1类型!字段2!字段2access!字段1类型
methods.dat:
类名 方法名 方法描述 是否静态方法
在持久化相关数据后,会通过Map来整合ClassReference.Handle和ClassReference之间的映射关系
Map<ClassReference.Handle, ClassReference> classMap = new HashMap<>(); for (ClassReference clazz : discoveredClasses) { classMap.put(clazz.getHandle(), clazz); }
接下来进行类的继承以及实现关系的整合分析
InheritanceDeriver.derive(classMap).save();
跟进到InheritanceDeriver.derive中,可以看到做的事就是利用Map来保存继承关系,形成了类- >(父类,接口,超类)的映射关系。
public static InheritanceMap derive(Map<ClassReference.Handle, ClassReference> classMap) { LOGGER.debug("Calculating inheritance for " + (classMap.size()) + " classes..."); Map<ClassReference.Handle, Set<ClassReference.Handle>> implicitInheritance = new HashMap<>(); //遍历所有类 for (ClassReference classReference : classMap.values()) { if (implicitInheritance.containsKey(classReference.getHandle())) { throw new IllegalStateException("Already derived implicit classes for " + classReference.getName()); } Set<ClassReference.Handle> allParents = new HashSet<>(); //获取classReference的所有父类、超类、接口类 getAllParents(classReference, classMap, allParents); //添加缓存:类名 -> 所有的父类、超类、接口类 implicitInheritance.put(classReference.getHandle(), allParents); } //InheritanceMap翻转集合,转换为{class:[subclass]} return new InheritanceMap(implicitInheritance); }
getAllParents方法会递归的将当前观察类的所有父类、接口的父类查找出来,并且添加到allParents集合中
private static void getAllParents(ClassReference classReference, Map<ClassReference.Handle, ClassReference> classMap, Set<ClassReference.Handle> allParents) { Set<ClassReference.Handle> parents = new HashSet<>(); //把当前classReference类的父类添加到parents if (classReference.getSuperClass() != null) { parents.add(new ClassReference.Handle(classReference.getSuperClass())); } //把当前classReference类实现的所有接口添加到parents for (String iface : classReference.getInterfaces()) { parents.add(new ClassReference.Handle(iface)); } for (ClassReference.Handle immediateParent : parents) { //从所有类数据集合中,遍历找出classReference的父类、接口 ClassReference parentClassReference = classMap.get(immediateParent); if (parentClassReference == null) { LOGGER.debug("No class id for " + immediateParent.getName()); continue; } //继续添加到集合中 allParents.add(parentClassReference.getHandle()); //继续递归查找,直到把classReference类的所有父类、超类、接口类都添加到allParents getAllParents(parentClassReference, classMap, allParents); } }
最后将类名与整合好的allParents形成映射关系,存储到implicitInheritance中:
implicitInheritance.put(classReference.getHandle(), allParents);
接下来会用InheritanceMap构造函数将implicitInheritance的子->父的映射关系进行逆转整合。
private final Map<ClassReference.Handle, Set<ClassReference.Handle>> inheritanceMap; //父-子关系集合 private final Map<ClassReference.Handle, Set<ClassReference.Handle>> subClassMap; public InheritanceMap(Map<ClassReference.Handle, Set<ClassReference.Handle>> inheritanceMap) { this.inheritanceMap = inheritanceMap; subClassMap = new HashMap<>(); for (Map.Entry<ClassReference.Handle, Set<ClassReference.Handle>> entry : inheritanceMap.entrySet()) { ClassReference.Handle child = entry.getKey(); for (ClassReference.Handle parent : entry.getValue()) { subClassMap.computeIfAbsent(parent, k -> new HashSet<>()).add(child); } } }
其中这一行代码会判断inheritanceMap中每个子类对应的set中的value(parent),是否在subClassMap中,如果不存在执行Lambda表达式,创建一个新的空HashSet,将parent作为key,HashSet作为value存入subClassMap,并且将child添加到HashSet中。最终subClassMap就变成了父类->子类的映射关系。
subClassMap.computeIfAbsent(parent, k -> new HashSet<>()).add(child);
举个例子:
假设 inheritanceMap 包含:
"Dog" → {"Animal", "Object"}"Cat" → {"Animal", "Object"}
则 subClassMap 的构建过程如下:
-
处理
Dog的父类Animal:subClassMap中没有Animal,创建HashSet →Animal: {Dog}
-
处理
Dog的父类Object:- 没有
Object,创建HashSet →Object: {Dog}
- 没有
-
处理
Cat的父类Animal:Animal已存在,直接添加 →Animal: {Dog, Cat}
-
处理
Cat的父类Object:Object已存在,添加 →Object: {Dog, Cat}
最终 subClassMap 结果:
"Animal" → {"Dog", "Cat"}"Object" → {"Dog", "Cat"}
最后调用save方法对继承关系进行保存,方法依旧和上面一样,会进行序列化后持久化存储:
public void save() throws IOException { //inheritanceMap.dat数据格式: //类名 父类或超类或接口类1 父类或超类或接口类2 父类或超类或接口类3 ... DataLoader.saveData(Paths.get("inheritanceMap.dat"), new InheritanceMapFactory(), inheritanceMap.entrySet()); }
最终形成的inheritanceMap.dat结构如下:
类名 父类或超类或接口类1 父类或超类或接口类2 父类或超类或接口类3 ...
0x04 入参返回值污染关系收集-PassthroughDiscovery
这一步类似于污点分析,我们对各个方法的参数对返回值的污染关系做出总结:
if (!Files.exists(Paths.get("passthrough.dat")) && ConfigHelper.taintTrack) { LOGGER.info("Analyzing methods for passthrough dataflow..."); PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery(); //记录参数在方法调用链中的流动关联(如:A、B、C、D四个方法,调用链为A->B B->C C->D,其中参数随着调用关系从A流向B,在B调用C过程中作为入参并随着方法结束返回,最后流向D) //该方法主要是追踪上面所说的"B调用C过程中作为入参并随着方法结束返回",入参和返回值之间的关联 passthroughDiscovery.discover(classResourceEnumerator, config); passthroughDiscovery.save(); }
跟进passthroughDiscovery.discover当中,首先会将我们上一步MethodDiscovery所生成的类、方法、继承信息读取进来
public void discover(final ClassResourceEnumerator classResourceEnumerator, final GIConfig config) throws IOException { //加载文件记录的所有方法信息 Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods(); //加载文件记录的所有类信息 Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses(); //加载文件记录的所有类继承、实现关联信息 InheritanceMap inheritanceMap = InheritanceMap.load();
接下来通过discoverMethodCalls,来找出所有方法间的调用关系,我们继续跟进
//搜索方法间的调用关系,缓存至methodCalls集合,返回 类名->类资源 映射集合 Map<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);
在该方法中,依然是通过ASM来先对所有的类进行一次观察,用到的visitor是MethodCallDiscoveryClassVisitor,并且这里的MethodCallDiscoveryClassVisitor内部是做了一些包装的,这一部分的执行顺序可能会有点乱,我会在方法分析结束后总结一下:
private 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; }
分别跟进MCDCV的visit以及visitMethod方法,visit方法中将传入进来的classname进行记录
@Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); if (this.name != null) { throw new IllegalStateException("ClassVisitor already visited a class!"); } this.name = name; }
visitMethod方法又创建了一个MethodCallDiscoveryMethodVisitor,并且可以看到在实例化时将上面的mv也传了进去。但其实我们观察MethodCallDiscoveryClassVisitor的构造函数,在调用父类构造函数时并没有传入任何的classvisitor,因此父类ClassVisitor的cv属性为null,最终返回的也是个null,在这里传入给MCDMV的mv也是个null:
@Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); //在visit每个method的时候,创建MethodVisitor对method进行观察 MethodCallDiscoveryMethodVisitor modelGeneratorMethodVisitor = new MethodCallDiscoveryMethodVisitor( api, mv, this.name, name, desc); return new JSRInlinerAdapter(modelGeneratorMethodVisitor, access, name, desc, signature, exceptions); }
// MethodCallDiscoveryClassVisitor的构造函数 MethodCallDiscoveryClassVisitor visitor = new MethodCallDiscoveryClassVisitor(Opcodes.ASM6); //父类ClassVisitor的构造函数 public ClassVisitor(final int api) { this(api, null); } public ClassVisitor(final int api, final ClassVisitor classVisitor) { if (api != Opcodes.ASM6 && api != Opcodes.ASM5 && api != Opcodes.ASM4 && api != Opcodes.ASM7_EXPERIMENTAL) { throw new IllegalArgumentException(); } this.api = api; this.cv = classVisitor; } //父类ClassVisitor的visitMethod方法 public MethodVisitor visitMethod( final int access, final String name, final String descriptor, final String signature, final String[] exceptions) { if (cv != null) { return cv.visitMethod(access, name, descriptor, signature, exceptions); } return null; }
跟进MethodCallDiscoveryMethodVisitor,可以发现父类为MethodVisitor,并且调用父类的构造函数时传入了mv,但其实我们这里静态分析可以分析出来mv是null的,即便传入了在调用MethodVisitor.visitXXX时,最终也不会走到cv.visitXXX上,我这里推测是作者为了工具的扩充性,如果我们需要添加其他的visitor来对方法进行其他处理,那么就可以形成我们之前提到的类似于责任链的方式,来遍历的调用visitXXX:
public MethodCallDiscoveryMethodVisitor(final int api, final MethodVisitor mv, final String owner, String name, String desc) { super(api, mv);
我们继续看,可以看到接下来会将传入的owner(此时正在观察的类名)封装到ClassReference.Handle中,并再将这个CRF.Handle和方法名、方法的相关描述封装到一个MethodReference.Handle中,calledMethods是每次观察到一个方法,都会创建的空HashSet,最终形成了观察方法:{被观察方法调用方法}的映射关系存入到methodCalls中:
// private final Map<MethodReference.Handle, Set<MethodReference.Handle>> methodCalls = new HashMap<>(); this.calledMethods = new HashSet<>(); methodCalls.put(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc), calledMethods); }
继续向下看,类中还有一个visitMethodInsn方法,当检测到方法内部的调用时就会执行(底层原理是检查到字节码指令INVOKEVIRTUAL、INVOKESPECIAL、INVOKESTATIC、INVOKEINTERFACE),从而将正在观察的方法中调用的方法加入到calledMethods中
@Overridepublic void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { calledMethods.add(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc)); super.visitMethodInsn(opcode, owner, name, desc, itf);}
指令
调用类型
适用方法
特点
INVOKEVIRTUAL
虚方法调用(动态绑定)
普通实例方法(非私有、非构造器、非静态)
运行时根据对象实际类型选择方法,支持多态
INVOKESPECIAL
特殊方法调用(静态绑定)
构造器、私有方法、super.xxx()
编译时就决定调用哪一个,不支持多态
INVOKESTATIC
静态方法调用
static 修饰的方法
无需对象即可调用,直接通过类名调用
INVOKEINTERFACE
接口方法调用
接口定义的方法
运行时通过接口表定位目标方法,支持多态
回到visitMethod中,最后会进行return操作,并且return的是JSRInlinerAdapter。为什么要return这个类呢,因为在早期的java版本中,使用JSR和RET跳转指令来进行程序流程控制,在后续版本已废弃并使用GOTO指令,因此需要进行兼容处理。JSRInlinerAdapter会将JSR和RET指令转为GOTO指令,从而兼容了早期项目。
return new JSRInlinerAdapter(modelGeneratorMethodVisitor, access, name, desc, signature, exceptions);
经过这些封装,调用cr.accept(visitor, ClassReader.EXPAND_FRAMES);将封装好的MethodCallDiscoveryClassVisitor传入进行方法调用关系收集。accept执行顺序如下:
-
MethodCallDiscoveryClassVisitor.visit对类进行观察
-
当观察到方法时调用MethodCallDiscoveryClassVisitor.visitMethod,其中会创建一个MethodCallDiscoveryMethodVisitor实例,并包装为JSRInlinerAdapter返回,创建实例时会自动为观察到的方法添加一个映射关系,即当前观察方法->calledMethods
-
当触发了visitxxx时,会先把这些visitxxx发给JSRInlinerAdapter,JSRInlinerAdapter通过各个visit方法对JSR和RET跳转指令进行转换。
JSRInlinerAdapter本身也是一个MethodVisitor,它的回调时机完全跟 ASM 的方法遍历流程一致,只不过它在内部额外“钩”了两个地方来做子例程(JSR/RET)内联:-
**
visitJumpInsn**每当 ASM 在浏览方法字节码时碰到一个跳转指令(visitJumpInsn(int opcode, Label lbl)),就会调用到它的这个方法。- 如果
opcode == JSR,它就把这个子例程入口标签记下来,标记说“后面要做内联”
- 如果
-
**
visitEnd**当 ASM 遍历完一个方法的所有指令并调用到visitEnd()时,JSRInlinerAdapter会先检查在visitJumpInsn里有没有记录过任何JSR。-
如果有,就走
markSubroutines()→emitCode()的流程,把所有老版本的JSR/RET全部展开成GOTO(以及必要的空值占位等) -
然后再把重写后的指令列表一次性转发给它下游的
MethodVisitor(通常是一个MethodWriter)代码浏览器
-
换句话说:
-
只要你把
JSRInlinerAdapter插到你的 MethodVisitor 链上(手动 new 一个 或者在使用ClassWriter.COMPUTE_FRAMES/ClassReader.EXPAND_FRAMES时 ASM 自动给你插入), -
在方法遍历时遇到跳转就会进
visitJumpInsn, -
在方法结束时(
visitEnd)就会真正触发“内联 JSR→GOTO” 的逻辑。
这样保证了旧版子例程指令在生成新的字节码之前就被全部消除,适配现代 JVM 对 StackMapFrame 的要求。
4.JSRInlinerAdapter将指令转换并内联后,会通过其visitend方法再次通过accept将visitXXX传递给下一个visitor,也就是传入的MethodCallDiscoveryMethodVisitor的visitMethodInsn方法,从而将被调用的方法添加到当前观察方法的calledMethods中。
accept方法结束后还剩一行,还是将类名和classResource的映射关系存储起来并return:
classResourcesByName.put(visitor.getName(), classResource);discoverMethodCalls逻辑结束后,接下来是对methodCalls进行一次逆拓扑排序,所谓逆拓扑排序就是把拓扑排序的序列倒过来,什么你还不知道什么是拓扑排序?或许你该学一下数据结构了,或者看一下这篇文章介绍的吧
List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();为什么我们要进行逆拓扑排序,因为在方法的调用链上,假设a方法传递参数给b方法,并且b方法的返回值影响到了a方法的返回值,那么我们在判断方法链的时候就不能从a方法来入手,需要从最深处被调用的b方法来入手,观察b方法的参数与返回值之间是否存在关系,如果存在关系则证明了a方法传入b方法的参数与b方法返回值有关,此时b方法返回值影响到了a方法返回值,那么我们也就可以断定ab方法之间存在污染关系。
在方法调用的关系中,我们可以将这些调用抽象为有向图,假设a方法内部调用了b方法,那么我们就可以将a方法对应的图节点引出一条有向边,指向b方法。最终将所有的调用关系全部依次类推,就形成了一个有向图。我们将指向其他节点的边的数量叫做一个点的出度,指向自己的边的数量叫做一个节点的入度,如果找到有向图中一个入度为0的节点,将其节点以及所有的边全部消去,并输出该节点。不断重复这一操作,直到图中所有节点和边全部被消除掉,我们就得到了一组拓扑排序序列,而这一个序列就对应了我们的方法调用顺序。
但事情并没有想象中这么顺利,在方法调用中会出现两种情况,一个是相同的方法可能会存在重复调用,并且方法调用中由于回调等方式的存在,造成图中可能会出现环路,而环路的出现会导致拓扑排序在某一时刻无法找到一个入度为0的点,也就没有拓扑序列的产生了,解决办法上面的文章也提到了。我们用一个例子来看一下具体的执行过程:
假设有以下方法调用关系:
A → B → CA → D对应的调用图为:
outgoingReferences = { A: {B, D}, B: {C}, C: {}, D: {}}-
初始调用:从根节点
A开始。dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, A); -
处理节点
A:-
stack为空,visitedNodes为空 → 继续。 -
获取
A的被调用方法集合{B, D}。 -
将
A加入stack(当前路径:[A])。 -
递归处理子节点
B:dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, B);
-
-
处理节点
B:-
stack包含A,不包含B→ 继续。 -
获取
B的被调用方法集合{C}。 -
将
B加入stack(当前路径:[A, B])。 -
递归处理子节点
C:dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, C);
-
-
处理节点
C:-
stack包含A, B,不包含C→ 继续。 -
获取
C的被调用方法集合{}(无子节点)。 -
将
C加入visitedNodes和sortedMethods:visitedNodes = {C}, sortedMethods = [C] -
返回处理
B。
-
-
回溯节点
B:-
从
stack中移除B(当前路径:[A])。 -
将
B加入visitedNodes和sortedMethods:visitedNodes = {C, B}, sortedMethods = [C, B] -
处理
B的下一个子节点(无剩余节点),返回处理A。
-
-
处理节点
A的第二个子节点D:-
将
D加入stack(当前路径:[A, D])。 -
递归处理
D:dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, D);
-
-
处理节点
D:-
获取
D的被调用方法集合{}(无子节点)。 -
将
D加入visitedNodes和sortedMethods:visitedNodes = {C, B, D}, sortedMethods = [C, B, D] -
返回处理
A。
-
-
回溯节点
A:-
从
stack中移除A(当前路径:[])。 -
将
A加入visitedNodes和sortedMethods:visitedNodes = {C, B, D, A}, sortedMethods = [C, B, D, A]
-
-
污点分析顺序:
-
先分析
C(无依赖),确定其污点传播规则。 -
分析
B(依赖C),利用C的结果。 -
分析
D(无依赖)。 -
最后分析
A(依赖B和D),确保所有被调用方法已处理。
-
若存在循环调用(如
A → B → A):-
处理
A→B→A时,第二次进入A的递归:-
stack包含A→ 触发if (stack.contains(node)) return; -
终止递归,避免死循环。
-
逆拓扑排序后,接下来就是对方法参数和返回值之间污染关系的分析:
passthroughDataflow = calculatePassthroughDataflow(classResourceByName, classMap, inheritanceMap, sortedMethods, config.getSerializableDecider(methodMap, inheritanceMap));
-
跟进calculatePassthroughDataflow,首先会遍历sortedMethods,如果是静态初始化代码,即静态代码块,就直接跳过,因为静态代码块是在类加载的时候就加载到JVM当中,我们一般没有办法在程序运行中进行控制
final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>(); //遍历所有方法,然后asm观察所属类,经过前面DFS的排序,调用链最末端的方法在最前面 for (MethodReference.Handle method : sortedMethods) { //跳过static静态初始化代码 if (method.getName().equals("<clinit>")) { continue; }
接下来就是对当前所遍历的方法的所属类进行ASM观察:
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);
跟进visitor逻辑,查看visit方法,visit方法会判断当前观察的类是否是要准备观察方法的所属类
@Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); this.name = name; //不是目标观察的class跳过 if (!this.name.equals(methodToVisit.getClassReference().getName())) { throw new IllegalStateException("Expecting to visit " + methodToVisit.getClassReference().getName() + " but instead got " + this.name); } }
接着看visitMethod,我们需要观察的类中的方法只需要是sortedMethod中的方法即可,也就是传入进来的methodToVisit,其他方法是不存在调用关系的:
//不是目标观察的method需要跳过,上一步得到的method都是有调用关系的method才需要数据流分析 if (!name.equals(methodToVisit.getName()) || !desc.equals(methodToVisit.getDesc())) { return null; }
接下来是对方法进行更细致的观察,依旧看封装后的PassthroughDataflowMethodVisitor
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); passthroughDataflowMethodVisitor = new PassthroughDataflowMethodVisitor( classMap, inheritanceMap, this.passthroughDataflow, serializableDecider, api, mv, this.name, access, name, desc, signature, exceptions);
下面作者用代码模拟了方法调用的过程,从而在模拟的局部变量表(污点变量表)中对参数进行污点标记。我们先来回顾JVM在进行方法调用时都做了哪些事情。假设现在A方法中要调用B方法,那么此时我们是在A方法内部的,那么JVM中会有A方法的栈帧,栈帧中主要两部分,一个是局部变量表,一个是操作数栈,当A方法内部准备调用B方法时,会先将要传给B方法的参数保存到A方法栈帧的操作数栈上,此时JVM会为B方法创建其对应的栈帧,然后在A方法操作数栈上的参数会被弹到B方法栈帧的局部变量表中。B方法内部使用这些参数时,会通过LOAD指令将其从局部变量表加载到操作数栈上,再进行使用。这里的思想就是用代码去模仿JVM的行为,从而将JVM的方法调用流程可视化。
下面的分析过程基于如下例子,这一段代码调用包含了入参与返回结果相同,返回结果与入参有关的情况,我们分别来看:
public class Main { public String main(String args) throws IOException { String cmd = new A().method1(args); return new B().method2(cmd); }}class A { public String method1(String param) { return param; }}class B { public String method2(String param) { return new C().method3(param); }}class C { public String method3(String param) { return param; }}
逆拓扑排序后的结果为:
A.method1C.method3B.method2main
A.method1
因此我们先从A.method1来进行分析:
这里我们看到visitCode方法,在进入方法的第一时间,ASM会先调用这个方法。对于非静态方法来说,方法参数插槽的第一个0号位位this,对于静态方法,0号位为参数,所以这里将方法内的所有参数保存在一个使用Java代码模拟的局部变量表中,localIndex为参数在局部变量表中的位置,由于参数的类型不同,所以其在局部变量表中占用的大小也不同。而argIndex对应了参数在方法中的索引,通过setLocalTaint方法,形成了局部变量表与方法参数索引之间的映射关系
@Override public void visitCode() { super.visitCode(); int localIndex = 0; int argIndex = 0; if ((this.access & Opcodes.ACC_STATIC) == 0) { //非静态方法,第一个局部变量应该为对象实例this //添加到局部变量表集合 setLocalTaint(localIndex, argIndex); localIndex += 1; argIndex += 1; } for (Type argType : Type.getArgumentTypes(desc)) { //判断参数类型,得出变量占用空间大小,然后存储 setLocalTaint(localIndex, argIndex); localIndex += argType.getSize(); argIndex += 1; } } protected void setLocalTaint(int index, T ... possibleValues) { Set<T> values = new HashSet<T>(); for (T value : possibleValues) { values.add(value); } savedVariableState.localVars.set(index, values); }
接下来执行A.method1方法内部逻辑时(即return param),要将局部变量表中的参数通过ALOAD指令读取到操作数栈上,继续模拟,在检测到ALOAD指令时(包括其他访问局部变量表的指令),会回调visitVarInsn,将参数push到模拟的污点栈上,这里的参数可以看到是列表localVars的值,也就是局部变量表中对应的参数索引
@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>()); } //变量操作,var为操作的本地变量索引 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); } super.visitVarInsn(opcode, var); sanityCheck(); } private void push(Set<T> possibleValues) { // Intentionally make this a reference to the same set savedVariableState.stackVars.add(possibleValues); }
接下来当方法调用结束return时,由于使用了ARETURN指令,在解析到无操作数的简单指令时触发visitInsn,我们查看其具体逻辑,可以发现在方法return时,将当前栈上的值返回,即返回的是参数索引set,并将存储到了returnTaint中,代表了A.method1这个方法的调用,参数索引为1的参数param会污染返回值:
@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); }
最后对于该方法的观察结束,将污点分析结果存到了passthroughDataflow中,可以看到形成了方法与污染参数目录集合之间的映射关系:
final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>(); passthroughDataflow.put(method, cv.getReturnTaint());
C.method3
与A.method1流程一样
B.method2
class B { public String method2(String param) { return new C().method3(param); }}
-
进入到方法内部,触发visitCode,将参数this、param放入虚拟局部变量表,并形成与参数列表索引的映射关系。
-
内部方法执行,ALOAD指令触发visitVarInsn,参数this、param push到污点栈。
-
方法内部调用C.method3,INVOKEVIRTUAL指令触发visitMethodInsn,该方法首先将C.method3参数类型提取,并判断该方法是否是静态方法,如果不是静态方法,将this(被调用方法所在类的实例对象)存入argTypes第一个,并依次存入其他参数。之后获取了方法的返回值类型的所占大小,后面进行使用:
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; } -
接下来初始化argTaint,将其内部元素设置为空,argTaint大小为参数的数量。然后将污点栈中的参数依次存放进argTaint中,对于污点栈savedVariableState.stackVars来说,list从右往左为栈底到栈顶,假设方法参数列表为abc,那么从栈底到栈顶分别为a、b、c。继续将污点栈栈顶元素取出后放在argTaint的最后一个位置,以此类推,从而保证了argTaint中存放的参数索引与C.method3的参数列表的顺序相同。
final List<Set<Integer>> argTaint = new ArrayList<Set<Integer>>(argTypes.length); for (int i = 0; i < argTypes.length; i++) { argTaint.add(null); } int stackIndex = 0; for (int i = 0; i < argTypes.length; i++) { Type argType = argTypes[i]; if (argType.getSize() > 0) { //根据参数类型大小,从栈顶获取入参,参数入栈是从左到右的 argTaint.set(argTypes.length - 1 - i, getStackTaint(stackIndex + argType.getSize() - 1)); } stackIndex += argType.getSize(); } -
接下来判断方法是否是构造器,如果是构造器的话意味着在当前调用方法(B.method2)当中会有这么一段代码:
C c = new C();因此可以确定被调用方法(C.method3)的返回值结果受到了this(C类实例对象)的污染,那么将argTaint中的0号索引取出,即为this,并将其加入resultTaint。如果不是构造器,那么就创造一个空的HashSet来存储后面的resultTaint。
-
从passthroughDataflow中拿到被调用方法C.method3的参数与返回值污点分析关系,并判断污点分析关系中的参数是否在当前的argTaint中,如果在则说明被调用方法的返回值被调用者传入的参数污染,这也就是为什么要进行逆拓扑排序。
Set<Integer> passthrough = passthroughDataflow.get(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc)); if (passthrough != null) { for (Integer passthroughDataflowArg : passthrough) { //判断是否和同一方法体内的其它方法返回值关联,有关联则添加到栈底,等待执行return时保存 resultTaint.addAll(argTaint.get(passthroughDataflowArg)); } -
最后还是return,将B.method2的结果存到passthroughDataflow中
main方法
public class Main { public String main(String args) throws IOException { String cmd = new A().method1(args); return new B().method2(cmd); }}
第一步,执行visitCode存储入参到局部变量表
第二步,执行visitVarInsn参数入栈
第三步,执行visitMethodInsn调用A.method1,A.method1被污染的返回结果,也就是参数索引会被放在栈顶
第四步,执行visitVarInsn把放在栈顶的污染参数索引,放入到本地变量表
第五步,执行visitVarInsn参数入栈
第六步,执行visitMethodInsn调用B.method2,被污染的返回结果会被放在栈顶
第七步,执行visitInsn,返回栈顶数据,缓存到passthroughDataflow,也就是main方法的污点分析结果
最后通过passthroughDiscovery.save方法保存分析数据
public static class PassThroughFactory implements DataFactory<Map.Entry<MethodReference.Handle, Set<Integer>>> { ... @Override public String[] serialize(Map.Entry<MethodReference.Handle, Set<Integer>> entry) { if (entry.getValue().size() == 0) { return null; } final String[] fields = new String[4]; fields[0] = entry.getKey().getClassReference().getName(); fields[1] = entry.getKey().getName(); fields[2] = entry.getKey().getDesc(); StringBuilder sb = new StringBuilder(); for (Integer arg : entry.getValue()) { sb.append(Integer.toString(arg)); sb.append(","); } fields[3] = sb.toString(); return fields; }}
最后持久化的passthrough.dat文件的数据格式如下:
类名 方法名 方法描述 能污染返回值的参数索引1,能污染返回值的参数索引2,能污染返回值的参数索引3...
0x05 方法调用污染关联-CallGraphDiscovery
我们用这个例子进行分析:
public class Main { private String name; public void main(String args) throws IOException { new A().method1(args, name); }}class A { public String method1(String param, String param2) { return param + param2; }}
跟进callGraphDiscovery.discover,读取前面收集的数据,然后使用ModelGeneratorClassVisitor进行观察,visitCode观察每一个类,visitMethod观察类中的每一个方法,继续跟进ModelGeneratorMethodVisitor
@Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); ModelGeneratorMethodVisitor modelGeneratorMethodVisitor = new ModelGeneratorMethodVisitor(classMap, inheritanceMap, passthroughDataflow, serializableDecider, api, mv, this.name, access, name, desc, signature, exceptions); return new JSRInlinerAdapter(modelGeneratorMethodVisitor, access, name, desc, signature, exceptions); }
进入main方法内部,触发visitCode,main方法不是静态,将this以及参数args存入局部变量表,此处与前面不同的是会在参数索引前加一个arg前缀来进行标识:
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; } }
我在写到这里的时候有一点疑问,对于visitVarInsn的调用时机。我们来看如下两个例子:
// example 1A a = new A();a.method1(args);//example 2new A().method1(args);
我们先来看第一个例子,new A()的字节码指令大概如下,可以看到是没有LOAD指令的,在调用构造方法时直接消费的是操作数栈上的A对象引用:
NEW A //创建A类实例DUP //创建对象引用INVOKESPECIAL A.<init>()V //调用构造方法
接下来由于要把对象引用存到a中,因此会把对象引用存储到局部变量表中(假设在局部变量表2号位,局部变量表1号位存储args),即ASTORE指令,此时会触发一次visitVarInsn。那么接下来在调用a.method1(args)时需要进行两次ALOAD,首先把a的对象引用加载到操作数栈上,再把args加载到操作数栈上,从而接着触发了两次visitVarInsn
NEW ADUPINVOKESPECIAL A.<init>()VASTORE 2 // 存到局部槽 2 —> visitVarInsn(ASTORE,2)ALOAD 2 // 再加载回来 —> visitVarInsn(ALOAD,2)ALOAD 1 // 加载 args —> visitVarInsn(ALOAD,1)INVOKEVIRTUAL A.method1…
继续我们看第二个例子,当构造函数执行完毕后,不需要进行ASTORE,并且再调用method1时也不需要从局部变量表中加载a的对象引用,因此最终只有加载args时才会调用一次visitVarInsn
NEW ADUPINVOKESPECIAL A.<init>()V// 上一步执行完 new A(),操作数栈上已经有了 A 的实例ALOAD 1 // 将 args(槽 1)加载到栈顶 — 触发一次 visitVarInsn(AL OAD,1)INVOKEVIRTUAL A.method1:(Ljava/lang/String;)Ljava/lang/Strin
检测到字节码指令new,触发visitTypeInsn,会push一个空的HashSet到污点栈中:
@Override public void visitTypeInsn(int opcode, String type) { switch(opcode) { case Opcodes.NEW: push(); break; case Opcodes.ANEWARRAY: pop(); push(); break; case Opcodes.CHECKCAST: // No-op break; case Opcodes.INSTANCEOF: pop(); push(); break; default: throw new IllegalStateException("Unsupported opcode: " + opcode); }
字节码指令INVOKESPECIALA.<init>()V,调用A的构造器,触发visitMethodInsn,判断是否是构造器,被调用方法为构造器,将this设置为argTypes第一个参数:
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; }
jiee下来检测启动工具时参数是否要进行污点分析,如果不进行污点分析,则直接把调用方法以及被调用方法封装为GraphCall,加入discoveredCalls中:
if (!ConfigHelper.taintTrack) { //不进行污点分析,全部调用关系都记录 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), 0, "", 0)); break; }
启动污点分析后的逻辑接着往下看,会从污点栈中取出对应的参数,但我们这里由于没有进入到visitVarInsn,因此污点栈目前只有一个在visitInsn中push进去的一个空的set,这一步不会对discoverdCalls做任何事情
接着我们分析method1(args,name)的调用情况,首先需要加载args,触发visitVarInsn,ALOAD指令,将args(arg1)推入污点栈,然后调用visitMethodInsn。由于要传递的参数name是a的属性,因此需要加载this,从this中拿到name属性。触发ALOAD指令,将this(arg0)推入污点栈。此时污点栈中为如下内容:
stackVars[{}, {"arg1"}, {"arg0"} ]
接下来需要读入实例a的name字段,检测到字节码指令GETFIELD,触发visitFieldInsn,首先在ClassReference中不断遍历,直到找到该字段,判断该字段是否是transient,如果是transient就没必要加入污点栈。如果是非transient属性,就把栈顶当前的arg0修改为arg0.name加入污点栈中
Set<String> newTaint = new HashSet<>(); if (!Boolean.TRUE.equals(isTransient)) { for (String s : getStackTaint(0)) { newTaint.add(s + "." + name); } } super.visitFieldInsn(opcode, owner, name, desc); //在调用方法前,都会先入栈,作为参数 setStackTaint(0, newTaint);
非静态方法,argTypes第一个为A(this),第二个为String(args),第三个为String(name),对应了污点栈上的[{},{"arg1"}, {"arg0"} ](从左到右为栈底到栈顶),for循环i从0到2,分别从污点栈中拿到了arg0.name,arg1和空set。首先对arg0.name进行拆解,最终拆解出来dotIndex为4,srcArgIndex为0,srcArgPath为name,并记录到了discoverdCalls当中。继续拆解arg1,dotindex为-1,srcArgIndexn为1,srcArgPath为null,记录到discoverdCalls中。
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(); }
最后save保存数据,持久化后的callgraph.dat格式如下:
调用者类名 调用者方法caller 调用者方法描述 被调用者类名 被调用者方法target 被调用者方法描述 调用者方法参数索引 调用者字段名 被调用者方法参数索引
0x06 利用链入口搜索-SourceDiscovery
在一开始我们也说到了,在挖掘反序列化链的时候需要指定类型,所以此处先获得对应的sourceDiscovery,我们这里以Jackson反序列化分析
if (!Files.exists(Paths.get("sources.dat"))) { LOGGER.info("Discovering gadget chain source methods..."); SourceDiscovery sourceDiscovery = config.getSourceDiscovery(); //查找利用链的入口(例:java原生反序列化的readObject) sourceDiscovery.discover(); sourceDiscovery.save(); }
跟进SourceDiscovery.discover在jackson中的实现,可以发现对于Jackson反序列化来说,source需要判断方法是否是无参构造、setter和getter,只有这些方法才能作为jackson反序列化的入口:
@Override public void discover(Map<ClassReference.Handle, ClassReference> classMap, Map<MethodReference.Handle, MethodReference> methodMap, InheritanceMap inheritanceMap, Map<MethodReference.Handle, Set<GraphCall>> graphCallMap) { final JacksonSerializableDecider serializableDecider = new JacksonSerializableDecider(methodMap); for (MethodReference.Handle method : methodMap.keySet()) { if (skipList.contains(method.getClassReference().getName())) { continue; } if (serializableDecider.apply(method.getClassReference())) { if (method.getName().equals("<init>") && method.getDesc().equals("()V")) { addDiscoveredSource(new Source(method, 0)); } if (method.getName().startsWith("get") && method.getDesc().startsWith("()")) { addDiscoveredSource(new Source(method, 0)); } if (method.getName().startsWith("set") && method.getDesc().matches("\\(L[^;]*;\\)V")) { addDiscoveredSource(new Source(method, 0)); } } } }
最后还是将方法保存持久化为sources.dat,格式如下:
类名 方法名 方法描述 污染参数索引
0x07 gadgetChain挖掘-GadgetChainDiscovery
跟进GadgetChainDiscovery.discover,首先进行所有重写方法的扫描,在一开始我们也说了工具没有办法在运行时进行扫描,所以对于各种方法的重写我们没有办法确定到底调用的是哪个方法
Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods(); InheritanceMap inheritanceMap = InheritanceMap.load(); Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver .getAllMethodImplementations( inheritanceMap, methodMap); Map<ClassReference.Handle, Set<MethodReference.Handle>> methodsByClass = InheritanceDeriver.getMethodsByClass(methodMap);
跟进InheritanceDeriver.getAllMethodImplementations,获取之前收集到的method的类,并通过之前收集到的继承关系来获取类的所有子孙类,最终形成类->子孙类的映射关系:
Map<Handle, Set<MethodReference.Handle>> methodsByClass = getMethodsByClass(methodMap); Map<ClassReference.Handle, Set<ClassReference.Handle>> subClassMap = new HashMap<>(); for (Map.Entry<ClassReference.Handle, Set<ClassReference.Handle>> entry : inheritanceMap.entrySet()) { for (ClassReference.Handle parent : entry.getValue()) { if (!subClassMap.containsKey(parent)) { Set<ClassReference.Handle> subClasses = new HashSet<>(); subClasses.add(entry.getKey()); subClassMap.put(parent, subClasses); } else { subClassMap.get(parent).add(entry.getKey()); } } }
接下来遍历所有的方法,并遍历subclasses,如果某一个subclass中存在与当前遍历的方法名和返回值一致的方法,就将其加入overridingMethods,最后整合所有重写的方法,形成方法名到重写方法之间的映射关系,由于静态方法不可重写,因此遇到静态方法直接跳过:
//遍历所有方法,根据父类->子孙类集合,找到所有的override的方法,记录下来(某个类的方法->所有的override方法) Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = new HashMap<>(); for (MethodReference method : methodMap.values()) { // Static methods cannot be overriden if (method.isStatic()) { continue; } Set<MethodReference.Handle> overridingMethods = new HashSet<>(); Set<ClassReference.Handle> subClasses = subClassMap.get(method.getClassReference()); if (subClasses != null) { for (ClassReference.Handle subClass : subClasses) { // This class extends ours; see if it has a matching method Set<MethodReference.Handle> subClassMethods = methodsByClass.get(subClass); if (subClassMethods != null) { for (MethodReference.Handle subClassMethod : subClassMethods) { if (subClassMethod.getName().equals(method.getName()) && subClassMethod.getDesc().equals(method.getDesc())) { overridingMethods.add(subClassMethod); } } } } } if (overridingMethods.size() > 0) { methodImplMap.put(method.getHandle(), overridingMethods); } }
然后下面的一大堆逻辑就是对重写方法关系的持久化存储,最终的methodimpl.dat格式如下:
类名 方法名 方法描述\t重写方法的类名 方法名 方法描述\t重写方法的类名 方法名 方法描述\t重写方法的类名 方法名 方法描述\t重写方法的类名 方法名 方法描述类名 方法名 方法描述\t重写方法的类名 方法名 方法描述\t重写方法的类名 方法名 方法描述
接下来对callgraph.dat的调用关系进行整合,对于同一个方法发起的调用,整合成caller->被调用方法集合之间的映射关系:
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); } }
剩下的挖掘逻辑我们用一个例子来分析:
设我们有如下方法间调用:
-
源:
A.sources()污染参数 0 -
A.sources(0)→ 调用B.load(0) -
B.load(0)→ 调用接口方法C.handle(0) -
C.handle(0)在实现类CImpl中有实现CImpl.handle(0) -
CImpl.handle(0)→ 调用D.sink(1)(这里假设它把参数 1 污染到 sink) -
D.sink(1)是最终的 sink
对应的数据结构:
-
sources.dat 只包含一个
Source(A.sources, taintedArgIndex=0) -
graphCallMap
A.sources → { GraphCall(callerArgIndex=0, targetMethod=B.load, targetArgIndex=0) }B.load → { GraphCall(callerArgIndex=0, targetMethod=C.handle, targetArgIndex=0) }C.handle → { GraphCall(callerArgIndex=0, targetMethod=C.handle, targetArgIndex=0) } // interfaceCImpl.handle → { GraphCall(callerArgIndex=0, targetMethod=D.sink, targetArgIndex=1) } -
implementationFinder.getImplementations(C.handle) →
{ CImpl.handle } -
isSink(D.sink,1) →
true
对于是否为sink点的判断逻辑如下:
private boolean isSink(MethodReference.Handle method, int argIndex, InheritanceMap inheritanceMap) { if (!customSlinks.isEmpty()) { for (CustomSlink customSlink:customSlinks) { boolean flag = false; if (customSlink.getClassName() != null) flag &= customSlink.getClassName().equals(method.getClassReference().getName()); if (customSlink.getMethod() != null) flag &= customSlink.getMethod().equals(method.getName()); if (customSlink.getDesc() != null) flag &= customSlink.getDesc().equals(method.getDesc()); if (flag) return flag; } return false; } if (config.getName().equals("sqlinject")) { //SQLInject只能检测注入 return isSQLInjectSink(method, argIndex, inheritanceMap); } if (config.getName().equals("hessian")) { //仅hessian可选BCEL slink if (ConfigHelper.slinks.contains("BCEL") && BCELSlink(method, argIndex, inheritanceMap)) { return true; } } //通用slink,不设定slink则全部都挖掘 if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("JNDI")) && JNDISlink(method, inheritanceMap)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("CLASSLOADER")) && ClassLoaderlink(method, argIndex, inheritanceMap)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("SSRFAndXXE")) && SSRFAndXXESlink(method, inheritanceMap)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("EXEC")) && EXECSlink(method, argIndex)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("FileIO")) && FileIOSlink(method)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("Reflect")) && ReflectSlink(method, argIndex, inheritanceMap)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("JDBC")) && JDBCSlink(method, argIndex, inheritanceMap)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("EL")) && ELSlink(method, argIndex, inheritanceMap)) { return true; } if ((ConfigHelper.slinks.isEmpty() || ConfigHelper.slinks.contains("SQLInject")) && isSQLInjectSink(method, argIndex, inheritanceMap)) { return true; } return false; }
配置参数:
maxChainLength = 10opLevel = 2taintTrack = true
1️⃣ 初始化
for each Source: srcLink = (A.sources, 0) methodsToExplore = [ [ A.sources(0) ] ] exploredMethods = { A.sources(0) }discoveredGadgets = { }
2️⃣ 第一次迭代
-
iteration=0 → pop first chain
chain = [ A.sources(0) ]lastLink = (A.sources,0) -
长度检查:
1 < maxChainLength→ 通过 -
取出 graphCallMap.get(A.sources) →
{ GC1 }- GC1:
(callerArgIndex=0 → targetMethod=B.load, targetArgIndex=0)
- GC1:
-
taintTrack:
GC1.callerArgIndex(0) == lastLink.taintedArgIndex(0)→ 通过 -
找实现:
allImpls = getImpls(B.load)→{ B.load }(普通方法) -
遍历 impls:
-
methodImpl = B.load -
newLink = (B.load,0) -
去重:
exploredMethods不含 → 继续 -
新链:
newChain = [ A.sources(0), B.load(0) ] -
sink 检测:
isSink(B.load,0)→false -
加入队列:
methodsToExplore = [ [A.sources(0),B.load(0)] ]exploredMethods.add(B.load(0))
-
3️⃣ 第二次迭代
-
iteration=1 → pop
chain = [A.sources(0),B.load(0)]lastLink = (B.load,0) -
graphCallMap.get(B.load) →
{ GC2 }- GC2:
(callerArgIndex=0 → targetMethod=C.handle, targetArgIndex=0)
- GC2:
-
taintTrack:匹配 → 通过
-
impls:
getImpls(C.handle)→{ },fallback 父类查找也无(接口),所以按注释 “GadgetInspector bug”,跳到父类去搜,依次找到C.handle本身,加入。 -
impls 变为 →
{ C.handle } -
for each impl:
-
newLink = (C.handle,0) -
去重通过
-
newChain = [A.sources(0),B.load(0),C.handle(0)] -
isSink(C.handle,0)→false -
加入:
methodsToExplore = [ [A.sources(0),B.load(0),C.handle(0)] ]exploredMethods.add(C.handle(0))
-
4️⃣ 第三次迭代
-
chain = [A.sources(0),B.load(0),C.handle(0)]
-
graphCallMap.get(C.handle) →
{ GC3 }- GC3:
(callerArgIndex=0 → targetMethod=C.handle, targetArgIndex=0)// 发自实现类
- GC3:
-
taintTrack:匹配
-
impls:
getImpls(C.handle)→{ CImpl.handle } -
for each:
-
newLink = (CImpl.handle,0) -
去重通过
-
newChain = [A.sources(0),B.load(0),C.handle(0),CImpl.handle(0)] -
isSink(CImpl.handle,0)→false -
入队 & 加入
exploredMethods
-
5️⃣ 第四次迭代
-
chain = [ …, CImpl.handle(0)]
-
graphCallMap.get(CImpl.handle) →
{ GC4 }- GC4:
(callerArgIndex=0 → targetMethod=D.sink, targetArgIndex=1)
- GC4:
-
taintTrack:匹配
-
impls:
getImpls(D.sink)→{ D.sink } -
for each:
-
newLink = (D.sink,1) -
去重通过
-
newChain = [ …, CImpl.handle(0), D.sink(1)] -
isSink(D.sink,1)→true -
加入
discoveredGadgets
-
此时 methodsToExplore 可能为空,循环结束。
接下来进行链路聚合优化
java复制编辑for (GadgetChain shortChain : methodsToExploreRepeat) { for (GadgetChain fullChain : discoveredGadgets) { if (shortChain.lastLink 出现在 fullChain 里) { // 把 fullChain 从 shortChain.lastLink 之后的部分拼过来 tmpDiscoveredGadgets.add( 拼合后的链 ); } }}discoveredGadgets.addAll(tmpDiscoveredGadgets);
- 比如如果我们因为
opLevel限制,把某条中间链放进了methodsToExploreRepeat而没展开到 sink,那么这段逻辑就能 把这些中途链 自动补全到 已知的完整 Chain,得到更多发现。