安卓逆向-五菱白盒AES
标签搜索

安卓逆向-五菱白盒AES

sana
2025-01-07 / 0 评论 / 16 阅读 / 正在检测是否收录...
温馨提示:
本文最后更新于2025年03月31日,已超过64天没有更新,若内容或图片失效,请留言反馈。

一、五菱白盒AES

1. 前置

[!sana]

版本:V8.0.14

包名:com.cloudy.linglingbang

加固:梆梆加固

so文件:libencrypt.so

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==
  • 触发了,看看与抓包结果是否一致,对比发现大致是一样的,但是将+给换成了空格,这个记住就好;
  • 那么位置没错就继续深入看看;

image-20250105211031395

  • 定位到具体位置了,很显然是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就行;

image-20250107150218253

  • 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. 总结

  • 由于我时间不多,写这篇文章也是断断续续的,所以中间的分析过程我自己也不知道有没有讲清楚,如果有不清楚的可以自己多多研究,我的水平也比较有限;
0

评论 (0)

取消