爬虫之-某生鲜APP加密参数逆向分析

本文由 简悦SimpRead 转码, 原文地址 www.yuanrenxue.com

本文是跟我学习爬虫的小伙伴:彭良怀的投稿,稿费是500

PS:他在北京,有看上的老板可以私信我,为人也不错。

学了一段时间APP逆向,刚刚入门,我以某生鲜APP为例,记录一下逆向过程和一些知识点。为了不影响对方的利益,我文中特意隐去了该APP的名字信息,本文仅供学习交流,请勿用作其他用途。

使用到的工具如下:

  • 一部root后的安卓手机,模拟器也可以
  • 抓包工具:Charles
  • 查壳工具:APK Messenger
  • APK反编译工具:jadx-gui 1.1
  • SO文件分析工具:IDA_Pro_v7.0
  • Hook框架:frida

二、抓包分析

首先手机配置好代理,打开APP,用Charles抓一下包,还好直接就抓到了,如下图所示:

可以看到很多的请求参数,翻页再抓包一次,把两次抓到的参数进行比对,看看哪些参数固定,哪些是变化的。这么多参数,要是自己用肉眼看,那就太费劲了,而且还容易看漏,所以直接用在线文本对比工具吧,我用的是这个网站:https://qqe2.com/word/diff,把两次抓包的参数复制上去,如下图所示:

不同的参数都高亮出来了,一目了然。我简单分析如下:

  • signKey:密文,长度32位,可能为MD5HmacMD5加密或随机UUID
  • signKeyV1:密文,长度64位,可能为SHA256HmacSHA256加密
  • t:13位时间戳
  • traceId:等于deviceId (固定的设备ID)加两个13位时间戳
  • currentPage:页码
  • lastStoreId:上一页最后一家店铺ID

其他固定参数很好理解,我就不阐述了。通过模拟请求验证,修改任意参数的值都无法获取数据,所以推测signKeysignKeyV1是由其他请求参数加密生成的。那接下来就去看看java代码吧。

三、Java层分析

1.查壳

在反编译apk之前,首先查下壳,因为加壳(加固)后的apk直接反编译是看不到有用信息的。查壳工具很多,这里我使用的是APK Messenger,打开后,直接将apk包拖入界面,即可看到有没有加壳,如下图所示:

结果疑似无壳,接下来就可以使用jadx反编译apk了。需要注意的是,查壳功能的实现往往只是遍历APK内文件和目录,以加固厂商(腾讯、360、阿里、百度、梆梆等)的常用文件名作为判断特征,比如百度的加固一般在lib目录下有一个libbaiduprotest.so文件,但有可能人家使用了新的名字,所以查壳有一定的误判率。你还可以在Apk Messenger中查看、增加或编辑加固的判别特征。

2.分析关键Java代码

jadx打开apk ,反编译为java代码,然后按Ctrl + Shift + F全局搜索signKeyV1,直接可以定位到如下代码:

这段代码很好理解,明显是在组装参数,可以从中得出以下信息:

  • t为当前时间戳;
  • subVersion为当前APP版本号;
  • signKey是由k方法生成的;
  • signKeyV1等于KEY_NEW_SIGNKEY_NEW_SIGN又是由k2方法生成的;
  • 传入方法k2的参数为formatQueryParaMap方法的返回值;
  • 方法kk2都在native层,加载的是libjdpdj.so文件;

3.分析formatQueryParaMap方法

kk2都在native层,我们还是先看看formatQueryParaMap方法吧,按住Ctrl键同时鼠标左键点击formatQueryParaMap即可跳转到该方法,如下图所示:

这段代码也好理解,传入该方法的第一个参数为Map类型,类似Python中的字典,它先根据key进行排序,然后再把value&字符进行拼接(functionId的值除外 ,用Python代码实现如下:

def formatQueryParaMap(param: dict) -> str:
return '&'.join(param[k] for k in sorted(param.keys()) if k != 'functionId')

4. Hook formatQueryParaMap方法

如果看不懂或不想分析formatQueryParaMap()也没关系,我们直接用frida hook一下这个方法,看看它的输入和输出,也能反向推测出这个方法是做什么的,hook代码如下:

Java.perform(function () {
var util = Java.use('jd.net.ASCIISortUtil');
util.formatQueryParaMap.implementation = function (arg1, arg2) {
console.log('param1: ', arg1);
console.log('param2: ', arg2);
var result = this.formatQueryParaMap(arg1, arg2);
console.log('return: ', result);
return result;
};
})

打印结果如下:

很明显,param1就是最开始抓包到的那些请求参数,那么我们就知道了方法k2的输入参数要怎么构造了,接下来分析方法kk2是怎么加密的,就不得不分析.so文件了。

5.关于反调试

这里提一下,该APP有反调试,开启frida-server后 ,启动APP就立即闪退,可别急着去破解它的反调试,即找到反调试的地方干掉后重新打包签名,可这样做就很麻烦了,不知道得掉多少头发。还好,先启动APP等进入主界面后再启动frida-server,就能正常进行hook了,虽然偶尔还是会被强制闪退,但频率不高,影响不大。

四、Native层分析

通过Java层的分析知道 ,signKeysignKeyV1分别是方法kk2生成的,而这两个方法又是定义在native层的,那么就得先找到kk2native层中对应的函数,然后再分析具体的加密过程。为了便于理解,我先讲知识点,再讲操作。

1.静态注册和动态注册

因为java层和native层的代码往往相互调用,使用的是一种叫JNI (Java Native Interface)的技术,在java层中调用native函数之前,要对javanative关键字定义的方法进行注册,注册方式有两种:静态注册和动态注册。下面简单介绍一下:

  • 静态注册:

    静态注册是通过固定格式方法名进行关联,命名规则如下:

    native函数名= Java +包名+类名+方法名

    例如,包名: com.example.test,类名:jd.net.z,方法名:k

    如果是静态注册的话,那么native中的函数名就该为:Java_com_example_test_jd_net_z_k

  • 动态注册:

    动态注册是通过RegisterNative()这个JNI函数动态添加映射关系来进行关联的,这种方式可以随便命名函数名,比较灵活。其申明示例如下:

  • jint RegisterNatives(JNIEnv *env, jclass clazz, const JNINativeMethod* methods, jint nMethods)
    

    1个参数是JNIEnv指针,所有JNI函数第一个参数都是它;

    2个参数clazz是注册方法对应Java层中的类,由FindClass函数获取;

    3个参数methods是一个数组,其中包含了注册方法结构体信息,我们可以从中找到注册前后的方法名,所以我们注意这个参数就行了;

    4个参数nMethods是动态注册方法的数量。

2.找到kk2对应的native函数

知道了native函数的两种注册方式,那就开始具体的操作吧。用IDA打开libjdpdj.so文件,切换到Exports窗口,我们先按照静态注册的命名规则搜索:Java,并没有搜到,那么便是动态注册了。

因为JNI_OnLoad()是加载so文件的初始函数,可以从中找到RegisterNative()。那么搜索JNI_OnLoad ,双击进入,按F5把汇编转成伪C代码,你会发现并没有找到RegisterNative,别急,这是因为IDA不能准确的识别函数声明或变量类型,反编译不完全正确造成的,但我们可手动将其还原。

凡是看到类似(_(_DWORD _)v2 + 860))(v2,)这种代码的其实都是JNI函数,我们选中参数v2后按Y键会弹出窗口,输入JNIEnv *,点击OK即可还原函数名,还原后如下所示:

根据前面的介绍,我们只需要看第3个参数即可,双击&off_117004跳转到如下汇编代码:

117004偏移量那一行开始,每3行为一个结构体,一共8个。我们看第一个,其第一行右边的注释 “k” 就是java层的方法名,第二行为JNI字段描述符,描述了该方法的参数类型和返回值类型,第三行就是我们要找的动态注册后的函数名,可以看到为:gk;同样的”k2″ 对应的就是:gk2。

搜搜看,这就很容易找到了:

不过有些APP为了防止被静态分析,对注册函数做了混淆,通过这种方式并不能直接找到,这里我就不讨论了,遇到的童鞋可以参考赵四这篇博客:http://www.520monkey.com/archives/1289

3. JNI静态调试的一些技巧

在分析gk函数之前我先谈谈静态分析native函数的一些技巧和个人经验。

(1)批量还原JNI函数名

