安卓逆向-快对算法分析

下雨天
1年前发布 /正在检测是否收录...

快对算法分析

1. 概述

版本:6.29.0

包名:com.kuaiduizuoye.scan

加固:未加固

  • 此样本未加固,接口是登录接口,抓包应该也没什么检测;frida的话不能attath,不知道问题所在;
  • 同样,此样本需要了解des算法流程,默认你有;
  • 整体样本不算特别复杂,但分析过程绝对值得一看;是一个综合性非常强的样本,会涉及很多unidbg相关的使用,以及简单的魔改des算法;
  • 此文章用作技术交流,侵权可下架;中间若有错别字请见谅;
  • 若是需要我附上样本可评论,我会贴出;

2. 抓包定位

  • 目标是登录,感觉重要的参数大概是这些:
data    GicW//OsDspTHVDzbe8DYNOGZZ9/W/fyY767oimlgc6BsBQlpY90kzUPY2RNostXg4XTuWNY+QYD
cuid    E0DA888804E90201780A9C0D17D0B050|0
token    1_XPXQH3c5HRPtFHkSwi3sCCURmT25QfxM
phoneDevice    oriole
adid    50e3e6225e071bb7a2bbd4c38faa336d23f51a3b
did    042a7996f140000253909500400000bf
sign    b90be668c7209699eefcfda24d97e6c1
_t_    1763087460

image-20251114104112770

  • 主要是sign参数,定位依然采用搜索大法;这里尝试搜索sign"、"sign、"sign"、=sign、sign=,最终 sign= 有比较明显的字眼;

image-20251114104909361

  • 直接去看最后一个,这里定位也只是经验之谈,往往这种朴素的方法在普通样本是最适用的;往里再跟一步就可以跟到native的位置;

image-20251114105023916

  • hook一下看看位置对不对;
function hook_java() {
    Java.perform(function () {
        let NativeHelper = Java.use("com.zuoyebang.baseutil.NativeHelper");
        NativeHelper["nativeGetSign"].implementation = function (str) {
            console.log(`NativeHelper.nativeGetSign is called: str=${str}`);
            let result = this["nativeGetSign"](str);
            console.log(`NativeHelper.nativeGetSign result=${result}`);
            return result;
        };
    })
}

hook_java()
  • 位置没错,打印结果如下:
[Pixel 6::com.kuaiduizuoye.scan ]-> NativeHelper.nativeGetSign is called: str=X3RfPTE3NjMwODg2NjZhYmlzPTFhZGlkPTUwZTNlNjIyNWUwNzFiYjdhMmJiZDRjMzhmYWEzMzZkMjNmNTFhM2JhcHBCaXQ9MzJhcHBJZD1zY2FuY29kZWFyZWE9YnJhbmQ9Z29vZ2xlY2hhbm5lbD10YW9iYW96aHVzaG91Y2l0eT1jdWlkPUUwREE4ODg4MDRFOTAyMDE3ODBBOUMwRDE3RDBCMDUwfDBkYXRhPUdpY1cvL09zRHNwVEhWRHpiZThEWU5PR1paOS9XL2Z5WTc2N29pbWxnYzZCc0JRbHBZOTBrelVQWTJSTm9zdFhnNFhUdVdOWStRWURkZXZpY2U9UGl4ZWwgNmRpZD0wNDJhNzk5NmYxNDAwMDAyNTM5MDk1MDA0MDAwMDBiZmtha29ycmhhcGhpb3Bob2JpYT0zMTg4MzA5MDVudD13aWZpb3BlcmF0b3JpZD1vcz1hbmRyb2lkb3NWZXJzaW9uPTEycGhvbmVEZXZpY2U9b3Jpb2xlcGtnTmFtZT1jb20ua3VhaWR1aXp1b3llLnNjYW5wcm92aW5jZT1zZGs9MzJ0b2tlbj0xX1hQWFFIM2M1SFJQdEZIa1N3aTNzQ0NVUm1UMjVRZnhNdmM9OTgwdmNuYW1lPTYuMjkuMA==
NativeHelper.nativeGetSign result=003030578ba3bb549a4fe86793664f92

image-20251114105218609

  • 和抓包结果也对上了,后续就开始分析了;so的名字在上一层也可以看到:libbaseutil.so;

3. 算法分析

  • 在看算法之前先看看参数,从上一层可以看出来是做了base64的,解一下;
_t_=1763088666abis=1adid=50e3e6225e071bb7a2bbd4c38faa336d23f51a3bappBit=32appId=scancodearea=brand=googlechannel=taobaozhushoucity=cuid=E0DA888804E90201780A9C0D17D0B050|0data=GicW//OsDspTHVDzbe8DYNOGZZ9/W/fyY767oimlgc6BsBQlpY90kzUPY2RNostXg4XTuWNY+QYDdevice=Pixel 6did=042a7996f140000253909500400000bfkakorrhaphiophobia=318830905nt=wifioperatorid=os=androidosVersion=12phoneDevice=oriolepkgName=com.kuaiduizuoye.scanprovince=sdk=32token=1_XPXQH3c5HRPtFHkSwi3sCCURmT25QfxMvc=980vcname=6.29.0
  • 实际上就是请求体的其他参数,做了排序编码一下传过来;试了一下果然不是标准的md5;

