找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4478

积分

0

好友

580

主题
发表于 昨天 06:06 | 查看: 16| 回复: 0

目标 App: com.globe.gcash.android (GCash)6.00.2 build 1213

对象层:自研 DynamicSecurity( RequestEncryption / GAESCipher / GRSACipher

本文结构:按分析顺序记录「假设 → 实验 → 否证/坐实 → 数据」。

00 分析目标与判据

需要回答三个问题:

  • 注册发码 / 验码走哪条网络路径(host、path、body 形态)?
  • 加密与签名的原语是什么、字段谁加密谁明文、如何做到本地组包字节级等价于真机?
  • 设备身份(apdid / udid / utdid / 机型)绑在哪一层、哪些能纯离设备造、哪些是硬缺口?

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

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

01 复现环境

GCash 注册协议复现分层表格:静态、密码、握手、铸 apdid、历史实网、真机动态、全链路闭合

02 抓包入口:系统代理探测

2.1 现象

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

This feature does not support Android ver4.4andlower

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

2.2 判断

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

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

03 RPC hook:设备指纹旁路,非注册 OTP

3.1 假设

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

3.2 实验

进程内 hook Proxy.newProxyInstance,对带 @OperationType 的 InvocationHandler 挂 invoke

同进程实际两套:

框架与 Handler 注册期实际用途对照表

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

operationType = alipay.security.deviceFingerPrint.staticData.report.v2
url = https://iclientgw-sea.alipay.com/imgw.htm
requestData = [{
"apdid": "eYOIklFxBezvpB4O2bCc1f4M…",
"bizData": {"reqType":"1"},
"dataMap": {
"AA1": "com.globe.gcash.android",
"AA2": "6.01.0",
"AA3": "APPSecuritySDK-OVERSEA",
"AA4": "P9.0.2.20250905",
"AE1": "android",
"AE10": "M2007J3SG",
"AE12": "12",
"AE13": "36"
  },
"os": "android"
}]

同次启动 TokenResult 缓存:

apdid      = eYOIklFxBezvpB4O2bCc1f4M…
apdidToken = …nwEAAA==
umidToken  = null

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

据此确认:

  • 进程内 hook 能绕过反 MITM 拿明文;
  • 改机字段(机型 AE10、系统 AE12 / AE13)进了指纹上报;
  • 铸造端点形态与 §10 离设备 mint 一致( os:"android" + imgw),token 尾缀同为 nwEAAA==
  • umidToken=null,GCash 的指纹 SDK 未启用 umid(§9.3 坐实 needUmid=false),排除了「必须复现 umid」这一方向。

3.3 否证

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

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

04 从 Activity 到 Retrofit 注解

4.1 调用链(smali 级)

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

body = LinkedHashMap{ msisdn ← params, udid ← params }   // scenarioID 不进 body
header = GKApiServiceDynamicSecurity.Companion
           .getEncHeaders( mapOf("scenarioID" → scenarioID) )
wcSign = RequestEncryption().generateSignedBody(
           header, body, listOf("msisdn"), "POST")
→ safeCall { service.generateOtpCodeNew(wcSign) }

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

# GKApiServiceDynamicSecurity.generateOtpCodeNew
.annotation runtime Lretrofit2/http/POST;
    value = "c4/v2.3/otp/generate_code"
.end annotation
# @Body WCSign

# GKApiServiceDynamicSecurity.verifyOtpCode
.annotation runtime Lretrofit2/http/POST;
    value = "/c4/v2.3/otp/verify_code"
.end annotation

Host 不在注解里,实测基址 https://api.mynt.xyz(与旧 c4/v1/otp/* Map body 路径并存;注册新路径走 v2.3 + WCSign)。

4.2 三种「在线 hook」落空的原因

打点为何空:标准 mPaaS RPC、Quake OTP、明文 okhttp body 的对照

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

05 generateSignedBody 结构(smali 调用图)

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

5.1 顶层五步( generateSignedBody smali)

p1 = EncryptedHeader headers
p2 = body Object
p3 = List encParams
p4 = method String

WCEncrypt = b(headers, body, encParams, method)   // 组 request + sec
payload    = n(WCEncrypt)                          // gson → m() Base64
sig        = l(payload)                            // GRSACipher.sign(priv)
sign       = sig + "." + payload
return new WCSign(sign, this.d /*aesKey*/, this.c /*iv*/)

b() 再拆:

EncryptedRequest  = h(headers, body, encParams, method)  // 内含 d() 头加密 + c() body 加密
EncryptedSecurity = i(headers, encParams)                // RSA 封 key/iv + enc 路径清单
return new WCEncrypt(request, security)

n()

gson.toJson(obj) → m(json)   // m = Base64 NO_WRAP

l()

GRSACipher.sign(payload, GHashConfigPrefService.getPrivateKey())

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

5.2 头加密 d():谁走 AES、谁走 Base64

smali 明确分支:

字段处理函数对照表:Authorization/X-Package-Id/channel 等走 AES,X-Env-Info 走 Base64

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

5.3 body 加密 c():只动 encParams 点名路径

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

5.4 i():EncryptedSecurity

initializer = GRSACipher.encrypt(serverPub, iv)      // 字段 this.c
key         = GRSACipher.encrypt(serverPub, aesKey)  // 字段 this.d
enc         = j(headers路径) + k(body encParams路径)
// EncryptedSecurity 构造顺序在模型里对应 enc / initializer / key

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):