native函数中经常会用到很多的JNI函数,而IDA并不能很好的识别,每次我们都要一个个手动修改未免太麻烦了点,所以我介绍一个可以批量转换的方式:

  • Ctrl + F9 ,选择jni.h头文件导入
  • 导入成功后,鼠标左键点击其中一个JNI函数的参数,然后右键选择Convert to Struct *
  • 在弹出的Select a structure窗口中 选择_JNIEnv,点击OK

这样就可以把当前打开的native函数里面所有JNI函数名一次性还原了。注意jni.h头文件第一导入会报错,需要根据报错信息修改jni.h对应的代码。

(2)强制调出函数参数

有时会遇到IDA反编译出来的函数连参数都没有,如下面的GetArrayLength函数后面的参数为空:

这时需要鼠标左键点击该函数,然后鼠标右键选择Force call type ,就能强制把参数调出来。

(3)常用快捷键
  • shift + F12:查看so文件中所有常量字符串的值;

  • tab键:汇编和伪C代码之间相互切换;

  • /键:添加注释;

  • N键:变量重命名;

  • X键:查看某变量的所有引用;

  • =键:消除冗余的中间变量;

    由于IDA反编译出来总是会有很多冗余的中间变量,如:

    v2 = v1;
    result = encrypt(v2);

    选中v2,按键盘上的=键,再点击OK,即可消除中间变量v2

    result = encrypt(v1);

(4)静态调试思路
  • 根据函数入参,至上而下分析

  • 根据函数返回值,至下而上分析

  • 寻找关键的函数进行分析,一般可以把函数分为以下几种:

    ① 标准库函数:如strlen(),计算字符串的长度,见名知意;

    JNI函数:如FindClass(),调用Java中的类,JNI函数一般也是见名知意;

    ③ 用户自定义的函数:如MD5::MD5(),一看就知道是MD5加密,这类需特别注意;

    IDA命名的函数:如sub567C()IDA会对没有名字的函数自动命名,命名规则就是sub+函数地址,这类函数也是重点。

    从追求效率的角度来说,最好先找关键函数,看看有没有常见的加密函数名,找到后直接用frida hook,一些简单的往往能够一击中的,快速破解。从学技术的角度来说,可以多尝试一行一行代码地分析,锻炼看代码的能力。当然复杂点的还不得不分析arm指令,要是被混淆后就更加难了,难的我也不会,以后多练多学吧。

4.静态分析gk函数

接下来开始具体操作吧,双击gk函数后看到汇编arm指令,按F5键反汇编为伪C代码,并把JNI函数名还原。我这里就不一一分析每行代码了,直接先找关键函数,很容易就找到如下代码:

很明显是MD5加密,MD5Init()是一个初始化函数,MD5Update()才是MD5的主计算过程,所以直接hook MD5Update(),用frida hook native层函数得需要找到目标函数的绝对地址,而目标函数可能是导出函数,也可能是未导出函数,我先分别介绍一下怎么获取他们的地址吧:

获取导出函数的绝对地址:

// JNI_OnLoad肯定是导出函数,可直接根据名字获取

var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');

获取未导出函数的绝对地址,我列举以下3种方式:

  • 方式一:

  • var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');
    var base_addr = parseInt(onload_addr ) - parseInt('0x34D6C');
    var md5_update_addr = ptr(base_addr + parseInt('0x34E18'));
    
  • 方式二:

  • var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');
    var md5_update_addr = onload_addr.sub(0x34D6C).add(0x34E18);
    
  • 方式三:

  • var md5_update_addr = Module.findBaseAddress("libjdpdj.so").add(0x34E18 + 1);
    

    方式一看注释很好理解,方式二其实就是方式一的简化,用frida提供的的add()sub()函数进行地址的加减。方式三是进一步简化,但是用这种方式一定要记得对地址+1,为什么要+1呢?我引用赵四的原话解释吧:

    因为thumbarm指令的区分,地址最后一位的奇偶性来进行标志

    获取未导出函数地址的方式也完全适用于导出函数,所以不管导出还是未导出,我都用方式三获取,代码简单优雅。

那么我们hook MD5Update()的代码如下:

var pointer = Module.findBaseAddress("libjdpdj.so").add(0x34E18 + 1);
console.log('MD5Update pointer:', pointer);
Interceptor.attach(pointer, {
onEnter: function(args) {
console.log('参数1:', args[0]);
console.log('参数2:', Memory.readCString(args[1]));
console.log('参数3:', parseInt(args[2]));
console.log('----------------');
 },
onLeave: function(retval) {
 }
})

