前言

主要是看看思路和ASM是怎么用的。


项目启动

用IDEA加载文件夹,配置好启动参数分析commons-collections-3.2.1.jar。

构造类加载器

为了分析输入jar文件的字节码,GadgetInspector创建了一个新的类加载器用于加载对应路径下的Java类:

final ClassLoader classLoader;
if (args.length == argIndex+1 && args[argIndex].toLowerCase().endsWith(".war")) {
    Path path = Paths.get(args[argIndex]);
    LOGGER.info("Using WAR classpath: " + path);
    classLoader = Util.getWarClassLoader(path);
} else {
    final Path[] jarPaths = new Path[args.length - argIndex];
    for (int i = 0; i < args.length - argIndex; i++) {
        Path path = Paths.get(args[argIndex + i]).toAbsolutePath();
        if (!Files.exists(path)) {
            throw new IllegalArgumentException("Invalid jar path: " + path);
        }
        jarPaths[i] = path;
    }
    LOGGER.info("Using classpath: " + Arrays.toString(jarPaths));
    classLoader = Util.getJarClassLoader(jarPaths);
}
final ClassResourceEnumerator classResourceEnumerator = new ClassResourceEnumerator(classLoader);

ClassResourceEnumerator类通过依赖引入的guava工具收集该类加载器(commons-collections-3.2.1.jar)及其父类加载器下的所有类名及加载路径用于后续遍历分析(JRE类)。

收集类信息及函数信息

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函数如下:

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);
            }
        }
    }
}

对收集到的类进行遍历,使用MethodDiscoveryClassVisitor开始分析。

MethodDiscoveryClassVisitor是一个ClassVisitor,当ASM框架遇到一个类时会触发其visit函数,visit函数没什么特别的。而遇到其中的属性时会触发visitField函数:

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);
}

如果该属性没有static修饰符,则将该属性的属性名、修饰符和所属类一起构造成一个ClassReference.Member对象,并添加到members里,可能是认为有static修饰符的属性不可控?

遇到其中的函数时会触发visitMethod函数:

@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);
}

跟属性类似的,用修饰符、函数名、参数类型、返回类型等信息生成一个MethodReference对象,添加到外部类MethodDiscovery的discoveredMethods属性里。

最后遇到类的结束边界时会触发visitEnd函数:

@Override
public void visitEnd() {
    ClassReference classReference = new ClassReference(
            name,
            superName,
            interfaces,
            isInterface,
            members.toArray(new ClassReference.Member[members.size()]));
    discoveredClasses.add(classReference);

    super.visitEnd();
}

为本类生成一个ClassReference对象,将members放入其中,并将该类添加到外部类MethodDiscovery的discoveredClasses属性中。

遍历完类加载器中的所有类后,discoveredMethods和discoveredClasses里面已经放满了类及其中属性和函数的相关信息,然后会调用MethodDiscovery的save函数:

DataLoader.saveData(Paths.get("classes.dat"), new ClassReference.Factory(), discoveredClasses);
DataLoader.saveData(Paths.get("methods.dat"), new MethodReference.Factory(), discoveredMethods);

首先将类表和函数表分别写入到classes.dat和methods.dat文件中,然后:


Map<ClassReference.Handle, ClassReference> classMap = new HashMap<>();
for (ClassReference clazz : discoveredClasses) {
    classMap.put(clazz.getHandle(), clazz);
}
InheritanceDeriver.derive(classMap).save();

用InheritanceDeriver.derive函数处理收集到的类信息表:

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函数,从类表中找到其所有父类:

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);
    }
}

先将其直接继承的类和接口加入parents表中,再递归调用getAllParents把所有父类都找出来。

函数流程分析

下一步,调用PassthroughDiscovery类的discover函数开始分析函数流程:

if (!Files.exists(Paths.get("passthrough.dat"))) {
    LOGGER.info("Analyzing methods for passthrough dataflow...");
    PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery();
    passthroughDiscovery.discover(classResourceEnumerator, config);
    passthroughDiscovery.save();
}

discover函数如下:

Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();
InheritanceMap inheritanceMap = InheritanceMap.load();

首先从文件中读出了上一步写入的类表、函数表及类继承信息表,然后调用discoverMethodCalls函数:

Map<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);

收集函数调用信息