3.1 unidbg模拟执行

  • 这里还是使用unidbg来辅助算法还原,先搭一个基本的框架;这里样本只提供了32位的so;
public class kuaidui extends AbstractJni implements IOResolver {
    private final AndroidEmulator emulator;
    private final VM vm;
    private final Module module;

    @Override
    public FileResult resolve(Emulator emulator, String pathname, int oflags) {
        System.out.println("pathName:" + pathname);
        return null;
    }

    public kuaidui() {
        emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.kuaiduizuoye.scan").build();
        Memory memory = emulator.getMemory();
        memory.setLibraryResolver(new AndroidResolver(23));
        vm = emulator.createDalvikVM(new File("src/test/java/com/Samples/KuaiDui/file/快对v6.29.0.apk"));
        emulator.getSyscallHandler().addIOResolver(this);
        emulator.getSyscallHandler().setEnableThreadDispatcher(true);

        vm.setVerbose(true);
        vm.setJni(this);
        DalvikModule dm = vm.loadLibrary(new File("src/test/java/com/Samples/KuaiDui/file/libbaseutil.so"), true);
        module = dm.getModule();
        dm.callJNI_OnLoad(emulator);

    }


    public static void main(String[] args) {
        kuaidui demo = new kuaidui();
    }
}
  • 运行后没有报错,并且输出了对应函数的偏移;

image-20251114110759656

  • 留个心眼,nativeInitBaseUtil、nativeSetToken这种函数极有可能是需要我们执行的,也就是初始化函数,先不着急看这些,直接call目标函数;
public String call_nativeGetSign(){
    List<Object> list = new ArrayList<>(10);
    list.add(vm.getJNIEnv());
    list.add(0);
    StringObject str1 = new StringObject(vm,"X3RfPTE3NjMwODg2NjZhYmlzPTFhZGlkPTUwZTNlNjIyNWUwNzFiYjdhMmJiZDRjMzhmYWEzMzZkMjNmNTFhM2JhcHBCaXQ9MzJhcHBJZD1zY2FuY29kZWFyZWE9YnJhbmQ9Z29vZ2xlY2hhbm5lbD10YW9iYW96aHVzaG91Y2l0eT1jdWlkPUUwREE4ODg4MDRFOTAyMDE3ODBBOUMwRDE3RDBCMDUwfDBkYXRhPUdpY1cvL09zRHNwVEhWRHpiZThEWU5PR1paOS9XL2Z5WTc2N29pbWxnYzZCc0JRbHBZOTBrelVQWTJSTm9zdFhnNFhUdVdOWStRWURkZXZpY2U9UGl4ZWwgNmRpZD0wNDJhNzk5NmYxNDAwMDAyNTM5MDk1MDA0MDAwMDBiZmtha29ycmhhcGhpb3Bob2JpYT0zMTg4MzA5MDVudD13aWZpb3BlcmF0b3JpZD1vcz1hbmRyb2lkb3NWZXJzaW9uPTEycGhvbmVEZXZpY2U9b3Jpb2xlcGtnTmFtZT1jb20ua3VhaWR1aXp1b3llLnNjYW5wcm92aW5jZT1zZGs9MzJ0b2tlbj0xX1hQWFFIM2M1SFJQdEZIa1N3aTNzQ0NVUm1UMjVRZnhNdmM9OTgwdmNuYW1lPTYuMjkuMA==");
    list.add(vm.addLocalObject(str1));
    Number number = module.callFunction(emulator, 0x1054 + 1, list.toArray());
    StringObject str = vm.getObject(number.intValue());
    return str.getValue();
}
  • 这里的参数还是之前那一组,直接执行会出现问题;

image-20251114111808475

  • 需要去看一下so里的实现;

image-20251114111832843

  • 很久没遇到这种清新的so了,很显然是可能是dword_A044这一块出的错,它应该就是一个初始化相关的东西,我们按下X去查看交叉引用,看看哪里可能是赋值的位置;

image-20251114112827750

  • 一共有三个函数位置在调用,但是看了一下,只有sub_DD0有在赋值,那我们就看它;函数内部经过一堆有的没的,最终给dwoed_A404这块内容赋值为1,那就再去看看谁调用了sub_DD0;

image-20251114113213173

  • 这两个函数或许不太熟悉,但是对比下面这张图;

image-20251114113304119

  • 其实就是另外的两个native函数,我们需要知道谁先执行;我们可以去把这三个函数全部hook上,然后清除数据或者重装apk来看谁先执行;
