安卓逆向-南银消金算法分析

下雨天
11月7日发布 /正在检测是否收录...

南银消金算法分析

1. 基础信息

版本:7.4.4

包名:cn.com.njxmxbank.mbank

加固:梆梆加固企业版

目标:服务 -> 惠聚生活+ -> 更多

接口:POST /xmxappmid/xmxcustexpand__queryHuiJuLife

  • 会有frida检测,我使用的是魔改过的可以直接过,读者可以自行寻找魔改版的frida,应该不严格,server就能直接过;
  • 关于壳,fart就可以脱完,在线的也可以脱,自行选择即可;
  • 由于众所周知的原因,请以 -f 的方式进行重启hook;

2. 抓包

  • 点击下图位置抓包,因为登录有验证码,懒得去划了,这里的算法是一样的;

image-20251104101717786

  • 直接抓包就可以,效果如下:

image-20251104101521010

  • 可以发现,一共是有两个位置需要分析的,请求体和响应体,都是密文,请求头没有什么东西;对两个位置的密文进行一定程度的分析,首先是请求体;
  • 经过对明文的观察,发现其是由三段组合而成的;

image-20251104101836996

  • 中间由 | 隔开,后续按照分段去分析;其次是响应体,很长,但是没有什么特征,需要注意其中包含了很多 \n 换行,可以大致猜测这两个都是有base64编码的参与的,接下来开始算法分析;

3. 响应体分析

  • 之所以先看这个是因为它是java层的,肯定是先把简单的说了,特征比较明显,搜一下passworddata看看有没有什么收获;这里提一嘴,如果返回值没有什么特征,就只能去hook一些系统库了,如getbytes方法等等;

image-20251104102148448

  • 一共就两处位置,这里去看哪一个不用多说了;

image-20251104102231131

  • 继续跟进就可以看到具体的算法;

image-20251104102417958

  • 至于key也是固定的,点进去就行,这里就直接解密测试了;需要注意,抓包的时候提过了,里面有大量的 \n ,上一张截图也可以看到是去掉了的,测试的时候自行去掉就行;

image-20251104102549979

  • 解密出来了,这个没什么可说的;

4. 请求体分析

  • 接下来分析请求体的算法,这里回顾抓包的时候提到的内容,密文说是分了三段的,后续注意一下;
  • 至于定位,同样是直接搜索即可,请读者不要拘泥在定位方式太过简单的思想上,都是需要慢慢积累的,样本很多,定位方式也各有不同,在分析的过程中逐渐积累即可;

image-20251104102851079

  • 先hook一下看看吧,入参出参整清楚先;
function hook_java() {
    Java.perform(function () {
        let PEJniLib = Java.use("com.csii.njaesencryption.PEJniLib");
        PEJniLib["getNativeValue"].implementation = function (str, str2) {
            console.log(`PEJniLib.getNativeValue is called: str=${str}, str2=${str2}`);
            let result = this["getNativeValue"](str, str2);
            console.log(`PEJniLib.getNativeValue result=${result}`);
            return result;
        };

    })
}

hook_java()
  • 前面说了,由于众所周知的原因,这里需要以spawn的方式启动,不想hook太多次怎么办?请将hook代码取消掉,目标接口触发前再解开;日志如下:
[Pixel 6::cn.com.njxmxbank.mbank ]-> PEJniLib.getNativeValue is called: str={"securityTime":1762243248605,"CHFTimeStamp":"1762243229325","CHFStr":"i6wb6ial3s57dw85","version":"157"}, str2=bcf7d443244521b377b1fb9dd40adfc8421be6e5c83823beb29cb0e9baccb68483bf60f2281ba6a0bd2df03354182d36419bdf8714b391f171ca83843a466802ddcd98004bcf2f8570ef5ac2ec87eb862392bff1d83c440601899704ffbd27711eef309e6963416a3dfb84d693dcb98320fce8e9baf1818a68e62c2d8dbc038f
PEJniLib.getNativeValue result=XL8fSFeF8YsjOhZP7rdHA+RssAH2FF7Ek+6BiVQhfc5ZzGIsBErwH2CZC9xk0nycsXuMu8UWICvDYSnm62svWrWqUazJh8IyodwKn42KSnAWfuyaMC3ohfdXfJ+eP5BGy0b4cg26FaIHilsfes5uTy1mr7kLzX2uoXrumznIGXg=|fb5b1d2V4XMtpsrFduCNnDu21TvIW7mT+WeOxizwzYYQycEgUOLoB38hCWUPtJXZZ44pegqraCaLAF1ZMI+1tmgmxCJa3hhK63e7QsAMiISnfcW83WBKp2X8RtPaAUZjdWznZaafIwPqMSyZS/YxDA==|dZnWjn0DUuNUUwGO9JRGyw==
  • 接下来可以写一个主动调用,方便后续算法还原;
function call_java() {
    Java.perform(function () {
        let PEJniLib = Java.use("com.csii.njaesencryption.PEJniLib");
        let instance_method = PEJniLib.$new();
        let str1 = "{\"securityTime\":1762243248605,\"CHFTimeStamp\":\"1762243229325\",\"CHFStr\":\"i6wb6ial3s57dw85\",\"version\":\"157\"}"
        let str2 = "bcf7d443244521b377b1fb9dd40adfc8421be6e5c83823beb29cb0e9baccb68483bf60f2281ba6a0bd2df03354182d36419bdf8714b391f171ca83843a466802ddcd98004bcf2f8570ef5ac2ec87eb862392bff1d83c440601899704ffbd27711eef309e6963416a3dfb84d693dcb98320fce8e9baf1818a68e62c2d8dbc038f"
        let result = instance_method.getNativeValue(str1, str2);
        console.log(`call_java result=${result}`);
    })
}
  • 去看看so的情况,名称为:csii_AESTelecomModule_v1_0;

image-20251104161504057

  • 第一反应,这个so很不正常,先来看一个正常的so;

image-20251104161538559

  • 它们有本质的区别,一般来说代码段ida都会是蓝色的,目标的so并不是,所以它应该是加固了;使用yang神的项目一把梭了;
python dump_so.py libcsii_AESTelecomModule_v1_0.so
  • 得到这样的结果,直接是被修复后的so,免去修复这一步了,非常好用,可以去给一个star;

image-20251104161828166

  • 现在打开就正常了许多,下一步寻找native方法在哪,去搜索java发现不是静态注册,那就去hook registernatives,这里还是用yang神的项目,最近应该是集成了另一种方法,两周前添加的,所以是能hook出来的;如果是以前的脚本是没办法找到位置的,但是这里直接去看jni_onload更简单,随性就行;

image-20251104164236611

  • 这里我们按照以往的脚本来hook是没有结果的,因为走的更加底层,用更新后的脚本来hook;
[Pixel 6::cn.com.njxmxbank.mbank ]-> lookup_RegisterNative_method("com.csii.njaesencryption.PEJniLib")
fnPtr='0x7959907770 libcsii_AESTelecomModule_v1_0.so!_Z10aesDecryptP7_JNIEnvP8_jobjectP8_jstring' method='private native java.lang.String com.csii.njaesencryption.PEJniLib.aesNativeDecrypt(java.lang.String)'
so_base 0x79598c1000
fnPtr='0x79599076f8 libcsii_AESTelecomModule_v1_0.so!_Z9getValuesP7_JNIEnvP8_jobjectP8_jstringS4_' method='private native java.lang.String com.csii.njaesencryption.PEJniLib.getNativeValue(java.lang.String,java.lang.String)'
so_base 0x79598c1000
fnPtr='0x79599077b4 libcsii_AESTelecomModule_v1_0.so!_Z23setAesRanKeyWithPaddingP7_JNIEnvP7_jclassihh' method='private static native void com.csii.njaesencryption.PEJniLib.setNativeAesKeyMode(int,boolean,boolean)'
so_base 0x79598c1000
  • 可以看到有对应的结果,但是没有打印函数的偏移,所以添加了打印so基址的代码,我们手动减一下就好了;