[
"request.header.X-Package-Id",
"request.header.X-Reg-Channel",
"request.body.msisdn"
]

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

5.5 RequestEncryption 私有方法全景(smali 单字母混淆 → 语义)

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

WCSign 与 WCEncrypt 混淆方法签名及关键点说明表

调用树:

generateSignedBody
├─ b → WCEncrypt
│   ├─ h → EncryptedRequest{ body:c(body,encParams), header:d(header), method }
│   │        c(叶子)→e()=AES        d(命中头)→e()=AES / X-Env-Info→m()=Base64
│   └─ i → EncryptedSecurity{ enc:j(header)+k(encParams),
│                             initializer:RSA(iv), key:RSA(aesKey) }
├─ n → payload = m( gson(WCEncrypt) )           // Base64(gson)
├─ l → sig     = RSA_sign(payload)              // SHA256withRSA
└─ WCSign( sig + "." + payload, aesKey, iv )

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

06 密码学原语与字节等价约束

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

6.1 AES — GAESCipher.encrypt

Cipher.getInstance("AES/CBC/PKCS5Padding")
SecretKeySpec( secretKey.getBytes(UTF_8), "AES" )
IvParameterSpec( iv.getBytes(UTF_8) )
Base64.encodeToString(doFinal(...), flag=2)   // 2 = NO_WRAP

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

  • getSecretKey(n) = NanoIdHelper.generate(n),注册用 n=32 → 32 个可打印字符 → 32 字节 → AES-256;iv = NanoId(16) → 16 字节。
  • NanoId 字母表:aesKey/iv 用 URL-safe 64 表 _-0-9A-Za-z(密钥每字节落在这 64 个可见 ASCII 内,密钥空间为 64^32 而非 256^32,熵略低;对复现无影响,dump 出来为可读字符串)。

固定材料自检:

key = "k"*32, iv = "i"*16
AES("63XXXXXX0774") = 3K3bz/aoHS3Hj8sN5ie/bw==

6.2 RSA — 一类三用(sign / seal / verify)

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

RSA 三类用途:签 payload、封 aesKey/iv、验签的 smali 与算法对照表

# sign
PKCS8EncodedKeySpec( Base64.decode(priv, 2) )
Signature.getInstance("SHA256withRSA")
initSign → update(message UTF-8) → sign
Base64.encodeToString(..., 2)

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

6.3 gson 字节等价约束(四条件)

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

四条约束:

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

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

...BvaAQU0caw\u003d\u003d","udid":"ANDoiGQf...

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

gson_dumps({"msisdn":"3K3bz/aoHS3Hj8sN5ie/bw==", "udid":"AND..."})
→ {"msisdn":"3K3bz/aoHS3Hj8sN5ie/bw\u003d\u003d","udid":"AND..."}

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

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

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

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

三个容器实测顺序:

容器字段字母序:WCEncrypt、EncryptedRequest、EncryptedSecurity

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