function hook_java() {
    Java.perform(function () {
        let NativeHelper = Java.use("com.zuoyebang.baseutil.NativeHelper");
        NativeHelper["nativeInitBaseUtil"].implementation = function (context, str) {
            console.log(`NativeHelper.nativeInitBaseUtil is called: context=${context}, str=${str}`);
            let result = this["nativeInitBaseUtil"](context, str);
            console.log(`NativeHelper.nativeInitBaseUtil result=${result}`);
            return result;
        };

        NativeHelper["nativeSetToken"].implementation = function (context, str, str2, str3) {
            console.log(`NativeHelper.nativeSetToken is called: context=${context}, str=${str}, str2=${str2}, str3=${str3}`);
            let result = this["nativeSetToken"](context, str, str2, str3);
            console.log(`NativeHelper.nativeSetToken result=${result}`);
            return result;
        };

        NativeHelper["nativeGetSign"].implementation = function (str) {
            console.log(`NativeHelper.nativeGetSign is called: str=${str}`);
            let result = this["nativeGetSign"](str);
            console.log(`NativeHelper.nativeGetSign result=${result}`);
            return result;
        };
    })
}

function hook_dlopen() {
    var android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext");
    Interceptor.attach(android_dlopen_ext, {
        onEnter: function (args) {
            var path_ptr = args[0];
            var path = ptr(path_ptr).readCString();
            if (path && path.indexOf("libbaseutil.so") !== -1) {
                console.log("[android_dlopen_ext:]", path);
                this.flag = true;
            }
        },
        onLeave: function (retval) {
            if (this.flag) {
                const module = Process.findModuleByName("libbaseutil.so");
                let jni_onload = module.findExportByName("JNI_OnLoad")
                console.log("JNI_OnLoad--->>>" + jni_onload);
                Interceptor.attach(jni_onload, {
                    onEnter: function (args) {
                        console.log("JNI_OnLoad onEnter")

                    },
                    onLeave: function (retval) {
                        console.log("JNI_OnLoad onLeave")
                        hook_java()

                    }
                })
            }
        }
    });

}
setImmediate(hook_dlopen)
  • 这里需要把握好时机的问题,一般来说直接用常规hook没输出的话就要考虑自己hook的时机是不是够早了,不行的话还可以往前找更早的时机;我这里是hook JNI_OnLoad后,时机是比较早的了;

image-20251114141453309

  • 可以发现,顺序分别是nativeInitBaseUtil、nativeSetToken、nativeGetSign;分别去unidbg调用,这里放出一份日志:
NativeHelper.nativeInitBaseUtil is called: 
context=com.kuaiduizuoye.scan.base.BaseApplication@90993f0, 
str=E0DA888804E90201780A9C0D17D0B050|0
NativeHelper.nativeInitBaseUtil result=0d040c0d0e0f01050c02040a0f0707040207050e05050a0c0b090d0f090f030f020400030a0f030903070e0708020300070f0f080306060b00000f0d04070d0d02070b0f020a01010c0d030a09080e090b040f02080c0b040c0e0e060c0d02010e0b0c050b02020c07050a06020a0c0e0c030b000e0f0f0907080c0c0e0b0404000d0b0903010f05080c040f0c0f0f060102080a050509040606040a0d0f0f0e0f05050a05000e0001010b020701000e

NativeHelper.nativeSetToken is called: 
context=com.kuaiduizuoye.scan.base.BaseApplication@90993f0, 
str=E0DA888804E90201780A9C0D17D0B050|0, str2=0d040c0d0e0f01050c02040a0f0707040207050e05050a0c0b090d0f090f030f020400030a0f03090307
0e0708020300070f0f080306060b00000f0d04070d0d02070b0f020a01010c0d030a09080e090b040f02080c0b040c0e0e060c0d02010e0b0c050b02020c07050a06020a0c0e0c030b000e0f0f0907080c0c0e0b0404000d0b0903010f05080c040f0c0f0f060102080a050509040606040a0d0f0f0e0f05050a05000e0001010b020701000e, str3=0c000a01090d0e04020c0a0e06010b0d050a080e0c0f03050c0a0602060e0c0207090c0a0d090f060a060e08030a0003
NativeHelper.nativeSetToken result=true
  • 初步观察一下,init函数执行后得到一个值,nativeSetToken用到了它,并且init的参数settoken也用到了,那么实际上只需要执行nativeSetToken就可以跑通了,这里为了固定数据,我这里先只call nativeSetToken函数;