aesNativeDecrypt  0x46770
getNativeValue  0x466f8
setNativeAesKeyMode  0x467B4
  • 去ida查看一下 目标函数;

image-20251104164756621

  • 应该没错,对应的逻辑就是getvalue方法,后续原本的计划是用unidbg模拟执行后辅助算法还原,但是这个so跑不起来;所以还是使用frida来hook;
  • 进入之后发现反编译是不太正确的,我们传入的明文和参数2都没怎么被用到;

image-20251105112326864

  • 读者可以使用其他版本的ida查看,我只有9.0和9.2,这两个没差别;那主要就需要看汇编了,首先我们看这个f5后的函数能得到这样一些信息:

    • RSA public key;
    • AES_cbc_encrypt、AES_set_encrypt_key;
  • 再看汇编能得到些什么:

    • AES_cbc_encrypt、AES_set_encrypt_key;
    • get_MD5_Hash;
    • "%s|%s|%s";
    • "10001"、RSA_public_encrypt;
  • 应该说几乎告诉了我们答案,虽然反编译并不全,但我们能汇总出这样的信息:首先,结果的确是我们前期猜测的那样,结果就是三段,而且理应包含AES、RSA、MD5、BASE64这几种算法或编码;
  • 基础信息了解了开始分段还原算法,那么这三段分别是什么算法?找到"%s|%s|%s"的位置反编译看看;

image-20251105113746768

  • 好家伙,一派胡言乱语,到这里我本身想去trace看汇编了,突然想起来前段时间同样遇到过一个ida反编译不理想但是binary ninja却非常出色的样本,于是我使用bn来反编译,结果果然非常理想;

image-20251105113947704

  • 针对bn的反编译可以发现,三段密文分别是:rsa、aes、md5;先看简单的,也就是最后一段,后续再出现密文就是part1、part2、part3来代指密文段,去看反编译结果;

image-20251105143059630

  • 这里有一个需要注意的点,bn的偏移和ida的有些许差别,做一个小对比;
bn:00446aa0
ida:46AA0
  • 还是很明显的,bn的起始地址是400000,这个知道就可以了,先去hook这个函数:
function hook_46AA0() {
    let so_addr = Module.getBaseAddress("libcsii_AESTelecomModule_v1_0.so");
    let funcPtr = so_addr.add(0x46AA0);
    Interceptor.attach(funcPtr, {
        onEnter: function (args) {
            console.log("[+]hook_46AA0 参数0 明文--->>>\r\n" + args[0].readCString());
        },
        onLeave: function (retval) {
            console.log("[!]hook_46AA0 返回值--->>>\r\n" + retval.readCString());
        }
    })
}
  • hook挂上之后主动调用一下,这也就是为什么之前要写主动调用的原因,不需要再去屏幕上点击会更加方便;
[Pixel 6::cn.com.njxmxbank.mbank ]-> call_java()
[+]hook_46AA0 参数0 明文--->>>  {"securityTime":1762243248605,"CHFTimeStamp":"1762243229325","CHFStr":"i6wb6ial3s57dw85","version":"157"}
[!]hook_46AA0 返回值--->>>
dZnWjn0DUuNUUwGO9JRGyw==
call_java result=Z8oi4928cuJGAd0AxuN2a7Sh72bvIVh0QojNHdOSJiI/BKD4HHsXdQ8h1796Dl26pBnBZUk3q7cWW2m/FNEXVvdZR2LbA449h3hNV74mhQlpfFpb5NiZk69gUTnl7Xa0TLlVwxfrPb04f1AtXSLr6THSR0Cf+t1+WjQd5PnzihY=|XYjRwjH1mxD3vjq1wXfsAlMuAen3Nf5TeFuMM0qEX8leeJTWaiVFqQSV74/JfI57kB5cfYOgZ8TOWlA2ZDG1SUr3kuFvZ+freQw9UkN7KTZtKLRPbuoJeZXqMk6sD3H8vfzdH0XX1NnBqCw1rnhQ2Q==|dZnWjn0DUuNUUwGO9JRGyw==
  • md5的输入就是整个函数的输入,也就是明文,返回值也和part3对得上,但这很明显不是一个哈希的结果,却很像base64,大胆验证一下;