Authorization, channel, channelSecret, Content-Type, Correlator-ID, Time,
X-AccountId, X-Correlator-Id, X-DBID, X-Env-Info, X-EventLinkId, X-FlowId,
X-LinkRequestId, X-Package-Id, X-Reg-Channel, X-Security-Id, X-Service-Prefix,
X-Tracker, X-UDID, X-UserId

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)。

07 离线 oracle:服务器视角往返验证

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

7.1 步骤

  • simulate_handshake() → 客户端对 + 假服务器对;
  • 铸一枚真实 apdidToken(可选,填 env);
  • build_generate_request(msisdn);拆 sign = sig + "." + payload
  • 客户端公钥验 sigGRSACipher.verify 镜像);
  • 服务器私钥 RSA 解 sec.key / sec.initializer → 拿回 aesKey/iv;
  • sec.enc AES 解密,与 cleartext 比对。

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

7.2 本次复现输出

=== APDID MINT ===
apdid_prefix  eYOIkj0lHUX4/PatvP6VEP2y...
token_suffix  ...uxyznwEAAA==

=== GENERATE CLEARTEXT ===
url             https://api.mynt.xyz/c4/v2.3/otp/generate_code
cleartext_body  { msisdn: 63XXXXXX0774, udid: ANDoiGQfhW4E7HFonHP1ZsFgMcRFtdoy }
enc_params      [msisdn]
scenario_id     first_time_otp
aesKey_len      32   iv_len 16   iv = p9JG_LpYItCkktgJ

=== WIRE ===
sign 总长 ~3733   sig_b64 ~344   payload_b64 ~3388
sec.enc = [request.header.X-Package-Id, request.header.X-Reg-Channel, request.body.msisdn]
body.msisdn 密文前缀 OM8Bq6ZJ/LFlBvaAQU0caw==...
body.udid   明文     ANDoiGQfhW4E7HFonHP1ZsFgMcRFtdoy
request 无 method 字段(POST → null → gson 省略)

=== X-Env-Info 解码摘录 ===
terminalType APP
appVersion   6.00.2:1213
osType       Android   channel GCASH_APP
scenario_id  first_time_otp
extendInfo.phoneModel SM-A125F
extendInfo.lbsErrorCode 1000
tokenId / dfpToken 尾缀 ...nwEAAA==

=== SERVER RECOVER ===
unsealed aesKey/iv 与本地一致
decrypted msisdn        = 63XXXXXX0774
decrypted X-Package-Id  = com.globe.gcash.android
decrypted X-Reg-Channel = 16

=== VERIFY ===
sec.enc 含 request.body.msisdn + request.body.code
code 解回 423323,udid 仍明文

离线单测 24 PASS / 0 FAIL。

7.3 从真值反推的结构图

flowchart TB
    subgraph clear [加密前]
      B["body {msisdn, udid}"]
      H["EncryptedHeader + Env JSON"]
      K["aesKey 32 / iv 16"]
    end
    subgraph wire [线路]
      S["sec.enc 路径清单"]
      R["RSA 封 key+iv"]
      P["gson+Base64 payload"]
      G["SHA256withRSA → sig.payload"]
      W["HTTP {sign}"]
    end
    B --> S
    H --> S
    K --> R
    B --> P
    H --> P
    R --> P
    S --> P
    P --> G --> W

08 密钥握手(联网复现)

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

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

# v1(本文实测走这条)
GET  https://api.mynt.xyz/c4/v1/key-agreement/handshake?v=2&du=<udid>
     → { pub: <服务器 RSA-2048 X509 公钥, 392 字符 b64>, flowId: <UUID>, traceId, version }
POST https://api.mynt.xyz/c4/v1/key-agreement/handshake
     body = { pub: [客户端公钥切 100 字符块, 逐块 RSA/ECB/PKCS1(serverPub)], du, flowId }
     → { code:"0", message:"Successfully saved!" }   ← 服务器保存我方客户端公钥

# v3(存在,另一形态)
POST https://api.mynt.xyz/c4/v3/key-agreement/handshake
     → ResponseAgreement{ pub, flowId, traceId }

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

本次实测:

udid        = ANDOHVotHEZBSfI1sDVfmZK7ZcmwqRHb
HANDSHAKE_OK
flowId      = 2ad9a51e-bf9e-43ac-901e-6424f4a69e47
server_pub  = MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC...  (X509 b64, len=392)
client_pub  len=392
client_priv len=1624(不贴全文)

