发表于 2026-09-07 目标:com.xxx.xxx v1.0.4(某银行移动 App) 环境:Pixel 6 / Android 15 / 小肩膀定制系统 / Frida 17.16.4 / eCapture / NDK 28 摘要:本文按时间顺序,完整记录一条"抓登录请求明文"的逆向全过程。目标 App 同时踩了四块硬骨头——梆梆加固(DexHelper 抽取式壳) 、反 Frida 主动自毁 、登录流量走 Go 纯 crypto/tls(不走系统库) 、业务逻辑是加密 JS bundle(无明文可读) 。三条常规路线(eCapture / Frida / 内核 HWBP)全部撞墙后,最终靠小肩膀定制系统的"任意 so 注入" + 自写 arm64 inline hook ,在 libgojni.so 的 JNI 业务入口把登录请求的参数和响应头完整落地。本文把走通的、走不通的、以及中途自己犯的错,一五一十都写出来。
起因很朴素:一位在金融行业做审计的朋友,手上有个银行移动端 App 的接口需要核对,托我做一次授权测试,把它的登录请求 抓出来。目标就是 com.xxx.xxx(某银行 App,v1.0.4),要的是 URL / headers / body / response 四件套,登录动作由人在设备上手动完成,需要点登录时停下来通知。
需求落到工具上就一句话:用 xiaojianbang-auto-reverse 工具链里的 eCapture 把这个登录请求抓出来。
当时设备上已经有:
测试机:Pixel 6(arm64-v8a,内核 5.10.209-android13,Magisk root,小肩膀定制系统)
eCapture(eBPF 内核态 TLS 抓包)在 /data/local/tmp/ecapture
frida-server-17.16.4 在 /data/local/tmp/frida-server-17.16.4-android-arm64
jadx 在本地,IDA 需要导出 so 时由人用 ida-no-mcp 插件导出(不能自己跑反汇编工具 )
一眼看去这是个标准的 TLS 抓包任务:eCapture 挂在系统 TLS 库上,一启动就该有明文。结果第一脚就踢到了铁板——而且后面证明,前面拦着的其实是四块硬骨头:梆梆加固壳、反 Frida 主动自毁、Go 纯 crypto/tls 网络栈、加密 JS bundle ,哪一块都不好啃。
还没开始抓包,先撞上了一个更麻烦的问题:App 正常安装启动后,每过几秒就自动退回到初始页面 ,无论点什么操作都是这个循环,根本点不到登录。
这个现象本身就是一条重要线索。我们先做了最基本的排查:这到底是不是 App 自身的 bug?还是我们这边环境引起的?
结论很快就锁定了。当时为了后面走 Frida,frida-server 是开着的 。而把 frida-server 停掉之后,App 立刻恢复正常。于是做了一个对照实验:
崩溃现场的特征很关键(logcat -b crash 摘出来的):
几个点串起来就非常可疑:fault addr 和 pc 都落在 0x97c——一个比任何合法映射都小得多的低地址(参考文章里那篇梆梆实战的 pc=0x1f4 是同一类签名);lr/sp 被清零;backtrace 只剩两个无符号帧。这不是野指针或栈溢出,而是控制流被强行劫持、跳到一个未映射的低地址 ,是加固壳主动自毁的典型手法——把 sp/lr 清零再 br 到低地址,让崩溃现场"干净"到没法回溯。之后 App 就是 Force finishing activity ...MADPWeexActivity,退回 Splash 重启,无限循环。到这里就确认了:这 App 有反 Frida 检测,检测到 frida-server 就自毁 。
这里埋了一个贯穿全过程的教训,我把它提前说出来:"App 能稳定运行"不等于"绕过了反 Frida",只是"没开 frida-server"而已。 后面我一度把这两件事搞混——有一次为了继续测试,手贱又把 frida-server 拉了起来,结果 App 的自毁机制立刻卷土重来,之前"已经稳定、能正常点登录"的环境被我自己亲手打回原形。这个错我记了很久:做对照实验时,一次只变一个变量。
回到抓包主线。既然不能开 frida,那就用 eCapture——它走 eBPF 内核态、通过 uprobe 挂系统 TLS 库,不需要 frida-server、不向目标进程注入,天然不触发自毁。理论上这是最干净的方案。
eCapture 的 tls -m text 模式只 hook 系统 conscrypt 库 /apex/com.android.conscrypt/lib64/libssl.so 的 SSL_write/SSL_read。第一轮抓到了 App 的一点流量:
这是遥测 流量,走系统 conscrypt/BoringSSL,所以 eCapture 抓得到。但登录请求始终不见踪影。
这里还踩了个小坑:一开始用 -p 4010 锁死 App 进程抓,结果 App 在登录流程里自己重启了(4010 → 10980),-p 4010 一直在盯一个已经死掉的 PID,等于抓了个寂寞。改成全量抓包 (不锁 PID)复测后,com.xxx.xxx 进程一条登录明文都没有,只抓到别的进程——小米系统 connect.rom.miui.com 的 GET /generate_204 联网探测。
这个对比其实很有信息量:eCapture 能抓到小米的 conscrypt 流量、也能抓到目标 App 自己的 /collect/i 遥测,说明工具本身是好的 ;偏偏抓不到目标 App 的登录,只能说明登录请求根本不经系统 TLS 库 。而 eCapture 的 tls 模式只挂系统库。这条路,死。
接着试 eCapture 专门针对 Go 程序的 gotls 模式,直接报:
原因:TLS 逻辑在动态库 libgojni.so 里(一个 gomobile 生成的 c-shared JNI 库),不是独立的 Go 可执行文件,gotls 要解析的 Go build info 段根本不存在。两条 eCapture 路子都堵死。
为了搞清楚 libgojni.so 里到底是什么,把 so 导出做了静态分析。这里我犯了一个实打实的错,值得写下来。
第一次导出的 libgojni.so,我拿来做分析,得出一套 crypto/tls 的地址(比如 Write 在 0x37379c)。结果后来发现全错了——第一次导出的是 32 位 ARM 版本(ELFCLASS32 / EM_ARM),而设备跑的是 arm64 。
32 位和 64 位下同一函数的偏移完全错位。重新导出 64 位版本后,地址才对上:
教训:分析 so 前先 file 一下架构 ,32/64 位搞错会浪费一整轮分析。这也是为什么后面每一步都强调"导出时确认是 arm64-v8a"。
libgojni.so 是个 gomobile bind 生成的 Go c-shared JNI 库(go1.15.15,包 github.com/justinlu/MADP_Client_Go)。登录业务处理层就在这里,它自己带 Go runtime、走 Go 自己的 crypto/tls,从不调用 C 的 libssl.so 。
这解释了为什么 eCapture 抓不到:eCapture 的 uprobe 挂在系统 libssl.so 上,而登录流量从头到尾就没进过 libssl.so。
crypto/tls.(*Conn).Write(0x47f800)的 prologue 先是一段 Go 栈检查,然后才建帧:
这帧总共 0xE0(224 字节)。结合反编译,栈槽映射是 Conn=var_D8(-0xD8)、明文 ptr=var_D0(-0xD0)、len=var_C8(-0xC8)、cap=var_C0(-0xC0)。明文 slice 的落点:
关键就是这行 writeRecordLocked(v5, 23, ...)——23(0x17)正是 TLS application data 的 record type。明文就在 writeRecordLocked 的入参里。理论上,hook 住这一层就能拿到登录明文。
既然不能开 frida(会自毁),就想到了 stealth-hook 的内核硬件断点(HWBP)方案:不开 frida-server、不触发自毁,用 KPM 在内核里下硬件断点打 Write 的明文。
但这个方案撞上了一个 go1.15 的栈式 ABI 死穴,是我读反汇编 + 反复追问后确认的:
Go 1.15 是栈式 ABI ,函数参数不是放在 X0–X7 寄存器里,而是放在栈上 。明文 slice 的 ptr/len/cap 是从 caller 通过栈传进来的。
而 stealth-hook 的 KPM handler 有个硬限制:只保存 X0–X7 寄存器,只 dump 寄存器指向的内存(上限 128 字节),不 dump SP 栈内存 。
结果就是:在 Write 裸入口下断点,X0–X7 里根本没有明文指针(在栈上),KPM 又读不到栈 → 抓不到 。
更糟的是,再往下追一层,writeRecordLocked 自己也是 Go 函数、也走栈式 ABI——调用它之前明文 ptr/len/cap 是被 STR 写回栈再传参的,BL 那一刻这些值不稳定停在某个 X 寄存器里 。
结论:HWBP 抓 Go 栈式明文,从原理上就别扭 ,不是"找对指令"能救的,而是"断点那一下寄存器里压根没有指针"。这条路放弃。
这个认知有个反过来的价值:它直接点明了 Frida 恰恰能读 SP 栈 (这正是 HWBP 做不到的)。所以后来重新审视时,结论是"能用 Frida 时,hook Go Write 是可行的;只是 Frida 本身又被反 Frida 挡在门外"——一个死循环。
于是决定正面解决反 Frida:用 Frida spawn 早注入 (frida -f,在 App 主逻辑跑起来之前就布防),而不是 attach(attach 时检测早已跑完)。
写了第一个绕过脚本 bypass_xxx.js,思路是"通用 libc 兜底 + 观察探针":
hook strstr(命中 frida 关键字返回 NULL)、strcmp(命中返回不相等)、fopen(敏感路径返回打开失败)、open/openat(观察)、readlink(含关键字改写为 /dev/ashmem)
观察 android_dlopen_ext(记录 so 加载顺序)、pthread_create(记录线程归属)、kill/tgkill/abort/raise/_exit/exit(自毁时打印 backtrace)
这里先踩了一个 Frida 17 的版本坑,值得单独记一笔。第一版脚本里 installDlopenObserver 用了 Module.findExportByName(null, "android_dlopen_ext")——这是 Frida 17 已经移除的全局符号静态查找 形式,运行直接报 TypeError: not a function。更要命的是,这段代码在 main() 里恰好排在自毁观察器 installSuicideObserver 之前 ,它一崩,后面的自杀观察器根本没装上。于是第一轮 spawn 虽然观察到 App 在 t≈2.2s 自毁,但一条 backtrace 都没抓到 ,白白浪费一轮。改成 Module.getGlobalExportByName(...)、把观察器提前、每个安装包再套上 try/catch 之后,才拿到下面这条决定性 backtrace。
spawn 注入后,App 在 t≈2.1s 自毁。但这次拿到了决定性的 backtrace:
翻译成人话:自毁点不在 libgojni.so(Go 业务层),而在 libDexHelper.so(梆梆加固壳)的 constructor 加载期 。call_constructors → 壳的 init 函数 0x4944 → 子函数 0x35d4 → 走 libc fdopendir 遍历目录(扫 /proc/self/fd 或 /proc/self/task)→ 发现 frida 特征 → abort()。
这还纠正了一个之前的误解:之前以为检测核心在 Go 层(direct syscall 绕 libc),实际上这个自毁点走的是 libc fdopendir,backtrace 里明明白白经过 libc.so ——所以 hook libc 层对它是有效的,svc 绕过 libc 的担心对这个点不成立。只是我们脚本当时没 hook fdopendir/opendir/readdir 这条路,所以没拦住。
但这条 backtrace 只够"定位到壳的 constructor",还不够定罪。为了排除"是我们自己的 fopen 拦截打断了壳加载器、才导致 abort"这种自摆乌龙的假设,又做了一轮 OBSERVE_ONLY 纯观察 对照:所有 libc 兜底只打印、不篡改任何返回值或 buffer。结果 App 仍然 t≈2.1s 自毁——说明自毁与我们的篡改无关,检测真实存在。
而这一次,观察探针抓到了检测的真面目 :strcmp 反复命中,被比较的字符串 s2 赫然是:
把所有线索串起来就通了:libDexHelper.so 的检测,是 fopen/open 反复读 /proc/self/maps(日志里 maps 被 touch 了几十次),逐行 strcmp 比对,命中 /memfd:frida-agent-64.so (deleted) 这一行就 abort() 自毁 。这正是 Frida 17 用 memfd_create 注入 agent 时,在进程内存映射里留下的铁证特征。前面 backtrace 里的 fdopendir 只是另一条并行的检测分支(遍历 /proc/self/task 或 fd 目录)。
顺带也解释了之前那个 pc=0x97c 的 SIGSEGV:它是自毁的一种表现,而 spawn 早注入让我们抓到了更早的 abort 路径。
到这里其实浮现过一个"对症下药"的念头:既然检测靠读 maps 文本 + strcmp,那 hook read/fgets 把 maps 里含 frida/memfd 的行擦掉不就行了?真去试才发现此路不通——壳的 ELF 加载器自己也合法地读 /proc/self/maps 做重定位,粗暴拦截/改内容会打断加载器。这也是后面转向 route(c) 的直接原因之一。
按流程转静态分析。用 function_index.txt 确认:0x35d4 和 0x4944 都落在 sub_33F8(0x33f8)内部(下一个函数是 0x4990),所以两个 backtrace 帧都在同一个函数里。
读了 33F8.c,这个函数不是简单的检测函数,而是一个重度 OLLVM 控制流平坦化混淆的自定义 ELF 加载器 / 重定位器 / 自解密器 :
而 imports.txt 是空的、strings.txt 里 grep 不到任何 frida 关键字、disassembly/ 目录也是空的——梆梆把导入表和字符串都加密了 ,静态看不到 fdopendir、/proc/self/maps 这些字面,检测逻辑只能靠运行时动态读出来(这也是为什么上一步得靠 strcmp_hit 才能抓到真面目)。检测逻辑埋在单个 OLLVM 巨型函数里,且壳几乎必然有 .text CRC 自校验(改磁盘 so 会触发另一层自毁)。
到这里做了个现实判断:硬刚这个 OLLVM blob 静态 patch 的性价比太低 ,而且 maps 扫描大概率只是第一个检测点,绕过之后还会弹出第二、第三个(打地鼠)。反 Frida 这条线,到此先按下不表,转而把"登录逻辑到底在哪"这条线并进来推进。
在正面硬刚反 Frida 的同时,另一条线在推进:搞清楚登录逻辑到底在哪一层。用定制系统的整体脱壳 + 抽取式脱壳 跑了一遍,把脱出来的 dex 拿 jadx 看:
整体脱壳产物(2.9MB):只有 8 个 java 类,全是梆梆壳加载器——IsoService、R、com.secneo.apkwrapper.{a,AP,AW,b,CP,H}
抽取式脱壳 classlist:只有 9 个壳类,同样没有任何业务类
登录业务逻辑一个都不在标准 Java dex 里
再查 App 运行时文件和 APK assets:
运行时 cache/metadata/central.dat:加密二进制,头 c4e0 72af 0d41 5429 ...(高熵);只有 etag.dat 是明文 JSON,内容就一个版本号,无业务
APK 内 assets/main/ 打包了 245 个 hash 命名的 .dat,大文件头 2179 e2b1 9a2a 61e3 ...——全部高熵、无 gzip(1f8b)、无 PK、无明文 JS
APK 内没有任何明文 .js / weex / bundle 文件
结论:这是一个 MADP/Weex 框架的 App ,登录页逻辑以加密 JS bundle 的形式存在,运行时由 MADP 解密后喂给 libweexjsb.so 执行。静态读明文 JS 这条路,彻底死。
但这一步没有白走——它把目标范围大大缩小了:登录逻辑不在地面文件里,只可能在运行时 的某一层。剩下能碰的,就是内存里的业务入口。
三条常规路线(eCapture / Frida / HWBP)各自都有硬伤,于是转向小肩膀定制系统自带的 native 能力。这里其实也有一道方法论上的刹车:工具链有一条纪律是同一检测链连续动态测试失败累计 3 次就得停下来转静态 ,而 Frida 路线已经撞墙多次,继续叠加 hook 只会越陷越深。综合"检测的是 memfd 特征"这个认知,最后定下 route(c)——这也是和作者反复对齐后确认的方向。核心逻辑一句话:
既然检测的是"frida-agent 以 memfd 映射进进程"这个特征,那就换一种"注入产物根本不产生 frida-agent memfd 映射"的方式。
小肩膀定制系统提供"任意批量 so 注入":把普通 .so 放进指定目录,App 启动时由系统加载器直接 dlopen。这个加载不经过 frida-agent、不产生 memfd 映射,libDexHelper.so 的 maps strcmp 检测天然命中不到——不触发自毁 。而且全程不用开 frida-server。
但注入一个 so 之前,得先知道 hook 哪个点。这里用定制系统的native 函数注册监听 (logcat 输出 xiaojianbang ArtMethod::RegisterNative),冷启动 App 抓了一次注册过程:
在 libgojni.so 的 34 条里,出现了一组关键方法:
这就是决定性突破 :libgojni.so 根本不是单纯的 TLS 库,而是一个用 Go 写的完整业务 ServiceWorker 引擎(方法签名里的 go.Seq/go.Universe 是 gomobile bind 的铁证)。resourceApi 是网络请求的总入口 (4 个 String + 1 个 boolean,返回 ApiResponse),所有 API 请求——包括登录——都从这里过;ApiResponse.getBody/getCode/getHeader 是响应的明文出口,aesEncrypt 是加密入口,Context.getParam/secureGetString 是参数与安全存储。
顺带排除另一个候选:libweexcore.so 那 80 条注册全是 com.taobao.weex.bridge.WXBridge.native* / JSContext.native* 之类的 Weex 渲染桥接,纯 UI 引擎,和登录网络无关。
而且地址换算能对上(证明 offset 就是 IDA 文件偏移,可直接跳转):
这比 crypto/tls 的 Write(0x47f800)高了整整一层 :不用去碰 Go 栈式 ABI 的 TLS 内部,直接在 JNI 边界就能拿到业务层看到的原始 URL / method / body / header。
定制系统只提供"让 App 加载任意 so"的机制,没有现成 hook 框架 so ,所以要自己写。核心是 arm64 的 inline hook,代码在 hook_src/libcdbhook.c。
用 dl_iterate_phdr 遍历加载的 so,匹配到 libgojni.so 取 dlpi_addr 作为基址,target = base + 0x637b1c。
这里的 trick 是免修复搬运 :先确认目标函数 prologue 的前 16 字节里没有 PC 相对寻址 (没有 adr/adrp/ldr literal/b 这类指令),这样把 prologue 原样搬到 trampoline 上执行,结果不变,不需要做指令重定位。
原理:
mmap 32 字节可执行 trampoline,前 16 字节原样拷贝 prologue,后 16 字节写 ldr x16,#8 + br x16 + 8 字节的 target+16 地址 → 这样 g_orig 调它时,执行完原 prologue 就跳回 target+16 继续原函数。
把 target 前 16 字节 patch 成 ldr x16,#8; br x16; .quad hk_resourceApi → 任何人调 resourceApi 都先进我们的 hook。
mprotect 连续两页 RWX(防止 patch 跨页)、写、__builtin___clear_cache 刷指令 cache、恢复 RX。
关键就是 GetStringUTFChars 把 4 个 jstring 读成明文落地(logcat tag cdbhook + 文件 /data/local/tmp/cdb_login_capture.txt),然后调 g_orig 拿 ApiResponse,再反射读 getCode/getHeader/getBody。
resourceApi 的 4 个 String 参数语义(哪个是 url / method / body / header)不是猜的,而是两步坐实的。
第一步静态:resourceApi 真正的 Go 实现 MADP_Client_Go._ptr_ServiceWorker.ResourceApi(在 IDA 导出目录 5F55F0.c)里有一段 debug 日志拼接,直接就把参数顺序写死在日志串里了:
即 a2=url、a3=method、a4=body、a5=header、a6=bool。再看它对应的 JNI 桩入口 0x637b1c 的 prologue:
这是 RegisterNative 注册的 JNI native,走标准 AAPCS64:X0=JNIEnv ,X1=this,X2=url,X3=method,X4=body,X5=header,W6=flag *。第二步动态:注入后打印的参数和真实业务 100% 吻合,才最终写死在 hk_resourceApi 的形参里。
顺带说一句为什么选"改 prologue 的 inline hook"而不是"换 RegisterNatives 表指针"这种更干净的做法:后者要 FindClass("worker/ServiceWorker") 重新拿类,而 worker/ServiceWorker 是 App 自己的类、由系统 classloader 持有,注入 so 里的 native 线程根本找不到这个 classloader——经典的 classloader 阻塞,不可靠。inline hook 则完全不用 FindClass,hook 里 X0 直接就是 JNIEnv*、返回值 jobject 也在手,全程无需 attach、无需找类,绕开了这个坑。
用 NDK(.../ndk/28.2.13676358)编译出 libcdbhook.so(11936 bytes,ELF64 ARM aarch64),只链接 log。
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。