discoverMethodCalls函数如下:

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;
}

使用MethodCallDiscoveryClassVisitor类访问所有类,当遇到函数时会触发visitMethod函数:

@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);
}

进入到MethodCallDiscoveryMethodVisitor类访问该函数,

public MethodCallDiscoveryMethodVisitor(final int api, final MethodVisitor mv,
                                    final String owner, String name, String desc) {
    super(api, mv);

    this.calledMethods = new HashSet<>();
    methodCalls.put(new MethodReference.Handle(new ClassReference.Handle(owner), name, desc), calledMethods);
}

@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);
}

将该函数内的所有函数调用都构造成一个个MethodReference.Handle对象,并收集到calledMethods这个Set里面,所有类的calledMethods都一并存放在methodCalls调用函数表中。

函数调用逆拓扑排序

回到discover函数,收集完所有函数调用后调用topologicallySortMethodCalls函数开始排序:

List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();

该函数第一部分如下:

Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences = new HashMap<>();
for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodCalls.entrySet()) {
    MethodReference.Handle method = entry.getKey();
    outgoingReferences.put(method, new HashSet<>(entry.getValue()));
}

首先遍历了调用函数表,重新放到outgoingReferences表中。

接下来开始排序:

Set<MethodReference.Handle> dfsStack = new HashSet<>();
Set<MethodReference.Handle> visitedNodes = new HashSet<>();
List<MethodReference.Handle> sortedMethods = new ArrayList<>(outgoingReferences.size());
for (MethodReference.Handle root : outgoingReferences.keySet()) {
    dfsTsort(outgoingReferences, sortedMethods, visitedNodes, dfsStack, root);
}
...

return sortedMethods;

遍历outgoingReferences表,对每个函数及其中的函数调用调用dfsTsort函数:

private static void dfsTsort(Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences,
                                List<MethodReference.Handle> sortedMethods, Set<MethodReference.Handle> visitedNodes,
                                Set<MethodReference.Handle> stack, MethodReference.Handle node) {

    if (stack.contains(node)) {
        return;
    }
    if (visitedNodes.contains(node)) {
        return;
    }
    Set<MethodReference.Handle> outgoingRefs = outgoingReferences.get(node);
    if (outgoingRefs == null) {
        return;
    }

    stack.add(node);
    for (MethodReference.Handle child : outgoingRefs) {
        dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, child);
    }
    stack.remove(node);
    visitedNodes.add(node);
    sortedMethods.add(node);
}

第一次调用不会触发前两个判断,跳过。dfsTsort函数首先从outgoingReferences表中取出该函数内的所有函数调用,并将该函数加入栈中,再遍历该函数中的所有函数调用,递归调用dfsTsort函数,当遇到一个没有函数调用的函数后,dfsTsort函数开始返回。

在后续遍历中,遇到栈中已存在的函数会直接返回,这样就不会出现回环问题。
完成对一个函数的逆拓扑排序后,会将其从栈出取出,并添加到visitedNodes和sortedMethods这两个Set中。visitedNodes用于避免再次展开已经排序好的函数,sortedMethods用于存放排序完成后的函数序列。

对函数和函数调用的逆拓扑排序确保了每一次对排序完成的函数序列中的第一个函数做数据流分析时,其下一级函数的数据流一定是已知的。比如函数A调用了函数B,排序后的函数序列就是B-A,当开始分析函数A的数据流时,函数调用主体和函数调用参数与函数返回的相关性就是已知的。

函数数据流分析

回到discover函数,调用calculatePassthroughDataflow函数开始分析函数数据流:

passthroughDataflow = calculatePassthroughDataflow(classResourceByName, classMap, inheritanceMap, sortedMethods,
        config.getSerializableDecider(methodMap, inheritanceMap));

该函数第一步如下:

if (method.getName().equals("<clinit>")) {
    continue;
}

遍历排序好的函数序列,首先将代表静态类构造器的clinit函数排除出去。然后通过类加载器加载该函数所属类的字节码:

ClassResourceEnumerator.ClassResource classResource = classResourceByName.get(method.getClassReference().getName());
try (InputStream inputStream = classResource.getInputStream()) {
    ClassReader cr = new ClassReader(inputStream);
    try {
        PassthroughDataflowClassVisitor cv = new PassthroughDataflowClassVisitor(classMap, inheritanceMap,
                passthroughDataflow, serializableDecider, Opcodes.ASM6, method);
        cr.accept(cv, ClassReader.EXPAND_FRAMES);
        passthroughDataflow.put(method, cv.getReturnTaint());
    } catch (Exception e) {
        LOGGER.error("Exception analyzing " + method.getClassReference().getName(), e);
    }
} 

加载完成后使用PassthroughDataflowClassVisitor开始访问,当遇到函数时会触发visitMethod函数:

@Override
public MethodVisitor visitMethod(int access, String name, String desc,
                                    String signature, String[] exceptions) {
    if (!name.equals(methodToVisit.getName()) || !desc.equals(methodToVisit.getDesc())) {
        return null;
    }
    if (passthroughDataflowMethodVisitor != null) {
        throw new IllegalStateException("Constructing passthroughDataflowMethodVisitor twice!");
    }

    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);

    return new JSRInlinerAdapter(passthroughDataflowMethodVisitor, access, name, desc, signature, exceptions);
}

使用PassthroughDataflowMethodVisitor开始分析函数字节码分析,这里就相当复杂起来了,需要知道几种字节码和对应的ASM访问函数的对应关系。

visitCode

开始访问函数里的字节码会触发该函数,首先是调用父类TaintTrackingMethodVisitor的visitCode函数:

@Override
public void visitCode() {
    super.visitCode();
    savedVariableState.localVars.clear();
    savedVariableState.stackVars.clear();

    if ((this.access & Opcodes.ACC_STATIC) == 0) {
        savedVariableState.localVars.add(new HashSet<T>());
    }
    for (Type argType : Type.getArgumentTypes(desc)) {
        for (int i = 0; i < argType.getSize(); i++) {
            savedVariableState.localVars.add(new HashSet<T>());
        }
    }
}

首先清空了savedVariableState,这是一个Set,里面存放着localVars变量表和stackVars栈表两个List。

如果该函数不是静态函数,就向localVars中放入一个代表调用主体的HashSet,并为每个函数参数创建HashSet(long和double占2个长度)。

然后回到自身的visitCode函数:

@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;
    }
}

如果是非静态函数,就将调用主体和所有参数设置相关性,由于对象和函数参数(对象就是函数参数索引为0的部分)在本地变量表中也有一席之地,所以这里设置的其实就是同一个引用在本地变量表与调用主体/参数之间的关联。如果是静态函数,就没有给调用主体设置相关性这一步。

visitFrame

执行某些特殊字节码时(如GOTO、THROW等无条件分支字节码)会触发该函数,理解起来应该是类似Java代码块,比如被for、if、try、catch等关键字包裹起来的代码块,它们里面会定义专属的本地变量。简单写一个函数测试一下,会发现numLocal表示的是进入该代码块前已定义的变量,local则是已定义变量和代码块将要定义变量的类型,前者是代表类名的字符串或者Integer等类型,后者则是null。

Gadget Inspector的visitFrame函数如下:

int stackSize = 0;
for (int i = 0; i < nStack; i++) {
    Object typ = stack[i];
    int objectSize = 1;
    if (typ.equals(Opcodes.LONG) || typ.equals(Opcodes.DOUBLE)) {
        objectSize = 2;
    }
    for (int j = savedVariableState.stackVars.size(); j < stackSize+objectSize; j++) {
        savedVariableState.stackVars.add(new HashSet<T>());
    }
    stackSize += objectSize;
}
int localSize = 0;
for (int i = 0; i < nLocal; i++) {
    Object typ = local[i];
    int objectSize = 1;
    if (typ.equals(Opcodes.LONG) || typ.equals(Opcodes.DOUBLE)) {
        objectSize = 2;
    }
    for (int j = savedVariableState.localVars.size(); j < localSize+objectSize; j++) {
        savedVariableState.localVars.add(new HashSet<T>());
    }
    localSize += objectSize;
}
for (int i = savedVariableState.stackVars.size() - stackSize; i > 0; i--) {
    savedVariableState.stackVars.remove(savedVariableState.stackVars.size()-1);
}
for (int i = savedVariableState.localVars.size() - localSize; i > 0; i--) {
    savedVariableState.localVars.remove(savedVariableState.localVars.size()-1);
}

