这篇文章上次修改于 952 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

原文 -> https://blog.csdn.net/qq_38851536/article/details/117419041


前言

这是SO逆向入门实战教程的第二篇,总共会有十三篇,十三个实战。有以下几个注意点:

  • 主打入门级的实战,适合有一定基础但缺少实战的朋友(了解JNI,也上过一些Native层逆向的课,但感觉实战匮乏,想要壮壮胆,入入门)。
  • 侧重新工具、新思路、新方法的使用,算法分析的常见路子是Frida Hook + IDA ,在本系列中,会淡化Frida 的作用,采用Unidbg Hook + IDA 的路线。
  • 主打入门,但并不限于入门,你会在样本里看到有浅有深的魔改加密算法、以及OLLVM、SO对抗等内容。
  • 细,非常的细,奶妈级教学。
  • 共十三篇,1-2天更新一篇。每篇的资料放在文末的百度网盘中。

准备

这是我们的分析目标

参数一是Context上下文,参数二是传入的明文,参数三是固定的值,疑似Key或者盐。

返回值是8位的Sign,且输入不变的情况下,输出也固定不变。

Unidbg模拟执行

IDA中打开libutility.so,先搜索一下会不会是静态绑定。

难得遇到静态绑定的Native函数,先参数重命名,在笔者的IDA 7.5中,JNIEnv不需要导入jni.h,设置一下type就可以识别JNI函数。

if ( sub_1C60(a1, context) )
{
if ( (*a1)->PushLocalFrame(a1, 16) >= 0 )
{
    v6 = (*a1)->GetStringUTFChars(a1, inputKey, 0);
    v18 = (char *)(*a1)->GetStringUTFChars(a1, inputBytes, 0);
    v7 = j_strlen(v18);
    v8 = v7 + j_strlen(v6) + 1;
    v9 = j_malloc(v8);
    j_memset(v9, 0, v8);
    j_strcpy((char *)v9, v18);
    j_strcat((char *)v9, v6);
    v10 = (_BYTE *)MDStringOld(v9);
    v11 = (char *)j_malloc(9u);
    *v11 = v10[1];
    v11[1] = v10[5];
    v11[2] = v10[2];
    v11[3] = v10[10];
    v11[4] = v10[17];
    v11[5] = v10[9];
    v11[6] = v10[25];
    v12 = v10[27];
    v11[8] = 0;
    v11[7] = v12;
    v21 = (*a1)->FindClass(a1, "java/lang/String");
    v22 = (*a1)->GetMethodID(a1, v21, "<init>", "([BLjava/lang/String;)V");
    v13 = j_strlen(v11);
    v19 = (*a1)->NewByteArray(a1, v13);
    v14 = j_strlen(v11);
    (*a1)->SetByteArrayRegion(a1, v19, 0, v14, v11);
    v15 = (*a1)->NewStringUTF(a1, "utf-8");
    v16 = (*a1)->NewObject(a1, v21, v22, v19, v15);
    j_free(v11);
    j_free(v9);
    (*a1)->ReleaseStringUTFChars(a1, (jstring)inputBytes, v18);
    inputBytes = (int)(*a1)->PopLocalFrame(a1, v16);
}
else
{
    inputBytes = 0;
}
}
return inputBytes;

如果sub_1C60函数False,函数直接返回0,显然这是一条错误的逻辑,而传入的参数又是context,这很容易让人想到是一个签名校验函数。先不往下看了,上Unidbg。

同样先搭一下基础的架子,这个样本连JNI OnLoad都没有。

package com.lession2;

// 导入通用且标准的类库
import com.github.unidbg.linux.android.dvm.AbstractJni;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.array.ByteArray;
import com.github.unidbg.memory.Memory;
import com.lession1.oasis;

import java.io.File;

public class sina extends AbstractJni{
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;

    sina() {
        // 创建模拟器实例,进程名建议依照实际进程名填写,可以规避针对进程名的校验
        emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.sina.International").build();
        // 获取模拟器的内存操作接口
        final Memory memory = emulator.getMemory();
        // 设置系统类库解析
        memory.setLibraryResolver(new AndroidResolver(23));
        // 创建Android虚拟机,传入APK,Unidbg可以替我们做部分签名校验的工作
        vm = emulator.createDalvikVM(new File("unidbg-android\\src\\test\\java\\com\\lession2\\sinaInternational.apk"));
        //
//        vm = emulator.createDalvikVM(null);

        // 加载目标SO
        DalvikModule dm = vm.loadLibrary(new File("unidbg-android\\src\\test\\java\\com\\lession2\\libutility.so"), true); // 加载so到虚拟内存
        //获取本SO模块的句柄,后续需要用它
        module = dm.getModule();
        vm.setJni(this); // 设置JNI
        vm.setVerbose(true); // 打印日志
        // 样本连JNI OnLoad都没有
        // dm.callJNI_OnLoad(emulator); // 调用JNI OnLoad
    };

