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文件(上面提到的类、方法等相关数据的本地化存储)的管理,以及反序列化链类型的指定。我们主要看这一部分是如何指定反序列化链类型的:
1 2 3 4 5 6 else if (arg.equals("--config" )) { config = ConfigRepository.getConfig(args[++argIndex]); if (config == null ) { throw new IllegalArgumentException ("Invalid config name: " + args[argIndex]); }
跟进到getConfig方法中,并且也可以看到所有的gadget-chain是通过不同的Config来实现的,并且都实现了GIConfig接口:
1 2 3 4 5 6 7 8 9 10 11 12 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。
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 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决策的类和方法:
1 2 3 4 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反序列化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @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方法来进行判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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 <>(); 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @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进行加载:
1 ClassLoader classLoader = initJarData(args, boot, argIndex, haveNewJar, pathList);
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 for (int i = 0 ; i < args.length - argIndex; i++) { String pathStr = args[argIndex + i]; if (!pathStr.endsWith(".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);
1 2 3 4 5 final ClassResourceEnumerator classResourceEnumerator = new ClassResourceEnumerator ( classLoader);
接下来进入beginDiscovery方法中,接下来我们开始分析具体的挖掘逻辑。
0x03 类、方法、继承关系数据收集-MethodDiscovery 首先进入methodDiscovery当中,可以看到如果不存在,会生成classes.dat、methods .dat、inheritanceMap.dat,分别对类数据、方法数据以及继承关系数据进行收集:
1 2 3 4 5 6 7 8 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 { 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 2 3 4 5 6 7 8 9 10 11 12 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中对于类名的封装,可以通过类名来操作类中相关属性
1 2 3 4 5 6 7 8 9 10 11 12 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中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @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[]数组中。
1 private final Member[] members;
1 2 3 4 5 6 7 8 9 10 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; }
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 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中。
1 2 3 4 5 6 7 8 9 10 11 @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中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @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进行序列化存储
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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:
1 类名(例:java/lang/String) 父类 接口A,接口B,接口C 是否接口 字段1!字段1access!字段1类型!字段2!字段2access!字段1类型
methods.dat:
在持久化相关数据后,会通过Map来整合ClassReference.Handle和ClassReference之间的映射关系
1 2 3 4 Map<ClassReference.Handle, ClassReference> classMap = new HashMap <>(); for (ClassReference clazz : discoveredClasses) { classMap.put(clazz.getHandle(), clazz); }
接下来进行类的继承以及实现关系的整合分析
1 InheritanceDeriver.derive(classMap).save();
跟进到InheritanceDeriver.derive中,可以看到做的事就是利用Map来保存继承关系,形成了类- >(父类,接口,超类)的映射关系。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 <>(); getAllParents(classReference, classMap, allParents); implicitInheritance.put(classReference.getHandle(), allParents); } return new InheritanceMap (implicitInheritance); }
getAllParents方法会递归的将当前观察类的所有父类、接口的父类查找出来,并且添加到allParents集合中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 private static void getAllParents (ClassReference classReference, Map<ClassReference.Handle, ClassReference> classMap, Set<ClassReference.Handle> allParents) { Set<ClassReference.Handle> parents = new HashSet <>(); if (classReference.getSuperClass() != null ) { parents.add(new ClassReference .Handle(classReference.getSuperClass())); } for (String iface : classReference.getInterfaces()) { parents.add(new ClassReference .Handle(iface)); } for (ClassReference.Handle immediateParent : parents) { ClassReference parentClassReference = classMap.get(immediateParent); if (parentClassReference == null ) { LOGGER.debug("No class id for " + immediateParent.getName()); continue ; } allParents.add(parentClassReference.getHandle()); getAllParents(parentClassReference, classMap, allParents); } }
最后将类名与整合好的allParents形成映射关系,存储到implicitInheritance中:
1 implicitInheritance.put(classReference.getHandle(), allParents);
接下来会用InheritanceMap构造函数将implicitInheritance的子->父的映射关系进行逆转整合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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就变成了父类->子类的映射关系。
1 subClassMap.computeIfAbsent(parent, k -> new HashSet <>()).add(child);
举个例子:
假设 inheritanceMap 包含:
1 2 "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 结果:
1 2 "Animal" → {"Dog", "Cat"} "Object" → {"Dog", "Cat"}
最后调用save方法对继承关系进行保存,方法依旧和上面一样,会进行序列化后持久化存储:
1 2 3 4 5 public void save () throws IOException { DataLoader.saveData(Paths.get("inheritanceMap.dat" ), new InheritanceMapFactory (), inheritanceMap.entrySet()); }
最终形成的inheritanceMap.dat结构如下:
1 类名 父类或超类或接口类1 父类或超类或接口类2 父类或超类或接口类3 ...
0x04 入参返回值污染关系收集-PassthroughDiscovery 这一步类似于污点分析,我们对各个方法的参数对返回值的污染关系做出总结:
1 2 3 4 5 6 7 8 if (!Files.exists(Paths.get("passthrough.dat" )) && ConfigHelper.taintTrack) { LOGGER.info("Analyzing methods for passthrough dataflow..." ); PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery (); passthroughDiscovery.discover(classResourceEnumerator, config); passthroughDiscovery.save(); }
跟进passthroughDiscovery.discover当中,首先会将我们上一步MethodDiscovery所生成的类、方法、继承信息读取进来
1 2 3 4 5 6 7 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,来找出所有方法间的调用关系,我们继续跟进
1 2 Map<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);
在该方法中,依然是通过ASM来先对所有的类进行一次观察,用到的visitor是MethodCallDiscoveryClassVisitor,并且这里的MethodCallDiscoveryClassVisitor内部是做了一些包装的,这一部分的执行顺序可能会有点乱,我会在方法分析结束后总结一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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进行记录
1 2 3 4 5 6 7 8 9 @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:
1 2 3 4 5 6 7 8 9 10 @Override public MethodVisitor visitMethod (int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super .visitMethod(access, name, desc, signature, exceptions); MethodCallDiscoveryMethodVisitor modelGeneratorMethodVisitor = new MethodCallDiscoveryMethodVisitor ( api, mv, this .name, name, desc); return new JSRInlinerAdapter (modelGeneratorMethodVisitor, access, name, desc, signature, exceptions); }
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 MethodCallDiscoveryClassVisitor visitor = new MethodCallDiscoveryClassVisitor (Opcodes.ASM6); 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; } 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:
1 2 3 4 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中:
1 2 3 4 5 6 this .calledMethods = new HashSet <>(); methodCalls.put(new MethodReference .Handle(new ClassReference .Handle(owner), name, desc), calledMethods); }
继续向下看,类中还有一个visitMethodInsn方法,当检测到方法内部的调用时就会执行(底层原理是检查到字节码指令INVOKEVIRTUAL、INVOKESPECIAL、INVOKESTATIC、INVOKEINTERFACE),从而将正在观察的方法中调用的方法加入到calledMethods中
1 2 3 4 5 @Override public 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指令,从而兼容了早期项目。
1 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:
1 classResourcesByName.put(visitor.getName(), classResource);
discoverMethodCalls逻辑结束后,接下来是对methodCalls进行一次逆拓扑排序,所谓逆拓扑排序就是把拓扑排序的序列倒过来,什么你还不知道什么是拓扑排序?或许你该学一下数据结构了,或者看一下这篇文章介绍的吧
https://paper.seebug.org/1034/
1 List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();
为什么我们要进行逆拓扑排序,因为在方法的调用链上,假设a方法传递参数给b方法,并且b方法的返回值影响到了a方法的返回值,那么我们在判断方法链的时候就不能从a方法来入手,需要从最深处被调用的b方法来入手,观察b方法的参数与返回值之间是否存在关系,如果存在关系则证明了a方法传入b方法的参数与b方法返回值有关,此时b方法返回值影响到了a方法返回值,那么我们也就可以断定ab方法之间存在污染关系。
在方法调用的关系中,我们可以将这些调用抽象为有向图,假设a方法内部调用了b方法,那么我们就可以将a方法对应的图节点引出一条有向边,指向b方法。最终将所有的调用关系全部依次类推,就形成了一个有向图。我们将指向其他节点的边的数量叫做一个点的出度,指向自己的边的数量叫做一个节点的入度,如果找到有向图中一个入度为0的节点,将其节点以及所有的边全部消去,并输出该节点。不断重复这一操作,直到图中所有节点和边全部被消除掉,我们就得到了一组拓扑排序序列,而这一个序列就对应了我们的方法调用顺序。
但事情并没有想象中这么顺利,在方法调用中会出现两种情况,一个是相同的方法可能会存在重复调用,并且方法调用中由于回调等方式的存在,造成图中可能会出现环路,而环路的出现会导致拓扑排序在某一时刻无法找到一个入度为0的点,也就没有拓扑序列的产生了,解决办法上面的文章也提到了。我们用一个例子来看一下具体的执行过程:
假设有以下方法调用关系:
对应的调用图为:
1 2 3 4 5 6 outgoingReferences = { A: {B, D}, B: {C}, C: {}, D: {} }
初始调用 :从根节点 A 开始。
1 dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, A);
处理节点 A :
处理节点 B :
处理节点 C :
回溯节点 B :
处理节点 A 的第二个子节点 D :
处理节点 D :
回溯节点 A :
污点分析顺序 :
先分析 C(无依赖),确定其污点传播规则。
分析 B(依赖 C),利用 C 的结果。
分析 D(无依赖)。
最后分析 A(依赖 B 和 D),确保所有被调用方法已处理。
若存在循环调用(如 A → B → A):
处理 A → B → A 时,第二次进入 A 的递归:
stack 包含 A → 触发 if (stack.contains(node)) return;
终止递归,避免死循环。
逆拓扑排序后,接下来就是对方法参数和返回值之间污染关系的分析:
1 2 passthroughDataflow = calculatePassthroughDataflow(classResourceByName, classMap, inheritanceMap, sortedMethods, config.getSerializableDecider(methodMap, inheritanceMap));
跟进calculatePassthroughDataflow,首先会遍历sortedMethods,如果是静态初始化代码,即静态代码块,就直接跳过,因为静态代码块是在类加载的时候就加载到JVM当中,我们一般没有办法在程序运行中进行控制
1 2 3 4 5 6 7 final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap <>(); for (MethodReference.Handle method : sortedMethods) { if (method.getName().equals("<clinit>" )) { continue ; }
接下来就是对当前所遍历的方法的所属类进行ASM观察:
1 2 3 4 5 6 7 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方法会判断当前观察的类是否是要准备观察方法的所属类
1 2 3 4 5 6 7 8 9 10 11 @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; if (!this .name.equals(methodToVisit.getClassReference().getName())) { throw new IllegalStateException ("Expecting to visit " + methodToVisit.getClassReference().getName() + " but instead got " + this .name); } }
接着看visitMethod,我们需要观察的类中的方法只需要是sortedMethod中的方法即可,也就是传入进来的methodToVisit,其他方法是不存在调用关系的:
1 2 3 4 if (!name.equals(methodToVisit.getName()) || !desc.equals(methodToVisit.getDesc())) { return null ; }
接下来是对方法进行更细致的观察,依旧看封装后的PassthroughDataflowMethodVisitor
1 2 3 4 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的方法调用流程可视化。
下面的分析过程基于如下例子,这一段代码调用包含了入参与返回结果相同,返回结果与入参有关的情况,我们分别来看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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; } }
逆拓扑排序后的结果为:
1 2 3 4 A.method1 C.method3 B.method2 main
A.method1 因此我们先从A.method1来进行分析:
这里我们看到visitCode方法,在进入方法的第一时间,ASM会先调用这个方法。对于非静态方法来说,方法参数插槽的第一个0号位位this,对于静态方法,0号位为参数,所以这里将方法内的所有参数保存在一个使用Java代码模拟的局部变量表中,localIndex为参数在局部变量表中的位置,由于参数的类型不同,所以其在局部变量表中占用的大小也不同。而argIndex对应了参数在方法中的索引,通过setLocalTaint方法,形成了局部变量表与方法参数索引之间的映射关系
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 @Override public void visitCode () { super .visitCode(); int localIndex = 0 ; int argIndex = 0 ; if ((this .access & Opcodes.ACC_STATIC) == 0 ) { 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的值,也就是局部变量表中对应的参数索引
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 @Override public void visitVarInsn (int opcode, int var ) { 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: break ; default : throw new IllegalStateException ("Unsupported opcode: " + opcode); } super .visitVarInsn(opcode, var ); sanityCheck(); } private void push (Set<T> possibleValues) { savedVariableState.stackVars.add(possibleValues); }
接下来当方法调用结束return时,由于使用了ARETURN指令,在解析到无操作数的简单指令时触发visitInsn,我们查看其具体逻辑,可以发现在方法return时,将当前栈上的值返回,即返回的是参数索引set,并将存储到了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: case Opcodes.FRETURN: case Opcodes.ARETURN: returnTaint.addAll(getStackTaint(0 )); break ; case Opcodes.LRETURN: case Opcodes.DRETURN: returnTaint.addAll(getStackTaint(1 )); break ; case Opcodes.RETURN: break ; default : break ; } super .visitInsn(opcode); }
最后对于该方法的观察结束,将污点分析结果存到了passthroughDataflow中,可以看到形成了方法与污染参数目录集合之间的映射关系:
1 2 3 final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap <>(); passthroughDataflow.put(method, cv.getReturnTaint());
C.method3 与A.method1流程一样
B.method2 1 2 3 4 5 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第一个,并依次存入其他参数。之后获取了方法的返回值类型的所占大小,后面进行使用:
1 2 3 4 5 6 7 8 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的参数列表的顺序相同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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.method3)的返回值结果受到了this(C类实例对象)的污染,那么将argTaint中的0号索引取出,即为this,并将其加入resultTaint。如果不是构造器,那么就创造一个空的HashSet来存储后面的resultTaint。
从passthroughDataflow中拿到被调用方法C.method3的参数与返回值污点分析关系,并判断污点分析关系中的参数是否在当前的argTaint中,如果在则说明被调用方法的返回值被调用者传入的参数污染,这也就是为什么要进行逆拓扑排序。
1 2 3 4 5 6 Set<Integer> passthrough = passthroughDataflow.get(new MethodReference .Handle(new ClassReference .Handle(owner), name, desc)); if (passthrough != null ) { for (Integer passthroughDataflowArg : passthrough) { resultTaint.addAll(argTaint.get(passthroughDataflowArg)); }
最后还是return,将B.method2的结果存到passthroughDataflow中
main方法 1 2 3 4 5 6 7 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方法保存分析数据
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 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 类名 方法名 方法描述 能污染返回值的参数索引1,能污染返回值的参数索引2,能污染返回值的参数索引3...
0x05 方法调用污染关联-CallGraphDiscovery 我们用这个例子进行分析:
1 2 3 4 5 6 7 8 9 10 11 12 13 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
1 2 3 4 5 6 7 8 9 @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前缀来进行标识:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public void visitCode () { super .visitCode(); int localIndex = 0 ; int argIndex = 0 ; 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的调用时机。我们来看如下两个例子:
1 2 3 4 5 6 A a = new A ();a.method1(args); new A ().method1(args);
我们先来看第一个例子,new A()的字节码指令大概如下,可以看到是没有LOAD指令的,在调用构造方法时直接消费的是操作数栈上的A对象引用:
1 2 3 NEW A //创建A类实例 DUP //创建对象引用 INVOKESPECIAL A.<init>()V //调用构造方法
接下来由于要把对象引用存到a中,因此会把对象引用存储到局部变量表中(假设在局部变量表2号位,局部变量表1号位存储args),即ASTORE指令,此时会触发一次visitVarInsn。那么接下来在调用a.method1(args)时需要进行两次ALOAD,首先把a的对象引用加载到操作数栈上,再把args加载到操作数栈上,从而接着触发了两次visitVarInsn
1 2 3 4 5 6 7 NEW A DUP INVOKESPECIAL A.<init>()V ASTORE 2 // 存到局部槽 2 —> visitVarInsn(ASTORE,2) ALOAD 2 // 再加载回来 —> visitVarInsn(ALOAD,2) ALOAD 1 // 加载 args —> visitVarInsn(ALOAD,1) INVOKEVIRTUAL A.method1…
继续我们看第二个例子,当构造函数执行完毕后,不需要进行ASTORE,并且再调用method1时也不需要从局部变量表中加载a的对象引用,因此最终只有加载args时才会调用一次visitVarInsn
1 2 3 4 5 6 NEW A DUP INVOKESPECIAL 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到污点栈中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Override public void visitTypeInsn (int opcode, String type) { switch (opcode) { case Opcodes.NEW: push(); break ; case Opcodes.ANEWARRAY: pop(); push(); break ; case Opcodes.CHECKCAST: break ; case Opcodes.INSTANCEOF: pop(); push(); break ; default : throw new IllegalStateException ("Unsupported opcode: " + opcode); }
字节码指令INVOKESPECIALA.<init>()V,调用A的构造器,触发visitMethodInsn,判断是否是构造器,被调用方法为构造器,将this设置为argTypes第一个参数:
1 2 3 4 5 6 7 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中:
1 2 3 4 5 6 7 8 9 10 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)推入污点栈。此时污点栈中为如下内容:
1 2 stackVars [{}, {"arg1"}, {"arg0"} ]
接下来需要读入实例a的name字段,检测到字节码指令GETFIELD,触发visitFieldInsn,首先在ClassReference中不断遍历,直到找到该字段,判断该字段是否是transient,如果是transient就没必要加入污点栈。如果是非transient属性,就把栈顶当前的arg0修改为arg0.name加入污点栈中
1 2 3 4 5 6 7 8 9 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中。
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 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) { 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 ); } 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格式如下:
1 调用者类名 调用者方法caller 调用者方法描述 被调用者类名 被调用者方法target 被调用者方法描述 调用者方法参数索引 调用者字段名 被调用者方法参数索引
0x06 利用链入口搜索-SourceDiscovery 在一开始我们也说到了,在挖掘反序列化链的时候需要指定类型,所以此处先获得对应的sourceDiscovery,我们这里以Jackson反序列化分析
1 2 3 4 5 6 7 if (!Files.exists(Paths.get("sources.dat" ))) { LOGGER.info("Discovering gadget chain source methods..." ); SourceDiscovery sourceDiscovery = config.getSourceDiscovery(); sourceDiscovery.discover(); sourceDiscovery.save(); }
跟进SourceDiscovery.discover在jackson中的实现,可以发现对于Jackson反序列化来说,source需要判断方法是否是无参构造、setter和getter,只有这些方法才能作为jackson反序列化的入口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @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,首先进行所有重写方法的扫描,在一开始我们也说了工具没有办法在运行时进行扫描,所以对于各种方法的重写我们没有办法确定到底调用的是哪个方法
1 2 3 4 5 6 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的类,并通过之前收集到的继承关系来获取类的所有子孙类,最终形成类->子孙类的映射关系:
1 2 3 4 5 6 7 8 9 10 11 12 13 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,最后整合所有重写的方法,形成方法名到重写方法之间的映射关系,由于静态方法不可重写,因此遇到静态方法直接跳过:
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 Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = new HashMap <>(); for (MethodReference method : methodMap.values()) { 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) { 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格式如下:
1 2 3 4 5 6 7 8 类名 方法名 方法描述 \t重写方法的类名 方法名 方法描述 \t重写方法的类名 方法名 方法描述 \t重写方法的类名 方法名 方法描述 \t重写方法的类名 方法名 方法描述 类名 方法名 方法描述 \t重写方法的类名 方法名 方法描述 \t重写方法的类名 方法名 方法描述
接下来对callgraph.dat的调用关系进行整合,对于同一个方法发起的调用,整合成caller->被调用方法集合之间的映射关系:
1 2 3 4 5 6 7 8 9 10 11 12 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
1 2 3 4 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) } // interface CImpl.handle → { GraphCall(callerArgIndex=0, targetMethod=D.sink, targetArgIndex=1) }
implementationFinder.getImplementations(C.handle) → { CImpl.handle }
isSink(D.sink,1) → true
对于是否为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 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" )) { return isSQLInjectSink(method, argIndex, inheritanceMap); } if (config.getName().equals("hessian" )) { if (ConfigHelper.slinks.contains("BCEL" ) && BCELSlink(method, argIndex, inheritanceMap)) { return true ; } } 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 ; }
配置参数:
1 2 3 maxChainLength = 10 opLevel = 2 taintTrack = true
1️⃣ 初始化
1 2 3 4 5 for each Source: srcLink = (A.sources, 0) methodsToExplore = [ [ A.sources(0) ] ] exploredMethods = { A.sources(0) } discoveredGadgets = { }
2️⃣ 第一次迭代
iteration=0 → pop first chain
1 2 chain = [ A.sources(0) ] lastLink = (A.sources,0)
长度检查 :1 < maxChainLength → 通过
取出 graphCallMap.get(A.sources) → { GC1 }
GC1 : (callerArgIndex=0 → targetMethod=B.load, targetArgIndex=0)
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
加入队列 :
1 2 methodsToExplore = [ [A.sources(0),B.load(0)] ] exploredMethods.add(B.load(0))
3️⃣ 第二次迭代
4️⃣ 第三次迭代
chain = [A.sources(0),B.load(0),C.handle(0)]
graphCallMap.get(C.handle) → { GC3 }
GC3 : (callerArgIndex=0 → targetMethod=C.handle, targetArgIndex=0) // 发自实现类
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)
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 可能为空,循环结束。
接下来进行链路聚合优化
1 2 3 4 5 6 7 8 9 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 ,得到更多发现。