类似前面对每个函数参数生成对应的HashSet,这里根据该代码块前已有的栈元素、已定义的变量修复stackVars表和localVars表,少增多删。

这个函数不太好理解,要结合实际运行中遇到的问题来看,先不管他。

visitInsn

访问大多数字节码时会触发该函数,GadgetInspector根据字节码执行push和pop等栈操作,push操作:

private void push(T ... possibleValues) {
    Set<T> vars = new HashSet<>();
    for (T s : possibleValues) {
        vars.add(s);
    }
    savedVariableState.stackVars.add(vars);
}
private void push(Set<T> possibleValues) {
    // Intentionally make this a reference to the same set
    savedVariableState.stackVars.add(possibleValues);
}

简单来说就是向stackVars里面放一个新的HashSet,里面存放了某些字节码执行结果的值。

pop操作:

private Set<T> pop() {
    return savedVariableState.stackVars.remove(savedVariableState.stackVars.size()-1);
}

从stackVars栈中弹出一个HashSet。

此外还有DUP字节码会用到的get操作:

private Set<T> get(int stackIndex) {
    return savedVariableState.stackVars.get(savedVariableState.stackVars.size()-1-stackIndex);
}

从栈中对应index的地方复制一个元素出来,用于后续压入栈中。

visitInsn函数的具体操作如下,遍历字节码类型,然后分别做不同栈操作:

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);

对IRETURN等返回字节码做了特殊处理,会调用getStackTaint函数获取当时栈顶元素的数据流相关性信息:

protected Set<T> getStackTaint(int index) {
    return savedVariableState.stackVars.get(savedVariableState.stackVars.size()-1-index);
}

由于long和double类型占两个单位长度,所以取的是次栈顶元素。

而其他没有处理的字节码会交给父类,父类也会做不同的栈操作:

  1. NOP:break结束switch,无操作。

  2. ACONST_NULL等常量定义:调用push函数。

  3. LCONST_0等常量定义:由于long和double类型占两个单位长度,所以调用两次push函数。

  4. IALOAD等数组操作:pop出代表数组索引和数组引用两个元素,push得到的数组数据。

  5. LALOAD等数组操作:由于long和double由于long和double类型占两个单位长度,所以调用两次push函数。

  6. IASTORE等数组操作:pop出要存放到数组中的值、数组索引及数组引用。

  7. LASTORE等数组操作:由于long和double由于long和double类型占两个单位长度,所以调用两次pop函数。

  8. POP:就是pop函数。

  9. POP2:调用两次pop函数。

  10. DUP、DUP2等复制操作:复制栈顶/次栈顶元素并压入栈中。

  11. DUP_X1、DUP_X2等奇妙复制操作:又pop又push的,顺序和次数不定。

  12. SWAP:交换栈顶两个元素,pop两次再交换顺序push两次。

  13. IADD等两参数计算操作:pop出两个操作数然后push结果。

  14. LADD等两参数计算操作:由于long和double由于long和double类型占两个单位长度,所以调用4次pop函数和2次push函数。

  15. INEG等取反操作:pop出来再push结果进去。

  16. LNEG等取反操作:由于long和double由于long和double类型占两个单位长度,所以调用2次pop函数和2次push函数。

  17. ISHL等移位操作:pop出操作数和移位数,再push结果进去。

  18. LSHL等移位操作:由于long和double由于long和double类型占两个单位长度,所以调用3次pop函数和2次push函数。

  19. IAND等位操作:pop出两个操作数,再push结果进去。

  20. LAND等位操作:由于long和double由于long和double类型占两个单位长度,所以调用4次pop函数和2次push函数。

  21. I2B等类型转换操作:根据操作数是否为long或者double类型,进行对应次数pop操作和push操作

  22. LCMP等比较操作:根据操作数是否为long或者double类型,进行对应次数pop操作和push操作。

  23. IRETURN等返回操作:根据操作数是否为long或者double类型,进行对应次数pop出要返回的数据。

  24. RETURN返回操作:void,不返回数据。

  25. ARRAYLENGTH计算数组长度操作:pop出栈顶的数组索引,再push获取到的数组长度进去。

  26. ATHROW抛出异常操作:pop出栈顶异常。

  27. MONITORENTER、MONITOREXIT等同步操作:pop出监视对象。