本地 prefs 语义( GHashConfigPrefService):

握手相关 prefs key 语义表

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

09 X-Env-Info 与设备标识

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

9.1 完整字段(对齐 GNetworkUtil.getMobileEnvInfo + d() + getEnvInfo(scenario)

getMobileEnvInfo() 产出的顶层 + extendInfo

terminalType="APP"  orderTerminalType="APP"  channel="GCASH_APP"
tokenId = apdidToken            deviceId = UTDevice.getUtdid()   ← utdid,有值就写
appVersion = "6.00.2:1213"      osType="Android"  osVersion=RELEASE
extendInfo{
  userAgent="GCash App Android"(固定注入,非 WebView UA)
  dfpToken = apdidToken
  appVersion, phoneBrand, phoneManufacturer, phoneModel,
  phoneOsVersion = "release,sdk"(如 "11,30"),
  udid, currencyCode="PHP", referenceId=NanoId32
}
定位成功 → 顶层 latitude/longitude + extendInfo{LBSType="gps", acc, LBSUpdateTime}
定位失败 → extendInfo.lbsErrorCode = 1000(lab 抓包常见此值)

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

User-Agent, X-DFP-TOKEN(=apdidToken), X-APP-VERSION,
X-PHONE-BRAND, X-PHONE-MANUFACTURER, X-PHONE-MODEL, X-PHONE-OS-VERSION

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

{
"terminalType": "APP", "tokenId": "eTPYTWV6...REDACTED",
"appVersion": "6.00.2:1213", "osType": "Android", "osVersion": "11",
"orderTerminalType": "APP", "channel": "GCASH_APP",
"extendInfo": { "phoneModel": "SM-A125F", "udid": "ANDoiGQf...",
"currencyCode": "PHP", "lbsErrorCode": "1000", ... },
"deviceId": "amtK7+u7WlUDAGJU3vpuQ3gI",
"X-DFP-TOKEN": "eTPYTWV6...oWjDuxyznwEAAA==",
"X-PHONE-MODEL": "SM-A125F", "scenario_id": "first_time_otp"
}

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

9.2 硬字段与 A/B

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

generate 硬字段实测与要求表

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

  • scenario_id / appVersion build 号是硬校验(缺则 code:1);
  • deviceId 不是「去掉就过 / 加上就失败」的开关—— deviceId=onoff 两种变体 generate 都 code:0。早前「删掉 deviceId 就好」的判断有误,smali 里 deviceId 恒等于 utdid;
  • 同一 session 第 3 次 generate → code:2 "Retries exceeded"(OT201421)——限流按 udid/apdid 身份计,不是字段问题; pm clear 可清本地侧计数。

9.3 三个设备身份标识:哪些能纯离设备造

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

apdidToken、umidToken、utdid 三个标识来源与纯离设备可造性对照表

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 18 字节结构字段表

  • HmacSHA1 固定 key(硬编码在 com.ta.utdid2.device.c): d6fc3a4a06adbde89223bvefedc24fecde188aaa9161
  • hashCode = Java String.hashCodeh = 31h + c,32 位溢出)
  • 校验器 c.b(String):长度 == 24 且字符集 [0-9a-zA-Z=/+]

utdid 半可复现:无法从设备确定性反推(含时间戳 + 随机),但算法已知,可铸一个结构合法、HMAC 自洽的新值( gen_utdid())。

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

10 两个误导性信号:apdid 白盒与 key:null

10.1 apdid 是否必须复现 getColorInfo 白盒

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

10.1.1 APSE 采集层结构(对照)

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

APP_NAME  = "GCash"
BIZ_TOKEN = "XNRt6WZc/5sZ038Ox3Q2fyX/"  // APSE appKey
Configuration{ gateway = "https://iclientgw-sea.alipay.com/imgw.htm",
               envMode  = 0 (ONLINE),
               needUmid = false,                  // ← GCash 不取 umid(§9.3)
               secret   = "1" (wbType) }
