最新发布
-
安卓逆向-五菱白盒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地址;
-
安卓逆向-md5算法 一、md5 1. C实现md5 1.1 大致流程 由三个函数来完成md5的加密操作;分别是MD5Init、MD5Update、MD5Final; void MD5Init (MD5_CTX *mdContext); void MD5Update (MD5_CTX *mdContext, char *inBuf, unsigned int inLen); void MD5Final (MD5_CTX *mdContext); void Transform (UINT4 *buf, UINT4 *in); MD5Init 进行初始化,传入的参数是一个结构体; MD5Update 第一个参数依然是结构体,第二个参数是明文,第三个参数是明文的长度; MD5Final 第一个参数为结构体,第二个参数就是返回值; 注:一个字节=8位(8个二进制位)1Byte=8bit;一个十六进制=4个二进制位。一个字节=2个十六进制。 1.2 明文处理 首先对明文进行Hex编码; xiaojianbang ==> 78 69 61 6f 6a 69 61 6e 62 61 6e 67 注:Hex编码(也称为十六进制编码)是一种将数据转换为十六进制表示形式的编码方法。与Base64编码不同,Hex编码使用16个字符来表示数据,这16个字符分别是0-9的数字和A-F的字母(不区分大小写,即a-f和A-F是等价的)。 填充 把明文填充到448bit 先填充一个1,后面跟对应个数的0 附加消息长度 用64bit表示消息长度 也就是明文长度 00 00 00 00 00 00 00 60 转小端序 60 00 00 00 00 00 00 00 如果内容过长,64个比特放不下。就取低64bit。所以MD5输入长度可以无限大,SHA3算法也是无限大,其他哈希算法不是; 明文的结果: 78 69 61 6f 6a 69 61 6e 62 61 6e 67 80 ...... 60 00 00 00 00 00 00 00 明文 填充 明文长度MD5输入数据无限大,不可能一起处理,需要分组 MD5分组长度为512bit,数据需要处理到512的倍数,因此需要填充 填充位数为1-512bit,如果明文长度刚好448bit,那么就填充512bit MD5算法使用的是小端字节序 把处理后的明文分成16块 M1-M16,用于后续计算; 1.3 初始化常量 MD5的初始化常量一共有四个: A: 01 23 45 67 B: 89 ab cd ef C: fe dc ba 98 D: 76 54 32 10 上面说了md5是小端序,所以输入的值不可能是01234567,所以需要传的值是: 四个初始化常量: A: 0x67452301; B: 0xEFCDAB89; C: 0x98BADCFE; D: 0x10325476; 常量是固定的,如果修改了那就不是一个标准算法,也就是魔改了,这也是魔改md5最多的位置。 1.4 MD5Transform 正式计算; 流程图如下: 关于流程解释如下: MD5总共64轮,每一轮都会把旧的D直接给新的A,旧的B直接给新的C,旧的C直接给新的D,也就是每一轮只计算一个新的B 图中的田代表相加 图中的F函数并不是一个函数,而是由四个函数组成 K表里面的值由公式计算的,但体现在代码中一般都是常量 大多数都是直接给的常量值 <<<s代表循环左移 最后把四个初始化常量不断变化后的值,拼接得到最终的摘要结果 通过对上图的理解,每一轮的流程为: A + F(B、C、D) + Mi + Ki 循环左移 +B F是一个函数,但并不是固定的一个,而是四个,每16轮一个函数; 在update中,并未直接进行计算,如果传入的参数大于64个字节,直接就去计算,如果没有超过则只进行拷贝,后续再进行填充; 1.5 源码实现 main.cpp #include <iostream> #include <memory.h> #include <string.h> #include "MD5.h" using namespace std; unsigned char PADDING[] = {0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; void MD5Init(MD5_CTX *context) { context->count[0] = 0; context->count[1] = 0; context->state[0] = 0x67452301; context->state[1] = 0xEFCDAB89; context->state[2] = 0x98BADCFE; context->state[3] = 0x10325476; } void MD5Update(MD5_CTX *context, unsigned char *input, unsigned int inputlen) { unsigned int i = 0, index = 0, partlen = 0; index = (context->count[0] >> 3) & 0x3F; partlen = 64 - index; context->count[0] += inputlen << 3; if (context->count[0] < (inputlen << 3)) context->count[1]++; context->count[1] += inputlen >> 29; if (inputlen >= partlen) { memcpy(&context->buffer[index], input, partlen); MD5Transform(context->state, context->buffer); for (i = partlen; i + 64 <= inputlen; i += 64) MD5Transform(context->state, &input[i]); index = 0; } else { i = 0; } memcpy(&context->buffer[index], &input[i], inputlen - i); } void MD5Final(MD5_CTX *context, unsigned char digest[16]) { unsigned int index = 0, padlen = 0; unsigned char bits[8]; index = (context->count[0] >> 3) & 0x3F; padlen = (index < 56) ? (56 - index) : (120 - index); MD5Encode(bits, context->count, 8); MD5Update(context, PADDING, padlen); MD5Update(context, bits, 8); printf("%.8x\n", context->state[0]); printf("%.8x\n", context->state[1]); printf("%.8x\n", context->state[2]); printf("%.8x\n", context->state[3]); MD5Encode(digest, context->state, 16); } // 大端序到小端序 void MD5Encode(unsigned char *output, unsigned int *input, unsigned int len) { unsigned int i = 0, j = 0; while (j < len) { output[j] = input[i] & 0xFF; output[j + 1] = (input[i] >> 8) & 0xFF; output[j + 2] = (input[i] >> 16) & 0xFF; output[j + 3] = (input[i] >> 24) & 0xFF; i++; j += 4; } } //分组 分成m1-16 void MD5Decode(unsigned int *output, unsigned char *input, unsigned int len) { unsigned int i = 0, j = 0; while (j < len) { output[i] = (input[j]) | (input[j + 1] << 8) | (input[j + 2] << 16) | (input[j + 3] << 24); i++; j += 4; } } void MD5Transform(unsigned int state[4], unsigned char block[64]) { unsigned int a = state[0]; unsigned int b = state[1]; unsigned int c = state[2]; unsigned int d = state[3]; unsigned int x[64]; MD5Decode(x, block, 64); FF(a, b, c, d, x[0], 7, 0xd76aa478); /* 1 */ FF(d, a, b, c, x[1], 12, 0xe8c7b756); /* 2 */ FF(c, d, a, b, x[2], 17, 0x242070db); /* 3 */ FF(b, c, d, a, x[3], 22, 0xc1bdceee); /* 4 */ FF(a, b, c, d, x[4], 7, 0xf57c0faf); /* 5 */ FF(d, a, b, c, x[5], 12, 0x4787c62a); /* 6 */ FF(c, d, a, b, x[6], 17, 0xa8304613); /* 7 */ FF(b, c, d, a, x[7], 22, 0xfd469501); /* 8 */ FF(a, b, c, d, x[8], 7, 0x698098d8); /* 9 */ FF(d, a, b, c, x[9], 12, 0x8b44f7af); /* 10 */ FF(c, d, a, b, x[10], 17, 0xffff5bb1); /* 11 */ FF(b, c, d, a, x[11], 22, 0x895cd7be); /* 12 */ FF(a, b, c, d, x[12], 7, 0x6b901122); /* 13 */ FF(d, a, b, c, x[13], 12, 0xfd987193); /* 14 */ FF(c, d, a, b, x[14], 17, 0xa679438e); /* 15 */ FF(b, c, d, a, x[15], 22, 0x49b40821); /* 16 */ /* Round 2 */ GG(a, b, c, d, x[1], 5, 0xf61e2562); /* 17 */ GG(d, a, b, c, x[6], 9, 0xc040b340); /* 18 */ GG(c, d, a, b, x[11], 14, 0x265e5a51); /* 19 */ GG(b, c, d, a, x[0], 20, 0xe9b6c7aa); /* 20 */ GG(a, b, c, d, x[5], 5, 0xd62f105d); /* 21 */ GG(d, a, b, c, x[10], 9, 0x2441453); /* 22 */ GG(c, d, a, b, x[15], 14, 0xd8a1e681); /* 23 */ GG(b, c, d, a, x[4], 20, 0xe7d3fbc8); /* 24 */ GG(a, b, c, d, x[9], 5, 0x21e1cde6); /* 25 */ GG(d, a, b, c, x[14], 9, 0xc33707d6); /* 26 */ GG(c, d, a, b, x[3], 14, 0xf4d50d87); /* 27 */ GG(b, c, d, a, x[8], 20, 0x455a14ed); /* 28 */ GG(a, b, c, d, x[13], 5, 0xa9e3e905); /* 29 */ GG(d, a, b, c, x[2], 9, 0xfcefa3f8); /* 30 */ GG(c, d, a, b, x[7], 14, 0x676f02d9); /* 31 */ GG(b, c, d, a, x[12], 20, 0x8d2a4c8a); /* 32 */ /* Round 3 */ HH(a, b, c, d, x[5], 4, 0xfffa3942); /* 33 */ HH(d, a, b, c, x[8], 11, 0x8771f681); /* 34 */ HH(c, d, a, b, x[11], 16, 0x6d9d6122); /* 35 */ HH(b, c, d, a, x[14], 23, 0xfde5380c); /* 36 */ HH(a, b, c, d, x[1], 4, 0xa4beea44); /* 37 */ HH(d, a, b, c, x[4], 11, 0x4bdecfa9); /* 38 */ HH(c, d, a, b, x[7], 16, 0xf6bb4b60); /* 39 */ HH(b, c, d, a, x[10], 23, 0xbebfbc70); /* 40 */ HH(a, b, c, d, x[13], 4, 0x289b7ec6); /* 41 */ HH(d, a, b, c, x[0], 11, 0xeaa127fa); /* 42 */ HH(c, d, a, b, x[3], 16, 0xd4ef3085); /* 43 */ HH(b, c, d, a, x[6], 23, 0x4881d05); /* 44 */ HH(a, b, c, d, x[9], 4, 0xd9d4d039); /* 45 */ HH(d, a, b, c, x[12], 11, 0xe6db99e5); /* 46 */ HH(c, d, a, b, x[15], 16, 0x1fa27cf8); /* 47 */ HH(b, c, d, a, x[2], 23, 0xc4ac5665); /* 48 */ /* Round 4 */ II(a, b, c, d, x[0], 6, 0xf4292244); /* 49 */ II(d, a, b, c, x[7], 10, 0x432aff97); /* 50 */ II(c, d, a, b, x[14], 15, 0xab9423a7); /* 51 */ II(b, c, d, a, x[5], 21, 0xfc93a039); /* 52 */ II(a, b, c, d, x[12], 6, 0x655b59c3); /* 53 */ II(d, a, b, c, x[3], 10, 0x8f0ccc92); /* 54 */ II(c, d, a, b, x[10], 15, 0xffeff47d); /* 55 */ II(b, c, d, a, x[1], 21, 0x85845dd1); /* 56 */ II(a, b, c, d, x[8], 6, 0x6fa87e4f); /* 57 */ II(d, a, b, c, x[15], 10, 0xfe2ce6e0); /* 58 */ II(c, d, a, b, x[6], 15, 0xa3014314); /* 59 */ II(b, c, d, a, x[13], 21, 0x4e0811a1); /* 60 */ II(a, b, c, d, x[4], 6, 0xf7537e82); /* 61 */ II(d, a, b, c, x[11], 10, 0xbd3af235); /* 62 */ II(c, d, a, b, x[2], 15, 0x2ad7d2bb); /* 63 */ II(b, c, d, a, x[9], 21, 0xeb86d391); /* 64 */ state[0] += a; state[1] += b; state[2] += c; state[3] += d; } int main(){ // void MD5Init(MD5_CTX *context); MD5_CTX context; MD5Init(&context); // void MD5Update(MD5_CTX *context,unsigned char *input,unsigned int inputlen); unsigned char* plainText = (unsigned char *) "xiaojianbang"; MD5Update(&context, plainText, strlen(reinterpret_cast<const char *>(plainText))); // void MD5Final(MD5_CTX *context,unsigned char digest[16]); unsigned char result[16]; MD5Final(&context, result); char temp[2] = {0}; char finalResult[33] = {0}; for(int i = 0; i < 16; i++){ int index = i; sprintf(temp, "%.2x", result[index]); strcat(finalResult, temp); } cout << finalResult << endl; return 0; } md5.h #ifndef HOOKDEMO_MD5_H #define HOOKDEMO_MD5_H typedef struct { unsigned int count[2]; unsigned int state[4]; unsigned char buffer[64]; } MD5_CTX; #define F(x,y,z) ((x & y) | (~x & z)) #define G(x,y,z) ((x & z) | (y & ~z)) #define H(x,y,z) (x^y^z) #define I(x,y,z) (y ^ (x | ~z)) #define ROTATE_LEFT(x,n) ((x << n) | (x >> (32-n))) #define FF(a,b,c,d,x,s,ac) \ { \ a += F(b,c,d) + x + ac; \ a = ROTATE_LEFT(a,s); \ a += b; \ } #define GG(a,b,c,d,x,s,ac) \ { \ a += G(b,c,d) + x + ac; \ a = ROTATE_LEFT(a,s); \ a += b; \ } #define HH(a,b,c,d,x,s,ac) \ { \ a += H(b,c,d) + x + ac; \ a = ROTATE_LEFT(a,s); \ a += b; \ } #define II(a,b,c,d,x,s,ac) \ { \ a += I(b,c,d) + x + ac; \ a = ROTATE_LEFT(a,s); \ a += b; \ } void MD5Init(MD5_CTX *context); void MD5Update(MD5_CTX *context,unsigned char *input,unsigned int inputlen); void MD5Final(MD5_CTX *context,unsigned char digest[16]); void MD5Transform(unsigned int state[4],unsigned char block[64]); void MD5Encode(unsigned char *output,unsigned int *input,unsigned int len); void MD5Decode(unsigned int *output,unsigned char *input,unsigned int len); #endif //HOOKDEMO_MD5_H2. IDA中的md5 2.1 md5init 进入md5init函数,可以看到在上面提到的初始化常量; image-20240828211349276" /> 可以发现和我们上面说的是不太一样的,这里是因为默认显示的是十进制,选中数字按下h即可转成16进制,或者右键选择Hexadecimal转换;