public void call_nativeSetToken(){
    List<Object> list = new ArrayList<>(10);
    list.add(vm.getJNIEnv());
    list.add(0);

    StringObject str1 = new StringObject(vm,"E0DA888804E90201780A9C0D17D0B050|0");
    StringObject str2 = new StringObject(vm,"0d040c0d0e0f01050c02040a0f0707040207050e05050a0c0b090d0f090f030f020400030a0f030903070e0708020300070f0f080306060b00000f0d04070d0d02070b0f020a01010c0d030a09080e090b040f02080c0b040c0e0e060c0d02010e0b0c050b02020c07050a06020a0c0e0c030b000e0f0f0907080c0c0e0b0404000d0b0903010f05080c040f0c0f0f060102080a050509040606040a0d0f0f0e0f05050a05000e0001010b020701000e");
    StringObject str3 = new StringObject(vm,"0c000a01090d0e04020c0a0e06010b0d050a080e0c0f03050c0a0602060e0c0207090c0a0d090f060a060e08030a0003|0");
    DvmObject<?> context = vm.resolveClass("android/content/Context").newObject(null);// context
    list.add(vm.addLocalObject(context));
    list.add(vm.addLocalObject(str1));
    list.add(vm.addLocalObject(str2));
    list.add(vm.addLocalObject(str3));
    Number number = module.callFunction(emulator, 0xe90 + 1, list.toArray());
    StringObject str = vm.getObject(number.intValue());
}
  • 不需要不环境,直接得到结果:

image-20251114143106237

  • 但是call_nativeSetToken的三个参数我们均未知,所以先去看看到底是什么;先看init函数;

image-20251114143439386

  • 参数一自然不用多说,参数二实际上是cuid,和抓包上传的也对得上;这里我清除数据发现依旧是这个值,可能与设备有关,这里我不去追究来源了;返回值怎么算的这里先不管,去看nativeSetToken函数;

image-20251114144428713

  • 前面说了,它的参数是context、cuid,还有a2、a3,从代码语义上看,a2、a3原先是没有值的,a2就是init函数算出来的结果,后续应该是存在了xml文件里,因为上面在取;a3是plutoAntispam.data;看起来postSync可能是和请求有关,问了问ai说是同步网络POST请求,那我们去搜一下,这里幸好抓包没停;

image-20251114144850006

  • 确实找到了,参数还是那一些,另外多说一句,我是重装+清数据过的,但是前面有疑问的adid、token之类的请求体还是固定的,那就证明与设备有关,所以后面不会再提这些参数了,统一当做已知数据;
  • 现在大概清楚了数据来源,现在暂时停下脚步,看看我们还有什么是未知的;

    • 一共出现了三个函数,我们称为init、setToken、getsign;
    • init有两个参数context、cuid均为已知,结果来源未知,而结果后续用到了,所以需要分析算法,记为init_res;
    • 而后发起一个请求,参数主要有init_res,响应记为req_res;
    • setToken函数有四个参数context、cuid、init_res、req_res,依旧需要解决init_res,结果为true;
    • getsign函数参数已知,结果来源未知,需要分析;
  • 所以需要分析的有init函数的返回值、getsign函数的返回值;

3.2 nativeInitBaseUtil

  • 开始分析第一部分,nativeInitBaseUtil函数,它的偏移是0xd15,我们跳过去看看;

image-20251114150353020

  • 这里的符号是我修改过的,看起来依旧清爽,我稍微修改了一样参数1的类型;函数不多,看样子需要先看sub_3C6C;

image-20251114150808948

  • 生成随机10位字符串,再看第二个函数 sub_1C18;

image-20251114150906891

  • 这个函数熟悉的读者可能会很熟悉,有点像说废话了,实际上是一个md5函数;MD5初始常量摆在那里,后续的两个函数分别是md5_update和final函数;标不标准我们先不管,继续往下看,现在只是预分析而已;
  • sub_1EF0不去看了,基本上是格式化字符串的函数,sub_3688可能与des有关;

image-20251114152606308

  • 最后大概是这些函数;

image-20251114152643420

  • 下面开始分析整个算法,先call吧;
public void call_init() {
    List<Object> list = new ArrayList<>(10);
    list.add(vm.getJNIEnv());
    list.add(0);
    list.add(vm.addLocalObject(vm.resolveClass("android/content/Context").newObject(null)));
    list.add(vm.addLocalObject(new StringObject(vm, "E0DA888804E90201780A9C0D17D0B050|0")));
    Number ret = module.callFunction(emulator, 0xd14 + 1, list.toArray());
    StringObject str = vm.getObject(ret.intValue());
    System.out.println("call_init result-->>" + str);
}
  • 这里开始需要补环境了,报错如下:

image-20251114152821803

  • 这是一个系统调用相关的错误,clock_gettime这个系统调用,不熟悉的读者可以留言我后面可以单独写一篇文章;我们可以从第一行报错点进去;

image-20251114153005460

  • 可以发现unidbg已经实现了一部分,去看看是哪一部分;

image-20251114153038181

  • 很好,没有id为2的情况,所以需要我们自己去补,正常补的话是一段苦人的差事,我选择偷懒;

image-20251114153541925

  • 非常非常不建议这么补,我这里只是偷懒,分析完我还要恢复的,但是这样执行就没问题了;另外,对于系统调用相关的报错可以多多结合ai,它比我们见多识广;

image-20251114153652758

  • 前面分析了,有随机数rand的参与,结果一直在变,给它固定住;
