找到
8
篇与
安卓逆向
相关的结果
-
安卓逆向-五菱白盒AES 一、五菱白盒AES 1. 前置 [!sana] 版本:V8.0.14 包名:com.cloudy.linglingbang 加固:梆梆加固 so文件:libencrypt.so 涉及到的知识点有白盒AES、DFA、unidbg补环境以及辅助算法还原、unidbg的hook与patch等; 可供参考资料: https://bbs.kanxue.com/thread-280335.htm https://bbs.kanxue.com/thread-284073.htm 文件相关链接: 链接: https://pan.baidu.com/s/1xwBKsBhdgXGeAjEYcfWLSA?pwd=sana 提取码: sana 整体思路参考了杨如画大佬的思路,在此先表示感谢; 2. 抓包分析 直接点击app会有更新提示与root提示,不更新就会闪退,可以去hook一下dialog或者吐司,我这里图方便就使用算法助手了,同时抓包也有检测,也一同勾上即可抓包,也是可以进入app的; image-20250105203813752图片 image-20250105203820918图片 这里的屏蔽关键字弹窗写升级或者更新都行,总之是进入了第一步,可以开始抓包了; 抓包结果如下: image-20250105203956057图片 这里请求体和返回都是密文的,都需要去解决,请求头也有一个签名,后续如果有时间也会说,首先看请求体这两个密文; 3. 定位分析 首先这个apk是加壳的,所以直接反编译是毛都不会有的,找个大佬帮忙脱一下壳,我会把脱下来的东西放在附件里; 直接搜索大法看看有没有效果; image-20250105205039322图片 这里是一个变量,比较可疑,我们进去查看谁引用了它,按下X或右键查看交叉引用; image-20250105205223410图片 这里随意选一个,我看最后一个比较炸眼,我选择进去看看; image-20250105205339916图片 这里就可以看到一些关键词,可以hook看看是不是正确的位置; 最开始我是选择直接attach附加的,发现hook不上,换成重启的方式也报错了,这里做了frida的检测,但是检测的不严格,换成魔改版本的就行,这里是启动的自己编译的魔改版frida,使用hluda好像也是能过去的; 这里选择hook encrypt函数,看看是不是正确位置,hook代码如下: function hook_enc(){ Java.perform(function () { let CheckCodeUtils = Java.use("com.cloudy.linglingbang.model.request.retrofit2.CheckCodeUtils"); CheckCodeUtils["encrypt"].implementation = function (str,i) { console.log(`CheckCodeUtils.encrypt is called: str=${str},i=${i}`); let result = this["encrypt"](str); console.log(`CheckCodeUtils.encrypt result=${result}`); return result; }; }) } 这里最好到要点登录的时候再调用这个函数,可能会多一些输出,不便于我们分析; [Pixel 4::com.cloudy.linglingbang ]-> CheckCodeUtils.encrypt is called: str=mobile=15211112222&password=111111&client_id=2019041810222516127&client_secret=c5ad2a4290faa3df39683865c2e10310&state=v8kTgUYjT8&response_type=token, i=2 CheckCodeUtils.encrypt result=MVNWGVFEpD18zqVt4x39yEZIE/y7BcfFhTbn9JUQ7bSrQ1EATn/MHXNQMOmE0Dlx0LYNvoPcIOPQlyRORq87sSsA5oUrlywzc6vjKJMlNb4gAL87KpYFQC9jznMlQVwFM2+fs59XMS6XehbNQ/yQHi9qidfcNyW/y379VFWCrN3Gy8T94fuqfA5L8Eb2Q3+m/5odBEjEYGCDAZgIxwz5K/JyV1rz/PctqINCcdSKozundoAkLwX7YUr+sPSlTcHa9dB4+yG/2jQQ6nDo0ckroqFRurapCUXP4MKTtMH05UQ5PdnXh6D6FyQ+LiUovi0P5iBBUwntGVn/n19e2xIG/KsiE0PEUL6uMdkXWUdoj2zftSXqHY38HaK+WCsy7gQGni5kIVzXNGRBgaQAc6HkGMg== 触发了,看看与抓包结果是否一致,对比发现大致是一样的,但是将+给换成了空格,这个记住就好; 那么位置没错就继续深入看看; 定位到具体位置了,很显然是native函数,checkcode是加密,那decheckcode就是解密了,一个个分析,先使用unidbg把算法跑出来再说; 4. unidbg复现 4.1 unidbg基本框架 把unidbg跑起来之后对算法还原是非常有帮助的,并且本文需要dfa,我是更喜欢用unidbg来攻击的,比较省心,先搭一个架子; public class WuLing extends AbstractJni { public static AndroidEmulator emulator; public static Memory memory; public static VM vm; public static Module module; public WuLing() { emulator = AndroidEmulatorBuilder .for32Bit() .addBackendFactory(new Unicorn2Factory(true)) .build(); memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); vm = emulator.createDalvikVM(new File("apks/whitebox/wuling/wuling.apk")); vm.setJni(this); vm.setVerbose(true); DalvikModule dm = vm.loadLibrary("encrypt", true); module = dm.getModule(); dm.callJNI_OnLoad(emulator); } public static void main(String[] args) { WuLing demo = new WuLing(); } }4.2 补环境 基本框架是大致是通用的,改apk之类的就可以了,直接运行看看会不会成功; java.lang.UnsupportedOperationException: android/app/ActivityThread->currentActivityThread()Landroid/app/ActivityThread; at com.github.unidbg.linux.android.dvm.AbstractJni.callStaticObjectMethod(AbstractJni.java:432) at com.github.unidbg.linux.android.dvm.AbstractJni.callStaticObjectMethod(AbstractJni.java:421) 一般来说没有进行主动调用的话问题不大,这里是报错了,那就直接进入补环境的主题; ActivityThread在Android框架中负责管理应用程序的生命周期和组件的创建与销毁等操作,这里直接返回一个空对象占位即可; @Override public DvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) { switch (signature){ case "android/app/ActivityThread->currentActivityThread()Landroid/app/ActivityThread;":{ return vm.resolveClass("android/app/ActivityThread").newObject(null); } } return super.callStaticObjectMethod(vm, dvmClass, signature, varArg); } 接着往下运行; java.lang.UnsupportedOperationException: android/os/SystemProperties->get(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; at com.github.unidbg.linux.android.dvm.AbstractJni.callStaticObjectMethod(AbstractJni.java:432) at com.xyt.sana.WuLing.callStaticObjectMethod(WuLing.java:80) 遇到这种有字符串参数的首先就应该去看看参数值,并且这里的方法调用的是get,估计是在获取什么东西; System.out.println(varArg.getObjectArg(0).getValue()); System.out.println(varArg.getObjectArg(1).getValue()); 看看会输出什么; ro.serialno unknown 在Android系统中,ro.serialno是一个系统属性,用于存储设备的序列号;这里应该怎么模拟? 通过adb命令获取即可,遇到这种拿不准的东西最好的方式就是google或者求助ai; adb shell getprop ro.serialno 以自己的结果为准即可,并且最好是与真机保持一致; case "android/os/SystemProperties->get(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;": { System.out.println(varArg.getObjectArg(0).getValue()); return new StringObject(vm, ""); } 继续走; java.lang.UnsupportedOperationException: android/app/ActivityThread->getSystemContext()Landroid/app/ContextImpl; at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:932) at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:866) 这里获取上下文,继续占位补; @Override public DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) { switch (signature){ case "android/app/ActivityThread->getSystemContext()Landroid/app/ContextImpl;":{ return vm.resolveClass("android/app/ContextImpl").newObject(null); } } return super.callObjectMethod(vm, dvmObject, signature, varArg); } 再次运行; java.lang.UnsupportedOperationException: android/app/ContextImpl->getPackageManager()Landroid/content/pm/PackageManager; at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:932) at com.xyt.sana.WuLing.callObjectMethod(WuLing.java:94) 这个也是相关的东西,感觉最后会是签名校验?同样处理; case "android/app/ContextImpl->getPackageManager()Landroid/content/pm/PackageManager;":{ return vm.resolveClass("android/content/pm/PackageManager").newObject(null); } 看看下面的信息; java.lang.UnsupportedOperationException: android/app/ContextImpl->getSystemService(Ljava/lang/String;)Ljava/lang/Object; at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:932) at com.xyt.sana.WuLing.callObjectMethod(WuLing.java:97) 输出一下参数看看; sana getSystemService--->>>wifi 那补一个wifi对象即可,它要获取wifi相关的信息吧; case "android/app/ContextImpl->getSystemService(Ljava/lang/String;)Ljava/lang/Object;":{ System.out.println("sana getSystemService--->>>" + varArg.getObjectArg(0).getValue()); String serviceName = (String) varArg.getObjectArg(0).getValue(); if ("wifi".equals(serviceName)) { // 创建模拟的WifiManager对象 DvmClass wifiManagerClass = vm.resolveClass("android/net/wifi/WifiManager"); DvmObject<?> wifiManager = wifiManagerClass.newObject(signature); return wifiManager; } } 接着看报错; java.lang.UnsupportedOperationException: android/net/wifi/WifiManager->getConnectionInfo()Landroid/net/wifi/WifiInfo; at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:932) at com.xyt.sana.WuLing.callObjectMethod(WuLing.java:108) 这个也是补位即可; case "android/net/wifi/WifiManager->getConnectionInfo()Landroid/net/wifi/WifiInfo;":{ return vm.resolveClass("android/net/wifi/WifiInfo").newObject(signature); } 继续看; java.lang.UnsupportedOperationException: android/net/wifi/WifiInfo->getMacAddress()Ljava/lang/String; at com.github.unidbg.linux.android.dvm.AbstractJni.callObjectMethod(AbstractJni.java:932) at com.xyt.sana.WuLing.callObjectMethod(WuLing.java:111) 这就比较显然了,获取mac地址; case "android/net/wifi/WifiInfo->getMacAddress()Ljava/lang/String;": { return new StringObject(vm, "c6:2c:b7:2c:a4:47"); } 可以使用adb获取; adb shell ip link show wlan0 或者 adb shell ip addr show 运行到这里没有报错了,那我们开始call方法,先去拿一个参数和返回值,用于进行对比,这里hook三个参数的checkcode方法; function hook_enc() { Java.perform(function () { let CheckCodeUtil = Java.use("com.bangcle.comapiprotect.CheckCodeUtil"); CheckCodeUtil["checkcode"].overload('java.lang.String', 'int', 'java.lang.String').implementation = function (str, i, str2) { console.log(`CheckCodeUtil.checkcode is called: str=${str}, i=${i}, str2=${str2}`); let result = this["checkcode"](str, i, str2); console.log(`CheckCodeUtil.checkcode result=${result}`); return result; }; }) } 拿到一份参数; [Pixel 4::com.cloudy.linglingbang ]-> hook_enc() [Pixel 4::com.cloudy.linglingbang ]-> CheckCodeUtil.checkcode is called: str=mobile=15211112222&password=111111&client_id=2019041810222516127&client_secret=c5ad2a4290faa3df39683865c2e10310&state=fhtTZiDQrY&response_type=token, i=2, str2=1736087879878 CheckCodeUtil.checkcode result= MVNWGVFEpD18zqVt4x39yEZIE/y7BcfFhTbn9JUQ7bSrQ1EATn/MHXNQMOmE0Dlx0LYNvoPcIOPQlyRORq87sSsA5oUrlywzc6vjKJMlNb4gAL87KpYFQC9jznMlQVwFM2+fs59XMS6XehbNQ/yQHi1NSGSeFGA9roRTajzYyacLDlMR9WZJqZ+mTUwylU3lZDcCYkian0uE+3Q5NAdMZ7fWFORCmhvWU5rBGBHrR17lccfF4Yrb6o4x+j3f3Unnfri1aCh9bnelkM2tvMrsd9xAmoy7wcwFRknsymvQzb8eHjkAFwtKQnBuMKARF3jQb5sp+85vd86jBnPUBQ3WI8xs1yDX97YqLWYq2WvDSisAzNO6P5RlvazEIoZHETjTfvqVhXubTSGx2Pdy7wTXD5Q== 接下来直接使用地址去调用,我个人比较喜欢地址的方式,比较自由,给个偏移就行; 首先去ida看看偏移信息,unidbg没有输出相关的信息,那可能是静态注册; image-20250105224734161图片 它的偏移是13A18,这里是32位,所以地址要 + 1; public String call_checkcode() { List<Object> list = new ArrayList<>(5); list.add(vm.getJNIEnv()); list.add(0); // 三个参数 list.add(vm.addLocalObject(new StringObject(vm, "mobile=15211112222&password=111111&client_id=2019041810222516127&client_secret=c5ad2a4290faa3df39683865c2e10310&state=fhtTZiDQrY&response_type=token"))); list.add(2); list.add(vm.addLocalObject(new StringObject(vm, "1736087879878"))); Number number = module.callFunction(emulator, 0x13A18 + 1, list.toArray()); String result = vm.getObject(number.intValue()).getValue().toString(); System.out.println("sana res --->>>" + result); return result; } 继续运行又开始补环境的路程; java.lang.UnsupportedOperationException: android/os/Build->MODEL:Ljava/lang/String; at com.github.unidbg.linux.android.dvm.AbstractJni.getStaticObjectField(AbstractJni.java:103) at com.github.unidbg.linux.android.dvm.AbstractJni.getStaticObjectField(AbstractJni.java:53) 这是获取设备的型号名称,本地可以这样获取; adb shell getprop ro.product.model 返回字符串即可; @Override public DvmObject<?> getStaticObjectField(BaseVM vm, DvmClass dvmClass, String signature) { switch (signature){ case "android/os/Build->MODEL:Ljava/lang/String;":{ return new StringObject(vm,"Pixel 4"); } } return super.getStaticObjectField(vm, dvmClass, signature); } 接着补; java.lang.UnsupportedOperationException: android/os/Build->MANUFACTURER:Ljava/lang/String; at com.github.unidbg.linux.android.dvm.AbstractJni.getStaticObjectField(AbstractJni.java:103) at com.xyt.sana.WuLing.getStaticObjectField(WuLing.java:139) 这个是获取设备制造商的名称,这里也是直接获取; adb shell getprop ro.product.manufacturer 返回即可; case "android/os/Build->MANUFACTURER:Ljava/lang/String;":{ return new StringObject(vm,"Google"); } 接着看; java.lang.UnsupportedOperationException: android/os/Build$VERSION->SDK:Ljava/lang/String; at com.github.unidbg.linux.android.dvm.AbstractJni.getStaticObjectField(AbstractJni.java:103) at com.xyt.sana.WuLing.getStaticObjectField(WuLing.java:142) 这个也很明显获取sdk的版本,我是安卓10,也就是29; case "android/os/Build$VERSION->SDK:Ljava/lang/String;":{ return new StringObject(vm, "29"); } 再次运行就跑出了结果; image-20250105230923288图片 但是相同的参数结果却对不上,可能是有什么东西在随机,那我这里想验证一下这个结果的正确性应该如何验证? 4.3 解密测试 这里我们去主动call解密方法,看看我们补出来的结果正确与否; 代码如下,地址在上面已经提到过了; public void call_decheckcode(String str){ List<Object> list = new ArrayList<>(5); list.add(vm.getJNIEnv()); list.add(0); list.add(vm.addLocalObject(new StringObject(vm, str))); Number number = module.callFunction(emulator, 0x165E0 + 1, list.toArray()); String result = vm.getObject(number.intValue()).getValue().toString(); System.out.println("sana 解密结果--->>>" + result); } 直接将得到的结果传进去看看返回值如何,这里可以暂时把jni的打印去掉; image-20250106174358171图片 这个结果很明显是不对的,可能是走了错误的逻辑,这里也有说在读文件,启动ida去看看情况,先静态分析一波再说; 它是静态注册的,直接点进去看; jstring __fastcall Java_com_bangcle_comapiprotect_CheckCodeUtil_decheckcode(JNIEnv *env, int a2, void *input) { // [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND] if ( !input ) return 0; v5 = malloc(0x21u); v6 = sub_138AC(env, v5); v7 = *env; if ( !v6 ) return v7->NewStringUTF(env, v5); v9 = v7->GetStringUTFChars(env, input, 0); v10 = *v9; v11 = v9; if ( v10 == 'M' ) { v12 = &aes_decrypt1_ptr; } else { if ( v10 != 'N' ) { v13 = v9; aes_decrypt = aes_decrypt1; goto LABEL_10; } v12 = &aes_decrypt2_ptr; } v13 = v9 + 1; aes_decrypt = *v12; LABEL_10: v14 = v13; do { v15 = v14++; v16 = *v15; } while ( *v15 ); v20 = (v15 - v13); v17 = malloc(v15 - v13); v18 = &v20[v17]; for ( i = v17; i < v18; i += 4 ) { *i = v16; if ( v18 <= (i + 1) ) break; i[1] = v16; if ( v18 <= (i + 2) ) break; i[2] = v16; if ( v18 <= (i + 3) ) break; i[3] = v16; } v21 = (base64_decode)(v13, v17, v16); (aes_decrypt)(v17, &v21); v17[v21] = 0; free(v5); (*env)->ReleaseStringUTFChars(env, input, v11); return (*env)->NewStringUTF(env, v17); } 最下面这个返回应该是正确的时候,我们用unidbg跑的时候结果是不正确的,那就找找其他的返回位置; image-20250106205844004图片 感觉这里会比较像,去看看v5的生成; image-20250106210157583图片 返回的位置大概会是v11,这里在做比较,那就去看看v5的位置; image-20250106210423126图片 去看看ED04这个函数; image-20250106210513586图片 非常长的一个函数,感觉是做了某种环境检测,这里也是有md5的特征,返回值也是32位的,感觉就有可能是这里了,那我们应该怎么做呢? 比较合适的方式就是在判断的位置打patch,不让它走错误逻辑,也就是v6的位置; image-20250106210711867图片 我们对它进行取反不就可以了吗,去看看这里的汇编代码; image-20250106210746486图片 CBNZ 是一个条件分支指令,用于检查寄存器的值是否不为零,如果不为零则跳转到指定的标签位置; 这条指令的作用是检查 R0 寄存器中的值是否为零,如果 R0 的值不为零,则程序将跳转到地址 0x00016610 处继续执行,如果 R0 的值为零,则程序将按照正常的顺序继续执行下一条指令; 这里直接修改指令即可,要对 CBNZ 指令进行取反,即当寄存器的值为零时跳转,可以使用 CBZ 指令。CBZ 是“如果为零则分支”的指令,与 CBNZ 相反; 这里可以用ida、010来修改,但既然用到了unidbg那就用它来patch,我觉得也是更省心的,方式比较多,先看看20 B9的指令是什么; image-20250106212328393图片 那我们需要的指令就是:cbz r0, #0xc ,去掉n嘛,unidbg既可以传机器码,也可以传汇编指令,我们先看看传汇编指令; Ⅰpatch方式1 public void patch() { try (Keystone keystone = new Keystone(KeystoneArchitecture.Arm, KeystoneMode.ArmThumb)) { KeystoneEncoded encoded = keystone.assemble("cbz r0, #0xc"); byte[] patchCode = encoded.getMachineCode(); emulator.getMemory().pointer(module.base + 0x16604).write(0, patchCode, 0, patchCode.length); } }Ⅱ patch方式2 UnidbgPointer pointer = UnidbgPointer.pointer(emulator, module.base + 0x16604); byte[] code = new byte[]{(byte) 0x20, (byte) 0xB1}; pointer.write(code); 两种都可以,区别也不大,这样就可以跑出正常的结果了; sana res --->>>MVNWGVFEpD18zqVt4x39yEZIE/y7BcfFhTbn9JUQ7bSrQ1EATn/MHXNQMOmE0Dlx0LYNvoPcIOPQlyRORq87sSsA5oUrlywzc6vjKJMlNb4gAL87KpYFQC9jznMlQVwFM2+fs59XMS6XehbNQ/yQHi1NSGSeFGA9roRTajzYyacLDlMR9WZJqZ+mTUwylU3lZDcCYkian0uE+3Q5NAdMZ7YqBhlEkjBKRN21UBsqHy8oLN/TIoTBXpy/TnI8DXxDdqb2Tv2SkR/YrDMO0Jnn4adjdrJvTEo0tEvXaI6Q1bRLspCZiSSVhsrQLs/HvRKTxMb2HV/xBafnJY7NxkXgoBTF7fe2I8m2QGxui8Q0aTv98KVj7p70Gcg2DStAkIV743nKmLp+XMMU8GhLDl4HI2g== sana 解密结果--->>>mobile=15211112222&password=111111&client_id=2019041810222516127&client_secret=c5ad2a4290faa3df39683865c2e10310&state=fhtTZiDQrY&response_type=token&ostype=ios&imei=99241FFAZ007VF&mac=c6:2c:b7:2c:a4:47&model=Pixel 4&sdk=29&serviceTime=1736087879878&mod=Google&checkcode=64e0d17b019b98a23f886f26a4b6f600 明文看起来东西不多,正是我们输入的东西拼接了一些环境相关的内容,最后有一个checkcode是一直在变化的,多跑几次就可以发现,也就是为什么上面我们固定入参却依然有变化的原因; unidbg跑通之后就可以进行算法还原了,最后的checkcode后续再分析; 5. 算法还原 在最开始进来的地方能看到很多符号信息,告诉我们aes,那我们就朝着aes去看; image-20250106220537539图片 这里具体去看哪一个呢?它这里做了判断,根据v10来判断,v10又等于v9,v9来自于输入的参数也就是密文,77和78按下r将它转换成字符; image-20250106215912125图片 这样就会明了很多,我们的密文多次测试都是以M开头的那么走的就是aes_encrypt1这里,点进去看看; image-20250106220605109图片 看符号的话,有很大的信息量,wb也就是白盒,模式可能是cbc,aes128加密,当然这些都只是猜测,我们就根据这些信息来进行求证; 继续向里面推进,进入WBACRAES128_EncryptCBC这个方法; image-20250106221127713图片 再次往里面进; image-20250106221147623图片 再进; image-20250106221158079图片 这里好像不太看得懂了,一般来说也没见过传值this,我们往回退,看看层层调用进来的时候最开始第一个参数是什么; image-20250106221343549图片 我们发现第一个参数是传的v7,在上面有定义,我们进去看看; image-20250106221425987图片 再点进去看看,进去之后按tab反编译; image-20250106221444861图片 我把整体的代码贴一下; int __fastcall CWAESCipher_Auth::WBACRAES_EncryptOneBlock( CWAESCipher_Auth *this, CSecFunctProvider *a2, unsigned __int8 *a3, int a4) { // [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND] memset(s, 0, 0x20u); v46 = CSecFunctProvider::PrepareAESMatrix(a2, &word_10, s, v5); if ( !v46 ) { for ( i = 0; ; ++i ) { if ( i >= a4 ) goto LABEL_39; // 9轮 关键信息 if ( i == 9 ) break; v7 = 0; v8 = 0; v45 = 4 * i; do { v9 = &s[2 * v8 + 32]; for ( j = 0; j != 4; ++j ) { v11 = *(this + 6); if ( v11 == 1 ) { v15 = (*(&unk_18D8E + v7) + j) & 3; // 表 v51 = roundTables_auth1[256 * (v8 + 4 * (v15 + v45)) + *(v9 + v15 - 128)]; } else { v12 = (*(&unk_18D8E + v7) + j) & 3; v13 = *(v9 + v12 - 128) + ((v8 + 4 * (v12 + v45)) << 8); if ( v11 == 2 ) v14 = &roundTables_auth2_ptr; else v14 = &roundTables_auth1_ptr; v51 = *(*v14 + 4 * v13); } s[4 * v8 + 16 + j] = v51; } ++v8; v7 += 2; } while ( v8 != 4 ); for ( k = 0; k != 4; ++k ) { v17 = &s[16] + k; for ( m = 0; m != 4; ++m ) { v19 = 0; v20 = *v17; v50[1] = v17[16]; v21 = v17[32]; v22 = v20 & 0xF; v23 = 96 * i + 24 * m; v50[0] = v20; v50[2] = v21; v24 = v20 & 0xF0; v50[3] = v17[48]; v25 = 6 * k; do { v26 = v50[++v19]; if ( v11 == 2 ) v27 = &xorTables_auth2_ptr; else v27 = &xorTables_auth1_ptr; v28 = *(*v27 + (v22 | (16 * (v26 & 0xF))) + ((v23 + v25) << 8)); v29 = v25 + 1; v44 = v26 & 0xF0 | (v24 >> 4); v22 = v28 & 0xF; if ( v11 == 2 ) v30 = &xorTables_auth2_ptr; else v30 = &xorTables_auth1_ptr; v25 += 2; v24 = (16 * *(*v30 + v44 + ((v29 + v23) << 8))); } while ( v19 != 3 ); v17 += 4; *(&s[2 * k] + m) = v24 | v22; } } } // 第10轮单独处理 if ( a4 == 10 ) { s[8] = s[0]; s[9] = s[1]; s[10] = s[2]; s[11] = s[3]; s[12] = s[4]; s[13] = s[5]; s[14] = s[6]; s[15] = s[7]; v31 = 0; v32 = *(this + 6); do { v33 = 0; v34 = 0; for ( n = 0; n != 4; ++n ) { if ( v32 == 1 ) { v38 = *(&unk_18D8E + v34); } else { if ( v32 == 2 ) { v36 = &finalRoundTable_auth2_ptr; v37 = (*(&unk_18D8E + v34) + v31) & 3; goto LABEL_37; } v38 = *(&unk_18D8E + v34); } v36 = &finalRoundTable_auth1_ptr; v37 = (v38 + v31) & 3; LABEL_37: v34 += 2; v39 = &s[2 * n + 32] + v37; v40 = n + 4 * v37; *(s + v31 + v33) = *(*v36 + *(v39 - 96) + (v40 << 8)); v33 += 8; } ++v31; } while ( v31 != 4 ); } LABEL_39: for ( ii = 0; ii != 4; ++ii ) { for ( jj = 0; jj != 4; ++jj ) a3[4 * ii + jj] = *(&s[2 * jj] + ii); } } return v46; } 我在里面标注了关键的信息,那么这里就很像aes的特征了,最后一轮没有列混合(MixColumns),所以单独处理是可以理解的;而且他这个表也非常大,符合白盒aes查表的特征; image-20250106222031641图片 那么这里可能就是加密的位置了,接下来就可以hook看看情况,这里最好修改一下输入明文,比较方便分析,得出的结果不会特别长,但在输入的时候直接给短明文后续也是会拼接环境参数的,所以在拼接完之后再进行替换; 5.1 key public void hook_debugger() { emulator.attach().addBreakPoint(module.base + 0x5A34, new BreakPointCallback() { @Override public boolean onHit(Emulator<?> emulator, long address) { String fakeInput = "hello"; int length = fakeInput.length(); MemoryBlock fakeInputBlock = emulator.getMemory().malloc(length, true); fakeInputBlock.getPointer().write(fakeInput.getBytes(StandardCharsets.UTF_8)); // 修改r0为指向新字符串的新指针 emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_R0, fakeInputBlock.getPointer().peer); return true; } }); } 这里可以返回值改成false看看有没有替换成功; image-20250106224309241图片 可以看到明文是替换成功了,然后看看密文是多少;那密文怎么看呢? LR寄存器存放了程序的返回地址,当函数跑到LR所指向的地址时,意味着函数结束跳转了出来;又因为断点是在目标地址执行前触发,所以在LR处的断点断下时,目标函数执行完且刚执行完,这就是Frida OnLeave 时机点的原理,所以我们在4DCC的位置下断后让他执行完,拿到LR的地址,在此处断下,也就是函数刚刚执行完的时机,那么此时应该如何查看返回值? public void hook_debugger() { emulator.attach().addBreakPoint(module.base + 0x5A34, new BreakPointCallback() { @Override public boolean onHit(Emulator<?> emulator, long address) { String fakeInput = "hello"; int length = fakeInput.length(); MemoryBlock fakeInputBlock = emulator.getMemory().malloc(length, true); fakeInputBlock.getPointer().write(fakeInput.getBytes(StandardCharsets.UTF_8)); // 修改r0为指向新字符串的新指针 emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_R0, fakeInputBlock.getPointer().peer); return true; } }); // 0xbffff51c就是返回值 emulator.attach().addBreakPoint(module.base + 0x4DCC, new BreakPointCallback() { RegisterContext context = emulator.getContext(); @Override public boolean onHit(Emulator<?> emulator, long address) { // 提前保存这个地址 final UnidbgPointer arg2 = context.getPointerArg(2); emulator.attach().addBreakPoint(context.getLRPointer().peer, new BreakPointCallback() { //onleave @Override public boolean onHit(Emulator<?> emulator, long address) { // 结束的时候读 Inspector.inspect(arg2.getByteArray(0, 100), "参数2、返回值"); return false; } }); return true; } }); } 上述的代码中,第一处断点是为了修改明文,第二个断点在上面已经说过了,拿到返回值的地址下断,此时刚刚执行完整个函数,在这里打印返回值就是密文的结果,为何0xbffff51c就是返回值呢? 这里去看ida的伪代码; image-20250106232344800图片 这里可能是在做明文state化的矩阵运算,第一个参数暂且不知,第二个参数是明文吧,第三个参数应该就是返回值,那么我们就需要先记录一下r2寄存器的地址,为何不在结束后直接mr2呢?因为这个时候寄存器中的地址已经不是原先的用来存返回值的地址了,而我们只需要去读最开始的地址里面的值就可以了,这点和frida的hook是一个意思,应该能理解; 当然我在代码里也提供了自动的方式,与frida是一样的思想;最后密文的结果如下: image-20250106232820517图片 正确密文:57b0d60b1873ad7de3aa2f5c1e4b3ff6 接下来就可以进行dfa攻击了,这个需要了解aes算法细节,我就不在这里提了,中心思想就是在也就是第八轮以及第九轮运算中两次列混淆之间进行; 那我们怎么来攻击呢? 首先看我们之前提到过的明文state化的矩阵运算,dfa攻击就需要去改变state的某些值来达到生成故障明文的目的;我们hook一下这个地址,看看state化后的明文到底在哪个地址,后续在合理的轮数进行攻击不就行了吗; emulator.attach().addBreakPoint(module.base + 0x4DF0); 直接在state的位置下断点,看看现在的r0的值; image-20250106234257026图片 很好,是明文,再看看r2的值,不知道为什么看r2的话去看看伪代码; image-20250106234339934图片 是空的,很好,符合预期;这里我们需要去看它在这个函数完之后是不是我们的明文经过运算的结果,去找一下函数结束的位置; image-20250106235512835图片 按照这个起始位置来下断点就行; emulator.attach().addBreakPoint(module.base + 0x4DF0); emulator.attach().addBreakPoint(module.base + 0x46C8); 在结束位置再次查看结果即可(m0xbffff468); 结果是我们推测的那样,那么我们要思考一个问题了,注入代码怎么写以及为什么要这么写; 先看看注入一次故障的密文,我们要的是四个字节的差异,多了也就失去了意义,代码如下: public void dfaAttack() { // 0x4E26是i==9的位置,在这里下断点 emulator.attach().addBreakPoint(module.base + 0x4E26, new BreakPointCallback() { int round = 0; // 上述明文state块的位置 对它进行修改以达到dfa的目的 UnidbgPointer statePointer = memory.pointer(0xbffff468); @Override public boolean onHit(Emulator<?> emulator, long address) { round += 1; System.out.println("round:"+round); // dfa的时机 if (round % 9 == 0){ statePointer.setByte(randInt(0, 15), (byte) randInt(0, 0xff)); } return true; } }); } 查看此处的密文结果; 正确密文:57b0d6 0b 1873 ad 7de3 aa 2f5c 1e 4b3ff6 dfa后: 57b0d6 fd 1873 a0 7de3 7a 2f5c 57 4b3ff6 可以看到确实是我们期望的那样,四个字节的偏差; 接下来就是获取大量密文了,另外,你要是直接看结果是不可以的,因为它最后还做了其他的操作,我们需要在4DCC处拿到加密后的结果,就像上面的操作,但是这里会输出蛮多东西,我做了明文的判断依旧无果,所以在拿故障密文的时候需要自己去拿,我循环30次,这已经足够了,看看大概的代码; public void hook_debugger() { // m0xbffff468 // emulator.attach().addBreakPoint(module.base + 0x4DF0); // emulator.attach().addBreakPoint(module.base + 0x46C8); emulator.attach().addBreakPoint(module.base + 0x5A34, new BreakPointCallback() { @Override public boolean onHit(Emulator<?> emulator, long address) { String fakeInput = "hello"; int length = fakeInput.length(); MemoryBlock fakeInputBlock = emulator.getMemory().malloc(length, true); fakeInputBlock.getPointer().write(fakeInput.getBytes(StandardCharsets.UTF_8)); // 修改r0为指向新字符串的新指针 emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_R0, fakeInputBlock.getPointer().peer); return true; } }); // m0xbffff51c emulator.attach().addBreakPoint(module.base + 0x4DCC, new BreakPointCallback() { RegisterContext context = emulator.getContext(); Arm32RegisterContext context1 = emulator.getContext(); final Pointer input = context1.getR1Pointer(); @Override public boolean onHit(Emulator<?> emulator, long address) { Arm32RegisterContext context1 = emulator.getContext(); final Pointer input = context1.getR1Pointer(); String plaintext = input.getString(0); // 判断是否为目标明文 if ("hello".equals(plaintext)) { System.out.println("明文--->>> " + plaintext); // 保存第三个参数的地址 final UnidbgPointer arg2 = context.getPointerArg(2); // final Pointer arg2 = context1.getR2Pointer(); // hook LR 只在此情况下添加断点 emulator.attach().addBreakPoint(context.getLRPointer().peer, new BreakPointCallback() { @Override public boolean onHit(Emulator<?> emulator, long address) { // 输出函数执行结果 // Inspector.inspect(arg2.getByteArray(0, 16), "AES密文--->>>"); // 也可以直接打印 arg2 地址和值 byte[] arg2Bytes = arg2.getByteArray(0, 16); // 读取 16 个字节 StringBuilder hexString = new StringBuilder(); for (byte b : arg2Bytes) { hexString.append(String.format("%02x", b)); } String result = hexString.toString().toLowerCase(); System.out.println("AES密文--->>>" + result.trim()); return true; } }); } return true; } }); } public void dfaAttack() { // 上面有 } public static int randInt(int min, int max) { Random rand = new Random(); return rand.nextInt((max - min) + 1) + min; } public static void main(String[] args) { WuLing demo = new WuLing(); demo.patch(); for (int i = 0; i < 30; i++) { demo.hook_debugger(); demo.dfaAttack(); demo.call_checkcode(); } } 这里注入的时候需要注意上面说的问题,我们要的密文结果并不是最终的结果,需要在4DCC函数执行完后输出,在上面有提到,其次就是按照我的方式来输出会很多,没排查到具体原因,可能是地址存放的东西变了,但不影响dfa,最后把结果收集起来用phoenixAES推K10就行; import phoenixAES phoenixAES.crack_file('data.txt', [], True, False, 3) data.txt就是故障密文,第一行是正确的密文,推出结果如下; image-20250107150108728图片 再使用轮密钥推到主密钥的工具去推K0就行; k0就是密钥,命令里的10代表轮数,密钥为:F6F472F595B511EA9237685B35A8F866; 5.2 iv 但是aes光有密钥是不够的,还需要看iv、模式、填充等;通过符号猜测可能是cbc模式,它是有iv的,那我们怎么去找这个iv? 我们知道,iv在最开始的时候需要与处理过的明文进行异或操作,而异或操作是可以做逆运算的,在上述位置我们dfa注入的时候,明文的矩阵运算位置我们是知道的,那这里不正好是可以看到矩阵混合之后的明文吗,它就在这里面与iv进行了异或,换句话说,我们拿到它运算后的结果再与我们的明文进行异或就可以得到iv,这是异或的特点,a ^ b = c也可以是 a ^ c = b; 根据这个思路我们去进行hook一下,前面正好打过断点,直接搬过来就行; public void hook_iv() { emulator.attach().addBreakPoint(module.base + 0x5A34, new BreakPointCallback() { @Override public boolean onHit(Emulator<?> emulator, long address) { String fakeInput = "hello"; int length = fakeInput.length(); MemoryBlock fakeInputBlock = emulator.getMemory().malloc(length, true); fakeInputBlock.getPointer().write(fakeInput.getBytes(StandardCharsets.UTF_8)); // 修改r0为指向新字符串的新指针 emulator.getBackend().reg_write(ArmConst.UC_ARM_REG_R0, fakeInputBlock.getPointer().peer); return true; } }); emulator.attach().addBreakPoint(module.base + 0x4DF0); emulator.attach().addBreakPoint(module.base + 0x46C8); } 在0x4DF0处断下后记得记住r3的地址,到0x46C8打印看就是它运算后的结果,不过之前的过程其实也知道这个地址是0xbffff468; image-20250107154130673图片 首先我们的明文是hello,转化成矩阵应该是这样的; 68 65 6C 6C 6F 00 00 00 00 00 00 00 00 00 00 00 对比它的结果,貌似是没什么区别啊,难道我们输出的位置并不是明文处理完成的位置?也不应该啊,这时候就突然想到,异或还有一个特点啊,任何数值和0异或得到的结果都是本身,那这里有没有可能是iv全是0? 带着疑惑我选择去测试一下,就拿之前的参数去加密一下看看; sana res --->>>MVNWGVFEpD18zqVt4x39yEZIE/y7BcfFhTbn9JUQ7bSrQ1EATn/MHXNQMOmE0Dlx0LYNvoPcIOPQlyRORq87sSsA5oUrlywzc6vjKJMlNb4gAL87KpYFQC9jznMlQVwFM2+fs59XMS6XehbNQ/yQHi1NSGSeFGA9roRTajzYyacLDlMR9WZJqZ+mTUwylU3lZDcCYkian0uE+3Q5NAdMZ7YqBhlEkjBKRN21UBsqHy8oLN/TIoTBXpy/TnI8DXxDdqb2Tv2SkR/YrDMO0Jnn4adjdrJvTEo0tEvXaI6Q1bRLspCZiSSVhsrQLs/HvRKTxMb2HV/xBafnJY7NxkXgoBTF7fe2I8m2QGxui8Q0aTv98KVj7p70Gcg2DStAkIV743nKmLp+XMMU8GhLDl4HI2g== sana 解密结果--->>>mobile=15211112222&password=111111&client_id=2019041810222516127&client_secret=c5ad2a4290faa3df39683865c2e10310&state=fhtTZiDQrY&response_type=token&ostype=ios&imei=99241FFAZ007VF&mac=c6:2c:b7:2c:a4:47&model=Pixel 4&sdk=29&serviceTime=1736087879878&mod=Google&checkcode=64e0d17b019b98a23f886f26a4b6f600 测试结果发现是一样的,只是在开头拼接了一个M,且这里是做了base64,在最开始也看到过; image-20250107155955111图片 那么这个iv我们也已经还原出来了,填充的话我是用cyberchef测试的,它默认是pkcs7填充,当然这里的填充也可以去验证,这里不细说了,所以我们就可以得到如下结论: 加密方式:aes128-cbc模式-pkcs7填充; key:F6F472F595B511EA9237685B35A8F866; iv:00000000000000000000000000000000; 结果需要base64后拼接M; 到这里还没完,明文参数里有一个checkcode是动态的,也需要看一下; 5.3 checkcode 它是32位的参数,首当其冲就是猜测它是md5,我们都很清楚md5的套路,无非是把前面的参数做了签名,最多加盐,然后才是魔改,我们肯定希望直接能出结果,所以我们去测试一下; 先拿一个密文解一下,然后再去测试; hook的密文:VNWGVFEpD18zqVt4x39yEZIE/y7BcfFhTbn9JUQ7bSrQ1EATn/MHXNQMOmE0Dlx0LYNvoPcIOPQlyRORq87sSsA5oUrlywzc6vjKJMlNb4gAL87KpYFQC9jznMlQVwFM2+fs59XMS6XehbNQ/yQHi1NSGSeFGA9roRTajzYyacLDlMR9WZJqZ+mTUwylU3lZDcCYkian0uE+3Q5NAdMZ7fWFORCmhvWU5rBGBHrR17lccfF4Yrb6o4x+j3f3Unnfri1aCh9bnelkM2tvMrsd9xAmoy7wcwFRknsymvQzb8eHjkAFwtKQnBuMKARF3jQb5sp+85vd86jBnPUBQ3WI8xs1yDX97YqLWYq2WvDSisAzNO6P5RlvazEIoZHETjTfvqVhXubTSGx2Pdy7wTXD5Q== 解密结果: mobile=15211112222&password=111111&client_id=2019041810222516127&client_secret=c5ad2a4290faa3df39683865c2e10310&state=fhtTZiDQrY&response_type=token&ostype=ios&imei=unknown&mac=02:00:00:00:00:00&model=Pixel 4&sdk=29&serviceTime=1736087879878&mod=Google&checkcode=379f0627ee7b3e895459a6d848219413 测试加密: 48219413ee7b3e895459a6d8379f0627 直接看让我们很失望,好像并不一样,但仔细看看会发现一点端倪; 48219413 ee7b3e895459a6d8 379f0627 379f0627 ee7b3e895459a6d8 48219413 很显然了,它把前四个字节和最后四个字节进行了交换,实际上是一个标准的md5,在这里会引出一个小细节,我为什么要去拿一个hook到的密文呢?而不是用unidbg跑出来的密文? 我在测试完成后去拿unidbg跑出的结果进行了测试,发现是和上面的理论对不上的,这是怎么回事?可上面确实是如此啊,我认为可能是环境的问题,或者其他的问题,咨询画神说unidbg跑出来对不上很正常,因为在之前的分析里md5特征是很明显的,而且是不止一处有特征,所以有些环境判断你有问题也是正常的,这里建议拿真机的结果来测试; 6. 总结 由于我时间不多,写这篇文章也是断断续续的,所以中间的分析过程我自己也不知道有没有讲清楚,如果有不清楚的可以自己多多研究,我的水平也比较有限;
-
安卓逆向-Bilibili播放量接口 一、Bilibili 1. 前置 [!sana] 版本:6.24.0 包名:tv.danmaku.bili so文件:libbili.so 目标:增加视频播放量算法 2. 抓包分析 使用socksdroid转发+charles抓包即可; 一共两个接口比较重要: https://api.bilibili.com/x/report/heartbeat/mobile https://api.bilibili.com/x/report/click/android2 抓包示例如下: image-20241222162234422图片 第一个是心跳包,熟悉的朋友可能知道,控制完播率这方面的,剩下一个就是增加播放量的; 3. click android2接口 首先看一下接口需要哪些参数: 请求地址: https://api.bilibili.com/x/report/click/android2 请求方式: POST 请求头: buvid XY88A4185B06248EA82BB0101C2ADC5DAD33B device-id bFpoC2lebA9uWm5ZJVkl fp_local d9c996c3fb98c1808bbf64ee054dda5420241222161236dee9d93eca085cd19e fp_remote d9c996c3fb98c1808bbf64ee054dda542024112221174303fb9f17f667dc29f3 session_id f815bbd9 请求体: ùouO\#âèhA^>o(](©ú······Ã<xxÚý \@aèyd 是一串乱码样式的内容3.1 请求体分析 直接反编译搜索地址的后部分即可,这样比较唯一,搜索到后直接点过去; image-20241222163602968图片 这里可以发现心跳接口其实也在这里,并且它是一个接口,那就去找它的实现,右键查找用例或者按x,也只有一个位置,点进去看看; image-20241222164440857图片 这里其实也跟明了,看不懂的可以去了解一下retrofit,这里调用reportClick方法,传入了create这个参数,参考接口定义的位置,这个变量应该就是请求体; 那么我们就应该去看create这个参数怎来的; public final void a() { long j2; long i = c2.f.f.c.j.a.i() / 1000; c2.f.b0.c.b.b.a.a E = c2.f.b0.c.b.b.a.a.E(); x.h(E, "EnvironmentPrefHelper.getInstance()"); long A = E.A(); if (A == -1) { c2.f.b0.c.b.b.a.a E2 = c2.f.b0.c.b.b.a.a.E(); x.h(E2, "EnvironmentPrefHelper.getInstance()"); E2.V(i); j2 = i; } else { j2 = A; } c0 create = c0.create(w.d(com.hpplay.sdk.source.protocol.h.E), d.this.H7(this.b.a(), this.b.b(), this.b.h(), i, j2, this.b.n(), this.b.m(), this.b.k(), this.b.c(), this.b.e(), this.b.l(), this.b.f())); x.h(create, "RequestBody.create(Media…ion/octet-stream\"), body)"); l<String> execute = ((tv.danmaku.biliplayerimpl.report.heartbeat.a) com.bilibili.okretro.c.a(tv.danmaku.biliplayerimpl.report.heartbeat.a.class)).reportClick(create).execute(); int b = execute.b(); String h = execute.h(); BLog.i("HeartBeatTracker", "player report click(vv): responseCode:" + b + ", responseMsg:" + h + ", responseBody:" + execute.a()); } 分析它的源码; x1 = w.d(com.hpplay.sdk.source.protocol.h.E) x2 = d.this.H7(this.b.a(), this.b.b(), this.b.h(), i, j2, this.b.n(), this.b.m(), this.b.k(), this.b.c(), this.b.e(), this.b.l(), this.b.f()) c0 create = c0.create(x1,x2); 其实他就两个参数,第一个参数是一个请求格式的常量,那么第二个参数看着这么长,就有可能是我们需要的请求体参数了; 进入H7方法看看; public final byte[] H7(long j2, long j4, int i, long j5, long j6, int i2, int i3, long j7, String str, int i4, String str2, String str3) throws Exception { long j8; int i5; Application f2 = BiliContext.f(); com.bilibili.lib.accounts.b client = com.bilibili.lib.accounts.b.f(f2); AccountInfo h = BiliAccountInfo.f.a().h(); if (h != null) { j8 = h.getMid(); i5 = h.getLevel(); } else { j8 = 0; i5 = 0; } TreeMap treeMap = new TreeMap(); treeMap.put("aid", String.valueOf(j2)); treeMap.put("cid", String.valueOf(j4)); treeMap.put("part", String.valueOf(i)); treeMap.put(EditCustomizeSticker.TAG_MID, String.valueOf(j8)); treeMap.put("lv", String.valueOf(i5)); treeMap.put("ftime", String.valueOf(j6)); treeMap.put("stime", String.valueOf(j5)); treeMap.put("did", com.bilibili.lib.biliid.utils.f.a.c(f2)); treeMap.put("type", String.valueOf(i2)); treeMap.put("sub_type", String.valueOf(i3)); treeMap.put("sid", String.valueOf(j7)); treeMap.put("epid", str); treeMap.put("auto_play", String.valueOf(i4)); x.h(client, "client"); if (client.r()) { treeMap.put("access_key", client.g()); } treeMap.put("build", String.valueOf(com.bilibili.api.a.f())); treeMap.put("mobi_app", com.bilibili.api.a.l()); treeMap.put("spmid", str2); treeMap.put("from_spmid", str3); StringBuilder sb = new StringBuilder(); for (Map.Entry entry : treeMap.entrySet()) { String str4 = (String) entry.getValue(); sb.append((String) entry.getKey()); sb.append('='); if (str4 == null) { str4 = ""; } sb.append(str4); sb.append('&'); } sb.deleteCharAt(sb.length() - 1); String sb2 = sb.toString(); x.h(sb2, "builder.toString()"); String b2 = t3.a.i.a.a.a.b.e.b(sb2); BLog.i("HeartBeatTracker", "player report click(vv), params: " + sb2 + " & sign=" + b2); sb.append("&sign="); sb.append(b2); String sb3 = sb.toString(); x.h(sb3, "builder.toString()"); return t3.a.i.a.a.a.b.e.a(sb3); } 看起来很庞大,但是貌似也有些比较敏感的参数,这个方法的主要目的是构建一个包含多个参数的请求,对这些参数进行签名,然后将签名后的参数字符串转换为字节数组,它会把map的参数逐一添加到sb中,中间以&符号拼接; 这里很明显就是看这个sign嘛,去看看b方法; public final String b(String params) { x.q(params, "params"); Charset charset = com.bilibili.commons.c.b; x.h(charset, "Charsets.UTF_8"); byte[] bytes = params.getBytes(charset); x.h(bytes, "(this as java.lang.String).getBytes(charset)"); String str = d; Charset charset2 = com.bilibili.commons.c.b; x.h(charset2, "Charsets.UTF_8"); if (str != null) { byte[] bytes2 = str.getBytes(charset2); x.h(bytes2, "(this as java.lang.String).getBytes(charset)"); String g = com.bilibili.commons.m.a.g(bytes, bytes2); x.h(g, "DigestUtils.sha256(param…yteArray(Charsets.UTF_8))"); Locale locale = Locale.US; x.h(locale, "Locale.US"); if (g != null) { String lowerCase = g.toLowerCase(locale); x.h(lowerCase, "(this as java.lang.String).toLowerCase(locale)"); return lowerCase; } throw new TypeCastException("null cannot be cast to non-null type java.lang.String"); } throw new TypeCastException("null cannot be cast to non-null type java.lang.String"); } 那这里的主要内容就是这个g方法了,同样进去看看; public static String g(byte[] bArr, byte[] bArr2) { try { MessageDigest messageDigest = MessageDigest.getInstance(AaidIdConstant.SIGNATURE_SHA256); messageDigest.reset(); messageDigest.update(bArr); if (bArr2 != null) { messageDigest.update(bArr2); } return g.H(messageDigest.digest()); } catch (NoSuchAlgorithmException e) { throw new AssertionError(e); } } 打眼一看应该就是一个sha256算法; 那么接下来就是需要hook验证了,先去hook H7看看位置对不对; function hook_h7() { Java.perform(function () { var ByteString = Java.use("com.android.okhttp.okio.ByteString"); let d = Java.use("tv.danmaku.biliplayerimpl.report.heartbeat.d"); d["H7"].implementation = function (j2, j4, i, j5, j6, i2, i3, j7, str, i4, str2, str3) { console.log(`d.H7 is called: j2=${j2}, j4=${j4}, i=${i}, j5=${j5}, j6=${j6}, i2=${i2}, i3=${i3}, j7=${j7}, str=${str}, i4=${i4}, str2=${str2}, str3=${str3}`); let result = this["H7"](j2, j4, i, j5, j6, i2, i3, j7, str, i4, str2, str3); console.log(`d.H7 result=${ByteString.of(result).hex()}`); return result; }; }) } hook_h7() hook的结果如下,转成16进制输出; [Pixel 4::哔哩哔哩 ]-> d.H7 is called: j2=1205801029, j4=1593015651, i=1, j5=1734867854, j6=1734855270, i2=3, i3=0, j7=0, str=, i4=0, str2=main.ugc-video-detail.0.0, str3=main.ugc-video-detail.0.0 d.H7 result=36c64b4e8b138184f3c14a734642b5a2168e1cd05181eed73655abdddca85f7d8d7379750746b64e55e8285d4ba87747f78439f6c9f629cc9fcd35edb2748d2ef7ad4e4221bb01363c86624dc7fc8256627fe6315ea001c3dcc701c6facd7dd46621225582b367c2ec7844035a20be2c9218c58c39d6641b90a7d2fb91e6dad2a1124c848d27cfcab1541714f1f8390e80de2a4ab0ce13dfb4210a75df6792286fe7029b7d11d1362add404442bedeee3e6911f36fa360a2d1cad53be0fce4aada0eb373f5f065a36e35db91242923ac23a3eac4b5c5d08f79298a840249a674b746af2273f97a3d3d07522061eb3e324824fe9701cf583e85c73ab4997ed06bcce957773dd909af1531e2bf2a477b4f2f7901137c7d96e82835d3a96b79952428a5f4474ae1b54ce901c9649eb397f12523b9b536d099acaa62043db34f3d59 抓包的结果拿去对比一下; image-20241222194700257图片 可以发现位置是没有错的,那么就可以去看sign怎么来的了,上面知道了sign怎么来的了,那我们也直接去hook一下; function hook_sign() { Java.perform(function () { let a = Java.use("com.bilibili.commons.m.a"); a["g"].implementation = function (bArr, bArr2) { console.log(`a.g is called: bArr=${Bytes2utf8String(bArr)}, bArr2=${Bytes2utf8String(bArr2)}`); let result = this["g"](bArr, bArr2); console.log(`a.g result=${result}`); return result; }; }) } hook_sign(); 输出结果如下: [Pixel 4::哔哩哔哩 ]-> a.g is called: bArr=aid=1002480418&auto_play=0&build=6240300&cid=1487958098&did=bFpoC2lebA9uWm5ZJVkl&epid=&from_spmid=main.ugc-video-detail.0.0&ftime=1734855270&lv=0&mid=0&mobi_app=android&part=1&sid=0&spmid=main.ugc-video-detail.0.0&stime=1734869003&sub_type=0&type=3, bArr2=9cafa6466a028bfb a.g result=9d5a27d1f98c1a9ae66558db3c2a3804419f696511cc9c1b037a6c86965267f4 上面我们看源码觉得像sha256算法,这里它传了两个参数,本身是字节数组,我这里直接转成字符串,因为我们知道传进来的是什么,那么这个参数2在源码里是这样用的: if (bArr2 != null) { messageDigest.update(bArr2); } 这毫无疑问就是盐嘛,也可以搜索一下看看; image-20241222200924850" style="zoom:80%;" /> 这里还有key iv等关键词,那么这个可以留个心眼了,那就看看是不是标准算法; 是对的上的,那就证明没问题,只是提交的时候是原格式而不是hex; 那么明文参数也需要看一看,aid和cid其实是视频相关的id,ftime是视频打开时间,stime是视频开始播放时间,剩下的就是一个did了; 经过多次清除数据尝试,最终定位到其实就是在取mac地址;
-
安卓逆向-jnitrace基本使用 一、jni-trace 1. 前置 jnitrace 是一个基于 Frida 的工具,用于跟踪 Android 应用程序中 JNI API 的使用; JNI 允许 Java 代码与 C/C++ 代码进行交互,而 jni-trace 通过动态分析,可以追踪这些交互过程中的 JNI 函数调用; 它会记录并显示每一个 JNI 函数的调用,包括方法签名、参数、返回值等详细信息; 项目地址:https://github.com/chame1eon/jnitrace 安装命令: pip install jnitrace 注意:在使用 jnitrace 之前需要先启动 frida-server 各方面版本信息如下: frida 16.5.7 frida-tools 13.6.0 hexdump 3.3 jnitrace 3.3.12. 使用方式 注意:在使用 jnitrace 之前需要先启动 frida-server; 2.1 跟踪 JNI 函数调用 执行下面命令附加到包名为 com.cyrus.exammple 的应用程序中,并开始跟踪 libnative-lib.so 中所有的 JNI 调用; jnitrace -l libnative-lib.so com.cyrus.exammple trace的结果示例如下: Tracing. Press any key to quit... Traced library "libnative-lib.so" loaded from path "/data/app/com.cyrus.example-Jh9YgSVDqZ5bKxgv0f1E6w==/base.apk!/lib/arm64-v8a". /* TID 13912 */ 5973 ms [+] JNIEnv->NewStringUTF 5973 ms |- JNIEnv* : 0x7292c3c180 5973 ms |- char* : 0x7fc2a5b5d1 5973 ms |: Hello From Native 5973 ms |= jstring : 0x71 { Hello From Native } 5973 ms ------------------------------------Backtrace------------------------------------ 5973 ms |-> 0x719d3b1080: libnative-lib.so!0x25080 (libnative-lib.so:0x719d38c000) Stopping application (name=com.cyrus.example, pid=13912)...stopped.2.2 跟踪特定的 JNI 函数 如果只想跟踪特定的 JNI 函数,例如 RegisterNatives,可以通过以下方式实现; jnitrace -l libGameVMP.so -i RegisterNatives com.shizhuang.duapp 这个命令会仅跟踪 RegisterNatives 调用,从而帮助找到动态注册的 native 方法; Tracing. Press any key to quit... Traced library "libGameVMP.so" loaded from path "/data/app/com.shizhuang.duapp-fTxemmnM8l6298xbBELksQ==/lib/arm64". /* TID 26932 */ 478 ms [+] JNIEnv->RegisterNatives 478 ms |- JNIEnv* : 0x757bdfe180 478 ms |- jclass : 0xe9 { lte/NCall } 478 ms |- JNINativeMethod* : 0x7fc96e3128 478 ms |: 0x747e51293c - dI(I)I 478 ms |- jint : 1 478 ms |= jint : 0 478 ms --------------------------------Backtrace-------------------------------- 478 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 493 ms [+] JNIEnv->RegisterNatives 493 ms |- JNIEnv* : 0x757bdfe180 493 ms |- jclass : 0xe9 { lte/NCall } 493 ms |- JNINativeMethod* : 0x7fc96e3140 493 ms |: 0x747e512aec - dS(Ljava/lang/String;)Ljava/lang/String; 493 ms |- jint : 1 493 ms |= jint : 0 493 ms --------------------------------Backtrace-------------------------------- 493 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 508 ms [+] JNIEnv->RegisterNatives 508 ms |- JNIEnv* : 0x757bdfe180 508 ms |- jclass : 0xe9 { lte/NCall } 508 ms |- JNINativeMethod* : 0x7fc96e3158 508 ms |: 0x747e512ad0 - dL(J)J 508 ms |- jint : 1 508 ms |= jint : 0 508 ms --------------------------------Backtrace-------------------------------- 508 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 522 ms [+] JNIEnv->RegisterNatives 522 ms |- JNIEnv* : 0x757bdfe180 522 ms |- jclass : 0xe9 { lte/NCall } 522 ms |- JNINativeMethod* : 0x7fc96e3170 522 ms |: 0x747e514fa8 - IV([Ljava/lang/Object;)V 522 ms |- jint : 1 522 ms |= jint : 0 522 ms --------------------------------Backtrace-------------------------------- 522 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 537 ms [+] JNIEnv->RegisterNatives 537 ms |- JNIEnv* : 0x757bdfe180 537 ms |- jclass : 0xe9 { lte/NCall } 537 ms |- JNINativeMethod* : 0x7fc96e3188 537 ms |: 0x747e514fa8 - IZ([Ljava/lang/Object;)Z 537 ms |- jint : 1 537 ms |= jint : 0 537 ms --------------------------------Backtrace-------------------------------- 537 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 609 ms [+] JNIEnv->RegisterNatives 609 ms |- JNIEnv* : 0x757bdfe180 609 ms |- jclass : 0xe9 { lte/NCall } 609 ms |- JNINativeMethod* : 0x7fc96e3200 609 ms |: 0x747e514fe8 - IF([Ljava/lang/Object;)F 609 ms |- jint : 1 609 ms |= jint : 0 609 ms --------------------------------Backtrace-------------------------------- 609 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 623 ms [+] JNIEnv->RegisterNatives 623 ms |- JNIEnv* : 0x757bdfe180 623 ms |- jclass : 0xe9 { lte/NCall } 623 ms |- JNINativeMethod* : 0x7fc96e3218 623 ms |: 0x747e514fa8 - IJ([Ljava/lang/Object;)J 623 ms |- jint : 1 623 ms |= jint : 0 623 ms --------------------------------Backtrace-------------------------------- 623 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 637 ms [+] JNIEnv->RegisterNatives 637 ms |- JNIEnv* : 0x757bdfe180 637 ms |- jclass : 0xe9 { lte/NCall } 637 ms |- JNINativeMethod* : 0x7fc96e3230 637 ms |: 0x747e515028 - ID([Ljava/lang/Object;)D 637 ms |- jint : 1 637 ms |= jint : 0 637 ms --------------------------------Backtrace-------------------------------- 637 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000) /* TID 26932 */ 652 ms [+] JNIEnv->RegisterNatives 652 ms |- JNIEnv* : 0x757bdfe180 652 ms |- jclass : 0xe9 { lte/NCall } 652 ms |- JNINativeMethod* : 0x7fc96e3248 652 ms |: 0x747e514fa8 - IL([Ljava/lang/Object;)Ljava/lang/Object; 652 ms |- jint : 1 652 ms |= jint : 0 652 ms --------------------------------Backtrace-------------------------------- 652 ms |-> 0x747e50d998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e507000)2.3 附加到正在运行的应用 通过 -m <spawn|attach> 指定 Frida 附加方式,默认为 spawn; 执行下面命令通过 -m attach 附加到正在运行的应用上; jnitrace -m attach -l libnative-lib.so com.cyrus.example3. 详细参数说明 参数说明: -l :指定要追踪的库,可以使用多次,例如 -l libnative-lib.so -l libanother-lib.so,或使用 -l * 追踪所有库 :目标应用的包名,必须已安装在设备上; 可选参数: -R ::指定远程 Frida server 的位置(默认是 localhost:27042); -m <spawn|attach>:指定 Frida 附加方式,默认为 spawn,推荐使用; -b <fuzzy|accurate|none>:控制追踪输出的堆栈信息,默认 accurate,可以选择 fuzzy 或 none 禁用; -i :指定方法名称的正则表达式进行追踪,可以多次使用,如 -i Get -i RegisterNatives; -e :排除指定方法名称的正则表达式,如 -e ^Find -e GetEnv; -I :指定库中的导出方法进行追踪,适用于只追踪部分导出方法,如 -I stringFromJNI; -E :排除指定导出方法的追踪,如 -E JNI_OnLoad -E nativeMethod; -o <path/output.json>:指定追踪数据保存路径,以 JSON 格式存储; -p <path/to/script.js>:加载自定义 Frida 脚本,jnitrace 加载前执行,用于绕过反调试; -a <path/to/script.js>:加载自定义 Frida 脚本,jnitrace 加载后执行; --hide-data:减少输出,隐藏 hexdump 和字符串引用; --ignore-env:隐藏通过 JNIEnv 的调用; --ignore-vm:隐藏通过 JavaVM 的调用; --aux <name=(string|bool|int)value>:传递自定义参数,例如 --aux='uid=(int)10'; 4. 输出说明 Traced library "libGameVMP.so" loaded from path "/data/app/com.xxx.duapp-fTxemmnM8l6298xbBELksQ==/lib/arm64". /* TID 9541 */ # 表示线程 ID(TID)为 9541,当前的 JNI 调用是在这个线程上执行的。 613 ms [+] JNIEnv->RegisterNatives # 表示在 613 ms 时间点调用了 JNIEnv->RegisterNatives 方法 613 ms |- JNIEnv* : 0x757bdfe180 # 参数,JNIEnv 指针,0x757bdfe180 是该指针的内存地址。 613 ms |- jclass : 0xe9 { lte/NCall } # 参数,0xe9 为传递的 Java 类对象的句柄 613 ms |- JNINativeMethod* : 0x7fc96e3128 # 参数,指向 JNINativeMethod 数组的指针 0x7fc96e3128,这个数组定义了要注册的本地方法。 613 ms |: 0x747e54d93c - dI(I)I # 这是 JNINativeMethod 数组中的一项数据细节。0x747e54d93c 是本地函数的地址,dI(I)I 是方法的签名 613 ms |- jint : 1 # 参数,表示要注册的本地方法数量 613 ms |= jint : 0 # RegisterNatives 方法的返回值 613 ms --------------------------------Backtrace-------------------------------- 613 ms |-> 0x747e548998: libGameVMP.so!0x6998 (libGameVMP.so:0x747e542000) # 这是调用 RegisterNatives 方法时的调用堆栈信息。 # 0x747e548998 是调用地址。 # libGameVMP.so!0x6998 表示 libGameVMP.so 库中的偏移地址 0x6998。 # (libGameVMP.so:0x747e542000) 指示库的基地址 0x747e542000。
-
安卓逆向-瑞幸咖啡白盒AES 一、瑞幸咖啡白盒AES 样本其实已经有很多大佬分享过了,之所以写这样一篇文档是希望理清整体的流程,加深逆向的思想; 仅作技术交流 请勿恶意请求 侵权麻烦联系删除1. 前置 [!sana] APP:瑞幸咖啡 包名:com.lucky.luckyclient 版本:5.0.01 加固:360加固 目标:q参数 这里选择的版本不是新版,因为新版有frida对抗,考虑到复现问题使用了旧版本,这个版本是没有frida检测的; 需要前置知晓的知识点大致如下: unidbg简单使用与补环境; AES算法详细流程; 参考文档:https://www.yuque.com/nanren-w8l2z/xgu63m/uoff9ovsmhqki9wh 白龙大佬的文章,请多多站在巨人的肩膀; 差分故障攻击(DFA); 在此默认读者已经有逆向环境所以不会提到环境的东西; 2. 抓包 抓包环境:charles + socksdroid转发; 在我的环境下直接就可以抓包,有对抗的话自行解决抓包问题; image-20241201111652996图片 这里主要的目标就是q参数,sign也是native层,篇幅足够的话会讨论; 关于壳在前置提到过,它是有一个360的加固,如图: image-20241201112405771图片 在这里不涉及脱壳的内容,其实也非常好脱,我在这里使用的是fart脱的壳; 3. 代码定位 脱完壳后直接搜索sign,但是并搜不到什么有价值的东西; image-20241201114539042图片 很明显这是人家的东西,所以这里用其他的方式定位; 在这里使用hook hashmap的put方法,可以直接定位到,hook代码如下: function showStacks() { console.log( Java.use("android.util.Log") .getStackTraceString( Java.use("java.lang.Throwable").$new() ) ); } Java.perform(function () { var hashMap = Java.use("java.util.HashMap"); hashMap.put.implementation = function (a, b) { if(a!=null && a.equals("q")){ showStacks(); console.log("hashMap.put: ", a, b); } return this.put(a, b); } }) 在这里可以先将打印的地方注释掉,因为这个app是有双进程保护的,所以需要重启注入,这样就可以避免还没点击登录却输出了过多信息,hook结果如下: java.lang.Throwable at java.util.HashMap.put(Native Method) at com.lucky.lib.http2.r.a(SourceFile:7) at com.lucky.lib.http2.AbstractLcRequest.getRequestParams(SourceFile:14) at com.lucky.lib.http2.m.b(SourceFile:14) at com.lucky.lib.http2.m.getRequest(SourceFile:1) at com.lucky.lib.http2.AbstractLcRequest.async(SourceFile:5) ···省略部分 at butterknife.internal.c.onClick(SourceFile:4) at android.view.View.performClick(View.java:7259) at android.view.View.performClickInternal(View.java:7236) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930) hashMap.put: q 8pR_VhJIrWrz_t·····省略部分SLoqiKvmgkJGOXxo= 根据堆栈信息去找,发现getRequestParams的位置比较像put的位置,而且也有cid的字眼; image-20241201134318185图片 但这里并不知道它的键究竟是不是我们需要的,而它放入的是看不懂的东西,其实这里是加密了,如何进行测试呢?主动调用即可,getString2这个方法按理来说就应该是解密字符串的方法,传入相应的参数即可; 主动调用代码如下: function dec_str(str){ Java.perform(function (){ let StubApp = Java.use("com.stub.StubApp"); let result = StubApp["getString2"](str); console.log("字符串解密结果: " + result); }) } 将上图加密的字符串分别解密结果如下: image-20241201134509590图片 [Pixel 4::com.lucky.luckyclient ]-> dec_str(7719) 字符串解密结果: sign [Pixel 4::com.lucky.luckyclient ]-> dec_str(16944) 字符串解密结果: uid [Pixel 4::com.lucky.luckyclient ]-> dec_str(4005) 字符串解密结果: cid [Pixel 4::com.lucky.luckyclient ]-> dec_str(457) 字符串解密结果: q 当然直接hook这个解密方法输出参数和结果也是可以找到需要的结果的; 那么这里正是我们需要的位置,接下来就可以去找生成q参数的native方法到底在哪; 没有什么难度,一路向下跟即可; image-20241201135019629图片 4. hook验证 这里其实是有分支的,也就是说这两个圈起来的方法是都有可能走的,所以可以都hook一下,在这里测试过是走的下面的方法,也就不再复现了,hook代码如下: function call_aes() { Java.perform(function () { let ByteString = Java.use("com.android.okhttp.okio.ByteString"); let CryptoHelper = Java.use("com.luckincoffee.safeboxlib.CryptoHelper"); CryptoHelper["localAESWork4Api"].implementation = function (bArr, i2) { console.log('参数1:' + Java.use("java.lang.String").$new(bArr)); console.log('参数2:' + i2); let result = this["localAESWork4Api"](bArr, i2); console.log(`result=${ByteString.of(result).base64()}`); return result; }; }) } call_aes() hook的时候有几个需要注意的点,最后在刚启动时注释掉方法调用,否则有壳的防护可能会找不到类,其次注意只在点击登录之前将注释解掉,这样输出也会少很多方便分析,最后hook到的结果如下: 参数1:{"blackBox":"eyJvcyI6ImFuZHJvaWQiLCJ2ZXJzaW9uIjoiMy4zLjciLCJwYWNrYWdlcyI6ImNvbS5sdWNreS5sdWNreWNsaWVudComNS4wLjAxIiwicHJvZmlsZV90aW1lIjozNDQsImluddmFsX3RpbWUiOjc5OCwidG9rZW5faWQiOiJuT1wvZWFscmNCRjZZTnBQYXR0OThDaVNmWmZMdlEyMEFnWHNrUk9OdVVNUXE4dTV0RFl1RWN0aUk5WUM4MHRxSVlaME8zS3BFeUsrNWhvdnNCdjVTcnp6N0NIeFk1d0lhanYyUXVFM2pZNkk9In0=","uniqueCode":"DUStsTlxA16lKIyUaLR_SGOxaGnmBnMTXJ1dRFVTdHNUbHhBMTZsS0l5VWFMUl9TR094YUdubUJuTVRYSjFkc2h1","regionId":"CO0001","mobile":"15244448888","countryNo":"86","validateCode":"456921","regId":"","appversion":"5001","type":1,"deviceId":"android_lucky_beb03f432402baaa","systemVersion":"29","deviceBrand":"google"} 参数2:0 result=8pR/VhJIrWrz/tZtV6BCHViaW+VcsIem1HE6z7xNRYyVtK9SqWgrrgfb3iFkKIEGsfrSjR3dQhXSTLVGCRHEigsSIniYTUc1GbWaPMS1lOY9vZ2x3z0mK7gNza9lU9h8N+SzBmY1heejAwpKAaGGZXcpEjvXKst3PxekVny9uCld8NXdBRh0Ur37jcyK5Ik8PQ/w5pNwRmJY2om/+ilph/d5FkrbdUtZf3pAmSiTcCNKJoI6zCBs1RIXN4y0yFw59a/PpV1vy0giAu8DocmLRpQT/vDriJd+tbsWfV9LqgSieNuAUiK1TKKM01fusC8ByR/zFDoMhEMZKbj7+Ln4p0y/Jy/gYLANWarDm7WUES6zf3rrG+i sYx1JzigdgHk9B+9qVo1gf4ftktwvQV0YHsFOPF6qdOkAZcXDCwDwsNG+rXlSAnxEgRun7ZyumWdOIJqGaZKmleHTI/GnMuAPVk4epMt0Gc8K1WL2s3x1eK1AUcYuK6bksvVa7E6poqshlcHdrtthW9QR5AxOKAUew0708bBlhZO/93tA9b7796JdbeHt pyKviexhfiDpIqeJ4y0qdhsrkvTlIW7hx+/ku6IBdHCBWw4Q1p11i56orROO6YEcTXEmoRzjgZAKrGJUkmD5kLVw8E1ufsTbTfVIzwQ/BCOJJiUQe6/Badbcx4vkPJ6fR9MIeIVOA6N5vn5Jll+itsF+ptKuERsP9IH3gxzRozBQAdU3gvpJhukvv2y/UTfJs+yreXXUj8/mHlyiX6ugE0mhGw+BTdeMEjR1iJ2BiK3FBfLCpP9KVPPVnWG9UDybqS4WKiHACrAZmjJ38MmnSPga6XagdgggDjtZYAokm1WSLoqiKvmgkJGOXxo= 与抓包结果对比是对的上的,这也就证明走的方法是没问题的,而且在hook的同时我们可以发现有些参数2是0,而有些是1,当它是1的时候结果解码之后其实就是请求的结果,也就是响应解密的结果,可以大概看出这个参数可能代表的就是加密解密; 这里的结果并不完全对的上,在返回的时候做了一些处理; image-20241201151222590图片 参数中有个blackBox是同盾的风控,在这里不在讨论范畴; 5. 算法分析 5.1 定位so 接下来就应该去分析so文件了,同样的道理,可以去调用解密函数来知道so文件是哪一个; image-20241201152442195图片 另外直接hook也可以,我这里偷懒就直接选择hook动态注册了,脚本如下: function hook_RegisterNatives() { var RegisterNatives_addr = null; var symbols = Process.findModuleByName("libart.so").enumerateSymbols(); for (var i = 0; i < symbols.length; i++) { var symbol = symbols[i].name; if ((symbol.indexOf("CheckJNI") == -1) && (symbol.indexOf("JNI") >= 0)) { if (symbol.indexOf("RegisterNatives") >= 0) { RegisterNatives_addr = symbols[i].address; console.log("RegisterNatives_addr: ", RegisterNatives_addr); } } } Interceptor.attach(RegisterNatives_addr, { onEnter: function (args) { var env = args[0]; var jclass = args[1]; var class_name = Java.vm.tryGetEnv().getClassName(jclass); var methods_ptr = ptr(args[2]); var method_count = args[3].toInt32(); console.log("RegisterNatives method counts: ", method_count); for (var i = 0; i < method_count; i++) { var name = methods_ptr.add(i * Process.pointerSize * 3).readPointer().readCString(); var sig = methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize).readPointer().readCString(); var fnPtr_ptr = methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2).readPointer(); var find_module = Process.findModuleByAddress(fnPtr_ptr); console.log("类: ", class_name, "方法: ", name, "签名: ", sig, "函数地址: ", fnPtr_ptr, "模块名: ", find_module.name, "函数偏移: ", ptr(fnPtr_ptr).sub(find_module.base)); } }, onLeave: function (retval) {} }); } hook_RegisterNatives() hook结果如下,有报错不用管,也可以自行修复: RegisterNatives method counts: 4 方法: localAESWork 签名: ([BI[B)[B 函数地址: 0xc113684d 模块名: libcryptoDD.so 函数偏移: 0x1984d 方法: localConnectWork 签名: ([B[B)[B 函数地址: 0xc113678d 模块名: libcryptoDD.so 函数偏移: 0x1978d 方法: md5_crypt 签名: ([BI)[B 函数地址: 0xc1137981 模块名: libcryptoDD.so 函数偏移: 0x1a981 方法: localAESWork4Api 签名: ([BI)[B 函数地址: 0xc11381cd 模块名: libcryptoDD.so 函数偏移: 0x1b1cd5.2 白盒AES 涉及到的四个native函数都找到了,so是libcryptoDD.so,函数地址也都找到了,比较方便; 去看一下so长什么样,此样本只有32位的so,不过9.0也不区分了,没有关系; image-20241201153650696图片 可以看到不是平时看到的那些状态,这是做了混淆,但也不影响,而且也没用Java开头的函数,所以我们直接hook动态注册是正确的; 上面已经得到了函数的偏移,在IDA按下G键,跳转到对应的偏移位置; image-20241201154413068图片 字面意思可以看出一点端倪,wb也就是白盒的缩写,所以它有可能是一个白盒AES,当然这也只是一个猜测而已,不过逆向就是要大胆猜测小心求证,猜一下无伤大雅; 进入到方法里面,实际上比较关键的点也就是这一个方法而已; image-20241201154731808图片 可以发现通篇看下来无非就是这两个方法,虽然不同的位置,但方法是同一个,管他什么逻辑进去看看先,因为被混淆了所以两个位置有一个位置条件永远不成立也不是不可能; 进入方法后进行分析,截取几个重要的位置做参考; 位置1: image-20241201155548754图片 位置2: image-20241201155634822图片 位置3: image-20241201155654563图片 位置4: image-20241201155731224图片 通篇看下来其实是没多少内容的,而且这个样本之所以不算特别难,是因为它的符号大多都在,这大大减少了我们在分析的难度,甚至可以直接告诉我们很多信息; 大概重要的就是位置4,可以看出大概是一个ecb模式的加密,但不能完全相信这个结果,我们可以hook一下这个函数,hook代码如下,32位的so地址需要+1; function call_so() { var base_addr = Module.findBaseAddress("libcryptoDD.so"); let funcPtr = base_addr.add(0x17BD4 + 1) Interceptor.attach(funcPtr, { onEnter: function (args) { console.log("参数0:" + args[0].readCString()); console.log("参数1:" + args[1].toInt32()); this.len = args[1].toInt32() this.args2 = args[2]; console.log("参数3:" + args[3].toInt32()); }, onLeave: function (retval) { var hexdumpString = hexdump(this.args2, { offset: 0, length: this.len, header: true, ansi: true }); console.log("参数2返回时(hexdump):\n" + hexdumpString); } }); } call_so() hook结果如下: java入参: 参数1:{"blackBox":"eyJvcyI6ImFuZHJvaWQiLCJ2ZXJzaW9uIjoiMy4zLjciLCJwYWNrYWdlcyI6ImNvbS5sdWNreS5sdWNreWNsaWVudComNS4wLjAxIiwicHJvZmlsZV90aW1lIjozMDEsImludGVydmFsXbWUiOjc5NSwidG9rZW5faWQiOiJuT1wvZWFscmNCRjZZTnBQYXR0OThDbmZhSWVPTlBWaW92ZHBMb09CZG13eTdpRmk4Ukp2clhwNlVZZTBYTmRuYUxVY3R0QzlBczUreXBGdDNyOFRaWGxOOXFoZHNKUFdtbDBqWnJGWjE1K1k9In0=","uniqueCode":"DUStsTlxA16lKIyUaLR_SGOxaGnmBnMTXJ1dRFVTdHNUbHhBMTZsS0l5VWFMUl9TR094YUdubUJuTVRYSjFkc2h1","regionId":"CO0001","mobile":"15211115555","countryNo":"86","validateCode":"123456","regId":"","appversion":"5001","type":1,"deviceId":"android_lucky_beb03f432402baaa","systemVersion":"29","deviceBrand":"google"} 参数2:0 native入参: 参数0:{"blackBox":"eyJvcyI6ImFuZHJvaWQiLCJ2ZXJzaW9uIjoiMy4zLjciLCJwYWNrYWdlcyI6ImNvbS5sdWNreS5sdWNreWNsaWVudComNS4wLjAxIiwicHJvZmlsZV90aW1lIjozMDEsImludGVydmFsX3RpbWUiOjc5NSwidG9rZW5faWQiOiJuT1wvZWcmNCRjZZTnBQYXR0OThDbmZhSWVPTlBWaW92ZHBMb09CZG13eTdpRmk4Ukp2clhwNlVZZTBYTmRuYUxVY3R0QzlBczUreXBGdDNyOFRaWGxOOXFoZHNKUFdtbDBqWnJGWjE1K1k9In0=","uniqueCode":"DUStsTlxA16lKIyUaLR_SGOxaGnmBnMTXJ1dRFVTdHNUbHhBMTZsS0l5VWFMUl9TR094YUdubUJuTVRYSjFkc2h1","regionId":"CO0001","mobile":"15211115555","countryNo":"86","validateCode":"123456","regId":"","appversion":"5001","type":1,"deviceId":"android_lucky_beb03f432402baaa","systemVersion":"29","deviceBrand":"google"}������������������������������������������������ 参数1:656 参数3:0 参数2返回时(hexdump): 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF c297e100 f2 94 7f 56 12 48 ad 6a f3 fe d6 6d 57 a0 42 1d ...V.H.j...mW.B. ···省略部分内容 c297e380 ae 46 23 46 05 9e 4c 11 53 d3 ca 59 ab a5 51 08 .F#F..L.S..Y..Q. 经过验证返回的内容就是抓包的结果,也就证明了函数是对的方向,而这个明文与java层的明文大致是一样的,只是后面多了一些不可见字符,我推测可能是末尾的填充,后续留个心眼; 也就是说接下来重点就在于这个wbaes_encrypt_ecb方法; 依旧是找几个关键点做分析; 位置1: image-20241201171249278图片 位置2: image-20241201171309352图片 选择第一个进去观察一下,看看是否会有白盒的特征; 这里看见了一个比较重要的字眼,循环左移,这个需要了解aes的算法流程; image-20241201171546565图片 除了这个以外,还有很多的操作,很符合查表的特征; image-20241201171721179图片 去看看表的大小; image-20241201171749148图片 很显然普通的查表法aes的表是没有这么大的,这也很符合白盒aes的特征; DFA的攻击点在哪里?这个也需要读者了解,这里直接给出; image-20241201172645678图片 也就是图中的四个攻击点,原理这里不展开; 而在这里我们一共发现了两个关键函数,点进去查看它的循环左移符号均未抹去,在结合上图的攻击点,不是正好有个点是ShiftRows吗,那我们就可以对它做一些文章;循环左移的入参是state块,左移后结果也存在里面,所以DFA就可以攻击这个位置;如果你了解AES的算法细节,你会知道它的加密轮数有10轮(仅在此案例讨论,具体轮数不定),攻击点就在8-9轮中间,而这里循环左移是最合适的,因为它符号并没有抹去,就可以很畅快的注入DFA; 准备好以后就可以进行故障攻击了; 5.3 DFA 这里由于不需要补环境,所以在后面就使用unidbg来攻击; 首先搭好基本的框架; package com.xyt.sana; 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.jni.ProxyDvmObject; import com.github.unidbg.memory.Memory; import java.io.File; public class RuiXin extends AbstractJni { public static AndroidEmulator emulator; public static Memory memory; public static VM vm; public static Module module; public RuiXin(){ emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.lucky.luckyclient").build(); memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); vm = emulator.createDalvikVM(new File("apks/ruixin/rxkf5.0.01.apk")); vm.setJni(this); vm.setVerbose(true); DalvikModule dm = vm.loadLibrary("cryptoDD", true); dm.callJNI_OnLoad(emulator); module = dm.getModule(); } public static void main(String[] args) { RuiXin rxkf = new RuiXin(); } } 不需要补环境,直接上DFA代码,在这里需要读者了解unidbg的hook,unidbg完整代码如下: package com.xyt.sana; import com.github.unidbg.AndroidEmulator; import com.github.unidbg.Emulator; import com.github.unidbg.Module; import com.github.unidbg.arm.context.RegisterContext; import com.github.unidbg.debugger.BreakPointCallback; import com.github.unidbg.debugger.Debugger; 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.jni.ProxyDvmObject; import com.github.unidbg.memory.Memory; import com.github.unidbg.memory.MemoryBlock; import com.github.unidbg.pointer.UnidbgPointer; import javax.xml.bind.DatatypeConverter; import java.io.File; import java.util.Random; public class RuiXin extends AbstractJni { public static AndroidEmulator emulator; public static Memory memory; public static VM vm; public static Module module; public RuiXin() { emulator = AndroidEmulatorBuilder.for32Bit().setProcessName("com.lucky.luckyclient").build(); memory = emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); vm = emulator.createDalvikVM(new File("apks/ruixin/rxkf5.0.01.apk")); vm.setJni(this); vm.setVerbose(true); DalvikModule dm = vm.loadLibrary("cryptoDD", true); dm.callJNI_OnLoad(emulator); module = dm.getModule(); } public static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { int unsignedInt = b & 0xff; String hex = Integer.toHexString(unsignedInt); if (hex.length() == 1) { sb.append('0'); } sb.append(hex); } return sb.toString(); } public static byte[] hexToBytes(String hexString) { return DatatypeConverter.parseHexBinary(hexString); } public static int randint(int min, int max) { Random rand = new Random(); return rand.nextInt((max - min) + 1) + min; } public void calldfa() { Debugger debugger = emulator.attach(); debugger.addBreakPoint(module.base + 0x14f98, new BreakPointCallback() { UnidbgPointer pointer; RegisterContext context = emulator.getContext(); int num = 1; @Override public boolean onHit(Emulator<?> emulator, long address) { pointer = context.getPointerArg(0); if (num % 9 == 0) { pointer.setByte(randint(0, 15), (byte) randint(0, 0xff)); } num += 1; return true; } }); } public void call_wbaes() { // 主动调用 MemoryBlock inputBlock = emulator.getMemory().malloc(16, true); UnidbgPointer inputPtr = inputBlock.getPointer(); MemoryBlock ouputBlock = emulator.getMemory().malloc(16, true); UnidbgPointer ouputPtr = ouputBlock.getPointer(); byte[] byteArray = hexToBytes("55669988555269996541441122554411"); assert byteArray != null; inputPtr.write(0, byteArray, 0, byteArray.length); module.callFunction(emulator, 0x17bd5, inputPtr, 16, ouputPtr, 0); String res = bytesToHex(ouputPtr.getByteArray(0, 0x10)); // System.out.println("白盒结果:" + res); System.out.println(res); inputBlock.free(); ouputBlock.free(); } public static void main(String[] args) { RuiXin rxkf = new RuiXin(); // rxkf.calldfa(); // rxkf.call_wbaes(); for (int i = 0; i < 300; i++) { rxkf.calldfa(); rxkf.call_wbaes(); } } } 首先主动调用一次工具,对比一下正确密文和故障密文有什么差别; 60 9b 2ab3 8e fb53e028d8b4 5a 191a f3 13 60 10 2ab3 3d fb53e028d8b4 3f 191a bf 13 可以发现有四个字节是不一样的,这个需要读者了解差分故障攻击,同时我会将涉及到的东西放在文件里; 接下来就是循环几百次,收集多个故障密文,再使用phoenixAES去推导第十轮的密钥,再去使用轮密钥推导主密钥,其中一些结果如下图: image-20241201180855488图片 使用phoenixAES库得到K10密钥,使用方法如下: # pip install phoenixAES import phoenixAES phoenixAES.crack_file('tracefile.txt', [], True, False, 3) tracefile.txt放得到的故障密文,第一行是正确的密文,其余的是故障密文; image-20241201181429228图片 它推导的k10:869D92BBB700D0D25BD9FD3E224B5DF2,再使用k10去推导主密钥; 推导结果如下: image-20241201181826009图片 所以它的原始密钥就是 644A4C64434A69566E44764D394A5570; 这里去测试结果是一样的,以下是加密的结果: image-20241201184058845图片 hook的结果: image-20241201184046888图片 对比来看也是对的上的; image-20241201184141560图片 至此q参数分析完毕,这篇文章的重点也就在此,一个简单的白盒AES; 本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关! 本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请联系作者立即删除! 6. 总结 很少写长篇幅的东西,很多东西也来不及仔细的写到文档,如有讲解不到位的欢迎指出; 技术交流+vx:HeiYuKuaiDou23