可以看到,对于这些不涉及到对象操作的字节码,GadgetInspector只是单纯做一些栈操作,而不会进行具体的数据计算,push进去的结果都是一些空的Set。

visitIntInsn

访问单Int类型操纵数的字节码时会触发该函数:

switch(opcode) {
    case Opcodes.BIPUSH:
    case Opcodes.SIPUSH:
        push();
        break;
    case Opcodes.NEWARRAY:
        pop();
        push();
        break;
    default:
        throw new IllegalStateException("Unsupported opcode: " + opcode);
}

BIPUSH和SIPUSH字节码:调用push函数将一个Int类型的常量压入栈中。

NEWARRAY字节码:创建数据成员类型为基本类型的数组,pop出数组长度,完成后将数组引用push入栈中。

visitVarInsn

访问本地变量时会触发该函数,输入参数为字节码操作和本地变量索引:

for (int i = savedVariableState.localVars.size(); i <= var; i++) {
    savedVariableState.localVars.add(new HashSet<T>());
}

首先扩展localVars表,确保该索引下存在对应的变量HashSet。

然后根据字节码操作采取对应的处理方式:

  1. ILOAD等入栈操作:(从局部变量表加载数据)然后push一次,long或者double就push两次。

  2. ALOAD引用类型入栈操作:从局部变量表localVars加载对应索引处的HashSet,再push进栈中。

  3. ISTORE等存入变量表操作:pop一次或两次弹出数据,再存储到localVars局部变量表里。

  4. ASTORE引用类型存入变量表操作:pop出引用,再存储到localVars局部变量表中。

  5. RET返回操作:不影响栈,不做处理。

可以看到,基本类型的入栈还是不管不问,而出栈则是向变量表中添加一个空的HashSet占位。而涉及到引用类型的出入栈则有不同处理,入栈会确确实实地压入局部变量表中的数据,出栈也会确确实实地将栈顶元素弹出。

visitTypeInsn

遇到类型相关的字节码时(如对象实例化、数组定义、对象类型检查和instanceof实例所属类判断)会触发该函数:

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);
}
  1. NEW对象实例化:将实例化完成的类引用压入栈中。

  2. ANEWARRAY引用类型数组定义:弹出数组长度,将创建完成的数组引用压入栈中。

  3. CHECKCAST类型检查:弹出对象引用又压回去,没有栈变化。(要检查是否属于的类从指令的后续中获得)

  4. INSTANCEOF实例所属类判断:弹出类引用,完成判断后再将结果压入

同样的对栈数据不管不问。

visitFieldInsn

访问对象属性时(包括属性对象和变量对象)会触发该函数,PassthroughDataflowMethodVisitor的visitFieldInsn函数对GETSTATIC、PUTSTATIC和PUTFIELD字节码没有处理。

而对GETFIELD取属性值有特殊处理,当该属性不是static,也不是void、long、double等类型时(其实主要处理的就是object等类型):

Boolean isTransient = null;

// If a field type could not possibly be serialized, it's effectively transient
if (!couldBeSerialized(serializableDecider, inheritanceMap, new ClassReference.Handle(type.getInternalName()))) {
    isTransient = Boolean.TRUE;
} else {
    ClassReference clazz = classMap.get(new ClassReference.Handle(owner));
    while (clazz != null) {
        for (ClassReference.Member member : clazz.getMembers()) {
            if (member.getName().equals(name)) {
                isTransient = (member.getModifiers() & Opcodes.ACC_TRANSIENT) != 0;
                break;
            }
        }
        if (isTransient != null) {
            break;
        }
        clazz = classMap.get(new ClassReference.Handle(clazz.getSuperClass()));
    }
}

首先是该对象的可序列化性判断,根据SimpleSerializableDecider的可序列化判断就是是否在黑名单中以及是否继承了java.io.Serializable接口:

if (isBlacklistedClass(handle)) {
    return false;
}

if (inheritanceMap.isSubclassOf(handle, new ClassReference.Handle("java/io/Serializable"))) {
    return true;
}

return false;

如果未继承该接口,则会将isTransient设置为true,代表该属性不可由反序列化控制。而如果继承了该接口,则会从classMap中找到该属性所属的类(属性对象或者变量对象),遍历其和其父类属性表找到该属性,查询其修饰符,如果有TRANSIENT修斯回复则同样将isTransient设置为true。