public void hook_rand() {
    IHookZz hookZz = HookZz.getInstance(emulator);
    hookZz.wrap(module.findSymbolByName("lrand48"), new WrapCallback<HookZzArm32RegisterContext>() {
        @Override
        public void preCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
        }

        @Override
        public void postCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
            ctx.setR0(1);
        }
    });

    hookZz.wrap(module.findSymbolByName("srand48"), new WrapCallback<HookZzArm32RegisterContext>() {
        @Override
        public void preCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
        }

        @Override
        public void postCall(Emulator<?> emulator, HookZzArm32RegisterContext ctx, HookEntryInfo info) {
            ctx.setR0(1);
        }
    });
}
  • 这部分hook代码可以留着,后续需要直接参考就可以,现在结果固定住了;回到我们刚进入函数的时候;

image-20251114160230673

  • hook一下rand函数,看看固定后的结果是什么,这也是为了分辨后续哪一部分值是随机出来的;
emulator.attach().addBreakPoint(module.base + 0x3C6C);
  • 断住后先在控制台敲 blr再按c查看返回值R0;

image-20251114160419963

  • 这里不理解的我也可以单独说一下,随机值需要记住;

image-20251114160446294

  • 这里rand的结果,再看md5函数;
emulator.attach().addBreakPoint(module.base + 0x1C18);

image-20251114160552505

  • 很长的数据,先看是不是标准的;这里有一些地方需要注意,参数1的长度有点长,尽量读的时候多读一些;最后加密对比是这样的:

image-20251114161156915

  • 首先证明了他是一个标准的md5,其次、参数还是整个apk的签名,首先看日志可以看出来这一点;

image-20251114161255791

  • 这两个值是一样的,其次,如果去分析md5_1C18(*(dword_A0D8 + 20))的话,也是可以找到证据的;

image-20251114161342265

  • 在sub_DD0函数就可以发现,从偏移20处开始取的,应用签名存的偏移就是这么多,那自然是对得上的;

image-20251114161452622

  • 所以这个md5的结果就是固定的,继续看sub_1EF0的参数和返回值;
emulator.attach().addBreakPoint(module.base + 0x1EF0);
  • 参数有好几个,我们写成持久化的风格;
emulator.attach().addBreakPoint(module.base + 0x1EF0, new BreakPointCallback() {
    @Override
    public boolean onHit(Emulator<?> emulator, long address) {
        RegisterContext context = emulator.getContext();
        UnidbgPointer pointer1 = context.getPointerArg(0);
        Inspector.inspect(pointer1.getByteArray(0, 0xe), "参数0");
        UnidbgPointer pointer2 = context.getPointerArg(1);
        Inspector.inspect(pointer2.getByteArray(0, 0x5), "参数1");
        UnidbgPointer pointer3 = context.getPointerArg(2);
        Inspector.inspect(pointer3.getByteArray(0, 0xa), "参数2 随机数");
        UnidbgPointer pointer4 = context.getPointerArg(3);
        Inspector.inspect(pointer4.getByteArray(0, 0x20), "参数3 md5签名");
        // 3. 获取栈指针
        UnidbgPointer sp = context.getStackPointer();
        int arg5 = sp.getInt(0); // 栈顶第一个32位值是第5个参数
        UnidbgPointer arg555 = UnidbgPointer.pointer(emulator, arg5);
        Inspector.inspect(arg555.getByteArray(0, 0x22), "参数4");
        //                int arg6 = sp.getInt(4); // 下一个32位值是第6个参数
        //                int arg7 = sp.getInt(8); // 再下一个32位值是第7个参数
        return true;
    }
});
  • 这里引申出一个知识点,在arm32下,参数一般是放在R0-R3中,如果参数超过则逐个放在栈中,以小端序存储,并且一般在一个函数进来之后会把所有的参数都放在栈中保存起来,返回值通常都是通过R0返回;
  • 看看结果;
>-----------------------------------------------------------------------------<
[16:47:10 476]参数0, md5=d688b762594d55ece22fa8bfcaeb226d, hex=2573232325732323257323232573
size: 14
0000: 25 73 23 23 25 73 23 23 25 73 23 23 25 73          %s##%s##%s##%s
^-----------------------------------------------------------------------------^

>-----------------------------------------------------------------------------<
[16:47:10 478]参数1, md5=6560c614ae407a4cf1f1c069e1f028c3, hex=382625642a
size: 5
0000: 38 26 25 64 2A                                     8&%d*
^-----------------------------------------------------------------------------^

>-----------------------------------------------------------------------------<
[16:47:10 478]参数2 随机数, md5=38b18761d3d0c217371967a98d545c2e, hex=42424242424242424242
size: 10
0000: 42 42 42 42 42 42 42 42 42 42                      BBBBBBBBBB
^-----------------------------------------------------------------------------^

>-----------------------------------------------------------------------------<
[16:47:10 478]参数3 md5签名, md5=34bbdff4e0bedd62d3d0bede8c05e251, hex=3266623533646536643338656666373130396631396436386530343731323362
size: 32
0000: 32 66 62 35 33 64 65 36 64 33 38 65 66 66 37 31    2fb53de6d38eff71
0010: 30 39 66 31 39 64 36 38 65 30 34 37 31 32 33 62    09f19d68e047123b
^-----------------------------------------------------------------------------^

