首页
社区
课程
招聘
某PH钱包 App 注册协议分析:DynamicSecurity「WCSign」全链路还原
发表于: 3天前 553

某PH钱包 App 注册协议分析:DynamicSecurity「WCSign」全链路还原

3天前
553

目标 Appcom.globe.gcash.android(GCash)6.00.2 build 1213
对象层:自研 DynamicSecurity(RequestEncryption / GAESCipher / GRSACipher),
本文结构:按分析顺序记录「假设 → 实验 → 否证/坐实 → 数据」;密钥/私钥只露前缀

需要回答三个问题:

起点条件:App 有反系统代理、同进程多套 Alipay 系 RPC、native 存在 APSE 白盒、进入注册页易崩溃。下面按分析顺序记录,包含走过的弯路。

先确立判据:能否用纯 Python 组包、打到真网关,使服务器验签通过、发出真 OTP,并跑完 generate → verify → isGcashRegistered → register 全链路。该判据区分「组包看似正确」与「服务器实际接受」两种情况,下文多数篇幅用于排除前者。

Java 层关键方法普遍插了 goto 噪声(if-eqz v0, :cond_1 自环),jadx 报 Method not decompiled。分析时直接读 smali 调用序,不依赖 Java 源。

开系统 HTTP 代理,注册步弹:

关掉代理后流程恢复,接码可收到 6 位 OTP。SSL unpin(TrustManagerImpl.verifyChain 放行)在同环境是生效的。

拦截的不是 TLS pin 证书,而是「系统代理设置被探测」。在 Windows + 系统代理 + mitmproxy 上直接抓注册 RPC,入口不可用;后续需透明路由,或在进程内、加密前抠明文。

此步无「解密失败的密文」可分析——请求未按预期路径发出。由此确定方向:该协议的明文只存在于进程内、RequestEncryption 组包之前,线路上只有一段 sign

注册走 Alipay mobilegateway / mPaaS RPC,hook com.alipay.mobile.common.rpc.* 或动态 Proxy 即可。

进程内 hook Proxy.newProxyInstance,对带 @OperationType 的 InvocationHandler 挂 invoke。同进程实际两套:

抓到的明文指纹 RPC(真机冷启动,本次复测 logcat),JsonSerializer / RPC hook 同时打出(URL 仍是 imgw):

同次启动 TokenResult 缓存:

另有配置类 RPC(冷启动):ap.mobileamcs.cloud.fetch.config / ap.mobileprod.amcs.config.local.fetch,明文里同样带 spoof 后的 mobileModel / osVersion / clientVersion

据此确认:

注册页按 Next,没有发码 operation-type。名字带 Otp 的 OtpVerificationFacade(Quake)在注册路径上也不触发——静态确认它挂在 reset_mpin,不是注册。

结论:RPC 线是旁路证据,不是注册 OTP 主路径。需从 UI 往下静态追。

OtpRepositoryImpl.generateOtpCodeWc 核心逻辑(去噪后语义):

接口注解(jadx smali,本次复现):