然后设置数据流相关性:

Set<Integer> taint;
if (!Boolean.TRUE.equals(isTransient)) {
    taint = getStackTaint(0);
} else {
    taint = new HashSet<>();
}

super.visitFieldInsn(opcode, owner, name, desc);
setStackTaint(0, taint);

如果isTransient为true,则taint是个空Set;如果isTransient为false,则taint的值取自栈顶元素即对象引用,也就是说该对象可控则该属性可控。

然后会调用父类的visitFieldInsn函数:

int typeSize = Type.getType(desc).getSize();
switch (opcode) {
    case Opcodes.GETSTATIC:
        for (int i = 0; i < typeSize; i++) {
            push();
        }
        break;
    case Opcodes.PUTSTATIC:
        for (int i = 0; i < typeSize; i++) {
            pop();
        }
        break;
    case Opcodes.GETFIELD:
        pop();
        for (int i = 0; i < typeSize; i++) {
            push();
        }
        break;
    case Opcodes.PUTFIELD:
        for (int i = 0; i < typeSize; i++) {
            pop();
        }
        pop();

        break;
    default:
        throw new IllegalStateException("Unsupported opcode: " + opcode);
}

就是不关心数据的平衡栈操作。

对于GETFIELD字节码,pop弹出对象引用,push压入取到的对象属性后,最后调用setStackTaint函数,为该对象属性设置好了数据流相关性。

visitMethodInsn

遇到调用函数的字节码时会触发该函数,PassthroughDataflowMethodVisitor的visitMethodInsn函数第一部分如下:

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;
}
int retSize = Type.getReturnType(desc).getSize();

如果调用的不是静态函数,则会在argTypes中放入调用主体和输入参数类型等多个Type对象。

然后遍历函数调用字节码(其实都是同一段处理代码):

final List<Set<Integer>> argTaint = new ArrayList<Set<Integer>>(argTypes.length);
for (int i = 0; i < argTypes.length; i++) {
    argTaint.add(null);
}

首先新建一个数据流相关性数组argTaint,长度与argTypes一致,值都初始化为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();
}

从栈顶取出输入参数、调用主体等元素,并将这些元素的数据流相关性传递到argTaint数据流相关性数组里,接下来:

if (name.equals("<init>")) {
    // Pass result taint through to original taint set; the initialized object is directly tainted by
    // parameters
    resultTaint = argTaint.get(0);
} else {
    resultTaint = new HashSet<>();
}

如果调用的是构造函数,则将对象引用的数据流相关性赋给它,该对象引用既是函数调用主体,又是函数调用的返回值。这里可以写一个简单的测试用代码然后看看字节码来理解:

import java.util.HashMap;

public class App {
    public static void main(String[] args) {
        HashMap map = new HashMap();
    }
}

字节码如下:

Code:
    stack=2, locals=2, args_size=1
        0: new           #2                  // class java/util/HashMap
        3: dup
        4: invokespecial #3                  // Method java/util/HashMap."<init>":()V
        7: astore_1
        8: return

可以看到一次new实例化对象的操作实际上由4个字节码组成:

  1. NEW:创建一个新的对象引用并放入栈中

  2. DUP:复制一个对象引用放到栈顶

  3. INVOKESPECIAL:弹出复制的对象引用,调用其构造函数

  4. ASTORE_1:将该新的对象引用放入本地变量表

而如果调用的不是构造函数,就创建一个新的数据流相关性。

再然后:

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));
    }
}
break;

遍历收集到的函数数据流分析结果表passthroughDataflow找到调用的函数数据流结果,再遍历该函数返回结果与调用主体、输入参数的相关性,根据输入污点确认返回污点。由于此时是第一次分析,所以这里没有数据,要到后面再回来看。

再调用父类的visitMethodInsn函数,流程大致上一致,只是多了些pop弹出调用主体和参数来保持栈平衡的操作,以及一些特殊函数污点的处理,包括但不限于:

  1. 调用ObjectInputStream.defaultReadObject函数时,污点从调用主体传递到this(即调用defaultReadObject函数所属函数所属的对象)。

  2. toString等函数的污点传递规则,如toString从调用函数主体传递到返回值。

  3. 如果调用的是Map/Collection的函数,那就默认输入参数会影响该Map/Collection,且函数返回也会受到该Map/Collection的影响。