    public static void main(String[] args) {
        sina test = new sina();
    }
}

接下来添加一个calculateS函数,依然是地址方式调用,ARM32有Thumb和ARM两种指令模式,此处是thumb模式,所以地址要在start基础上+1。

注意看代码,相较于第一讲,这里的入参有一些新情况

  • context如何构造
  • 字符串类型如何构造

除了基本类型,比如int,long等,其他的对象类型一律要手动 addLocalObject。

    public String calculateS(){
        List<Object> list = new ArrayList<>(10);
        list.add(vm.getJNIEnv()); // 第一个参数是env
        list.add(0); // 第二个参数,实例方法是jobject,静态方法是jclazz,直接填0,一般用不到。
        DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(null);// context
        list.add(vm.addLocalObject(context));
        list.add(vm.addLocalObject(new StringObject(vm, "12345")));
        list.add(vm.addLocalObject(new StringObject(vm, "r0ysue")));
        // 因为代码是thumb模式,别忘了+1
        Number number = module.callFunction(emulator, 0x1E7C + 1, list.toArray())[0];
        String result = vm.getObject(number.intValue()).getValue().toString();
        return result;
    };

完整代码如下

package com.lession2;

// 导入通用且标准的类库
import com.github.unidbg.linux.android.dvm.AbstractJni;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.array.ByteArray;
import com.github.unidbg.memory.Memory;
import com.lession1.oasis;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

public class sina extends AbstractJni{
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;

    sina() {
        // 创建模拟器实例,进程名建议依照实际进程名填写,可以规避针对进程名的校验
        emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.sina.International").build();
        // 获取模拟器的内存操作接口
        final Memory memory = emulator.getMemory();
        // 设置系统类库解析
        memory.setLibraryResolver(new AndroidResolver(23));
        // 创建Android虚拟机,传入APK,Unidbg可以替我们做部分签名校验的工作
        vm = emulator.createDalvikVM(new File("unidbg-android\\src\\test\\java\\com\\lession2\\sinaInternational.apk"));
        //
//        vm = emulator.createDalvikVM(null);

        // 加载目标SO
        DalvikModule dm = vm.loadLibrary(new File("unidbg-android\\src\\test\\java\\com\\lession2\\libutility.so"), true); // 加载so到虚拟内存
        //获取本SO模块的句柄,后续需要用它
        module = dm.getModule();
        vm.setJni(this); // 设置JNI
        vm.setVerbose(true); // 打印日志
        // 样本连JNI OnLoad都没有
        // dm.callJNI_OnLoad(emulator); // 调用JNI OnLoad
    };

    public String calculateS(){
        List<Object> list = new ArrayList<>(10);
        list.add(vm.getJNIEnv()); // 第一个参数是env
        list.add(0); // 第二个参数,实例方法是jobject,静态方法是jclazz,直接填0,一般用不到。
        DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(null);// context
        list.add(vm.addLocalObject(context));
        list.add(vm.addLocalObject(new StringObject(vm, "12345")));
        list.add(vm.addLocalObject(new StringObject(vm, "r0ysue")));

        Number number = module.callFunction(emulator, 0x1E7C + 1, list.toArray())[0];
        String result = vm.getObject(number.intValue()).getValue().toString();
        return result;
    };

    public static void main(String[] args) {
        sina test = new sina();
        System.out.println(test.calculateS());
    }
}

顺嘴一提如何判断是Thumb还是Arm模式,最粗暴的方式就是试错法,比如此处不加1,指令肯定就跑偏,会报错非法指令

这个办法粗鲁且有效,第二个办法是从知识角度出发,ARM模式指令总是4字节长度,Thumb指令长度多数为2字节,少部分指令是4字节。

IDA顶部选项框:Options-General

查看汇编指令的机器码

我们发现此处指令大多为两个字节长度,那就是Thumb。如果你还不放心,找准一行汇编,Alt+G快捷键。

Thumb模式是1,ARM模式是0。除此之外,如果偶尔IDA反汇编出了问题,可以考虑它识别错了模式,需要Alt+G手动修改,调整模式。