inputParams = { tid: DeviceUtils.getDeviceId(ctx), utdid: UTDevice.getUtdid(ctx) }
APSecuritySdk.init(ctx, "GCash", BIZ_TOKEN); APDID.initToken("GCash", inputParams, cb)
getToken() = APDID.getTokenResult("GCash").apdidToken     // ← 注册 X-Env-Info 用的就是它

铸造流水线ApdidManager):

baseInitToken → doFirst → createStaticRequest → RPCService.updateStaticData → doResponse → saveToStorage
env 变清缓存        组上行(采集+双封装)         mgw 铸造 RPC              回填服务器铸的 apdid/token

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

APSE dataMap 四组字段族:AD 硬件指纹、AE Build 环境、AC 身份、AA App/SDK

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

  • Java 侧实读: TelephonyManager.getDeviceId()(IMEI)、 Settings.Secure.android_idWifiManager.getBSSID()getInstalledPackages(64)(装机列表);
  • Java 不读、若采则在 native:IMSI、SIM serial、 getMacAddress、BOOTLOADER——这些在 libAPSE 里采,Java hook 看不到;
  • Configuration.secret != null 时整张 dataMap 再过 JNIBridge.aesEncrypt native 加密 → {default:<enc>, wbType:secret}。这即离设备直铸中 default 字段的来历。

native JNI 面libAPSE_9.0.2.soApdidJNIBridge):

initCollect / getCollectInfo / getCrashInfo / isCrashBefore / decryptConfig /
getDynData / getAA13 / getAD102 / getAD104 / getAD108 / getAE20 / getNativeProp

硬件指纹计算、 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 + 服务器铸,共四道门。但下述删减实验将其推翻。

10.1.2 铸造端点不校验白盒

对铸造端点做输入删减:

POST https://iclientgw-sea.alipay.com/imgw.htm
operationType=alipay.security.deviceFingerPrint.staticData.report.v2
requestData=[{ "apdid":"", "os":"android", "dataMap":{"wbType":"1","default":<任意 base64>} }]
  • requestData 带上 os:"android"resultCode=SUCCESSbizData 垃圾/缺省/截断都行, default 填随机 600 字节也行。
  • PARAM_ERROR 根因是缺 os 字段,而非白盒未复现。该 2940 字节 VM 字节码 / 16 字节 getColorInfo 信封与铸造成败无关(伪线索)。
  • 铸出的两个值来源不同: apdid = 确定性 f(dataMap.default)(改 default → 换新 apdid,用于 OTP 限流时轮换设备身份), token = 服务器每次现发。

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

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

10.2 verify 回包 key:null

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

generate、verify、isGcashRegistered 三个步骤的 HTTP 状态与响应体

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

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

全新号与已注册号的 verify 回包和 isGcashRegistered 返回对照表

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

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

verify 输入 回包
错 OTP 000000 / 123456 422 code:1011 "Your OTP is invalid." / "Invalid authentication code."
真 OTP 201 code:0 key:null

若为签名/加密错误,错码与真码应同样在验签前失败;实际错码走到了业务层的 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 文案,服务端也回了同句——文案表示失败,字段表示成功。

key:null 相关假说与证据结论对照表

结论:验码成功判据为 code:0OtpAccepted), key:null 是与成功并存的噪声。「虚拟号 + 离设备铸 apdid 即可完整通过 generate → verify(code:0) → isGcashRegistered(200 可导航体)

11 register 终段:组包正确性与 RTS 风控

OTP 闭环之后,注册协议还有最后一跳 register(将 PII + MPIN + apdidToken 打包建号)。该步骤用于验证组包字节级正确性:服务器需完成解密、执行 register 业务逻辑,方可确认整套 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 个加密字段:

encParams(19) = [msisdn, firstName, lastName, email, dateOfBirth, address, tokenId, mpin,
                 caCountry, caProvince, caTown, caZipcode, paCountry, paStreet, paProvince,
                 paTown, paZipcode, nationality, mainSourceOfFunds]

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

11.1 rdsData:一个易错点

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

影响:

  • rdsData 明文进 RSA 签名 payload。填非空 → 本地 payload 字节与真机不一致 → 422 code:400 "Invalid Signature Key: Failed verification at prehandling"(验签在 prehandling 阶段失败);
  • 填空串才是真机字节,过 crypto prehandling → 进业务层。

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