hook的时候我们同时对其抓包,以便验证,hook打印的结果如下:

MD5Update pointer: 0xaed5ae19
参数1: 0xbef0eb8c
参数2: {"city":"重庆市","latitude":29.57252,"longitude":106.53355,"address":"观音桥", "coordType":"2","channelId":"4037","appVersion":"7.4.0","platform":"2","currentPage":1, "pageSize":10,"areaCode":4,"ref":"home","ctp":"channel"}923047ae3f8d11d8b19aeb9f3d1bc002
参数3: 259

—————-

可以看到参数2为部分请求参数再上加尾部的盐值,这便是加密前的原文。我们把它拿去用MD5在线加密一下,其结果和抓包到的signKey进行对比,经验证完全相同,那么signKey被一击中的,具体的代码都不用去分析了。其实服务器并没有对该参数进行校验,我们直接生成一个随机的32位字符就行,我这里主要是讲一下方法。

5.静态分析gk2函数

然后再来看gk2函数,同样首先找有没有常见的加密,很快在最后几行看到如下代码:

很明显是hmac_sha256加密,看到它有6个参数,往上追溯可知,第1个参数s为加密前的字符串,第2个参数v23s的长度,这里v2332说明加密前需要去掉最后32个字符,第3个参数为密钥,第4个参数是密钥的长度,最后两个参数没有什么操作,不用管。那么我们就直接用frida hook hmac_sha256函数,打印一下参数看看,代码如下:

var pointer = Module.findBaseAddress("libjdpdj.so").add(0x361B8 + 1);
console.log("hmac_sha256 pointer: ", pointer);
Interceptor.attach(pointer, {
onEnter: function(args) {
console.log("参数1:", Memory.readUtf8String(args[0]));
console.log("参数2:", parseInt(args[1]));
console.log("参数3:", Memory.readCString(args[2]));
console.log("参数4:", parseInt(args[3]));
console.log('---------------');
},
onLeave:function(retval){
 }
});

hook的时候我们同时对其抓包,以便验证,hook打印的结果如下:

参数1去掉末尾的32位字符就是入参,参数3是密钥,于是把入参拿去用HmacSHA256加密一下,其结果再和抓包到signKeyV1进行对比,经验证完全相同,由此signKeyV1也被一击中的。

抱着学习的心态再去分析一下伪C代码,具体分析过程我就不介绍了,就说一下大致的逻辑:

  • 先调用java层的getsign方法获取基础key
  • 对基础key每个字符的ASCII码进行修改,同时拼接到输入参数的尾部,
  • 取出入参尾部的32位作为密钥,
  • 最后对输入参数进行hmac_sha256加密,通过指针返回加密结果。

逆向到这儿就结束了,后面用python实现不难,我就不贴代码了,关键过程讲清楚了就行。

6. native函数的参数

我再啰嗦一下,native函数要比java层对应方法多2个参数,它们前两个参数是固定的,第1个参数为JNIEnv指针;第2个为jobjectjclass;从第3个参数开始才是java层传递过来的。比如:gk()函数的申明如下:

其中a3才是javak()方法的参数。前两个参数之所以是int类型,前面也说过,是因为IDA经常不能正确识别参数类型,这里按Y键手动转换一下,或者直接忽略,没什么影响。

五、总结

本篇文章的案例APP也是大厂开发的,而我们对其java层和native层的加密函数分析都不难,没有复杂难懂的逻辑,也没有混淆,只有个鸡肋的反调试,直接静态分析加frida hook就搞定了。其实目前市面上大多数APP的加密参数都能通过这种方式搞定,当然很难的也不少,学习逆向是个无底洞,但我们做爬虫的不要怕逆向,我们只是逆向它的那个加密参数而已,先要有信心,多学习多实操多总结,一点点深入,会学有所成。共勉!

再次跨一下这篇文章,非常不错,继续接受投稿,稿费还不错300-500 /篇,快来投稿吧。

PS,给自己广告一下:我继续在教爬虫,真正的爬虫技术。教APP逆向抓取/ JS逆向抓取/大规模爬虫框架设计/利用爬虫技术做被动收入。

感兴趣的加我微信私聊,备注:爬虫。

最近打算建一个爬虫技术交流群,感兴趣的也可以加我。

上一页
下一页