言归正传,运行我们的代码。

真恼人,竟然报错了,而且没有较为明确的提示

看一下Warn一行显示的报错所处地址

IDA快捷键G跳转到0x2c8d,看这个架势a1是JNIEnv指针

把a1转成JNIEnv

按X查看一下交叉引用,再往上看看,可以发现就是sub_1C60函数。从先前的分析可以看出,这个函数会返回一个值,如果为真,就继续执行,为假,就返回0。再结合此地里面找的这些类,诸如PackageManager之流,很难不让人联想到签名校验函数。

可以直接patch掉对这个函数的调用,说人话就是把这儿的函数跳转改成不跳转了呗。

正常执行这个函数的话,如果校验没问题返回真,比如1,校验失败返回0。

根据ARM调用约定,入参前四个分别通过R0-R3调用,返回值通过R0返回,所以这儿可以通过"mov r0,1"实现我们的目标——不执行这个函数,并给出正确的返回值。除此之外还有一个幸运的地方在于,这个函数并没有产生一些之后需要使用的值或者中间变量,这让我们不需要管别的寄存器。

此处的机器码是FF F7 EB FE, 查看一下"mov r0,1"的机器码,这里我们使用ARMConvert看一下,除此之外,使用别的工具查看汇编代码也是可以的。

即把 FF F7 EB FE 替换成 4FF00100 即可

这个事儿我们过去用Keypatch干,用Frida 干,用010Editor干,现在用Unidbg干罢了,新瓶装旧酒!

Unidbg提供了两种方法打Patch,简单的需求可以调用Unicorn对虚拟内存进行修改,如下

public void patchVerify(){
    int patchCode = 0x4FF00100; // 
    emulator.getMemory().pointer(module.base + 0x1E86).setInt(0,patchCode);
}

需要注意的是,这儿地址可别+1了,Thumb的+1只在运行和Hook时需要考虑,打Patch可别想。

看一下现在的完整代码,以及运行结果。

package com.lession2;

// 导入通用且标准的类库
import com.github.unidbg.linux.android.dvm.AbstractJni;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.array.ByteArray;
import com.github.unidbg.memory.Memory;
import com.lession1.oasis;
import org.apache.log4j.Level;
import org.apache.log4j.Logger;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

public class sina extends AbstractJni{
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;

    sina() {
        // 创建模拟器实例,进程名建议依照实际进程名填写,可以规避针对进程名的校验
        emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.sina.International").build();
        // 获取模拟器的内存操作接口
        final Memory memory = emulator.getMemory();
        // 设置系统类库解析
        memory.setLibraryResolver(new AndroidResolver(23));
        // 创建Android虚拟机,传入APK,Unidbg可以替我们做部分签名校验的工作
        vm = emulator.createDalvikVM(new File("unidbg-android\\src\\test\\java\\com\\lession2\\sinaInternational.apk"));
        //
//        vm = emulator.createDalvikVM(null);

        // 加载目标SO
        DalvikModule dm = vm.loadLibrary(new File("unidbg-android\\src\\test\\java\\com\\lession2\\libutility.so"), true); // 加载so到虚拟内存
        //获取本SO模块的句柄,后续需要用它
        module = dm.getModule();
        vm.setJni(this); // 设置JNI
        vm.setVerbose(true); // 打印日志
        // 样本连JNI OnLoad都没有
        // dm.callJNI_OnLoad(emulator); // 调用JNI OnLoad
    };

    public String calculateS(){
        List<Object> list = new ArrayList<>(10);
        list.add(vm.getJNIEnv()); // 第一个参数是env
        list.add(0); // 第二个参数,实例方法是jobject,静态方法是jclazz,直接填0,一般用不到。
        DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(null);// context
        list.add(vm.addLocalObject(context));
        list.add(vm.addLocalObject(new StringObject(vm, "12345")));
        list.add(vm.addLocalObject(new StringObject(vm, "r0ysue")));

        Number number = module.callFunction(emulator, 0x1E7C + 1, list.toArray())[0];
        String result = vm.getObject(number.intValue()).getValue().toString();
        return result;
    };

    public void patchVerify(){
        int patchCode = 0x4FF00100; //
        emulator.getMemory().pointer(module.base + 0x1E86).setInt(0,patchCode);
    }

    public static void main(String[] args) {
        sina test = new sina();
        test.patchVerify();
        System.out.println(test.calculateS());
    }
}

