本文是跟我学习爬虫的小伙伴:彭良怀的投稿,稿费是500。本文写得非常好,完全可以当着APP逆向抓取的教程来学。从逆向思路的分析,逆向工具的搭配使用,到逆向知识结构的掌握,显示出了他扎实的爬虫逆向基础功底。
PS:他在北京,有看上的老板可以私信我,为人也不错。
一、前言
-
一部 root 后的安卓手机,模拟器也可以 -
抓包工具:Charles -
查壳工具:APK Messenger -
APK反编译工具:jadx-gui 1.1 -
SO文件分析工具:IDA_Pro_v7.0 -
Hook 框架:frida
二、抓包分析
-
signKey:密文,长度 32 位,可能为 MD5、HmacMD5 加密或随机 UUID -
signKeyV1:密文,长度 64 位,可能为 SHA256、HmacSHA256 加密 -
t :13 位时间戳 -
traceId:等于 deviceId (固定的设备ID)加两个13位时间戳 -
currentPage:页码 -
lastStoreId:上一页最后一家店铺 ID
三、Java层分析
1. 查壳
2. 分析关键 Java 代码
-
t 为当前时间戳; -
subVersion 为当前APP版本号; -
signKey 是由 k 方法生成的; -
signKeyV1 等于 KEY_NEW_SIGN, KEY_NEW_SIGN 又是由 k2 方法生成的; -
传入方法 k2 的参数为 formatQueryParaMap 方法的返回值; -
方法 k 和 k2 都在 native 层,加载的是 libjdpdj.so 文件;
3. 分析 formatQueryParaMap 方法
def formatQueryParaMap(param: dict) -> str:
return '&'.join(param[k] for k in sorted(param.keys()) if k != 'functionId')
4. Hook formatQueryParaMap 方法
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;
};
})
5. 关于反调试
四、Native层分析
1. 静态注册和动态注册
-
静态注册: 静态注册是通过固定格式方法名进行关联,命名规则如下: 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. 找到 k、k2 对应的 native 函数
3. JNI 静态调试的一些技巧
(1) 批量还原 JNI 函数名
-
按 Ctrl + F9 ,选择 jni.h 头文件导入 -
导入成功后,鼠标左键点击其中一个 JNI 函数的参数,然后右键选择 Convert to Struct * -
在弹出的 Select a structure 窗口中 选择 _JNIEnv,点击OK
(2) 强制调出函数参数
(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命名的函数:如 sub_567C(),IDA 会对没有名字的函数自动命名,命名规则就是 sub_ + 函数地址,这类函数也是重点。 从追求效率的角度来说,最好先找关键函数,看看有没有常见的加密函数名,找到后直接用frida hook,一些简单的往往能够一击中的,快速搞定。从学技术的角度来说,可以多尝试一行一行代码地分析,锻炼看代码的能力。当然复杂点的还不得不分析 arm 指令,要是被混淆后就更加难了,难的我也不会,以后多练多学吧。
4. 静态分析 gk 函数
var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');
-
方式一: -
// 获取JNI_OnLoad的地址:
var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');
// 基地址 = JNI_OnLoad地址 - JNI_OnLoad偏移:
var base_addr = parseInt(onload_addr ) - parseInt('0x34D6C');
// MD5Update地址 = 基地址 + MD5Update偏移:
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 呢?我引用赵四的原话解释吧: 因为thumb和arm指令的区分,地址最后一位的奇偶性来进行标志 获取未导出函数地址的方式也完全适用于导出函数,所以不管导出还是未导出,我都用方式三获取,代码简单优雅。
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])); // Memory.readCString()就是读取地址为字符串
console.log('参数3:', parseInt(args[2]));
console.log('----------------');
},
onLeave: function(retval) {
}
})
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
5. 静态分析 gk2 函数
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){
}
});
-
先调用 java 层的 getsign 方法获取基础 key, -
对基础 key 每个字符的 ASCII 码进行修改,同时拼接到输入参数的尾部, -
取出入参尾部的 32 位作为密钥, -
最后对输入参数进行 hmac_sha256 加密,通过指针返回加密结果。
说点什么吧...