某某快递 App 逆向分析记录
1 2 3
| 目标 App:某某快递(com.*******.*******) 分析日期:2026-03 阶段:Phase 2 Day 12 实战
|
前言
本文仅作学习分享,我说省略五百字叠甲。
找一个没加固又有实战意义的apk好麻烦(主要是我不知道怎么找),正好发现小区外装了一个新的快递柜,感觉这种新软件可能会比较容易。
hook真好玩,我说比pwn和web有意思,花活太多了。
忽略中间的Phase啥的,那是我搞的checklist里的东西。
ai真是太叼了,本篇全文使用ai创作,除了隐私保护部分。
打码还是得打的,不然万一找上门了咋办。
一、目标 App 基本信息
| 项目 |
内容 |
| 包名 |
com.*******.******* |
| 架构 |
armeabi-v7a(无 arm64-v8a) |
| dex 数量 |
10 个,总大小约 73MB |
| 加固情况 |
无整体加壳,部分 so 有 OLLVM 混淆 |
| 主要入口 |
SplashActivityNew → LoginActivity |
二、APKiD 静态分析结果
1 2 3 4
| assets/jy.jar!arm64-v8a-cjyah13ad.so → OLLVM 9.x + 字符串加密 assets/gdt_plugin/gdtadv2.jar!libturingau.so → anti_hook: syscalls(腾讯反作弊) 多个 dex → anti_debug + anti_vm 检测 3 个 so → Alipay 混淆
|
当前能力边界:
- ✅ JADX 分析 Java 层逻辑
- ✅ Frida Hook Java 层方法
- ✅ 绕过基础 Debug/VM 检测
- ❌ OLLVM 还原(Phase 4 内容)
- ❌ libturingau.so syscall 反 Hook(超出当前阶段)
三、登录流程分析
3.1 调用链
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 用户点击登录按钮 ↓ onClick(R.id.bt_login) → k() ↓ 检查:隐私协议勾选、手机号11位、密码非空 ↓ 请求位置权限 ↓ a(手机号, 密码) ↓ i.getPasswordKey(...) ↓ Step1: getPasswordKeyAndToken() ← 请求服务器拿 token 和加密 key ↓ Step2: EncryptionUtil.encrypt7Padding(key, 密码) ← 本地加密 ↓ Step3: loginV2(手机号, 加密密码, token, ...) ← 真正的登录请求
|
3.2 字符串混淆还原
所有算法名通过 Utility.getValue() 在运行时解密,Frida Hook 结果:
| 混淆字符串 |
真实值 |
mfx |
AES |
mfx/ono/wMox7wPddhlE |
AES/CBC/PKCS7Padding |
jNPhdhFCAq666666 |
******help666666(这里是固定值) |
Utility.getValue() 有两个重载:
getValue(String) → 单参数
getValue(String, String) → 双参数(第一个参数为映射表)
3.3 密码加密逻辑
1 2 3 4 5 6 7 8
| Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding"); cipher.init(ENCRYPT_MODE, new SecretKeySpec(key.getBytes("utf-8"), "AES"), new IvParameterSpec("******help666666".getBytes()) ); byte[] encrypted = cipher.doFinal(password.getBytes("utf-8")); return Base64.encodeToString(encrypted, 0);
|
关键特性:
- IV 固定:
******help666666
- Key 动态:每次由服务器下发,防止重放攻击
四、登录接口
4.1 BaseUrl
1 2 3 4
| 主: https://api2.******help.com 备用:https://api.******help.com 备用:https://api5.******help.com WS: https://vapi.******help.com
|
4.2 Step1 - 获取 Key 和 Token
1 2 3 4 5 6 7
| POST https://api2.******help.com/g_account_core/v2/KdyNewWduser/generateKey
请求参数: jsonType = "object"
返回: { "key": "xxx", "token": "yyy" }
|
4.3 Step2 - 本地加密密码
1 2 3 4 5 6
| AES/CBC/PKCS7Padding( key = 服务器返回的 key, iv = "******help666666", data = 密码 UTF-8 字节 ) → Base64 → URLEncode
|
4.4 Step3 - 登录
1 2 3 4 5 6 7 8 9 10
| POST https://api2.******help.com/g_account_core/v2/KdyNewWduser/newLogin
请求参数(FormUrlEncoded): user_name = 手机号 user_pwd = 加密后的密码(已 URLEncode) token = Step1 返回的 token is_send = "" verify_code = "" lat = 纬度(可为空) lng = 经度(可为空)
|
五、Python 复现脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| import requests from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 import urllib.parse
BASE_URL = "https://api2.******help.com" IV = "******help666666"
def get_key_and_token(): url = f"{BASE_URL}/g_account_core/v2/KdyNewWduser/generateKey" resp = requests.post(url, data={"jsonType": "object"}) print("[generateKey]", resp.text) data = resp.json() return data["key"], data["token"]
def encrypt_password(key, password): cipher = AES.new( key.encode("utf-8"), AES.MODE_CBC, IV.encode("utf-8") ) encrypted = cipher.encrypt(pad(password.encode("utf-8"), AES.block_size)) return base64.b64encode(encrypted).decode("utf-8")
def login(phone, password): key, token = get_key_and_token() print(f"[key] {key}") print(f"[token] {token}")
encrypted_pwd = encrypt_password(key, password) encoded_pwd = urllib.parse.quote(encrypted_pwd) print(f"[encrypted_pwd] {encrypted_pwd}")
url = f"{BASE_URL}/g_account_core/v2/KdyNewWduser/newLogin" data = { "user_name": phone, "user_pwd": encoded_pwd, "token": token, "is_send": "", "verify_code": "", "lat": "", "lng": "" } resp = requests.post(url, data=data) print("[loginV2]", resp.text) return resp.json()
if __name__ == "__main__": login("13800138000", "yourpassword")
|
六、Frida Hook 脚本
6.1 字符串混淆解密 Hook
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Java.perform(function() { var Utility = Java.use("com.common.utils.Utility");
Utility.getValue.overload('java.lang.String').implementation = function(str) { var result = this.getValue(str); console.log("[getValue(1)] in=" + str + " out=" + result); return result; };
Utility.getValue.overload('java.lang.String', 'java.lang.String').implementation = function(str, str2) { var result = this.getValue(str, str2); console.log("[getValue(2)] in1=" + str + " in2=" + str2 + " out=" + result); return result; };
console.log("[*] Utility hooks installed"); });
|
6.2 加密参数 Hook
1 2 3 4 5 6 7 8 9 10 11 12
| Java.perform(function() { var EncryptionUtil = Java.use("com.common.utils.EncryptionUtil");
EncryptionUtil.encrypt7Padding.implementation = function(key, pwd) { console.log("[encrypt7Padding]"); console.log(" key = " + key); console.log(" pwd = " + pwd); var result = this.encrypt7Padding(key, pwd); console.log(" out = " + result); return result; }; });
|
6.3 登录接口入参 Hook
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| Java.perform(function() { var apiClass = Java.use("com.*******.*******.retrofit.api.b");
apiClass.loginV2.implementation = function(str, str2, str3, str4, str5, str6, str7) { console.log("[loginV2]"); console.log(" user_name = " + str); console.log(" user_pwd = " + str2); console.log(" token = " + str3); console.log(" is_send = " + str4); console.log(" verify_code = " + str5); console.log(" lat = " + str6); console.log(" lng = " + str7); return this.loginV2(str, str2, str3, str4, str5, str6, str7); }; });
|
七、工具与环境
| 工具 |
版本/路径 |
| Frida |
17.7.3 |
| frida-server |
android-x86_64(模拟器架构) |
| JADX |
用于 Java 层反编译 |
| APKiD |
3.0.0 |
| 模拟器设备 ID |
127.0.0.1:5555(V2366GA) |
| 脚本目录 |
C:\Users\win11\Desktop\tool\frida_env\JsScripts\ |
| APK 目录 |
C:\Users\win11\Desktop\tool\apk\******yuan\ |
Frida Attach 命令:
1 2 3 4 5 6 7
| frida-ps -Uai | findstr *******
frida -U -p <PID> -l ******_hook.js
用 -f spawn 模式会导致 React Native JS 引擎线程崩溃(SIGSEGV),必须用 `-p` attach 模式。
|
八、找回密码流程分析
8.1 入口
LoginActivity 点击”忘记密码”跳转到 RegisterOrModifyInfoActivity,通过 from_where = "forget_pwd" 参数区分流程。该 Activity 同时承担注册、修改信息、找回密码三个功能。
8.2 调用链
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| 点击"获取验证码" ↓ g() → i() → getVerifyCode(手机号) POST v1/Wduser/register user_name = 手机号 forget_password = "true"(硬编码)
点击"完成" ↓ d() → getPasswordKeyAndToken() ← 复用登录的 key/token 接口 ↓ a(bVar, json) ↓ findBackPwd( encrypt7Padding(key, 新密码), ← 加密方式与登录完全相同 验证码, 手机号, ← 客户端直接传入! "isCheck", token ) POST v1/Wduser/register ← 与获取验证码同一个接口! password = 加密新密码 verify_code = 验证码 forget_password = "true"(硬编码,服务端靠此字段区分操作) user_name = 手机号 passAuth = "isCheck" token = token
|
8.3 关键发现
发现一:获取验证码和重置密码共用同一接口
1
| POST https://api2.******help.com/v1/Wduser/register
|
服务端通过请求体中是否含有 password 字段来区分”发验证码”和”重置密码”两个操作。
发现二:60秒冷却是纯客户端限制
1
| new CountDownTimer(60000L, 1000L)
|
服务端是否有频率限制未验证。
发现三:手机号由客户端传入(潜在越权重置)
1
| findBackPwd(加密新密码, 验证码, tv_reg_mobile.getText(), "isCheck", token)
|
手机号直接从输入框读取后传给服务端,不经过服务端的 session 绑定校验。如果服务端不校验”验证码是否属于该手机号”,理论上可以用手机号 A 收到的验证码去重置手机号 B 的密码(任意账号密码重置漏洞)。受限于没有真实手机号,该漏洞暂未验证。
8.4 请求体结构(Frida 抓包)
实际发出的请求并非简单 FormUrlEncoded,外层有签名包装:
1 2 3 4 5 6 7 8 9 10
| app_id = 10002 ts = 1773135454335 ← 时间戳 sign = fevw1uk2gwja... ← 签名,防篡改,算法未还原 data = {"forget_password":"true","user_name":"手机号"} did = 80ce9354-ba1b-3ce3-... ← 设备 ID devInfo = vivo-V2366GA devDetail = {"product":"PD2366","dun2_token":"...","model":"V2366GA",...} clientAgent = app-native lj = 113.424671 ← 经度 lw = 23.184421 ← 纬度
|
请求头:
1 2 3 4 5 6
| clientAgent: app-native version: 11.9.0 appVersion: 11900 app_id: 10002 pname: androids channel: *******
|
8.5 签名机制(待分析)
sign 字段防止请求被直接篡改和重放,是进一步分析的关键障碍。裸 Python 请求因缺少正确的 sign 而返回 1005 invalid appid。后续需在 JADX 中搜索 sign 相关类,或用 Frida 枚举含 sign 方法名的类来定位算法。
九、待办 / 下一步