>-----------------------------------------------------------------------------<
[16:47:10 478]参数4, md5=0be9dbcbe36e5f2cc0d0ecabc9c737f9, hex=45304441383838383034453930323031373830413943304431374430423035307c30
size: 34
0000: 45 30 44 41 38 38 38 38 30 34 45 39 30 32 30 31    E0DA888804E90201
0010: 37 38 30 41 39 43 30 44 31 37 44 30 42 30 35 30    780A9C0D17D0B050
0020: 7C 30                                              |0
^-----------------------------------------------------------------------------^
  • 参数1好像不太认识,它是来自byte_A0DC,按下X没有交叉引用,但是他实际上是A0D8偏移4位存储的数据,但是我进去看发现不太对得上;

image-20251114165018337

  • 对应的位置是在获取ro.build.version.sdk,hook发现确实是;

image-20251114165304196

  • 那这里就并不是我们分析的那样,换一个ida版本看看;

image-20251114165725685

  • 解决问题,是有交叉引用的,在sub_DD0函数里;

image-20251114165800806

  • 传进来的参数就是byte_A0DC,这个函数就是真正赋值的地方;从aFda893hflEsi这个内容里取数据,一共循环5次,我们可以打印一下2010函数的返回值 ;
emulator.attach().addBreakPoint(module.base + 0x2010);
  • 返回值确实是这个,这里不贴图了,我们去看分析一下它的生成;
  • 重点肯定就在于v3的值,这里反编译很差,所以去看汇编;

image-20251114174820988

  • 可以发现,MUL.W R0, R5, R1是乘法指令,也就是说R1就是v3的值,我们去hook打印一下;
emulator.attach().addBreakPoint(module.base + 0x5868, new BreakPointCallback() {
    @Override
    public boolean onHit(Emulator<?> emulator, long address) {
        RegisterContext context1 = emulator.getContext();
        int pointer2 = context1.getIntArg(1);
        System.out.println("0x5868 参数1:" + pointer2);
        emulator.attach().addBreakPoint(context1.getLRPointer().peer, new BreakPointCallback() {
            @Override
            public boolean onHit(Emulator<?> emulator, long address) {
                RegisterContext context = emulator.getContext();
                int pointer1 = context.getIntArg(1);
                System.out.println("0x5868 返回值:" + pointer1);
                return true;
            }
        });
        return true;
    }
});
  • 输出如下:

image-20251114174957615

  • 返回值分别是:0、0、2、0、5;根据这段算法还原一下;
aFda893hflEsi = "fda&8^%$#)93hfl_esi.*"
def sub_2010():
    a1 = [0, 0, 0, 0, 0]
    v3 = [0, 0, 2, 0, 5]
    for i in range(0, 5):
        # v3 = sub_5868(0xC, (i + 3))
        a1[i] = aFda893hflEsi[i * v3[i] - i + 4]
    return a1

print(sub_2010())
  • 生成的结果就是:8&%d*,和hook的结果是对得上的;这里的v3跟进去实际上也不是一个多复杂的算法,感兴趣可以去还原一下,实际上没有进行随机化,每个设备都可能是这个值,就说到这里;
  • 回到正题,接下来看看sub_3688函数;参数1是这样的:

image-20251115144431598

emulator.attach().addBreakPoint(module.base + 0x3688);
  • 其实就是前面的函数拼接起来,两个参数没有问题,但是返回值有些不对劲,请看:

image-20251114175634353

  • 这是在函数结束时读的r0,看不出什么东西,不太像结果,但是伪代码很清晰的说: v7 = sub_3688(v6, "@fG2SuLA"),返回值就是r0,所以只能看汇编了;

image-20251114175806757

  • 没看出什么内容,那就进去sub_3688函数看看返回的可能是什么类型;

image-20251115130731217

  • v13进行了三次赋值,第三次才是真正的结果,前两次是一致的都是v14,感觉是长度,所以读第三个位置的数据应该就是结果;这里的结构体类似于这样:
struct DesResult {
    DWORD size; // 第一个字段,v14
    DWORD size_copy; // 第二个字段,v14(与第一个相同)
    DWORD* data; // 第三个字段,v6,指向加密后的数据
};
  • 那我们下断点后去读第三个字段,也就是这一部分;

image-20251114175942570

  • 小端序读,m0x401e8114,长度正好是0x58,对得上;

image-20251114180016804

  • 还记得它说他是des,我们尝试去看看是不是标准的;

