从抓包到 unidbg:一个 Akamai BMP 移动端接口的离机复现全记录
一次为竞品声量(SOV)分析做的移动端接口逆向:从 FlowTrans 抓包定位接口,到脱离 App 重放,再到 Frida 现场 harvest,最后用 unidbg 把 Akamai BMP 的
x-acf-sensor-data完全离机现算、闭环验证被服务端接受。⚠️ 全文中所有
client_id/client_secret、设备标识、代理账密、目标域名细节均为占位/脱敏,仅用于授权范围内的调试与学习。请遵守你所检测应用的服务条款与法律。
一个电商 App 的搜索接口,返回的是”某关键词下的排名商品 + 广告位”——正是做 Share of Voice 的数据源。难点只有一个:它挂在 Akamai Bot Manager 后面,每个请求都要带一个几 KB 的 x-acf-sensor-data 头,缺了就 403 Access Denied。
这篇把从抓包到”纯 PC 离机现算 sensor”的完整链路记下来。
姊妹篇:需要设备的路线(Frida 主动调用 + 云手机 / RK3588 redroid 设备池)见《从 x-acf-sensor-data 到规模化》;本文是它的进阶——完全离机。
一、抓包:先看清接口
目标 App 是 OkHttp/Cronet 栈,忽略系统 HTTP 代理,所以不能靠 settings put global http_proxy。用 FlowTrans(mihomo TUN)在三层透明劫持,把指定 App 的流量转到 PC 的 mitmweb:
1 | |
抓到的关键接口(HTTP/2,base apis.example.com):
POST /v1/oauthprovider/oauth2/token— 取 Bearer(guest token)GET /fulcra/search/items?searchTerms=…— SOV 搜索,返回itemList[](顺序=排名)、itemCount、sponsoredItemCount,每项含brand / sponsored / price / rating
sponsored=true 的就是广告位——按 brand 聚合、按 sponsored 拆自然/广告,就是关键词维度的品牌声量。
二、脱离 App 重放:静态凭据 + 唯一活门槛
看 token 接口的请求体:
1 | |
是 client_credentials,client_id/secret 是 App 内置常量——不需要用户登录,谁都能换 guest token。于是整条链只剩一个真正的门槛:Akamai 的 x-acf-sensor-data(token 和 search 两个接口都要它)。
实测:
- 不带 sensor →
403 Access Denied(errors.edgesuite.net,Akamai 边缘拒绝) - 带一条抓到的 sensor → 过 Akamai,进到应用层
而且这条路 Akamai 没死磕 TLS/JA3(Python 默认指纹都过),sensor 在时间窗内可复用。唯一的坑:同一条 sensor / 同一出口 IP 反复猛打会被限流——实测十几次后连 App 自己都 403,得换干净出口 IP。
结论很清楚:要脱机自动化,核心就是能自己造 sensor。
三、定位 sensor 生成逻辑
x-acf-sensor-data 的值以 6,a, 开头 = Akamai BMP sensor v6。静态扫 dex:
1 | |
一开始以为是纯混淆 Java,后来在 split_config.arm64_v8a.apk 里发现 libakamaibmp.so(2.8MB),导出静态 JNI:
1 | |
Java 侧采集设备/行为信号 → JNI 调 buildN 加密 → 出 sensor。这决定了两条路:Frida 现场 hook,或 unidbg 离机模拟。
四、Frida:现场 harvest(要设备,但先跑通)
hook getSensorData()(混淆后是 a())就能按需拿新鲜 sensor。踩了几个坑:
- Frida 17 移除了全局 Java:裸脚本
Java.perform直接ReferenceError: 'Java' is not defined,要import Java from "frida-java-bridge"+frida-compile。 - App 有自-ptrace 反调试:
attach一律ServerNotRunningError: closed(枚举正常、attach 就断),得用 spawn 模式早注入,并 hookptrace→no-op。 - frida-server 端口被抢:设备上有个 uid=2000 的 frida-server 自动重生抢占 27042,客户端连它就
need Gadget / jailed。解法:root 起在非默认端口,客户端add_remote_device定连。
跑通后 rpc.mint() 直接调 a() 返回全新 sensor——但这仍要一台跑着 App 的设备。真正想要的是完全离机。
五、unidbg:把 sensor 搬到纯 JVM 现算
unidbg 能在 JVM 里加载 ARM64 .so 并调 JNI 函数。buildN 是静态 Java_ 导出——正是 unidbg 的强项。
5.1 先 trace 出 buildN 的输入规格
Frida 在真机上 hook SensorDataBuilder.buildN,dump 它的入参。关键发现:buildN(java.util.ArrayList) : String,入参是一个自包含的 ArrayList<Pair>(27 项),native 不靠回调自取、而是把采集好的数据整个传进来:
1 | |
输入自包含 → unidbg 只需拼同样的 ArrayList + 调 buildN,不用 mock 一堆回调。这把难度从”地狱级”降到”可做”。
5.2 卡点一:反调试不是 brk,是 undefined 指令
harness 载入 .so 时,.init_array 直接把 unidbg 顶进交互式调试器、疯狂刷寄存器 dump(表现为”卡死”)。第一反应是 brk 反调试,但检查 EXCP_BKPT 拦不住。加日志打印实际 intno:
1 | |
原来反调试用的是 undefined 指令陷阱,不是 brk。解法——子类化 AndroidARM64Emulator,重写 createSyscallHandler 注入一个自定义 handler:凡非-SWI 中断一律推进 PC 跳过、不进调试器(真 syscall 交给 unidbg,sigprocmask 之类它本就支持):
1 | |
一改,loaded libakamaibmp.so——init 过了。
5.3 卡点二:补 JNI 回调
init 之后,native 通过 JNI 读设备字段:
1 | |
在 AbstractJni 里补上:getStaticIntField(SDK_INT=34)+ getStaticObjectField(一批 Build.* 模板值),再把 27 项入参经 ArrayList.size/get + Pair.first/second 喂进去。再跑:
1 | |
纯 JVM、无设备,吐出了合法 6,a,… sensor。
5.4 闭环验证:服务端认不认
格式合法不等于能用。把 unidbg 现算的 sensor 直接拿去打接口(走干净美区出口、不经 mitmproxy):
1 | |
Akamai 接受了。 整条生产链在纯 PC 上跑通:
1 | |
六、几个可复用的经验
- Akamai BMP 4.x 的核心在 native(
libakamaibmp.so),buildN静态 JNI 导出、入参自包含 → 对 unidbg 友好。 - 反调试可能是 undefined 指令陷阱(intno=1)而非 brk——
intno != EXCP_SWI一律跳过更省事。 - Frida 17 无全局 Java(frida-java-bridge)、自-ptrace 要 spawn、frida-server 抢端口用非默认端口定连。
- sensor 可复用但有配额:一 sensor 低频用 + 轮换干净出口 IP,别用一条猛打招限流。
- 生产化还要:时间戳/行为序列动态化(免 stale)、把 unidbg 包成常驻 sensor 服务。
参考
- unidbg:https://github.com/zhkl0228/unidbg
- Frida / frida-java-bridge / objection
- FlowTrans(本文抓包转发器):https://github.com/KingJem/FlowTrans