安卓逆向实战:某 App 360脱壳、加密协议还原与请求伪造全记录(学习记录贴)
前言
“这是我第一篇关于 App 逆向的学习笔记,属于是一篇新手学习记录帖,文中可能存在术语使用不当或逻辑描述不够精炼的地方,还请各位前辈多多指正。
本篇主要记录了我对某 App 发包协议的分析过程,从抓包数据分析、加密逻辑定位到最后的 Python 代码复现。
一、抓包
打开小黄鸟(Reqable),启动app

输入11位手机号码,点击发送验证码,查看小黄鸟的抓包结果

发现数据包里有一个Encrypt参数,这就是我们本次复现的目标
二、脱壳
我这里选择用ApkCheckPack来查壳
结果为:
===================== 加固特征扫描结果 =====================
[加固特征]
Sopath 360加固 -> assets/libjiagu.so
Soname 360加固 -> assets/libjiagu.so
Soname 360加固 -> assets/libjiagu.so
Soname 360加固 -> assets/libjiagu_a64.so
Soname 360加固 -> assets/libjiagu_x64.so
Soname 360加固 -> assets/libjiagu_x86.so
Other 360加固 -> assets/.appkey
Soregex 360加固 -> assets/libjiagu_a64.so
Soregex 360加固 -> assets/libjiagu_x64.so
Soregex 360加固 -> assets/libjiagu_x86.so
Soname 阿里聚安全 -> lib/armeabi/libsgmain.so
Soname 阿里聚安全 -> lib/armeabi/libsgsecuritybody.so
Soname 阿里加固 -> lib/armeabi/libsgmain.so
Soname 阿里加固 -> lib/armeabi/libsgsecuritybody.so
===================== 安全检测特征扫描结果 =====================
===================== 第三方SDK特征扫描结果 =====================
[360]
360 加固 -> assets/libjiagu.so
360 加固 -> assets/libjiagu_a64.so
360 加固 -> assets/libjiagu_x64.so
360 加固 -> assets/libjiagu_x86.so
[Alibaba]
阿里聚安全 -> lib/armeabi/libsgmain.so
阿里聚安全 -> lib/armeabi/libsgnocaptcha.so
阿里聚安全 -> lib/armeabi/libsgsecuritybody.so
结果说明我们这个app真正的壳是360加固的,其它的壳只是第三方SDK中的壳
这里我们使用frida-dexdump脱壳,可以看到脱壳结果为一堆dex文件

把整个文件夹丢进Jadx,得到结果:

三、算法复现
我们要先找到生成Encrypt参数的地方,按惯例先来搜索大法

可以发现在com.dodnew.online.http类下有多个出现Encrypt的地方,这也是构造http请求的地方,点进去查看一下,发现一个关键函数addRequestMap

String strEncodeDesMap = RequestUtil.encodeDesMap(RequestUtil.paraMap(map, Config.BASE_APPEND, "sign"), this.desKey, this.desIV);
这个方法中有一个关键语句是strEncodeDesMap,应该是在拼接字符串,分析一下
·参数1:RequestUtil.paraMap(map, Config.BASE_APPEND, “sign”)
·参数2:this.desKey
·参数3:this.desIV
我们通过hook encodeDesMap搞清楚这三个参数都是什么,写出hook脚本:
Java.perform(function(){
var RU=Java.use("com.dodonew.online.http.RequestUtil");
RU.encodeDesMap.overload('java.lang.String', 'java.lang.String', 'java.lang.String').implementation = function(a,b,c){
console.log("RequestUtil.paraMap(map, Config.BASE_APPEND, sign) = " + a);
console.log("this.desKey = " + b);
console.log("this.desIV" + c);
var r = this.encodeDesMap(a,b,c);
return r;
}
})
得到结果为
RequestUtil.paraMap(map, Config.BASE_APPEND, “sign”) = {"phone":"11111111111","sign":"DE8E45BF23365EB43FCD2A21BFD8FA6C","timeStamp":"1766147735492"}
this.desKey = 65102933
this.desIV = 32028092
·通过hook结果,我们可以发现其中的deskey、desIV、BASE_APPEND都是硬编码
·RequestUtil.paraMap(map, Config.BASE_APPEND, “sign”) 的结果是由手机号,一个动态的sign,以及时间戳构成的
现在我们的目标比较明确了,就是找到sign的生成逻辑,先看看paraMap方法