image-20251114181517294

  • 和结果完全不一样,这里我先是猜测是ecb模式,因为只传了一个key,结果是对不上的,但是位数是一样的,所以有可能是魔改,也有可能是cbc模式,还需要继续分析;
  • 首先来看是什么模式,这里分享一个知识,ECB模式是最简单的加密模式,每个明文块都使用相同的密钥独立加密,没有任何反馈或交互,相同的明文块一定会加密成相同的密文块;
  • 那么我们就可以修改明文来进行测试,给定相同的明文,看看对应加密出来是否一致,一致则是ecb模式,不一致则是cbc或其他;
  • 这里肯定不能修改init的入参了,需要去修改des的入参,代码参考如下:
emulator.attach().addBreakPoint(module.base + 0x3688, new BreakPointCallback() {
    @Override
    public boolean onHit(Emulator<?> emulator, long address) {
        String Input = "111111111111111111111111111111111111111111111111111111111111111111111";
        int length = Input.length();
        MemoryBlock fakeInputBlock = emulator.getMemory().malloc(length, true);
        fakeInputBlock.getPointer().write(Input.getBytes(StandardCharsets.UTF_8));
        // 修改r0为指向新字符串的新指针
        emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_R0, fakeInputBlock.getPointer().peer);
        return false;
    }
});
  • 这里断下来之后可以去查看是否改成功了;

image-20251115143625838

  • 然后输入blr再按c,去读对应的返回结果;

image-20251115143716973

  • 肉眼可见的一致,去cyberchef对比一下;

image-20251115143743748

  • 最后应该是填充的事情,所以它应该是一个ecb模式的des,这里被魔改了,但是比较简单,后续就知道改了哪里了;
  • 对于填充比较好判断,我们前面知道了sub_3250是分组加密的位置,所以去hook一下,这里我不给代码了,可以手动的按c一直跑,自己估摸着最后一组停下来,就会是这样:

image-20251115144302927

  • 这是pkcs7的填充应该没跑了,接下来看具体魔改了哪里;
  • 这里需要知道des算法的流程,我实在是不知道怎么写,因为几乎没有魔改算法,改的全是常量表;
  • 先看看秘钥编排的过程,sub_3620函数;

image-20251115145106512

  • 引入眼帘的是sub_3554函数,点进去看看;

image-20251115145223393

  • 我们知道,des秘钥编排首先就是与PC1表进行置换,pc1表长度是56;这里也是56次循环置换,所以这是pc1置换,那dword_8180处的数据就是pc1表;

image-20251115145447218

  • 长度也很合理,但是数据完全不一样,去看一下正常的pc1表应该是多少;

image-20251115145606294

  • 基本上都完全不一样了,接下来应该是循环左移,这里又有个表SHIFT;
SHIFT = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
# 长度:16
  • 正好这里有16次循环,且dword_8320也是这样的数据;

image-20251115145826306

  • 他俩貌似是一致的,再看sub_3578函数;

image-20251115150013386

  • 一个道理,这里对应的应该是PC_2表,后续我不贴了,把对应的表都扣下来就行,s盒是标准的,剩下的改的基本上就是这些常量;
  • 但还有一个重要的魔改点,他把字节序翻转了,byte转bit的时候高位低位是相反的;

image-20251115152451266

  • 这个函数将1个字节(a1)转换为8个位,但转换顺序低位在前的位序反转;
i=0: 取第0位(最低位)
i=1: 取第1位
...
i=7: 取第7位(最高位)
  • sub_2E28是和他对应的翻转函数,其余的就正常了,我们手动改一下这些魔改的位置,然后加密对应的明文;
    def bytes_to_bits_reverse(self, data: bytes) -> List[bool]:
        """将字节转换为位列表,注意高位低位反转"""
        bits = []
        for byte in data:
            # 将字节转换为位,但顺序反转(低位在前)
            for i in range(8):
                bits.append((byte >> i) & 1)
        return bits

    def bits_to_bytes_reverse(self, bits: List[bool]) -> bytes:
        """将位列表转换为字节,注意高位低位反转"""
        result = bytearray()
        for i in range(0, len(bits), 8):
            byte = 0
            for j in range(8):
                if i + j < len(bits) and bits[i + j]:
                    byte |= (1 << j)  # 注意这里j是从低位开始
            result.append(byte)
        return bytes(result)

image-20251115152626286

  • 加密结果对比:
本地:8df33e23f867646595a8e3a2b3f019693b82fd90bbf6ae3afe1f6cd600bfe2bbe4fd5488b35c19972d4f312d7367b384d7a34d34ae655473c30df79f1e33d722b09d8caf31f2f36f4851aa296652fb7faf5a0a07884d8e70
正确:
8df33e23f867646595a8e3a2b3f019693b82fd90bbf6ae3afe1f6cd600bfe2bbe4fd5488b35c19972d4f312d7367b384d7a34d34ae655473c30df79f1e33d722b09d8caf31f2f36f4851aa296652fb7faf5a0a07884d8e70
  • 是对应上的,所以这个函数也就分析结束,需要读者熟悉des的加密流程;
  • 这一部分的分析太长了,回到最外层先看看;

