从抓包到 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
App ─▶ FlowTrans TUN ─▶ 设备127.0.0.1:8080 ─▶ adb reverse ─▶ PC mitmweb ─▶ 出口

抓到的关键接口(HTTP/2,base apis.example.com):

  • POST /v1/oauthprovider/oauth2/token — 取 Bearer(guest token)
  • GET /fulcra/search/items?searchTerms=…SOV 搜索,返回 itemList[](顺序=排名)、itemCountsponsoredItemCount,每项含 brand / sponsored / price / rating

sponsored=true 的就是广告位——按 brand 聚合、按 sponsored 拆自然/广告,就是关键词维度的品牌声量。

二、脱离 App 重放:静态凭据 + 唯一活门槛

看 token 接口的请求体:

1
grant_type=client_credentials&client_id=<CLIENT_ID>&client_secret=<CLIENT_SECRET>

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
2
3
com.akamai.botman.CYFMonitor.getSensorData() : String   ← 对外壳(被 R8 混淆成 a())
com.cyberfend.cyfsecurity.CYFMonitor / SensorDataBuilder ← 真实实现(Cyberfend=Akamai 收购)
Akamai BMPSDK/4.0.5

一开始以为是纯混淆 Java,后来在 split_config.arm64_v8a.apk 里发现 libakamaibmp.so(2.8MB),导出静态 JNI:

1
2
Java_com_cyberfend_cyfsecurity_SensorDataBuilder_buildN          ← 核心生成
Java_..._encryptKeyN / initializeKeyN / decryptN ← 密钥态

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 模式早注入,并 hook ptrace→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
2
3
4
5
[0] Pair{"" "4.0.5"}              # SDK 版本
[4] Pair{"-100" "…1080,2209,zh,<DEVICE_MODEL>,<BUILD_FINGERPRINT>,<ANDROID_ID>,…"} # 设备指纹大串
[14..19] Pair{"-142"/"-143"/… "2;100.00;5422.00;…"} # 加速度/陀螺/触摸 行为序列
[21] Pair{"-163" "…<app_version>,<timestamps>"}
…RET = "6,a,<b64>,<b64>$<b64>$<counts>"

输入自包含 → unidbg 只需拼同样的 ArrayList + 调 buildN,不用 mock 一堆回调。这把难度从”地狱级”降到”可做”。

5.2 卡点一:反调试不是 brk,是 undefined 指令

harness 载入 .so 时,.init_array 直接把 unidbg 顶进交互式调试器、疯狂刷寄存器 dump(表现为”卡死”)。第一反应是 brk 反调试,但检查 EXCP_BKPT 拦不住。加日志打印实际 intno:

1
DIAG interrupt intno=1 swi=63518     ← intno=1 = EXCP_UDEF(未定义指令!)

原来反调试用的是 undefined 指令陷阱,不是 brk。解法——子类化 AndroidARM64Emulator,重写 createSyscallHandler 注入一个自定义 handler:凡非-SWI 中断一律推进 PC 跳过、不进调试器(真 syscall 交给 unidbg,sigprocmask 之类它本就支持):

1
2
3
4
5
6
7
8
9
10
11
static class SkipBrkHandler extends ARM64SyscallHandler {
SkipBrkHandler(SvcMemory svc) { super(svc); }
@Override public void hook(Backend backend, int intno, int swi, Object user) {
if (intno != ARMEmulator.EXCP_SWI) { // brk/udef 反调试
long pc = backend.reg_read(Arm64Const.UC_ARM64_REG_PC).longValue();
backend.reg_write(Arm64Const.UC_ARM64_REG_PC, pc + 4); // 跳过
return;
}
super.hook(backend, intno, swi, user);
}
}

一改,loaded libakamaibmp.so——init 过了。

5.3 卡点二:补 JNI 回调

init 之后,native 通过 JNI 读设备字段:

1
UnsupportedOperationException: android/os/Build$VERSION->SDK_INT:I

AbstractJni 里补上:getStaticIntField(SDK_INT=34)+ getStaticObjectField(一批 Build.* 模板值),再把 27 项入参经 ArrayList.size/get + Pair.first/second 喂进去。再跑:

1
2
3
4
=== SENSOR ===
6,a,g/PmFy9q6Nl0dESAw9g6BXg/YRoVU2tfOPto/…$334000,92000,10000
looksValid=true
BUILD SUCCESS

纯 JVM、无设备,吐出了合法 6,a,… sensor。

5.4 闭环验证:服务端认不认

格式合法不等于能用。把 unidbg 现算的 sensor 直接拿去打接口(走干净美区出口、不经 mitmproxy):

1
2
3
4
5
6
7
# POST oauth2/token(静态 client_credentials + unidbg sensor)
token: 200# 拿到 Bearer

# GET fulcra/search/items(Bearer + 同一 sensor + 应用头 x-api-version 等)
search: 200 ✅ itemCount=468 sponsoredItemCount=3
1 BISSELL $199.99
4 AD Bosch $569.0 # 广告位

Akamai 接受了。 整条生产链在纯 PC 上跑通:

1
2
3
4
unidbg(libakamaibmp.so → sensor)
→ oauth2/token(静态凭据)→ Bearer
→ fulcra/search/items → SOV 数据
无设备 · 无 App · 无 mitmproxy

六、几个可复用的经验

  • Akamai BMP 4.x 的核心在 native(libakamaibmp.so),buildN 静态 JNI 导出、入参自包含 → 对 unidbg 友好。
  • 反调试可能是 undefined 指令陷阱(intno=1)而非 brk——intno != EXCP_SWI 一律跳过更省事。
  • Frida 17 无全局 Java(frida-java-bridge)、自-ptrace 要 spawnfrida-server 抢端口用非默认端口定连
  • sensor 可复用但有配额:一 sensor 低频用 + 轮换干净出口 IP,别用一条猛打招限流。
  • 生产化还要:时间戳/行为序列动态化(免 stale)、把 unidbg 包成常驻 sensor 服务。

参考


从抓包到 unidbg:一个 Akamai BMP 移动端接口的离机复现全记录
https://kingjem.github.io/2026/08/12/从抓包到unidbg-Akamai-BMP离机复现/
作者
Ruhai
发布于
2026年8月12日
许可协议