免责声明:本文仅用于安全研究、协议分析与技术交流学习,所涉对象为作者在授权范围内持有的账号与设备。文中软件名称统一以「某 App」代替。请勿将本文任何内容用于未授权访问、数据爬取、绕过风控或其他侵犯他人权益与违反法律法规的行为。因滥用造成的一切后果由使用者自行承担。阅读即视为同意以上条款。
一、要解决的问题
某 App 是一款能源行业的移动端应用,主功能之一是 LNG 储配站的巡检任务管理。在做接口协议分析时,遇到三个典型的工程化难点,也是本文的核心:
- 登录为什么有时要短信验证码、有时不要?同一个账号在不同设备上行为不一致,需要定位服务端的判定依据。
- 登录密码不是明文提交,请求体里是一段 Base64 密文,需要还原加密算法、Padding 和编码细节,否则无法脱离 App 自行登录复现。
- 业务接口分散在宿主 App + 内嵌 uni-app 小程序两套体系,token 还要二次交换,需要厘清调用链才能复现任务列表这类核心接口。
下文按「静态反编译定位 → 关键算法还原 → 动态 Hook 验证」的顺序展开,每一步都给出实际用到的命令与判断依据。
二、反编译:从 APK 到可读代码
2.1 基础信息提取
拿到 app.apk 后,先看清楚它的结构和加固情况:
# 解出 manifest、资源、smali,-r 跳过资源回编、-s 可选跳过 dex(这里需要 dex 所以不加 -s)
apktool d app.apk -o apktool_out
# 单独看签名与包信息
aapt dump badging app.apk | grep -E "package|launchable-activity"
unzip -l app.apk | grep -E "classes.*dex|\.so"
反编译产物的实际目录结构如下,注意这是一个多 dex 应用:
apktool_out/
├── AndroidManifest.xml
├── apktool.yml
├── assets/ # 内含 uni-app 小程序包
├── lib/ # native .so
├── smali/ # classes.dex
├── smali_classes2/ # classes2.dex
├── smali_classes3/
└── smali_classes4/
难点 1:多 dex 导致类不在预期目录。 一个类到底落在
smali还是smali_classes4,取决于打包时的 dex 分包结果,不能只在smali/里找。跨全部 dex 目录搜索是基本功。
2.2 跨 dex 全局搜索关键词
逆向定位的第一招永远是「按字符串/类名全局搜索」。登录相关的入口,可以从 Activity 名、接口路径、字段名三个维度切入:
# 按业务关键词搜登录入口
grep -rn "LoginActivity" apktool_out/smali*
# 按接口路径搜网络层(路径片段往往是常量,最稳)
grep -rn "getAppToken" apktool_out/smali*
grep -rn "mobileDoubleFactorVerify" apktool_out/smali*
# 按字段名搜请求体组装位置
grep -rn "verificationCode" apktool_out/smali*
grep -rn "imageCode" apktool_out/smali*
通过这种方式,本案例定位到的关键 smali 类是:
com/某/module/login/LoginActivity.smali # 登录页与预检
com/某/util/NetworkUtil.smali # 业务网络封装
com/某commonlib/BaseNetworkUtil.smali # 网关头注入
com/某/widget/SmsVerifyCodeDialog.smali # 短信验证码弹窗
2.3 配合 jadx 看伪代码
smali 适合精确定位和后续插桩,但读逻辑还是 Java 伪代码快。两者配合:
# 生成可读 Java(-d 输出目录,--show-bad-code 容忍反编译失败的方法)
jadx -d jadx_out app.apk
# 只想快速看某个类,用图形界面交叉引用跳转
jadx-gui app.apk
实践经验:用 jadx 读懂逻辑、记下方法签名,再回到 smali 里按签名做 Hook 或插桩,定位效率最高。
三、难点拆解一:登录为什么时要时不要验证码
3.1 静态结论:判定发生在「预检」而非「登录」
读 LoginActivity 的预检方法可以看到,它在真正登录前先打了一个 mobileDoubleFactorVerify,请求体只有三个字段:
{
"meId": "<deviceId>",
"loginCode": "<username>",
"domainName": "mportal.crcgas.com"
}
请求头部分由 BaseNetworkUtil 统一注入:
X-Apig-AppCode: 1a52f7e8e19b4490a2953da9bcf0e9544a5739e9c850421898d897f1e21f9ca4
AuthType: APP
服务端返回后,客户端的分支判定逻辑是:
| 预检返回 | 客户端行为 |
|---|---|
verifyResult == false | 直接账号密码登录,不弹任何验证码 |
verifyResult == true && authMethod == "captcha" | 弹图形验证码,带 imageId/imageCode 登录 |
verifyResult == true(其他) | 走短信弹窗,带 verificationCode 登录 |
关键点:password 没有参与预检,预检的唯一可变输入就是 meId(设备 ID)。
3.2 设备指纹 meId 的来源
继续跟 meId 的取值,定位到设备 ID 工具类。其逻辑可归纳为:
- Android 10+ 且有
READ_PHONE_STATE权限:使用本地持久化的device_id,首次生成值为System.currentTimeMillis() + random(0..8); - 低版本有权限:优先
TelephonyManager.getDeviceId(); - 无权限:返回固定字符串
Device ID without permission。
这个值会被持久化在应用数据库 default_database 的 KEY_VALUE_PAIR 表中,键名 device_id。
难点 2 的本质:
meId不是普通展示字段,而是服务端风控里的「可信设备」凭据。 一旦某个meId在某账号上验证成功过,服务端会把它标记为可信,后续预检直接verifyResult=false,所以「老设备不要验证码」;换设备/重置后meId变化,被当作新设备,于是要短信。这解释了「时要时不要」的根因——它由服务端按设备指纹决定,客户端无法单方面绕过。
工程上能做的正确处理只有两件事:
- 让已验证设备复用同一份持久化
device_id,避免无谓地触发二次验证; - 在确实被要求验证码时,把图形/短信验证码流程走通,而不是当作失败。
四、难点拆解二:RSA 密码加密还原
4.1 静态定位加密点
登录请求体里的 password 是密文。在网络层方法里向上回溯,会发现密码在交给 login() 之前先过了一层加密工具:
RSAEncrypt.encryptByPublicKey(password)
读该工具类,提取出完整的加密参数(这是脱离 App 复现登录的核心,必须 100% 对齐):
-
公钥(X509/SPKI,Base64 DER):
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIsDNTSUWkTF+tfRwZoTUCGYVt+V1uFAJxW+qg/f92N+i3Csw0p9boaid+XssOKZGnwxeHQPeO6l9FWAc8zWr2sCAwEAAQ== -
Cipher:
RSA/ECB/PKCS1Padding -
输出编码:Android
Base64.NO_WRAP(无换行) -
提交前处理:把密文里的
+全部替换为%2B
难点 3:易踩的三个坑。 ① PKCS1Padding 每次加密结果不同(含随机填充),不能用「固定密文比对」来验证算法对错,要用「能否成功登录」来验证;② Android 默认
Base64.NO_WRAP,若用带换行的标准 Base64 会导致服务端解析失败;③+ → %2B这步是业务自定义的 URL 安全处理,漏了会偶发失败(仅当密文恰好含+时复现,极具迷惑性)。
4.2 还原实现(可复现模板)
下面给出两种等价实现,参数严格对齐上面的静态结论。公钥与算法来自反编译事实,可直接使用;账号密码请按你自己授权的凭据补齐。
Node.js 版:
import { constants, publicEncrypt } from "node:crypto";
// 来自反编译的登录公钥(X509/SPKI, DER, base64)
const PUB_DER_B64 =
"MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIsDNTSUWkTF+tfRwZoTUCGYVt+V1uFAJxW+qg/f92N+i3Csw0p9boaid+XssOKZGnwxeHQPeO6l9FWAc8zWr2sCAwEAAQ==";
export function encryptPasswordForLogin(password) {
if (!password) throw new Error("password is required");
const encrypted = publicEncrypt(
{
key: Buffer.from(PUB_DER_B64, "base64"),
format: "der",
type: "spki",
padding: constants.RSA_PKCS1_PADDING, // 对齐 PKCS1Padding
},
Buffer.from(String(password))
);
// 对齐 Android Base64.NO_WRAP + 业务自定义的 +→%2B
return encrypted.toString("base64").replaceAll("+", "%2B");
}
Android/Kotlin 版(与原 App 等价,用于交叉验证):
import android.util.Base64
import java.security.KeyFactory
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
object RsaCrypto {
private const val PUB_DER_B64 =
"MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIsDNTSUWkTF+tfRwZoTUCGYVt+V1uFAJxW+qg/f92N+i3Csw0p9boaid+XssOKZGnwxeHQPeO6l9FWAc8zWr2sCAwEAAQ=="
fun encryptPasswordForLogin(password: String): String {
require(password.isNotEmpty()) { "password is required" }
val keyBytes = Base64.decode(PUB_DER_B64, Base64.DEFAULT)
val publicKey = KeyFactory.getInstance("RSA")
.generatePublic(X509EncodedKeySpec(keyBytes))
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
val out = cipher.doFinal(password.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(out, Base64.NO_WRAP).replace("+", "%2B")
}
}
4.3 登录请求组装
还原后的账号密码主流程(仅当预检 verifyResult=false 时):
POST https://openapi.crcgas.com/mobile/cas/getAppToken?service=mportal.crcgas.com&clientSecret=28f37e3802e546f5ac453fa22af78508
Headers:
X-Apig-AppCode: 1a52f7e8e19b4490a2953da9bcf0e9544a5739e9c850421898d897f1e21f9ca4
Body:
{
"username": "<username>",
"password": "<上面还原的 RSA 密文>",
"meId": "<deviceId>"
}
captcha 分支额外加 imageId/imageCode,短信分支额外加 verificationCode,二者互斥。
五、动态验证:Frida Hook 思路
静态还原后必须用动态 Hook 交叉验证「我算的密文 / 我猜的字段,和 App 实际发出去的是否一致」。
5.1 Hook 加密函数,核对明文与密文
直接 Hook 那一层加密方法,打印入参(明文)和返回(密文),与自己实现的结果比对:
// frida -U -f <pkg> -l hook.js
Java.perform(function () {
var Enc = Java.use("com.某.util.RSAEncrypt");
// 方法名按反编译实际签名替换
Enc.encryptByPublicKey.overload("java.lang.String").implementation = function (plain) {
var ret = this.encryptByPublicKey(plain);
console.log("[RSA] plain =", plain);
console.log("[RSA] cipher=", ret);
return ret; // 不改行为,仅观测
};
});
5.2 Hook 网络层,dump 真实请求体
为了确认请求体字段(尤其是 meId、各验证码字段在不同分支下的取舍),Hook 统一网络封装:
Java.perform(function () {
var Net = Java.use("com.某.util.NetworkUtil");
// 以 login 方法为例,按实际重载签名调整
Net.login.overload(/* ...args... */).implementation = function () {
console.log("[login] args =", JSON.stringify([].slice.call(arguments)));
return this.login.apply(this, arguments);
};
});
如果业务层方法太多,更通用的做法是 Hook OkHttp 的 RequestBody/Interceptor,统一抓全量出网请求。
5.3 解决 native .so 加载时机
当关键逻辑在 lib/ 下的 native 层、或类在 App 启动较晚才加载时,过早 Hook 会找不到符号。两个常用对策:
// 对策 A:等目标 so 加载后再 hook native 导出
var pending = Module.findExportByName("libtarget.so", "Java_xxx");
if (!pending) {
// 用 dlopen 拦截,等 so 真正加载
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {
onEnter: function (args) { this.path = args[0].readCString(); },
onLeave: function () {
if (this.path && this.path.indexOf("libtarget.so") >= 0) {
// 此处再做 native hook
}
},
});
}
// 对策 B:Java 层用 spawn 模式 + Java.perform 内做类存在性判断
Java.perform(function () {
try { Java.use("com.某.module.login.LoginActivity"); }
catch (e) { console.log("class not loaded yet"); }
});
若目标做了 Frida 检测,可优先考虑 spawn 模式、改默认端口、或使用 Gadget 注入等方式,本文不展开对抗细节。
六、业务链路:宿主 App 与内嵌小程序的 token 交换
某 App 的巡检业务实际跑在内嵌的 uni-app 小程序里。反编译 assets/ 会看到小程序包,其请求走的是另一套网关:
apigHost: https://sms-openapi.crcgas.com/apis/
x-apig-appcode: 9fda8c7b6ff94e1ab063c98c101c2904aefd6062068342fd979eea2e9f8e1210
难点 4:token 不能直接复用。 普通登录 token 不能直接请求小程序网关,必须先做一次交换:
POST https://openapi.crcgas.com/mobile/exchangeToken?mobileAppId=2d2cc5f3f0614d64b677bdd8b4790da1
Headers:
AuthType: APP
Authorization: Bearer <普通登录 token>
X-Apig-AppCode: 1a52f7e8e19b4490a2953da9bcf0e9544a5739e9c850421898d897f1e21f9ca4
交换得到的新 access_token 才能请求小程序接口。任务列表的岗位上下文 companyCode 来自:
POST https://sms-openapi.crcgas.com/apis/mgt/security/getLoginData (body: {})
→ userBasicVo.currentPost.treeNodeId 即 companyCode
最终任务列表接口:
POST https://sms-openapi.crcgas.com/apis/factoryBusiness/station/patrol/task/findStationmgrPatrolTaskListPage
Body:
{
"pageNum": 1,
"pageSize": 20,
"condition": { "isHistory": false, "companyCode": "<treeNodeId>" }
}
调用链小结:账密登录 → exchangeToken 换小程序 token → getLoginData 取 companyCode → 业务接口。漏掉中间任何一步,要么 401、要么缺岗位上下文返回空。
七、异常边界与上线前检查
复现/接入时,以下边界最容易出问题,建议逐项核对:
- token 续期:登录返回里同时有
access_token与refresh_token(本案例expires_in约 15 天)。access_token 过期应优先用 refresh_token 续期接口换新,避免重新登录又触发设备风控/验证码。注意部分实现刷新响应不回refresh_token,需保留旧值。 - 验证码互斥:图形验证码只服务于「发送短信」一步,用后即失效;最终登录只认
verificationCode,不要再带已消费的imageId/imageCode,否则服务端校验失败。 - Base64 与转义:RSA 密文务必无换行 +
+→%2B;JSON 序列化注意字段顺序对部分老网关的影响。 - 设备 ID 一致性:复现时固定一个已被服务端信任的
meId,不要每次随机生成,否则会反复进入短信分支。 - 请求头完整性:
X-Apig-AppCode、AuthType、Authorization缺一不可,且宿主网关与小程序网关的 AppCode 不同。
测试建议:
- 用 Frida Hook 出的真实请求体作为「黄金样本」,对自己复现的请求逐字段 diff;
- 验证 RSA 时以「能否换到 token」为准,不要比对密文;
- 分别覆盖「可信设备直登」「captcha 分支」「短信分支」「token 过期续期」四条路径。
八、总结
本文以某 App 的登录与巡检链路为例,串起了一套通用的 Android 协议逆向方法论:
- 静态先行:apktool 多 dex 反编译 + 跨目录
grep关键词 + jadx 读逻辑,快速定位入口与算法点; - 算法还原讲究细节:RSA 的 Padding、Base64 换行、自定义转义任何一处错都会导致复现失败,且 PKCS1Padding 不能靠密文比对验证;
- 动态 Hook 收尾:Frida 观测明文/密文/请求体,解决 native so 加载时机,用真实数据校验静态结论;
- 业务链路要厘清:宿主 App 与内嵌小程序两套网关、token 二次交换,是能否跑通核心接口的关键。
逆向工程的价值在于理解系统、发现风险与做兼容性研究。请始终在授权范围内进行,并遵守相关法律法规与平台协议。
再次声明:本文仅供安全研究与技术学习,严禁用于任何未授权用途。


被折叠的 条评论
为什么被折叠?