直接出结果了。

我们的Patch效果非常可,帮助我们绕过了签名校验的烦人逻辑。但有些情况下,我们可能要动态打Patch,或者我们并不想上什么网站,看MOV R0,1的机器码是什么,这时候可以使用Unidbg给我们封装的Patch方法。

    public void patchVerify1(){
        Pointer pointer = UnidbgPointer.pointer(emulator, module.base + 0x1E86);
        assert pointer != null;
        byte[] code = pointer.getByteArray(0, 4);
        if (!Arrays.equals(code, new byte[]{ (byte)0xFF, (byte) 0xF7, (byte) 0xEB, (byte) 0xFE })) { // BL sub_1C60
            throw new IllegalStateException(Inspector.inspectString(code, "patch32 code=" + Arrays.toString(code)));
        }
        try (Keystone keystone = new Keystone(KeystoneArchitecture.Arm, KeystoneMode.ArmThumb)) {
            KeystoneEncoded encoded = keystone.assemble("mov r0,1");
            byte[] patch = encoded.getMachineCode();
            if (patch.length != code.length) {
                throw new IllegalStateException(Inspector.inspectString(patch, "patch32 length=" + patch.length));
            }
            pointer.write(0, patch, 0, patch.length);
        }
    };

逻辑也非常清晰,先确认有没有找对地方,地址上是不是 FF F7 EB FE,再用Unicorn的好兄弟Keystone 把patch代码"mov r0,1"转成机器码,填进去,校验一下长度是否相等,收工。

算法分析

在第一篇中,我们使用Findhash对算法做了分析,但是纯粹用Unidbg做算法分析一定是件激动人心的事,让我们来试一下吧。

代码的逻辑非常简单,将text和key拼接起来,然后放到MDStringOld函数中,出来的结果,从中分别抽出第1位(从0开始),第5位,等8位,就是结果了。

所以这个时候我们的关注点就是MDStringOld函数,首要的就是获取它的参数和返回值。

  • 它的参数可以验证我们对MDStringOld函数前面的分析有没有出错
  • 它的返回值可以验证我们对MDStringOld函数后面和结果的分析有没有出错

这个函数的地址是0x1BD0+1

如果是Frida动态分析,我们会通过如下方式Hook

function hookMDStringOld() {
    var baseAddr = Module.findBaseAddress("libutility.so")
    var MDStringOld = baseAddr.add(0x1BD0).add(0x1)
    Interceptor.attach(MDStringOld, {
        onEnter: function (args) {
            console.log("input:\n", hexdump(this.arg0))
        },
        onLeave: function (retval) {
            console.log("result:\n", hexdump(retval))
        }
    })
}

那么在Unidbg中,我们该怎么做呢

Unidbg内嵌了多种Hook工具,目前主要是四种

  • Dobby
  • HookZz
  • xHook
  • Whale

但我们没必要四种都学

xHook 是爱奇艺开源的基于PLT HOOK的Hook框架,它无法Hook不在符号表里的函数,也不支持inline hook,这在我们的逆向分析中是无法忍受的,所以在这里不去理会它。

Whale 在Unidbg的测试用例中只有对符号表函数的Hook,没看到Inline Hook 或者 非导出函数的Hook,所以也不去考虑。

HookZz是Dobby的前身,两者都可以Hook 非导出表中的函数,即IDA中显示为sub_xxx的函数,也都可以进行inline hook,所以二选一就行了。我喜欢HookZz这个名字,所以就HookZz了。

使用HookZz hook MDStringOld函数,MDStringOld是导出函数,可以传入符号名,解析地址,但管他什么findsymbol,findExport呢,我就认准地址,地址,yyds。

看一下完整代码

package com.lession2;