image-20251115152753513

  • 目前还需要分析sub_2EA0这个函数,他是一个str转hex的函数,但也是需要自己还原的;重点就在这个函数,可以自行hook验证一下,入参就是des的结果,出参就是最后的结果;
  • 也可以借助ai帮助还原,例如:
def sub_2EA0_detailed(byte_array):
    hex_str = [''] * (len(byte_array) * 2 + 1)
    # 转换每个字节
    for i, byte_val in enumerate(byte_array):
        # 调用 sub_2F18 和 sub_2E44 的等效操作
        # 直接将字节转换为十六进制字符串
        hex_chars = f"{byte_val:02x}"
        # 写入到输出数组
        hex_str[i * 2] = hex_chars[0]
        hex_str[i * 2 + 1] = hex_chars[1]

    # 添加换行符
    hex_str[len(byte_array) * 2] = '\n'

    # 合并为字符串
    return ''.join(hex_str)
  • 测试一下;

image-20251115161427881

  • 结果对了,至此整个init函数才算是分析完成;

image-20251115161512210

  • 稍微做个小总结,这里我们得到的是nativeSetToken函数的参数2,涉及到了md5、魔改des等;

3.3 nativeSetToken

  • 先看看大体的代码吧;

image-20251115162257870

  • 实际上,它和init函数呈现一种逆序的感觉,后者是加密,他其实是在解密,这里也有加密用的key;
  • 这里不去分析了,在unidbg call的时候传入的参数是与主动调用的时候不同的,我们测试解一下看看;

image-20251115162812788

  • 这里注意,hex2byte这个方法是前面的反写,解密的话让ai帮你写就行;
  • 成功的解出来数据了,和我们的明文是一个情况,在这里回想一下,前面提到了参数3是一个请求返回的,并且3.2部分我们已经解释了它的来源,这里我们应思考响应是否也可以解密?

image-20251115163258642

  • 很显然下方还有一次调用,key的话是第一次解密结果的7-11位,后续再拼接 "#G4";测试一下;
key2 = b'95b8L#G4'
ciphertext2 = hex2byte("0c000a01090d0e04020c0a0e06010b0d050a080e0c0f03050c0a0602060e0c0207090c0a0d090f060a060e08030a0003")

decrypted2 = des.decrypt(ciphertext2, key2)
print(f"Decrypted2: {decrypted2}")

image-20251115164831021

  • 解出来了一段明文:95b8LzKrr3##t2BOaKTul1,前面是随机数那部分,也就是key的一部分,后面的可能就是需要设置的内容,毕竟函数并没有返回值;
  • 这时候请回想分析getsign函数的时候,dword_A0D4也有判断,并且md5还需要加密使用到它;

image-20251115165229260

  • 那这个函数刚好也有设置值的位置;

image-20251115165147790

  • 这个函数的目的应该就是设置这个返回值解密后的数据了;
  • 并且参数我们也都解决了,算法主要就是des解密,和init的魔改函数是同一对;

3.4 nativeGetSign

  • 首先把unidbg的call打开,别忘了call_nativeSetToken;到这个算法其实就简单了,直接hook对应的md5函数先;
emulator.attach().addBreakPoint(module.base + 0x1C18);
  • 第一次端下来是之前获取应用签名的位置,第二次是这样的:

image-20251115165827209

  • 这个着实有些眼熟,是setToken函数解密出来的后半部分,原来是用在这里了;继续往下跟;

image-20251115170053663

  • 这个结果有点熟悉;

image-20251115170110537

  • 就是主动调用的结果,所以算法很简单,就是标准的md5,参数的话是这种组成形式;

image-20251115170222076

  • 第一部分在前面详细的说了来源,f061614527adba41881c85c075b6bf52这一部分是前面 t2BOaKTul1 md5结果,后面一部分是传进来的参数,至此算法分析完毕;

4. 总结

  • 整体的算法不算太难,但是分析过程属实花了不少时间,des扣表的时候请仔细,因为我就踩了不久的坑;
  • 最后对整体我们做了什么做个总结,针对三个方面:入参、返回值、算法;
  • nativeInitBaseUtil函数:

    • 入参:context、cuid,总体来说cuid与设备有关,可能是设备id;
    • 返回值:一组16进制结果,后续会用做nativeSetToken函数的入参;
    • 算法:魔改des加密、md5等;des的入参也分析的很透彻了;
  • nativeSetToken函数:

    • 入参:cuid、nativeInitBaseUtil返回值、一个请求的响应结果;
    • 返回值:没有返回值,函数用于给全局变量赋值,此结果会在nativeGetSign函数使用;
    • 算法:魔改des解密、md5等;与init函数紧密联系;
  • nativeGetSign函数:

    • 入参:base64形式的明文,前面也分析过;
    • 返回值:最终的sign;
    • 算法:md5等;
  • 总体的算法难度非常小,但综合性非常强,分析每一个明文的来源才是这篇文章的目的;
  • by:2025-11-15;
© 版权声明
THE END
喜欢就支持一下吧
点赞 0 分享 收藏
评论 抢沙发
OωO
取消
SSL