某某快递 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 混淆
主要入口 SplashActivityNewLoginActivity

二、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
// EncryptionUtil.encrypt7Padding(key, password)
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
cipher.init(ENCRYPT_MODE,
new SecretKeySpec(key.getBytes("utf-8"), "AES"), // key = 服务器下发
new IvParameterSpec("******help666666".getBytes()) // IV = 固定
);
byte[] encrypted = cipher.doFinal(password.getBytes("utf-8"));
return Base64.encodeToString(encrypted, 0); // 再 URLEncode 后发送

关键特性:

  • 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
# 查 PID
frida-ps -Uai | findstr *******

# Attach(App 完全启动后再执行)
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)  // 本地 Timer,直接调用接口即可绕过

服务端是否有频率限制未验证。

发现三:手机号由客户端传入(潜在越权重置)

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 方法名的类来定位算法。


九、待办 / 下一步

  • 还原 sign 签名算法(JADX 搜索或 Frida 枚举含 sign 方法的类)
  • 有真实账号后验证完整登录流程
  • 有两个手机号后测试任意账号密码重置漏洞(用 A 的验证码重置 B 的密码)
  • 测试验证码频率限制(服务端是否有限制)
  • 分析登录成功后的 session 机制(session_id 存储与使用)
  • 研究 libturingau.so 的 syscall 反 Hook 机制(Phase 3 内容)