// 导入通用且标准的类库
import com.github.unidbg.Emulator;
import com.github.unidbg.arm.context.Arm32RegisterContext;
import com.github.unidbg.hook.hookzz.*;
import com.github.unidbg.linux.android.dvm.AbstractJni;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.array.ByteArray;
import com.github.unidbg.memory.Memory;
import com.github.unidbg.pointer.UnidbgPointer;
import com.github.unidbg.utils.Inspector;
import com.lession1.oasis;
import com.sun.jna.Pointer;
import keystone.Keystone;
import keystone.KeystoneArchitecture;
import keystone.KeystoneEncoded;
import keystone.KeystoneMode;
import org.apache.log4j.Level;
import org.apache.log4j.Logger;

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class sina extends AbstractJni{
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;

    sina() {
        // 创建模拟器实例,进程名建议依照实际进程名填写,可以规避针对进程名的校验
        emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.sina.International").build();
        // 获取模拟器的内存操作接口
        final Memory memory = emulator.getMemory();
        // 设置系统类库解析
        memory.setLibraryResolver(new AndroidResolver(23));
        // 创建Android虚拟机,传入APK,Unidbg可以替我们做部分签名校验的工作
        vm = emulator.createDalvikVM(new File("unidbg-android\\src\\test\\java\\com\\lession2\\sinaInternational.apk"));
        //
//        vm = emulator.createDalvikVM(null);

        // 加载目标SO
        DalvikModule dm = vm.loadLibrary(new File("unidbg-android\\src\\test\\java\\com\\lession2\\libutility.so"), true); // 加载so到虚拟内存
        //获取本SO模块的句柄,后续需要用它
        module = dm.getModule();
        vm.setJni(this); // 设置JNI
        vm.setVerbose(true); // 打印日志
        // 样本连JNI OnLoad都没有
        // dm.callJNI_OnLoad(emulator); // 调用JNI OnLoad
    };

    public String calculateS(){
        List<Object> list = new ArrayList<>(10);
        list.add(vm.getJNIEnv()); // 第一个参数是env
        list.add(0); // 第二个参数,实例方法是jobject,静态方法是jclazz,直接填0,一般用不到。
        DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(null);// context
        list.add(vm.addLocalObject(context));
        list.add(vm.addLocalObject(new StringObject(vm, "12345")));
        list.add(vm.addLocalObject(new StringObject(vm, "r0ysue")));

        Number number = module.callFunction(emulator, 0x1E7C + 1, list.toArray())[0];
        String result = vm.getObject(number.intValue()).getValue().toString();
        return result;
    };

    public void patchVerify(){
        int patchCode = 0x4FF00100; //
        emulator.getMemory().pointer(module.base + 0x1E86).setInt(0,patchCode);
    }

    public void patchVerify1(){
        Pointer pointer = UnidbgPointer.pointer(emulator, module.base + 0x1E86);
        assert pointer != null;
        byte[] code = pointer.getByteArray(0, 4);
        if (!Arrays.equals(code, new byte[]{ (byte)0xFF, (byte) 0xF7, (byte) 0xEB, (byte) 0xFE })) { // BL sub_1C60
            throw new IllegalStateException(Inspector.inspectString(code, "patch32 code=" + Arrays.toString(code)));
        }
        try (Keystone keystone = new Keystone(KeystoneArchitecture.Arm, KeystoneMode.ArmThumb)) {
            KeystoneEncoded encoded = keystone.assemble("mov r0,1");
            byte[] patch = encoded.getMachineCode();
            if (patch.length != code.length) {
                throw new IllegalStateException(Inspector.inspectString(patch, "patch32 length=" + patch.length));
            }
            pointer.write(0, patch, 0, patch.length);
        }
    };

    public void HookMDStringold(){
        // 加载HookZz
        IHookZz hookZz = HookZz.getInstance(emulator);

        hookZz.wrap(module.base + 0x1BD0 + 1, new WrapCallback<HookZzArm32RegisterContext>() { // inline wrap导出函数
            @Override
            // 类似于 frida onEnter
            public void preCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
                // 类似于Frida args[0]
                Pointer input = ctx.getPointerArg(0);
                System.out.println("input:" + input.getString(0));
            };

            @Override
            // 类似于 frida onLeave
            public void postCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
                Pointer result = ctx.getPointerArg(0);
                System.out.println("input:" + result.getString(0));
            }
        });
    }

    public static void main(String[] args) {
        sina test = new sina();
//        test.patchVerify();
        test.patchVerify1();
        test.HookMDStringold();
        System.out.println(test.calculateS());
    }
}

运行结果

可以发现,入参就是text+key

验证返回值:439a333788b0cecfce1389d4b83ba1cb

result = 439a333788b0cecfce1389d4b83ba1cb 

result[1] = 3
result[5] = 3
result[2] = 9
result[10] = b

验证发现我们关于结果来源的猜想也完全正确。

那么接下来的焦点就是MDStringOld函数了,因为结果是32位,我们首先想到MD5函数,验证一下。

结果完全一致。

NICE,大功告成。

尾声

这个样本十分简单,但让我们更多的理解了Unidbg的功能,下一讲中,让我们解锁更难的样本,探索Unidbg更多功能吧!