最后将函数结果的数据流相关性入栈,再回到PassthroughDataflowMethodVisitor:

if (retSize > 0) {
    getStackTaint(retSize-1).addAll(resultTaint);
}

如果存在返回值,就将其污点设置到返回父类压入栈的返回元素上。

visitInvokeDynamicInsn

进行动态函数调用时会触发该函数:

int argsSize = 0;
for (Type type : Type.getArgumentTypes(desc)) {
    argsSize += type.getSize();
}
int retSize = Type.getReturnType(desc).getSize();

for (int i = 0; i < argsSize; i++) {
    pop();
}
for (int i = 0; i < retSize; i++) {
    push();
}

只做了栈平衡操作。

visitJumpInsn

遇到跳转相关的字节码时(如IFEQ等与0比较跳转、IF_ICMPEQ等比较跳转),即if等结构体时会调用该函数,除了弹出来平衡栈以外,还调用了mergeGotoState函数来保存和读取标签对应代码块的栈和本地变量表:

private void mergeGotoState(Label label, SavedVariableState savedVariableState) {
    if (gotoStates.containsKey(label)) {
        SavedVariableState combinedState = new SavedVariableState(gotoStates.get(label));
        combinedState.combine(savedVariableState);
        gotoStates.put(label, combinedState);
    } else {
        gotoStates.put(label, new SavedVariableState(savedVariableState));
    }
}
visitLabel

遇到标签时会触发该函数,可以根据标签找到代码块对应的栈和本地变量表:

if (gotoStates.containsKey(label)) {
    savedVariableState = new SavedVariableState(gotoStates.get(label));
}
if (exceptionHandlerLabels.contains(label)) {
    // Add the exception to the stack
    push(new HashSet<T>());
}
visitLdcInsn

LDC指令将常量池中的常量等数据压入栈中,里面只有平衡栈的操作。

visitTableSwitchInsn/visitLookupSwitchInsn

看起来是跟switch有关的字节码,会有多个标签,两个函数的使用方法似乎跟标签稀疏度有关,的处理代码相同:

// Operand stack has a switch index which gets popped
pop();

// Save the current state with any possible target labels
mergeGotoState(dflt, savedVariableState);
for (Label label : labels) {
    mergeGotoState(label, savedVariableState);
}

弹出栈中放的index,然后给每个标签都保存一份栈和本地变量表。

visitMultiANewArrayInsn

看名字是跟复杂类型数组实例化有关的,只有栈平衡操作。

visitTryCatchBlock

添加标签:

exceptionHandlerLabels.add(handler);
没有特殊处理的函数

visitIincInsn

visitInsnAnnotation

visitTryCatchAnnotation

visitMaxs

visitEnd

调用链分析

完成函数数据流分析,得到输入参数与返回值的关系后,回到GadgetInspector类的mian函数:

if (!Files.exists(Paths.get("callgraph.dat"))) {
    LOGGER.info("Analyzing methods in order to build a call graph...");
    CallGraphDiscovery callGraphDiscovery = new CallGraphDiscovery();
    callGraphDiscovery.discover(classResourceEnumerator, config);
    callGraphDiscovery.save();
}

callGraphDiscovery的discover函数如下:

Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();
Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();
InheritanceMap inheritanceMap = InheritanceMap.load();
Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = PassthroughDiscovery.load();

SerializableDecider serializableDecider = config.getSerializableDecider(methodMap, inheritanceMap);

for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {
    try (InputStream in = classResource.getInputStream()) {
        ClassReader cr = new ClassReader(in);
        try {
            cr.accept(new ModelGeneratorClassVisitor(classMap, inheritanceMap, passthroughDataflow, serializableDecider, Opcodes.ASM6),
                    ClassReader.EXPAND_FRAMES);
        } catch (Exception e) {
            LOGGER.error("Error analyzing: " + classResource.getName(), e);
        }
    }
}

加载好前几步得到的类表、函数表、类继承信息表、函数信息流表和可序列化判断器后,使用ModelGeneratorClassVisitor遍历所有类,里面再使用ModelGeneratorMethodVisitor遍历所有函数,大部分函数跟PassthroughDataflowMethodVisitor基本相同,稍有不同的是visitCode函数在初始化调用主体和参数在本地变量表中的关系性的时候,设置的不是索引而是一个arg前缀加索引的字符串:

