某 LNG 巡检 App 登录协议逆向实战:双因子设备指纹、RSA 密码加密与 Hook 定位
免责声明本文仅用于安全研究、协议分析与技术交流学习所涉对象为作者在授权范围内持有的账号与设备。文中软件名称统一以「某 App」代替。请勿将本文任何内容用于未授权访问、数据爬取、绕过风控或其他侵犯他人权益与违反法律法规的行为。因滥用造成的一切后果由使用者自行承担。阅读即视为同意以上条款。一、要解决的问题某 App 是一款能源行业的移动端应用主功能之一是 LNG 储配站的巡检任务管理。在做接口协议分析时遇到三个典型的工程化难点也是本文的核心登录为什么有时要短信验证码、有时不要同一个账号在不同设备上行为不一致需要定位服务端的判定依据。登录密码不是明文提交请求体里是一段 Base64 密文需要还原加密算法、Padding 和编码细节否则无法脱离 App 自行登录复现。业务接口分散在宿主 App 内嵌 uni-app 小程序两套体系token 还要二次交换需要厘清调用链才能复现任务列表这类核心接口。下文按「静态反编译定位 → 关键算法还原 → 动态 Hook 验证」的顺序展开每一步都给出实际用到的命令与判断依据。二、反编译从 APK 到可读代码2.1 基础信息提取拿到app.apk后先看清楚它的结构和加固情况# 解出 manifest、资源、smali-r 跳过资源回编、-s 可选跳过 dex这里需要 dex 所以不加 -sapktool d app.apk-oapktool_out# 单独看签名与包信息aapt dump badging app.apk|grep-Epackage|launchable-activityunzip-lapp.apk|grep-Eclasses.*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-rnLoginActivityapktool_out/smali*# 按接口路径搜网络层路径片段往往是常量最稳grep-rngetAppTokenapktool_out/smali*grep-rnmobileDoubleFactorVerifyapktool_out/smali*# 按字段名搜请求体组装位置grep-rnverificationCodeapktool_out/smali*grep-rnimageCodeapktool_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-djadx_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在某账号上验证成功过服务端会把它标记为可信后续预检直接verifyResultfalse所以「老设备不要验证码」换设备/重置后meId变化被当作新设备于是要短信。这解释了「时要时不要」的根因——它由服务端按设备指纹决定客户端无法单方面绕过。工程上能做的正确处理只有两件事让已验证设备复用同一份持久化device_id避免无谓地触发二次验证在确实被要求验证码时把图形/短信验证码流程走通而不是当作失败。四、难点拆解二RSA 密码加密还原4.1 静态定位加密点登录请求体里的password是密文。在网络层方法里向上回溯会发现密码在交给login()之前先过了一层加密工具RSAEncrypt.encryptByPublicKey(password)读该工具类提取出完整的加密参数这是脱离 App 复现登录的核心必须 100% 对齐公钥X509/SPKIBase64 DERMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIsDNTSUWkTFtfRwZoTUCGYVtV1uFAJxWqg/f92Ni3Csw0p9boaidXssOKZGnwxeHQPeO6l9FWAc8zWr2sCAwEAAQCipherRSA/ECB/PKCS1Padding输出编码AndroidBase64.NO_WRAP无换行提交前处理把密文里的全部替换为%2B难点 3易踩的三个坑。① PKCS1Padding 每次加密结果不同含随机填充不能用「固定密文比对」来验证算法对错要用「能否成功登录」来验证② Android 默认Base64.NO_WRAP若用带换行的标准 Base64 会导致服务端解析失败③ → %2B这步是业务自定义的 URL 安全处理漏了会偶发失败仅当密文恰好含时复现极具迷惑性。4.2 还原实现可复现模板下面给出两种等价实现参数严格对齐上面的静态结论。公钥与算法来自反编译事实可直接使用账号密码请按你自己授权的凭据补齐。Node.js 版import{constants,publicEncrypt}fromnode:crypto;// 来自反编译的登录公钥X509/SPKI, DER, base64constPUB_DER_B64MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIsDNTSUWkTFtfRwZoTUCGYVtV1uFAJxWqg/f92Ni3Csw0p9boaidXssOKZGnwxeHQPeO6l9FWAc8zWr2sCAwEAAQ;exportfunctionencryptPasswordForLogin(password){if(!password)thrownewError(password is required);constencryptedpublicEncrypt({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 业务自定义的 →%2Breturnencrypted.toString(base64).replaceAll(,%2B);}Android/Kotlin 版与原 App 等价用于交叉验证importandroid.util.Base64importjava.security.KeyFactoryimportjava.security.spec.X509EncodedKeySpecimportjavax.crypto.CipherobjectRsaCrypto{privateconstvalPUB_DER_B64MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIsDNTSUWkTFtfRwZoTUCGYVtV1uFAJxWqg/f92Ni3Csw0p9boaidXssOKZGnwxeHQPeO6l9FWAc8zWr2sCAwEAAQfunencryptPasswordForLogin(password:String):String{require(password.isNotEmpty()){password is required}valkeyBytesBase64.decode(PUB_DER_B64,Base64.DEFAULT)valpublicKeyKeyFactory.getInstance(RSA).generatePublic(X509EncodedKeySpec(keyBytes))valcipherCipher.getInstance(RSA/ECB/PKCS1Padding)cipher.init(Cipher.ENCRYPT_MODE,publicKey)valoutcipher.doFinal(password.toByteArray(Charsets.UTF_8))returnBase64.encodeToString(out,Base64.NO_WRAP).replace(,%2B)}}4.3 登录请求组装还原后的账号密码主流程仅当预检verifyResultfalse时POST https://openapi.crcgas.com/mobile/cas/getAppToken?servicemportal.crcgas.comclientSecret28f37e3802e546f5ac453fa22af78508 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.jsJava.perform(function(){varEncJava.use(com.某.util.RSAEncrypt);// 方法名按反编译实际签名替换Enc.encryptByPublicKey.overload(java.lang.String).implementationfunction(plain){varretthis.encryptByPublicKey(plain);console.log([RSA] plain ,plain);console.log([RSA] cipher,ret);returnret;// 不改行为仅观测};});5.2 Hook 网络层dump 真实请求体为了确认请求体字段尤其是meId、各验证码字段在不同分支下的取舍Hook 统一网络封装Java.perform(function(){varNetJava.use(com.某.util.NetworkUtil);// 以 login 方法为例按实际重载签名调整Net.login.overload(/* ...args... */).implementationfunction(){console.log([login] args ,JSON.stringify([].slice.call(arguments)));returnthis.login.apply(this,arguments);};});如果业务层方法太多更通用的做法是 Hook OkHttp 的RequestBody/Interceptor统一抓全量出网请求。5.3 解决 native .so 加载时机当关键逻辑在lib/下的 native 层、或类在 App 启动较晚才加载时过早 Hook 会找不到符号。两个常用对策// 对策 A等目标 so 加载后再 hook native 导出varpendingModule.findExportByName(libtarget.so,Java_xxx);if(!pending){// 用 dlopen 拦截等 so 真正加载Interceptor.attach(Module.findExportByName(null,android_dlopen_ext),{onEnter:function(args){this.pathargs[0].readCString();},onLeave:function(){if(this.paththis.path.indexOf(libtarget.so)0){// 此处再做 native hook}},});}// 对策 BJava 层用 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难点 4token 不能直接复用。普通登录 token 不能直接请求小程序网关必须先做一次交换POST https://openapi.crcgas.com/mobile/exchangeToken?mobileAppId2d2cc5f3f0614d64b677bdd8b4790da1 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 密文务必无换行 →%2BJSON 序列化注意字段顺序对部分老网关的影响。设备 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 二次交换是能否跑通核心接口的关键。逆向工程的价值在于理解系统、发现风险与做兼容性研究。请始终在授权范围内进行并遵守相关法律法规与平台协议。再次声明本文仅供安全研究与技术学习严禁用于任何未授权用途。