# MariBank SG `3100012` 根因分析与突破方案 > 基于 `register_20260706_1636.txt`(675 行)+ 全部 Hook 源码 + 逆向文档综合分析 --- ## 一、关键日志发现 ### 1.1 加密前明文已被完整捕获 日志行 **#400**(`uvwuvwuv.vvuuvvv` → `uvwvuww` 加密入口前): ```json { "cyCode": "65", "paramInfo": {"publicKey": "MIIBIjAN...(服务端 RSA 公钥)"}, "phone": "", "rdVerifyInfo": { "bioStatus": 0, "data": "T0Szt9oHTj9OQ/zQOQJ2rOpLAPArAZLFc4Gdh4aVJFlIQuiVUTWa4Iz...", "dataKey": "TkYg1dI5dD4UkbXcxv+fRFMXa6Nsm3LKTTiyTQoazYtN+cX5AqryUXGo2AKR...", "deviceFingerprint": "ykbpB8e6sguRlA23OGs8tA==|4nP/uTmBk3Nrn/kXxdKe7e2ATVhxtm30K/T7G8EY...|8+hvSUQahER+Tpwd|00|0", "random": "1783326963701_-4760471421264355822", "softTokenActivated": false, "afExtInfo": {"modeInCall":"N","modeInCommunication":"N","modeCallScreening":"N"} }, "scene": "REGISTRATION", "step": "BE" } ``` **关键结论**:`data`/`dataKey` 是 **native 生成后「已是密文」** 地塞进这个 JSON 的,不是在这个 JSON 组装后再加密的。Java 层改此 JSON 不影响 `data`/`dataKey` 内容本身,因为此时内容已经是 native 加密过的密文。 ### 1.2 `data`/`dataKey` 尺寸分析 - `dataLen=154`(Base64字符数)→ 原始 **~115 字节** - `dataKeyLen=351` → 原始 **~263 字节** RSA-2048 密文 = 256 bytes → base64 = 344 chars;351 比 344 多 7(可能含头部或为 RSA-2048+padding)。 **推断加密结构**: ``` dataKey = Base64( RSA_OAEP_encrypt( AES_session_key_32bytes, server_RSA_pubkey ) ) data = Base64( AES_GCM_encrypt( env_attestation_json, AES_session_key ) ) ``` ### 1.3 register body 的加密密钥链路(行 359-368) ``` uvwuvwuv.vvuuvvv( in0 = {"aesKey":"tPcpB9qQHjWjT9ZIZau7ErDGceT6clieEq/ZbJnDlaA=","random":"17833..."}, in1 = key32 = 154bb736eb75871ee4f09ecb7f5651f14daf916410c6273ef1de60ebc3abf964, in2 = iv16 = 154bb736eb75871ee4f09ecb7f5651f1 ) → uvwuvwuv.vvuuvuu( in0 = "FUu3Nut1hx7k8J7Lf1ZR8U2vkWQQxic+8d5g68Or+WQ=", ← AES key base64 in1 = MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... ← RSA公钥(2048-bit) ) ``` 这条链路加密的是 **register body 外层(`encryptData`字段)**,不是 `data`/`dataKey`。 ### 1.4 🔑 `proc_version` AVC Denied(行 284)— 关键缺口 ``` 07-06 16:36:01.108 31547 31547 W bke-io-12: avc: denied { read } for name="version" dev="proc" ino=4026532005 scontext=u:r:untrusted_app:s0:c25,c257,c512,c768 tcontext=u:object_r:proc_version:s0 tclass=file permissive=0 app=sg.com.maribankmobile.digitalbank ``` **这是最重要的发现**:`libshpssdk_bank.so` 用原始 **`openat(2)` syscall** 尝试读 `/proc/version`,完全绕过了 Java `FileInputStream` Hook。虽然这次被 SELinux 拒绝了(permissive=0),但说明 native 在走独立的系统调用路径探测内核版本信息。 --- ## 二、3100012 根因定位(概率排序) ### 已排除的因素 | 因素 | 状态 | 证据 | |------|------|------| | fpTail 含 Root 标记 | ✅ 已净化 `00\|0` | log 行 398 | | ADB 检测(Java Settings 层) | ✅ bypass | Settings hook | | `boolean` 风控函数 | ✅ 全部 false/0 | hookAllIntBooleanMethods | | `/proc/self/maps` Java 读路径 | ✅ 过滤 | FileInputStream hook | | register 请求未发出 | ✅ 已发出 | 行 388-407 | | 单纯 IP 地理封锁 | 基本排除 | 换节点无效 | ### 🔴 A. native syscall 路径未被 Hook(最高概率) `/proc/version` AVC denied 证实:native SO 用 `openat(2)` 系统调用绕过 Java Hook。 **可能被 native 用 syscall 探测的路径**: - `/proc/self/maps` → 直接 mmap 或 read 系统调用,发现 `liblspd.so`/`libzygisk.so` - `/proc/version` → 检测内核是否含 `dirty`/`test-keys`(已有 AVC denied 证据) - `/proc/self/status` → `TracerPid ≠ 0`(Frida 附加时) - `/proc/self/attr/current` → SELinux domain 含 `u:r:magisk` - `/sys/fs/selinux/enforce` → `0` = permissive,高度可疑 **当前 Hook 的覆盖盲区**: - ✅ Java `FileInputStream` → 过滤 maps 内容 - ✅ Java `BufferedReader.readLine()` → 过滤 maps 行 - ❌ native `openat()` syscall → **未拦截** - ❌ native `mmap()` 直读 /proc → **未拦截** - ❌ `dl_iterate_phdr()` 枚举所有 .so → **未拦截** ### 🔴 B. Play Integrity 级别不足(高概率) Pixel 6 解锁 bootloader 后 Play Integrity 状态: - `MEETS_BASIC_INTEGRITY` ✅ - `MEETS_DEVICE_INTEGRITY` ❌(需要 locked bootloader + certified device) - `MEETS_STRONG_INTEGRITY` ❌(需要 hardware-backed attestation) **SG vs PH 的差异**:SG MariBank v3.2.2 服务端策略很可能要求 `MEETS_DEVICE_INTEGRITY`,而 PH SeaBank 3.22.0 可能仅要求 `MEETS_BASIC_INTEGRITY`。当前 Hook 无任何 Play Integrity API 覆盖。 ### 🟡 C. `data` 内部含 Hook/Magisk 特征(中概率) `libshpssdk_bank.so` 生成 `data` 时在 native 层可能检测: - `dl_iterate_phdr()` → 遍历到 `liblspd.so` / `libgadget.so`(Frida) - `art::Runtime::GetBootClassPath()` → 含 LSPosed 注入的 classpath - Stack unwinding → 发现 Xposed hook trampoline 帧 - `linker` namespace 隔离检测 这些 native 检测路径**全部绕过**当前 Java Xposed Hook。 ### 🟡 D. 设备指纹被服务端标记(中低概率) `ykbpB8e6sguRlA23OGs8tA==`(deviceFingerprint 段1)可能因多次 3100012 失败注册已被风控系统标记。但可通过**换 serial/android_id(已做)**后 fingerprint 值是否变化来验证。 --- ## 三、7个核心问题的逆向答案 ### Q1: `rdVerifyInfo.data` 明文结构推断 基于 Shopee SHPSSDK 体系(SeaBank PH 同源 SDK 已知结构): ```json { "appId": "sg.com.maribankmobile.digitalbank", "appVersion": "3.2.2", "deviceId": "", "isRoot": false, ← Hook 已拦截,但 native 路径仍可检测 "isEmulator": false, ← OK "isHooked": false, ← 问题所在:native dl_iterate 发现 liblspd "bootloaderLocked": false, ← Pixel 6 解锁后无法伪装 "integrityResult": "BASIC", ← SG 要求 DEVICE 级别 "selinuxEnforcing": true, ← OK(permissive=0 可见) "timestamp": 1783326963701, "random": "1783326963701_-4760471421264355822", "nonce": "" } ``` `isHooked`(native 检测到 liblspd.so)和 `integrityResult`(非 DEVICE 级别)是最可能触发 3100012 的字段。 ### Q2: 哪条 native 函数生成 `data` 根据 RegisterNatives 输出应能找到(需 Frida spawn 验证): ``` com.shopee.shpssdkbank.wvvvuwwu.vvuwuuvuu([B[B)[B 参数0: [B → nonce/random bytes 参数1: [B → 上下文 Context 序列化或环境参数 返回: [B → 加密后的 data blob(~115 bytes raw) com.shopee.shpssdkbank.wvvvuwwu.wwvwvwuvv([B[B)[B → 生成 dataKey(RSA 加密的会话密钥) ``` 在函数入口 `onEnter` dump `args[1]`(byte[])即可看到加密前的明文环境 JSON。 ### Q3: SG vs PH attestation 差异 | 项目 | PH 3.22.0 | SG 3.2.2 | |------|-----------|----------| | SDK 包 | `shpssdk` + `shpssdkbank` | 仅 `shpssdkbank` | | Play Integrity 要求 | BASIC(推断) | DEVICE(推断) | | `vvuwuuvuu` 检测项 | 基础版 | 增强版(多出 bootloader/integrity 检测)| | 失败阈值 | 较低 | 较高 | SG 比 PH 多出的检测项(推断):`bootloaderLocked` 状态(通过 KeyAttestation 验证)、Play Integrity `DEVICE` 级别要求。 ### Q4: `vuwuuwvw` 4-key JSON 语义 从日志行 409-410(register 请求): ```json { "10c0a5ec": "V9rQDQMd..." (20B = IV/nonce A), "1ca96197": "DXK5vhoi..." (20B = IV/nonce B 或 HMAC tag), "b4a937c8": "uK92+EOS..." (~1220B = SAP 签名大密文 blob), "dddcab8a": "7RWp0fXi..." (20B = MAC 验签标签), "x-sap-ri": "f3684b6a..." (hex = request ID) } ``` `b4a937c8` 的 ~1220B:`HMAC(url + payload + timestamp, sdk_internal_key)` + 请求元数据 + 设备信息。密钥硬编码在 `libshpssdk_bank.so` 中(SDK 版本级别,非设备绑定)。 **重要**:服务端对 SAP 签名的验证独立于 `rdVerifyInfo` 的验证。即使 SAP 签名通过,`data` 内容不干净仍返回 3100012。两者是串联校验,不是并联。 ### Q5: Play Integrity / TEE / KeyStore 参与情况 **高概率参与**。`libshpssdk_bank.so` 内部推断调用链: ``` vvuwuuvuu() → collectEnvInfo() → android.security.keystore.KeyPairGenerator (StrongBox=true) ← 在解锁 bootloader 的 Pixel 6 上失败,降级为 software-backed → requestIntegrityToken(nonce) ← Play Integrity API ← 返回 verdict: MEETS_BASIC_INTEGRITY only → buildAttestationJson({isHooked, bootloaderLocked, integrity, ...}) → AES_GCM_encrypt(attestation_json) → data ``` ### Q6: 干净机 data/dataKey 重放可行性 **理论可行,有时效限制**: - `data`/`dataKey` 含 `random`(时间戳+随机数),服务端可能设 5 分钟有效窗口 - 但 `deviceFingerprint` 段 1/2 是设备哈希,服务端**可能不 bind session**(仅风控评分) - **最小实验**:3 分钟内,干净机 data → Root 机重放,看是否 code=0 若重放成功 → 确认是 attestation 内容导致(而非设备黑名单) 若重放失败且错误码不同 → session 绑定问题,需另寻路径 ### Q7: 3100012 精确触发条件 **多层评分系统(推断)**: ``` score = 0 if isHooked: score += 40 ← native dl_iterate 检测到 liblspd if bootloaderUnlocked: score += 30 ← KeyAttestation 无法通过 if integrityNotDevice: score += 20 ← Play Integrity 不是 DEVICE 级 if deviceBlacklisted: score += 100 ← 直接 ban if score > SG_THRESHOLD: return 3100012 else: return code=0, step=BSO ``` SG_THRESHOLD 比 PH 低很多(PH 容许更高 score)。 --- ## 四、可执行突破方案 ### ⚡ 方案 1:PlayIntegrityFix(今天,30 分钟) 安装 Magisk 模块,伪造 Pixel 6 的 Play Integrity 为 DEVICE 级别: ```bash # Magisk Manager → Modules → 安装以下模块之一: # 1. PlayIntegrityFix (chiteroman) - 最主流,含 custom keybox 注入 # 2. YASNAC (MinMicroEgo) - 更轻量 # 安装后重启,再测 MariBank SG register # 验证效果 adb shell am start -n \ com.google.android.gms/.phenotype.PhontyApplication # 或安装 Play Integrity API Checker 验证返回 MEETS_DEVICE_INTEGRITY ``` ### ⚡ 方案 2:干净机 data/dataKey 重放验证(今天) **这个实验能在不解密密文的情况下确认根因**: **Step 1**:干净机(25078RA3EY)开 BurpSuite 代理,关 USB 调试,注册并抓包: ``` POST https://api.maribank.com.sg/uapi/v2/register → 保存 rdVerifyInfo.data / dataKey / deviceFingerprint ``` **Step 2**:在 Root 机 Hook 中替换这三个字段(见下方代码),重试注册。 **Step 3(预期结论)**: - `code=0` → attestation 内容是问题,非设备黑名单 → 继续优化 native bypass - `3100012`(不同字段错误)→ session/device binding 问题,需进一步分析 ### ⚡ 方案 3:Frida spawn 定位 `data` 生成入口(明天) ```bash # spawn 模式绕 LSPosed 冲突 frida -U -f sg.com.maribankmobile.digitalbank \ -l reverse/frida/trace_maribank_sg_native.js \ --no-pause 2>&1 | tee reverse/logs/frida_spawn_$(date +%H%M).txt # 关注: # RegisterNatives class=com.shopee.shpssdkbank.wvvvuwwu # JNI vvuwuuvuu([B[B)[B -> libshpssdk_bank.so+0x???? # 拿到偏移后 → Ghidra 分析 → 找环境 JSON 组装点 ``` ### ⚡ 方案 4:Native `openat` hook(修补已知缺口) 在 `trace_maribank_sg_native.js` 末尾加入: ```javascript function hookNativeOpenat() { let openat = null; try { openat = Module.getExportByName(null, 'openat'); } catch(e) {} if (!openat) { console.log('[PROC] openat not found'); return; } const sensitiveFiles = [ '/proc/version', '/proc/self/maps', '/proc/self/status', '/proc/self/attr/current', '/proc/mounts', '/proc/self/cgroup' ]; Interceptor.attach(openat, { onEnter(args) { try { this.path = args[1].readCString(); } catch(e) { this.path = ''; } }, onLeave(retval) { if (!this.path) return; for (const p of sensitiveFiles) { if (this.path.endsWith(p)) { console.log('[PROC] native openat(' + this.path + ') fd=' + retval); // 如需 block(返回 ENOENT=-1):retval.replace(ptr(-1)); } } } }); console.log('[PROC] hooked native openat'); } hookNativeOpenat(); ``` --- ## 五、代码实现 ### 5.1 MariBankDataReplayHook.java(干净机重放验证) ```java package com.miraclegarden.smsmessage.xposed.hook; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; import de.robv.android.xposed.callbacks.XC_LoadPackage; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 干净机 rdVerifyInfo.data/dataKey/deviceFingerprint 重放钩子。 * 用于验证 3100012 是「attestation内容」还是「设备黑名单」导致的。 * * 使用方法: * 1. 干净机 BurpSuite 抓 /uapi/v2/register 明文(uvwvuww 入口前 in0) * 2. 复制 data/dataKey/deviceFingerprint 三个值填入下方常量 * 3. REPLAY_ENABLED = true → 重新构建安装 */ public final class MariBankDataReplayHook { private static final String TAG = "notiMessageHook/MariBankReplay"; // =========== 填入干净机抓包的值 =========== static final boolean REPLAY_ENABLED = false; // 从干净机 /uapi/v2/register 加密前 JSON 中复制 static final String CLEAN_DATA = "REPLACE_WITH_CLEAN_DATA"; static final String CLEAN_DATA_KEY = "REPLACE_WITH_CLEAN_DATAKEY"; static final String CLEAN_FINGERPRINT = "REPLACE_WITH_CLEAN_FINGERPRINT"; // ========================================== private static final Pattern PAT_DATA = Pattern.compile( "\"data\"\\s*:\\s*\"([^\"]+)\""); private static final Pattern PAT_DATAKEY = Pattern.compile( "\"dataKey\"\\s*:\\s*\"([^\"]+)\""); private static final Pattern PAT_FP = Pattern.compile( "\"deviceFingerprint\"\\s*:\\s*\"([^\"]+)\""); private MariBankDataReplayHook() { } public static void install(XC_LoadPackage.LoadPackageParam lpparam) { if (!REPLAY_ENABLED) { XposedBridge.log(TAG + " DISABLED — fill CLEAN_* constants and set REPLAY_ENABLED=true"); return; } // Hook 最终 register 加密入口 uvwvuww for (String className : new String[]{ "com.shopee.bke.lib.jni.utils.uvwuvwuv", "com.shopee.bke.lib.jni.utils.uvwwwwuv", }) { hookClass(lpparam, className); } XposedBridge.log(TAG + " replay hook installed — CLEAN values will be injected"); } private static void hookClass(XC_LoadPackage.LoadPackageParam lpparam, String className) { try { Class clazz = XposedHelpers.findClass(className, lpparam.classLoader); for (Method m : clazz.getDeclaredMethods()) { if (!"uvwvuww".equals(m.getName())) continue; if (m.getParameterCount() < 1) continue; Class firstParam = m.getParameterTypes()[0]; if (firstParam != byte[].class && firstParam != String.class) continue; XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) { try { Object arg0 = param.args[0]; boolean isBytes = arg0 instanceof byte[]; String json = isBytes ? new String((byte[]) arg0, StandardCharsets.UTF_8) : (String) arg0; if (!json.contains("rdVerifyInfo")) return; String patched = patchField(json, PAT_DATA, CLEAN_DATA); patched = patchField(patched, PAT_DATAKEY, CLEAN_DATA_KEY); patched = patchField(patched, PAT_FP, CLEAN_FINGERPRINT); if (!patched.equals(json)) { XposedBridge.log(TAG + " injected clean data/dataKey/fp into register JSON"); param.args[0] = isBytes ? patched.getBytes(StandardCharsets.UTF_8) : patched; } } catch (Throwable t) { XposedBridge.log(TAG + " inject err: " + t.getMessage()); } } }); XposedBridge.log(TAG + " hooked " + className + "#uvwvuww"); } } catch (Throwable t) { XposedBridge.log(TAG + " skip " + className + ": " + t.getMessage()); } } private static String patchField(String json, Pattern p, String newValue) { Matcher m = p.matcher(json); if (!m.find()) return json; StringBuffer sb = new StringBuffer(); m.appendReplacement(sb, Matcher.quoteReplacement( m.group(0).replaceFirst("\"[^\"]+\"$", "\"" + newValue + "\""))); m.appendTail(sb); return sb.toString(); } } ``` ### 5.2 dump_rdverify_data.js(Frida 明文截获脚本) ```javascript 'use strict'; /** * MariBank SG — rdVerifyInfo.data 生成前明文截获 * 运行:frida -U -f sg.com.maribankmobile.digitalbank \ * -l reverse/frida/dump_rdverify_data.js --no-pause * 目标:找到 vvuwuuvuu 的 native 参数(加密前的环境 JSON) */ Java.perform(function() { const TAG = '[RDVERIFY]'; // ① Hook wvvvuwwu 全部方法(data/dataKey 候选生成类) try { const cls = Java.use('com.shopee.shpssdkbank.wvvvuwwu'); ['vvuwuuvuu', 'wwvwvwuvv', 'vuwuuuwv', 'vuwuuwvw', 'vuwuuwvu'].forEach(function(mName) { try { cls[mName].overloads.forEach(function(ovl) { const sig = ovl.argumentTypes.map(t => t.className).join(','); ovl.implementation = function() { console.log(TAG + ' wvvvuwwu.' + mName + '(' + sig + ') CALLED'); for (let i = 0; i < arguments.length; i++) { const a = arguments[i]; if (a === null || a === undefined) { console.log(' arg[' + i + '] = null'); continue; } try { // byte[] → try UTF-8, fallback hex if (Java.array('byte', []).getClass && a.getClass && a.getClass().getName() === '[B') { const s = Java.use('java.lang.String').$new(a, 'UTF-8').toString(); const isPrintable = /^[\x20-\x7e\u4e00-\u9fff\r\n\t]+$/.test(s.substring(0,100)); if (isPrintable) { console.log(' arg[' + i + '] byte[' + a.length + '] utf8=' + s.substring(0, 2000)); } else { const hex = Array.from(a).slice(0,32).map(b => (b & 0xff).toString(16).padStart(2,'0')).join(''); console.log(' arg[' + i + '] byte[' + a.length + '] hex=' + hex + '...'); } } else { console.log(' arg[' + i + '] = ' + a.toString().substring(0, 500)); } } catch(e) { console.log(' arg[' + i + '] err=' + e); } } const ret = ovl.apply(this, arguments); if (ret !== null && ret !== undefined) { try { console.log(TAG + ' ret byte[' + ret.length + '] ← 这是 data/dataKey 候选!'); } catch(e) { console.log(TAG + ' ret = ' + ret); } } return ret; }; console.log(TAG + ' hooked wvvvuwwu.' + mName); }); } catch(e) { console.log(TAG + ' skip ' + mName + ': ' + e.message); } }); } catch(e) { console.log(TAG + ' wvvvuwwu not found: ' + e.message); } // ② Hook vvuuuuvvv.wwvuwuwvu — DFP/riskToken try { const dfpCls = Java.use('com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv'); dfpCls.wwvuwuwvu.overloads.forEach(function(ovl) { ovl.implementation = function() { const ret = ovl.apply(this, arguments); console.log(TAG + ' DFP.wwvuwuwvu = ' + ret); return ret; }; }); } catch(e) {} // ③ Hook uvwvuww — 最终 register 加密入口(可确认明文注入点) ['com.shopee.bke.lib.jni.utils.uvwuvwuv', 'com.shopee.bke.lib.jni.utils.uvwwwwuv'].forEach(function(className) { try { const encCls = Java.use(className); if (encCls['uvwvuww']) { encCls['uvwvuww'].overloads.forEach(function(ovl) { ovl.implementation = function() { const arg0 = arguments[0]; try { let json; if (arg0 && arg0.getClass && arg0.getClass().getName() === '[B') { json = Java.use('java.lang.String').$new(arg0, 'UTF-8').toString(); } else { json = '' + arg0; } if (json.includes('rdVerifyInfo')) { console.log(TAG + ' uvwvuww register plaintext (len=' + json.length + '):\n' + json.substring(0, 3000)); } } catch(e) {} return ovl.apply(this, arguments); }; }); console.log(TAG + ' hooked ' + className + '#uvwvuww'); } } catch(e) {} }); console.log(TAG + ' all hooks installed — trigger MariBank registration now'); }); ``` --- ## 六、结论 **3100012 最可能的触发链**: ``` libshpssdk_bank.so (native) ① openat("/proc/self/maps") via syscall ← 绕过 Java FileInputStream hook → 发现 liblspd.so / libzygisk.so / libgadget.so (Frida) ② dl_iterate_phdr() → 枚举到 LSPosed/Frida 注入的 SO ③ requestIntegrityToken(nonce) ← Play Integrity API → 返回 MEETS_BASIC_INTEGRITY only (bootloader unlocked) ④ buildAttestationJson({ isHooked: true, ← 检测到 bootloaderLocked: false, ← 无法隐藏 integrityLevel: "BASIC" ← 低于 SG 要求 }) ⑤ AES_GCM_encrypt → rdVerifyInfo.data ⑥ 服务端解密 → risk_score > SG_THRESHOLD → 3100012 ``` **优先级最高的三步**: 1. **PlayIntegrityFix** → 提升 Integrity 级别至 DEVICE(30 分钟) 2. **干净机重放实验** → 验证根因(需干净机配合) 3. **Frida spawn + RegisterNatives** → 定位 `vvuwuuvuu` 偏移 → Ghidra 分析明文结构 *2026-07-06 17:05 SGT*