setLocalTaint(localIndex, "arg" + argIndex);

还有就是visitFieldInsn在设置对象属性的关联性的时候:

Set<String> newTaint = new HashSet<>();
if (!Boolean.TRUE.equals(isTransient)) {
    for (String s : getStackTaint(0)) {
        newTaint.add(s + "." + name);
    }
}

加入的关联性会是一个arg0.name这样的东西。

不同的主要是处理函数调用的visitMethodInsn函数:

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();
}

遍历调用主体和函数参数,构造一条两个函数之间的调用链,该调用链由几个部分组成,一个函数调用有调用主体和多个参数,每个调用主体和参数都有一个调用链对象GraphCall:

  1. 代表两个函数的MethodReference.Handle对象

  2. 从调用主体或者函数参数到该函数调用的调用主体和参数的关联

  3. 函数调用主体和参数索引

完成后调用save函数,将这些函数调用都保存到文件里。

sources分析

接下来开始分析反序列化入口及污染源点:

if (!Files.exists(Paths.get("sources.dat"))) {
    LOGGER.info("Discovering gadget chain source methods...");
    SourceDiscovery sourceDiscovery = config.getSourceDiscovery();
    sourceDiscovery.discover();
    sourceDiscovery.save();
}

即SimpleSourceDiscovery,有以下几种:

  1. finalize函数,对象本身可控

  2. readObject函数,第一个参数可控

  3. 继承了InvocationHandler接口的动态代理的invoke函数,对象本身可控

  4. hashCode和equals函数,用于分析点后移,参数可控

  5. 特殊函数,如Groovy反序列化漏洞中出现的Closure.doCall函数,参数可控

奇怪的是,它一个函数两个污染源没有合并在一起,而是生成了两个sources。

反序列化链分析

最后一步,搜集完信息之后开始反序列化链分析:

LOGGER.info("Searching call graph for gadget chains...");
GadgetChainDiscovery gadgetChainDiscovery = new GadgetChainDiscovery(config);
gadgetChainDiscovery.discover();

读取完存储的信息后,开始遍历sources,再根据sources遍历函数调用表:

if (graphCall.getCallerArgIndex() != lastLink.taintedArgIndex) {
    continue;
}

如果这条函数调用链的污染源跟链尾函数(这里是sources)的污染源对不上,就跳过。

然后使用SimpleImplementationFinder类,获取所有能调用的下一个函数(该函数本身及子类重写过的该函数),并加入链中继续循环。

期间会通过isSink函数判断是否到达终点,主要有几个种类:

  1. FileInputStream等文件操作类的函数

  2. Runtime、ProcessBuilder等命令执行类的函数

  3. 其他系统类

不过它会将每个sources和链中已有的函数污染源放在exploredMethods中,后面再遇到就会直接跳过。

不太好的地方

  1. 通过类加载器获取所有类,会加载到依赖和本项目中的很多冗余类,而这些类不会出现在漏洞环境中。

  2. MethodDiscoveryClassVisitor的visitField存储类属性的时候,同一个类会有多个ClassReference.Handle实例,或许会浪费一些内存?

  3. 分析不够细致,如INSTANCEOF等类型判断字节码是存在影响利用链挖掘的可能性的,比如前一个函数在if条件中要求了一个变量为A类型并作为参数传入下一个函数,下一个函数却又以该参数为B类型的前提继续调用下一函数,这样就会导致误判。

  4. 分析不够细致,如PassthroughDataflowMethodVisitor-PUTFIELD类属性赋值字节码没有做污染传递处理,没有将给属性赋值后再使用的情况考虑进来,属性的污染性经赋值后就不再变化。

  5. 分析不够细致,如PassthroughDataflowMethodVisitor-visitFieldInsn函数处理GETFIELD字节码时,无法划分属性对象和变量对象,对变量对象也采取是否继承java.io.Serializable接口的方式判断其属性的污染性会导致误判。

  6. 一个函数多个污染源生成了多个sources,emmmm。

  7. Sinks的判断不够细致


参考

字节码指令集

JVM指令集


Web

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

ThinkPHP多语言模块文件包含漏洞
CVE-2022-39197 CobaltStrike RCE漏洞学习