image-20251105144816207

  • part3明确了,再去看part2,结合前面的猜想,大概是与aes有关,所以需要知道以下信息,这对于任意aes都是适用的分析思路:

    • 是否是aes;
    • 加密模式;
    • key、iv;
    • 填充;
  • 伪代码会告诉我们一些信息;

image-20251105145122690

  • 也就是说,这个算法是cbc模式的,那么就是有key也有iv,点进去分析一下;

image-20251105145218801

  • 拿着CRYPTO_cbc128_encrypt去google一下,你会发现与openssl有关,这种是最简单的;找key、iv、明文就行;
  • 去找这种函数的定义就行,然后根据对应的参数来hook;

image-20251105145808650

  • set_encrypt_key参数0就是key,在so里是一块地址,实际上肯定会被赋值,这个后续会说;
  • 明文则是AES_cbc_encrypt的参数0,iv是参数4,返回值是参数1,根据这个逻辑去hook就行;
function hook_aes() {
    let so_addr = Module.getBaseAddress("libcsii_AESTelecomModule_v1_0.so");
    let funcPtr = so_addr.add(0x4ACF0); // set_encrypt_key
    Interceptor.attach(funcPtr, {
        onEnter: function (args) {
            console.log("[+]aes key--->>>\r\n" + hexdump(args[0]));
        },
        onLeave: function (retval) {
        }
    })
    let key_addr = so_addr.add(0x4C1A4); // AES_cbc_encrypt
    Interceptor.attach(key_addr, {
        onEnter: function (args) {
            console.log("[+]aes 明文--->>>\r\n" + hexdump(args[0]));
            this.res = args[1]
            this.len = args[2]
            console.log("[+]aes iv--->>>\r\n" + hexdump(args[4]));
        },
        onLeave: function (retval) {
            console.log("[!]aes 返回值--->>>\r\n" + hexdump(this.res,{length:this.len.toInt32()}));
        }
    })
}
  • 拿到所有目标数据;
[Pixel 6::cn.com.njxmxbank.mbank ]-> call_java()
[+]aes key--->>>
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
7c059b7a50  6a 64 5a 64 4f 65 33 36 78 54 4f 65 79 33 54 30  jdZdOe36xTOey3T0
7c059b7a60  48 79 7a 63 74 57 77 62 72 4d 4d 4e 56 6b 64 34  HyzctWwbrMMNVkd4
7c059b7a70  00 00 00 00 00 00 00 00 10 81 e6 32 7d 00 00 b4  ...........2}...
···省略部分
7c059b7b40  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
[+]aes 明文--->>>
                   0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
b400007d12f07890  7b 22 73 65 63 75 72 69 74 79 54 69 6d 65 22 3a  {"securityTime":
b400007d12f078a0  31 37 36 32 32 34 33 32 34 38 36 30 35 2c 22 43  1762243248605,"C
b400007d12f078b0  48 46 54 69 6d 65 53 74 61 6d 70 22 3a 22 31 37  HFTimeStamp":"17
b400007d12f078c0  36 32 32 34 33 32 32 39 33 32 35 22 2c 22 43 48  62243229325","CH
b400007d12f078d0  46 53 74 72 22 3a 22 69 36 77 62 36 69 61 6c 33  FStr":"i6wb6ial3
b400007d12f078e0  73 35 37 64 77 38 35 22 2c 22 76 65 72 73 69 6f  s57dw85","versio
b400007d12f078f0  6e 22 3a 22 31 35 37 22 7d 07 07 07 07 07 07 07  n":"157"}.......
b400007d12f07900  00 65 e6 62 7d 00 00 b4 7f 6e 36 9f 7d 00 00 b4  .e.b}....n6.}...
···省略部分
b400007d12f07980  b0 38 f3 b2 7c 00 00 b4 b4 38 f3 b2 7c 00 00 b4  .8..|....8..|...
[+]aes iv--->>>
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
7c88231ab0  6a 64 5a 64 4f 65 33 36 78 54 4f 65 79 33 54 30  jdZdOe36xTOey3T0
7c88231ac0  00 b7 f0 12 7d 00 00 b4 84 4f 3f cf 79 14 08 95  ....}....O?.y...
7c88231ad0  30 e4 f1 12 7e 00 00 b4 00 00 00 00 00 00 00 00  0...~...........
···省略部分
7c88231ba0  b8 7e bf 7e 7c 00 00 00 30 e4 f1 12 7e 00 00 b4  .~.~|...0...~...
[!]aes 返回值--->>>
                   0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