hook一下看看参数,写出hook脚本
RU.paraMap.overload('java.util.Map', 'java.lang.String', 'java.lang.String').implementation = function(d,e,f){
var HashMap = Java.use("java.util.HashMap");
console.log("map = " + Java.cast(d, HashMap).toString());
console.log("str = " + e);
console.log("str2 = " + f);
var rr = this.paraMap(d,e,f);
return rr;
}
结果为:
map = {timeStamp=1766151217885, phone=11111111111}
str = sdlkjsdljf0j2fsjk
str2 = sign
可以发现手机号和时间戳已经被加入map里了,那么这里就是生成sign并放入map的地方,再次看paraMap函数,发现里面有一行:
map.put(str2, Utils.md5(sb.toString()).toUpperCase());
说明这里使用了md5的加密方式,点进去看发现是标准md5,总结一下流程
sign 生成全过程复现:
输入数据为:
· map: {“timeStamp”: “1766151217885”, “phone”: “11111111111”}
· str (Salt): “sdlkjsdljf0j2fsjk”
· str2 (Key Name): “sign”
第一步:格式化并放入 List
代码逻辑:遍历 Map,拼接成 key=value 格式放入 arrayList。
phone=11111111111
timeStamp=1766151217885
第二步:字典排序 (ASCII Sort)
代码逻辑:Collections.sort(arrayList); 按首字母排序:
phone=11111111111 (p 开头)
timeStamp=1766151217885 (t 开头) (这时候phone会与timeStamp交换位置)
第三步:拼接字符串 (最关键的一步)
for (int i = 0; i < arrayList.size(); i++) {
sb.append((String) arrayList.get(i)); // 放入键值对
sb.append(C1792a.f1657b); // 放入连接符 "&"
}
sb.append("key=" + str); // 放入盐值
拼接过程:
加入第1个:phone=11111111111 + &
加入第2个:timeStamp=1766151217885 + &
加入盐值:key=sdlkjsdljf0j2fsjk
最终待加密字符串 (sb.toString()):
phone=11111111111&timeStamp=1766151217885&key=sdlkjsdljf0j2fsjk
第四步:MD5 加密并转大写
代码逻辑:Utils.md5(sb.toString()).toUpperCase()
phone=11111111111&timeStamp=1766151217885&key=sdlkjsdljf0j2fsjk
MD5 (32位小写):2dbf5ffa76fef475d202d84827ee6a90
转大写:2DBF5FFA76FEF475D202D84827EE6A90
对比一下我们之前hook得到的sign的值,发现完全一致,我们成功得到了动态sign的计算方法
现在我们回到这个语句
RequestUtil.encodeDesMap(RequestUtil.paraMap(map, Config.BASE_APPEND, "sign"), this.desKey, this.desIV);
来看看它最外层的函数encodeDesMap是什么逻辑

发现里面调用了DesSecurity和encrypt64方法,分别点进去看,具体的功能为:
把我们得到的包含phone的json字符串转字节,进行标准DES加密,再进行base64编码
需要注意的点就是deskey我们看到的那个硬编码字符串,而是它MD5后的前8位
有了这些信息,我们可以得到最终的算法复现脚本
import time
import hashlib
from Crypto.Cipher import DES
from Crypto.Util.Padding import pad
import base64
import json
#初始化参数
phone = "11111111111"
timestamp = str(int(time.time() * 1000))
key = "sdlkjsdljf0j2fsjk"
deskey = "65102933"
desIV = "32028092"
def cal_sign(phone,timestamp,key):
str = "phone=" + phone + "&" + "timeStamp=" + timestamp + "&" + "key=" + key
print(str)
md5_object = hashlib.md5()
md5_object.update(str.encode())
sign = md5_object.hexdigest().upper()
return sign
sign = cal_sign(phone, timestamp, key)
payload_dict= {
"phone": phone,
"sign": sign,
# "sign":"",
"timeStamp": timestamp
# "timeStamp":"",
}
json_payload = json.dumps(payload_dict, separators=(',', ':'))
# 对deskey与desiv进行处理
key_hash = hashlib.md5(deskey.encode()).digest()
real_key = key_hash[:8]
real_iv = desIV.encode()[:8]
# 填充与加密
cipher = DES.new(real_key, DES.MODE_CBC, real_iv)
padded_data = pad(json_payload.encode(), DES.block_size)
#执行加密
encrypted_bytes = cipher.encrypt(padded_data)
# base64编码
final_result = base64.b64encode(encrypted_bytes).decode()
print(f"最终结果 (Encrypt): \n{final_result}")
来测试一下我们写的对不对,打开抓包软件,hook paraMap方法打印三个参数,然后把这三个参数放进脚本里进行加密计算,看看与抓包得到的密文是否一致

计算一下

与抓包结果对比

完全一致!我们已经得到了这个密文的生成方式了
五、协议复现
我们可以用python的request库来伪造请求,用time库来获取时间戳,脚本运行结果如下图

发现返回的也是加密后的数据,写一个解密脚本:先base64解码再DES解密再去填充即可
解析结果为:

以上就是我作为逆向小白的第一篇实战笔记。由于是初次尝试记录,文中的思路和表述难免会有不成熟的地方,甚至可能存在一些逻辑上谬误,还请各位圈内的大佬们多多包容,不吝赐教。