11.2 RTS 风控与「组包已对」的判据

过了 prehandling 之后是业务风控:

register 回包码与含义对照表

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

成功样本:

"register": { "http": 200, "body": {
"KYCDetails": { "applicationStage":"FOR CREATION", "applicationStatus":"APPROVED",
"firstName":"PEDRO", "lastName":"GAMBOA", "nationality":"FILIPINO",
"registrationChannel":"APP_REGISTRATION", ... },
"KYCLevel":"1", "code":0, "message":"User Successfully Registered",
"transactionID":"5f561d74698dc62e9adcef77174da47f" } }

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

12 进程内明文抓取点

12.1 打点

// 伪代码
hookAllMethods(RequestEncryption, "generateSignedBody", before → {
// ⚠️ 必须 before:方法内 d() 会就地 AES/Base64 改写 header
// args[0] EncryptedHeader 明文(含 X-Env-Info 原文)
// args[1] body 明文 {msisdn,udid[,code]}
// args[2] encParams
}, after → {
// result  WCSign(sign, aesKey, iv)
});
hookAllMethods(RequestEncryption, "decryptRequest", after → log(result));
// 密钥:GHashConfigPrefService.getApiPublicKey / getPrivateKey / getApiFlowId
// 辅助:GRSACipher.sign/encrypt、AntApSecurityServiceImpl.getToken、UTDevice.getUtdid

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

[GCash-rpc]  proxy-discovery armed
[GCash-http] hooked okhttp3…RealCall.getResponseWithInterceptorChain
[GCash-dynsec] hooked generateSignedBody
[GCash-dynsec] hooked GRSACipher sign/encrypt
[GCash-dynsec] hooked getToken
installed … rpc=true

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 组的包结构逐字段吻合。

12.2 为什么选 dex 层打点

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

  • 现象:点输入 / Next 后约 2–4s 死, main gone status=11
  • 根因: libgcash_sc.so 在线程 gcash-sc-loadphdr_cb 触发 SIGSEGV——它遍历自己的 program header 做 .so CRC 自校,Dobby 改了目标 .text 即被检出;
  • 另一处:早期 seccomp 用 SECCOMP_RET_ERRNO|EPERMexit/exit_group,触发 ApplicationExitInfo SIGILL status=4

处理(保活栈,非本文重点,简述):删除 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 的完整性检查。

13 最终效果

完成脱机注册协议的复现:纯 Python 跑通握手 → 发码 → 验码 → 查号 → 建号。

14 附录:关键类(6.00.2)


# 注册协议主链
gcash.common_data.source.otp.OtpRepositoryImpl
gcash.common.android.network.api.service.GKApiServiceDynamicSecurity
gcash.common.android.util.encryption.RequestEncryption
gcash.common.android.util.agreement.GAESCipher
gcash.common.android.util.agreement.GRSACipher
gcash.common.android.util.agreement.AgreementAPICallImpl
gcash.common.android.model.encryption.WCSign / WCEncrypt / EncryptedHeader / EncryptedSecurity
gcash.common.android.util.GNetworkUtil                       # getMobileEnvInfo / d / getEnvInfo
gcash.common.android.pref.GHashConfigPrefService             # 握手密钥 prefs

# 建号 / 查号
(Gcash)RegistrationDataSourceImpl / GcashRegistrationApiService   # c4/v3.4/gcash/register
(Gcash)IsGcashRegisteredUseCase                                   # isGcashRegistered 查号
gcash.common.android.util.OtpCodeUtilImp                           # GENERIC_HEADER

# native 标识
com.gcash.iap.apsecurity.AntApSecurityServiceImpl                 # getToken()=apdidToken
com.alipay.alipaysecuritysdk.apdid.manager.ApdidManager           # 铸造/缓存
ApdidJNIBridge(libAPSE_9.0.2.so)                                 # initCollect/getCollectInfo
com.ta.utdid2 / com.ut.device.UTDevice(UTUtdid.a)                # utdid 生成



上一篇:RTX Spark笔电首批售罄 华硕微星紧急加单采购
下一篇:美光台湾工会酝酿罢工获80%会员支持,HBM供应链或受冲击
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-3 20:07 , Processed in 1.151720 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表