b400007d12f1c470  5d 88 d1 c2 31 f5 9b 10 f7 be 3a b5 c1 77 ec 02  ]...1.....:..w..
b400007d12f1c480  53 2e 01 e9 f7 35 fe 53 78 5b 8c 33 4a 84 5f c9  S....5.Sx[.3J._.
b400007d12f1c490  5e 78 94 d6 6a 25 45 a9 04 95 ef 8f c9 7c 8e 7b  ^x..j%E......|.{
b400007d12f1c4a0  90 1e 5c 7d 83 a0 67 c4 ce 5a 50 36 64 31 b5 49  ..\}..g..ZP6d1.I
b400007d12f1c4b0  4a f7 92 e1 6f 67 e7 eb 79 0c 3d 52 43 7b 29 36  J...og..y.=RC{)6
b400007d12f1c4c0  6d 28 b4 4f 6e ea 09 79 95 ea 32 4e ac 0f 71 fc  m(.On..y..2N..q.
b400007d12f1c4d0  bd fc dd 1f 45 d7 d4 d9 c1 a8 2c 35 ae 78 50 d9  ....E.....,5.xP.
call_java result=Z8oi4928cuJGAd0AxuN2a7Sh72bvIVh0QojNHdOSJiI/BKD4HHsXdQ8h1796Dl26pBnBZUk3q7cWW2m/FNEXVvdZR2LbA449h3hNV74mhQlpfFpb5NiZk69gUTnl7Xa0TLlVwxfrPb04f1AtXSLr6THSR0Cf+t1+WjQd5PnzihY=|XYjRwjH1mxD3vjq1wXfsAlMuAen3Nf5TeFuMM0qEX8leeJTWaiVFqQSV74/JfI57kB5cfYOgZ8TOWlA2ZDG1SUr3kuFvZ+freQw9UkN7KTZtKLRPbuoJeZXqMk6sD3H8vfzdH0XX1NnBqCw1rnhQ2Q==|dZnWjn0DUuNUUwGO9JRGyw==
  • 汇总一下;

    • key:jdZdOe36xTOey3T0HyzctWwbrMMNVkd4
    • iv:jdZdOe36xTOey3T0
    • 明文:略
  • 可以发现,iv其实是key的一半,这其实也符合常理,至于为什么后续就明白了;
  • 还剩密文,把那一堆dexdump的结果去做base64;

image-20251105151110604

  • 结果也是对得上的,明文也是md5的明文,这里就可以引出一个常见的观点了;剩下的part1必定是aes的key做了rsa再上传,这是一个惯用的套路,那这个key绝对不是固定的,毕竟固定的就没必要上传了;
  • 那么我们去寻找一下key是随机的证据,还记得set_key的参数1吗?当时我提到了再so里它是一块地址,按下x查看谁赋值了;

image-20251105151616615

  • 符号告诉了你,是随机的,这个函数进去ida依旧反编译得一般,去看bn的表现;

image-20251105152049995

  • 算得上是很清晰的伪代码,这个函数实际上就是jadx里可以看见的三个函数之一:setNativeAesKeyMode;
  • 它会在应用启动的时候触发,传递的参数是2、false、true;带入进去就可以发现,它的目的就是生成一个32位的key,并且设置到那个变量里去,到这里,key是随机的我们搞清楚了;
  • 最后所有的疑问就还剩下part1,我们带着答案去找理由;去hook其中的RSA_public_encrypt方法发现,根本不触发;这里我花了很多时间验证,写文章只是几分钟,验证的时候花了大量的时间(还得练);
  • 后来去看伪代码就发现了原因;

image-20251105152804358

  • data_532b00这块数据会保存应用启动后的结果,后续根据一个flag来确定走哪个分支,第一个分支则会重新生成,第二个则直接取;所以我们需要去hook第一次打开时候的值,同时为了验证参数是key,把aes的部分也打开;
function hook_dlopen(addr, soName) {
    Interceptor.attach(addr, {
        onEnter: function (args) {
            var soPath = args[0].readCString();
            if (soPath.indexOf(soName) !== -1) this.hook = true;
        }, onLeave: function (retval) {
            if (this.hook) {
                hook_rsa()
            }
        }
    });
}
var android_dlopen_ext = Module.findExportByName("libdl.so", "android_dlopen_ext");
hook_dlopen(android_dlopen_ext, "libcsii_AESTelecomModule_v1_0.so");

function hook_rsa() {
    let so_addr = Module.getBaseAddress("libcsii_AESTelecomModule_v1_0.so");
    let funcPtr = so_addr.add(0x57844);
    Interceptor.attach(funcPtr, {
        onEnter: function (args) {
            console.log("[+]rsa 明文--->>>\r\n" + hexdump(args[1]));
            this.res = args[3]
        },
        onLeave: function (retval) {
        }
    });
}
  • 这里直接hook时机不太合适,dlopen是可以hook到的;后续自己手动call一下,报错的话先把hook去掉;可以看到rsa传递的就是key,加密结果这里不做追究,本身rsa出来结果就不会一致,我也没遇到魔改rsa的;当然我对这个算法也不熟悉;
[Pixel 6::cn.com.njxmxbank.mbank ]-> [+]rsa 明文--->>>
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
7c041a1a50  30 6d 42 58 77 33 78 36 5a 74 4d 54 35 78 49 65  0mBXw3x6ZtMT5xIe
7c041a1a60  47 39 32 43 7a 46 41 48 42 45 43 73 48 77 69 35  G92CzFAHBECsHwi5
7c041a1a70  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
···省略
7c041a1b30  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
7c041a1b40  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
[Pixel 6::cn.com.njxmxbank.mbank ]->
[Pixel 6::cn.com.njxmxbank.mbank ]-> call_java()
[+]aes key--->>>
             0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  0123456789ABCDEF
7c041a1a50  30 6d 42 58 77 33 78 36 5a 74 4d 54 35 78 49 65  0mBXw3x6ZtMT5xIe
7c041a1a60  47 39 32 43 7a 46 41 48 42 45 43 73 48 77 69 35  G92CzFAHBECsHwi5
7c041a1a70  00 00 00 00 00 00 00 00 90 8e e6 32 7d 00 00 b4  ...........2}...
···省略
7c041a1b40  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
  • 总结一下:

    • part1:rsa(key)
    • part2:aes(data)
    • part3:md5(data)
  • rsa还需要一个公钥,那自然就是沉寂已久的主动调用时传递的参数2,它是module,这个可以自行去了解;

image-20251105154253281

  • 10001是公钥指数,前面见过,模数就是传递的参数2,它在jadx是不能直接看到的,不过公钥一般不会变的,当做固定就好;
  • 到这里就可以知道为什么iv是key的一半了,整个请求并没有传递过去,那它自然就不能随机;也是一个套路,当你发现key或者iv在随机,那就需要注意它的传递方式;

5. 总结

  • 整体的算法非常简单,之所以写这篇文章是希望读者可以对其他工具产生一些认识,打好组合拳往往更加事半功倍;
  • 致谢:此样本来源于我的好兄弟的付费课程案例(B站id:带带弟弟学爬虫),课程性价比非常高,有兴趣可以私聊他或者我;
  • By:下雨天 2025.11.5
© 版权声明
THE END
喜欢就支持一下吧
点赞 0 分享 收藏
评论 抢沙发
OωO
取消
SSL