Host 不在注解里,实测基址 d0fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2E0P5h3&6@1i4K6u0W2P5s2W2*7(与旧 c4/v1/otp/* Map body 路径并存;注册新路径走 v2.3 + WCSign)。

正确 choke point:RequestEncryption.generateSignedBody(dex 层,免壳,是整个协议里唯一能一次获取「明文 body + 明文 header + 本地 aesKey/iv + 最终 sign」的点)。

类:gcash.common.android.util.encryption.RequestEncryption
模型:WCSign{ sign, aesKey, iv }(字段 jadx 坐实)

b() 再拆:

n()

l()

线路模型:sign = base64(RSA_sign(payload)) + "." + base64(gson(WCEncrypt))@Body(WCSign)@Exposesign 一个字段;aesKey/iv 本地留存(用于解响应、解字段),服务器侧的密钥在 sec.key/sec.initializer 中被 RSA 封装。

smali 明确分支:

组包时此处易错:把 X-Env-Info 也做 AES 会与真机不一致。「非空才加密」是一个隐含条件——Authorization 在注册期是空串,d() 判空后置 null,gson 省略 null 键,因此最终 payload 中没有 Authorization 键,sec.enc 清单里也不出现它。这条「空 → 缺席」连锁在复现时容易被忽略。

语义:gson → JsonObject → 按 path 遍历 → 叶子 e() AES → 回写。注册 generate 的 path 列表为 ["msisdn"],故 udid 明文留在 body。原实现支持 a.b[0].c 嵌套路径遍历,但注册/登录/建号全程只用扁平字段名,无需实现嵌套。

GRSACipher.encrypt 的原语(§6.2 展开):服务器公钥为 X509/SubjectPublicKeyInfo,填充是 RSA/ECB/PKCS1Padding(PKCS#1 v1.5),输出 Base64 NO_WRAP。封装对象是本请求随机生成的 aesKey(32 字符)与 iv(16 字符);服务器用自身私钥解出这一对,即可解 sec.enc 清单点名的密文。

oracle 解出的 sec.enc 真值(本次复现 generate):

verify 会多一条 "request.body.code"sec.enc 是「本次哪些叶子被 AES」的路径索引,须与 d()/c() 实际加密的字段逐一对齐,多一条少一条服务器解密都会错位。

该类各 helper 方法如下。方法名在 dex 里被混淆成单字母,调用图仍清晰:

调用树:

需区分 e()(AES)与 m()(Base64):头里只有 X-Env-Infom(),其余敏感头与 body 点名字段走 e()sec.enc(=j()+k())是「本次哪些叶子走了 e()」的路径清单,服务器据此反查解密。

原语为 AES-CBC + SHA256withRSA。复现的关键在于序列化字节需与真机完全一致,否则签名无法通过;本文的多数细节集中于此。

关键细节:key/iv 是「可打印字符串」,直接取 UTF-8 字节作密钥,不是 raw bytes。

固定材料自检(任何语言可对拍):

GRSACipher 承担三类操作,填充与密钥格式不同:

私钥空时 signblockingGet 触发握手再签——对应「第一次请求前必握手」(§8)。

签名字节来自 new Gson() 的默认序列化,本地需逐字节复刻。四条约束:

① HTML 转义new Gson() 默认把 = < > & ' 转成 \u00XX 形式。AES 密文 Base64 常带 == padding,进 gson 后 padding 变成 \u003d\u003d

oracle 真实 payload 片段(本次复现,注意 = 已被转义成 \u003d):

固定 key 演示(左侧是 AES 原始密文里的 ==,右侧是它进 gson 后变成的样子):

缺此步则本地 payload 字节与 App 不一致(每个 = 变 6 字符 \u003d),SHA256withRSA 无法通过。

② 紧凑分隔符,:,无空格。

③ 省略 null 键:值为 null 的键 gson 默认不输出(前面 Authorization 空 → 缺席、method=POST → null → 省略,都依赖此)。

④ 字段顺序 = ART 字段名字母序。此条容易被忽略:gson 按 Java 反射得到的字段顺序序列化,ART 上该顺序为字段名字母序。本地组包时 dict 的键须按此序构造,否则 payload 字节改变、签名不通过。三个容器实测顺序:

EncryptedHeader 同理,其 JSON key 输出顺序(省略 null 后)实测为:

OTP 路径只设了其中 Time / X-Correlator-Id / X-Env-Info / X-FlowId / X-Package-Id / X-Reg-Channel / X-Tracker / X-UDID(与实测 header_keys 一致),其余键在场为 null → 省略。

四条中缺任一条:本地 payload 字节即与 App 不一致,SHA256withRSA 无法通过服务端验签。其表现为验签失败而非解密失败——服务端多回 422 Invalid Signature 或网关拒绝,易被误判为加密实现错误(§11 的 register 段即出现过一次 code:400 Failed verification at prehandling)。

思路:先不打真网关,用自造服务器密钥对验证「组包结构可解回」。该步骤将「密码学是否正确」与「服务器业务/风控是否接受」解耦;§10 的 key:null 判定依赖它排除签名错误的可能。

七步全部通过即证明:签名字节等价(步 5)、密钥封装正确(步 6)、字段级加密与 sec.enc 索引一致(步 7)。

离线单测 24 PASS / 0 FAIL。

密钥非硬编码:每台设备装机时本地生成一对 RSA-2048,再与服务器交换公钥。握手基址来自 Configuration.getDomainV5("tc_login_key_agreement_endpoint"),实测落在 api.mynt.xyz

现网存在两个版本,AgreementAPICallImpl 里都有:

POST 中客户端公钥切成 100 字符一块、逐块用服务器公钥 RSA 封装后上传(单次 RSA/ECB/PKCS1 明文长度受限,公钥 b64 共 392 字符,需分块)。服务器保存客户端公钥后,即可验证客户端私钥签名的 WCSign,因此握手是首个业务请求的前置。

本次实测:

本地 prefs 语义(GHashConfigPrefService):

触发时机:GRSACipher.sign 发现 agreement_private_key 为空时,blockingGet 阻塞执行握手,握手完成后再签。因此「首个请求前必有一次握手」由 lazy-init 决定,非显式编排。

注册 body 几乎只有号与 udid。设备画像在 X-Env-Info JSON 中(再 Base64 进 WCSign header,X-Env-Infom() 即纯 Base64,不 AES——见 §5.2)。

getMobileEnvInfo() 产出的顶层 + extendInfo

然后 d() 会把 MobileEnvInfo 转成 Map,再往同一个 JSON 里塞一批镜像键——这些键塞进 X-Env-Info 这个 JSON 对象里,不是 HTTP 头:

最后 getEnvInfo(scenario) 再补 scenario_id。真机样本(token 脱敏):

deviceId: "amtK7+u7WlUDAGJU3vpuQ3gI" 即 utdid(24 字符 Base64)——§9.3 展开其生成算法。

对齐后,OTP generate 路径的硬要求:

A/B 思路(只改 env 一个维度,看 generate 的 code)已坐实:

X-Env-Info 里三个 native 相关标识,逆向后可复现性差别较大,决定整条离设备链的可行性。

umid(伪线索)modules.x.o.a(ctx) 解析链最终 fallback 到 UTDevice.getUtdid(即 utdid),铸造 RPC 下行无 umid 字段,且 GCash 配置 needUmid=falsecreateStaticRequestumidToken="",X-Env-Info 实测无此键。Alibaba SecurityGuard 的 IUMIDComponent.getSecurityToken(libsgmain)在 GCash 里 0 xref,存在但未接线。无需复现 umid。

utdidcom.ta.utdid2 / UTUtdid.a(),18 字节 → Base64 NO_WRAP = 24 字符):

utdid 半可复现:无法从设备确定性反推(含时间戳 + 随机),但算法已知,可铸一个结构合法、HMAC 自洽的新值(gen_utdid())。持久化较顽固(跨 app、跨卸载):Settings.System["mqBRboGZkQPcAkyk"](明文)/["dxCRMxhQkdGePGnp"](加密),SP Alvin2/UTDID2,外置 /sdcard/.UTSystemConfig/Global/Alvin2/sdcard/.DataStorage/ContextData——任一命中就回填其余。更换 utdid 需清除上述全部位置,否则仅清 app 数据无效。

小结:三者中 umid 无关、utdid 可铸,唯一硬缺口为 apdidToken,下一节展开。

早期 unidbg 路线卡在 PARAM_ERROR,形态上类似未跑通 16 字节 getColorInfo VM 白盒信封。要判断它是否为硬门槛,先逆清 App 原本铸 apdid 的整条 native 链(jadx 看 com.alipay.alipaysecuritysdk.*、ghidra/strings 看 libAPSE_9.0.2.so),再与「离设备直铸」对照。

入口与配置com.gcash.iap.apsecurity.AntApSecurityServiceImpl,Kotlin,基本没混淆):

铸造流水线ApdidManager):

dataMap 字段族createStaticRequest 组包,采集面主体;离设备直铸时将其替换为一个随机 default):

谁在 Java、谁在 native(区分「改机盖得到」与「盖不到」):

native JNI 面libAPSE_9.0.2.soApdidJNIBridge):

硬件指纹计算、ed/ek 加密、dataMap 的 AES 封装均在此,Java 层不可见——这是「必须复现白盒」这一判断的来源。

落库saveToStorage 把服务器铸的 token 写进 SharedPreferences 文件 openapi_file_pri / key openApiGCash(加密);相关 vkeyid_profiles_v4(dynamic_key)、last_apdid_env、native crash-guard filesDir/sc_edge。之后 getToken() 都从这里取缓存,不重算。

该链完整时,「apdid 不可离设备铸造」看似成立:native 采集 + 双重加密 + mgw 签名 RPC + 服务器铸,共四道门。但下述删减实验将其推翻。

对铸造端点做输入删减:

本次 mint 形态:apdideYOIk…tokennwEAAA==(与真机 harvest 一致)。一次 HTTP POST 铸一份,约几百毫秒,替代了在真机上 hook getToken 收割的方式。

方法:对一个看似必须逆向的 native 白盒,通过在铸造端点做输入删减 A/B(逐字段删除、观察服务器响应)即可判定其是否被校验,无需逆向 VM 字节码。

历史实网会话 F6C07A5E(profile vivo 1806 / Android 10):

key:null + "Something went wrong." 形态上类似被风控拦截。以下对照数据逐一排除该假设:

① 同形态在相反状态下逐字段一致 → 不携带状态信息:

key:null 在未注册与已注册下出现且完全一致 → 不可能是「拦截/放行」信号。

② 错码对照证明真 OTP 路径已过 OTP 比对:

若为签名/加密错误,错码与真码应同样在验签前失败;实际错码走到了业务层的 OTP 比对(1011),真码过了比对(code:0),说明加密签名层正确。

③ 反证「拦截说」key:null 的 verify 之后,isGcashRegistered 立刻回 200 + 可导航业务体。若 verify 被风控拦,下一跳应是 403 {code:143}(该形态确实复现过——即「没有前置 verify 就裸调 isGcashRegistered」,补上 verify 即 200),而非业务体。

④ 换真机铸造 token 仍 key:null:A/B 过「离设备空壳 mint 的 apdid」与「Pixel6 真机跑完整 getColorInfo 白盒铸的 token」,两者 verify 都是 key:null,排除「token 太水被拦」。

⑤ jadx 坐实SuccessVerifyBody 只有 key 一个字段;新注册 UI 的成功 handler(g1)不读 key。"Something went wrong." 同时是客户端本地 OtpCodeUtilImp.GENERIC_HEADER 文案,服务端也回了同句——文案表示失败,字段表示成功。

结论:验码成功判据为 code:0OtpAccepted),key:null 是与成功并存的噪声。「号源信誉 / 设备图谱 / KYC 挂起」等假设不成立:虚拟号 + 离设备铸 apdid 即可完整通过 generate → verify(code:0) → isGcashRegistered(200 可导航体)

OTP 闭环之后,注册协议还有最后一跳 register(将 PII + MPIN + apdidToken 打包建号)。该步骤用于验证组包字节级正确性:服务器需完成解密、执行 register 业务逻辑并返回结构化 KYC 信封,方可确认整套 WCSign(header / body / sec.enc)正确。

端点 c4/v3.4/gcash/registerGcashRegistrationApiService.register@Body WCSign),header 走 RegistrationDataSourceImpl.getHeader()(只有 Content-Type / X-UDID / X-FlowId / X-Env-Info / X-Reg-Channel,无 Package/Time/Tracker),env 走 getEnvInfo()(不带 scenario_id,但 Map 里补一个 X-UDID 键)。body 19 个加密字段:

明文 body 里还有 referralCodeversion="1213"rdsDatatermsAndConditions="true"(后两者明文,不进加密集),udid 键置 null(gson 省略,服务器读 X-UDID 头)。caZipcode/paZipcode 是小写 c——这类「1213 版精确键名」错一个字母服务器就当缺字段。

明文 body 里的 rds_data 易被想当然填成非空。但 jadx 确认:注册期 PinEnhancePresenter.h()rdsData 硬编码为空串,注册链路不执行 RDSClient/zipAndEncryptDatasetRdsData 0 xref)。影响:

这印证 §6.3:此处的错误是多填 rdsData 导致签名字节不等价,而非少填。

过了 prehandling 之后是业务风控:

13449 按设备指纹速度累计(deviceThreshold / maxDevicePreCom):复用同一枚陈旧 apdid 或同一份 PII 会累计风险值。对应处理是每个身份现铸 fresh apdid(§10.1,清 device velocity)+ fresh 合成菲律宾 PII(避免 PII velocity)。成功样本:

至此判据满足:纯离设备(无真机、无 frida)跑通 handshake → generate → verify(code:0) → isGcashRegistered → register(code:0 KYC APPROVED),整套 WCSign 组包字节级正确,服务器全程接受。register 段在此仅用作协议正确性证据,不展开为建号工具。

真机冷启动 log(钩子就绪,尚未进 OTP UI):

generateSignedBody 的 BODY/HEADER 日志要等注册发码/验码真正组包才出现;冷启动只先打指纹 RPC(§3.2)和配置 RPC。一处 generateSignedBody 即注册协议明文全集。真机实抓到的 generate BODY(6.01.0):{"msisdn":"0955…","udid":"ANDxdAv…"}scenario_id=first_time_otpappVersion=6.01.0:1216X-FlowId=95a81226-…,与离线 oracle 组的包结构逐字段吻合。

GCash 存在 native 完整性自校,通用 Dobby/inline hook 会触发崩溃:

处理(保活栈,非本文重点,简述):删除 code_cache/data/local/tmp 下的坏 libgcash_sc.so,seccomp 改 RET_TRACE(plain exit(93) 放行),配 ptrace guard 持续 FREEZE exit_groupvmtrace 关闭(Dobby 挂 libAPSE 会触发 CRC)。处理后 OtpMsisdnActivity 存活 ≥25–35s,主 pid 稳定。

generateSignedBody 位于 dex/ART 层,不落入被 CRC 自校的 native .text,因此 Java 层 hook 既可获取完整明文,又不触发 libgcash_sc / libAPSE 的完整性检查。

完成了脱机注册协议的复现:无真机、无 Frida,纯 Python 跑通握手 → 发码 → 验码 → 查号 → 建号,实网建号成功。

本次复现 产出
静态 jadx-headless 加载 gcash_1213_base.apkclass_count=82230load≈58s 关键类 smali(Java 反编译被 anti-decompile 干扰,smali 可读)
密码 oracle 本地临时 RSA 对 + 组包 + 服务器视角解封 24 项离线单测 PASS
握手 直连 api.mynt.xyz key-agreement(v1 GET+POST) 真实 flowId + 服务器 X509 公钥
铸 apdid iclientgw-sea.alipay.com/imgw.htm {apdid,token} 形态与真机一致
历史实网 lab 会话 F6C07A5E(2026-07-20) generate/verify/isreg 明文响应
真机动态 进程内 sergei GCashHook 冷启动(6.01.0) 指纹 RPC 明文 + DynSec 钩子就绪(见 §3.2 / §12)
全链路闭合 纯离设备 register(虚拟号 + 铸 apdid) KYC level 1 APPROVED / FOR CREATION(见 §11)
框架 Handler 注册期实际用途
IAP AC com.iap.ac.android.rpc.RpcInvocationHandler 配置 / 设备指纹上报
Quake com.alipay.imobile.network.quake.rpc.RpcInvocationHandler 改 MPIN 等
打点 为何空
标准 mPaaS RPC 不是那条线
Quake OtpVerificationFacade 改 MPIN,不是注册
明文 okhttp body @Body 已是 WCSign,字段已 AES

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
免费 23
打赏
分享
最新回复 (6)
雪    币: 109
活跃值: (570)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
666
3天前
0
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
感谢分享
3天前
0
雪    币: 254
活跃值: (770)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
444
3天前
0
雪    币: 112
活跃值: (9085)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
kkb
1天前
0
雪    币: 404
活跃值: (135)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
11
22小时前
0
雪    币: 216
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
666
22小时前
0
游客
登录 | 注册 方可回帖
返回