众所周知,某E对战APP又名美团外卖启动器,启动速度比自己直接开美团还快,甚至你点击跳过也会跳转,摇一摇广告,就算不动也会自动跳,现在AI这么流行,我也让AI分析一下,下面是AI分析的内容
先给结论:
- 模拟器里第一次不弹广告,直接原因不是模拟器检测,而是服务端的两层拦截:新用户判断和失败间隔。
- 广告位下发之后,倍孜 SDK 才会初始化并拉取云控。云控里确实有全屏点击、摇一摇和跳过按钮的参数。
- 跳过按钮的点击监听里存在“第一次点击转成广告点击”的分支,合成点击坐标和手指位置无关。本次快照里跳过专用概率没有开启,但同一份云控把部分订单的全屏点击概率下发成了 100。
- 没有触摸也会跳,是因为广告落地页由一个透明 Activity 在
onCreate 里直接打开,并且准备了普通启动、直连 ATMS、替换 mInstrumentation 三条路径。
- 加速度计、陀螺仪没有运行时授权框。传感器注册失败会被吞掉,倍孜还有一套按云控参数伪造摇一摇的定时器。本次快照里伪造参数是 0,但代码路径存在。
1. 测试环境
| 项目 |
值 |
| 设备 |
MuMu 模拟器,Android 15,API 35,x86_64 |
| 机型伪装 |
V2284A,厂商 vivo |
| 应用 ABI |
primaryCpuAbi=arm64-v8a,通过 houdini 运行 ARM64 库 |
| 应用进程 |
app_process64 |
| 应用版本 |
7.3.0,versionCode 610161033 |
| 壳 |
腾讯 ACE,assets/jiami.tsa,释放 15 个 DEX |
| 网络 |
mitmproxy,系统 CA 装入 APEX 证书目录,adb reverse |
代码位置按 jadx 反编译后的文件行号。不同反编译工具、不同版本的行号会有偏差,类名和方法名以实际样本为准。
2. 第一次启动没有广告,不是“没有投放”
最开始在模拟器里装好应用,启动几次都没有开屏广告。抓 https://ya-api-app.5eplay.com/v1/home/adv_slot/list 的响应,第一次是这样的:
{
"success": false,
"errcode": 100,
"message": "welcome new user",
"data": null
}
本地 inID 的时间戳和安装时间几乎一致。服务端把这个设备当成新用户,直接返回 errcode=100,广告位列表根本不进入渲染。
本次测试用到的设备标识:
原始 inID: 1793073348436_2829bcf0c7cd01181175cbabe50d3a23
测试 inID: 1600000000000_0123456789abcdef0123456789abcdef
android_id: 3186eca9c04a9fb4
sdkUID: 8dd12aa752ee9c16eb98f536e9fb97d2_1_6353_215265915098
这些值只用于对照抓包。原始 inID 是应用安装时生成的,测试 inID 只是为了绕开新用户门槛,不涉及服务端漏洞。
把 inID 改成测试值之后,第二次请求有数据了,但广告位是空的:
{
"success": true,
"errcode": 0,
"data": {
"adv_display": 2,
"adv_slot_items": [],
"debug": "STEP2: advDisplay:2 todaySuccessNum(0) LT rule.MaxDisplayNum(5) gap1(1793073873) GT successGap(300) gap2(52) GT failureGap(180) ",
"srv_ts": "1793073873"
}
}
adv_display=2,adv_slot_items 为空。调试串里 gap2=52,小于 failureGap=180。也就是说,距离上一次广告失败才 52 秒,服务端认为还没到重试时间。
本地 shared_prefs 里有 ad_last_fail_time。把它改成 1000000000 之后,同一个模拟器、同一个网络环境,第三次请求就拿到了广告位:
{
"adv_display": 1,
"adv_slot_items": [
{
"adv_id": "startup_1721121177",
"adv_provider": "sigmob",
"adv_code_name": "开屏安卓0402",
"adv_name": "【冷启动】多厂商-Android-01"
}
]
}
这里改的是本地状态,不是攻击服务端。它只能说明:这次广告不展示,是服务端按设备状态做的分层判断;同样的模拟器在本地状态恢复后可以拿到广告位。模拟器检测确实存在,但至少这一次不是它直接把广告拦掉的。
3. 启动广告的调用链
开屏广告的入口在 SplashFragment,实际请求由 AdUtils.showSplashAd 发起,最终走到 AdServiceImpl.C("startup"):
SplashFragment
-> AdUtils.showSplashAd(...)
-> AdServiceImpl.C("startup")
-> POST https://ya-api-app.5eplay.com/v1/home/adv_slot/list
拿到响应后,宿主只认两个条件:
if (response.adv_display != 1) {
closeSplash()
return
}
if (response.adv_slot_items.isEmpty()) {
closeSplash()
return
}
loadAd(response.adv_slot_items)
本次返回的 adv_provider 是 sigmob,但倍孜 SDK 仍然被初始化,随后请求了自己的云控接口。这说明宿主把多家 SDK 放在同一套聚合层里,广告位由谁填充和倍孜是否初始化不是一回事。
4. 倍孜云控的请求和解密
广告位下发之后,应用初始化 BeiZis,请求:
POST https://sdk.beizi.biz/v4/api/sdk/cf/p
请求体不是明文。解密后的参数里有这些字段:
| 字段 |
本次值 |
appId |
121022 |
packageName |
com.fiveplay |
installTime |
1793073344880 |
sdkUID |
8dd12aa752ee9c16eb98f536e9fb97d2_1_6353_215265915098 |
root |
no |
developerMode |
false |
sdkVersion |
5.5.0.5 |
响应 code=200,内容是一段 Base64 字符串。解密流程是:
Base64 解码
-> AES/CBC/PKCS5PADDING 解密
-> GZIP 解压
-> JSON
4.1 密钥、IV 和签名密钥
样本里能直接还原出的值如下:
| 项目 |
值 |
位置 |
| AES key 1 |
8iuaKct.PMN38!!1 |
ug.java、tg.java |
| AES key 2 |
sKto^kqU%jDgEa8v |
tg.java |
| IV |
abcdefghijk1mnop |
g2.java、h2.java |
| HMAC-SHA256 secret |
28137ed61f21705e2cddf498e22fae8f |
SignatureInterceptor.java |
本次云控响应用 AES key 1 和上面的 IV 解出;tg.java 里还有第二把 key,样本中不同接口会走到不同分支。HMAC secret 用于 x-ca-signature,请求头里同时带 x-ca-key: 5eplay 和 x-ca-signature-method: HmacSHA256。签名串由请求方法、路径、查询参数、请求头和 body 按固定顺序拼接,具体实现见 SignatureInterceptor。
解密后得到 23242 字节的 JSON。应用本地 /data/data/com.fiveplay/shared_prefs/fusion_config.xml 里的 globalConfig 解密后,和网络响应完全一致,SHA-256 都是:
689e47cbe468158964daa8d44551d4ea303eccf6e1fc610022075f575eb094e8
这一点比较关键:下面引用的参数不是从静态代码里猜的,是运行时实际下发、并且被应用写入本地缓存的那一份。
5. 云控里和跳转有关的字段
完整 JSON 有 23KB,正文只保留相关部分:
{
"configVersion": "19668$100$1$0$0$1-31689$100$1$0$0$1-32158$100$1$0$0$1",
"expireTime": 43200000,
"manager": {
"configVersion": "a5f800ec1892a97eb8a7eac0a9bf97d0",
"openSensor": 0,
"adSpaces": [
{
"spaceId": 113847,
"adType": "SPLASH",
"buyer": [
{
"buyerSpaceUuId": "49d92",
"spaceId": "19668",
"appId": "7517",
"fullScreenClick": {
"randomClickNum": 0,
"orderData": [
{
"randomClickNum": 100,
"fullScreenClickUuid": "290480"
},
{
"randomClickNum": 0,
"fullScreenClickUuid": "124984"
}
]
},
"shakeView": {
"sensorParam": 0,
"shakeStartAmplitude": 1.122,
"shakeEndAmplitude": 1.022,
"shakeCount": 1,
"randomClickTime": 0,
"randomClickNum": 0,
"orderData": [
{
"shakeStartAmplitude": 1.01,
"userProtectTime": 43200000
},
{
"shakeStartAmplitude": 1.3,
"userProtectTime": 86400000
},
{
"shakeStartAmplitude": 1.5,
"userProtectTime": null
}
]
},
"renderView": [
{
"type": "SKIPVIEW",
"skipText": "跳过",
"skipViewTotalTime": 5000,
"delayDisplaySkipButton": 0,
"skipUnavailableTime": 0,
"passPolicy": [],
"layerPosition": {
"centerY": 10.0,
"centerX": 85.0,
"width": 15.0,
"height": 45.0
},
"tapPosition": {
"centerY": 10.0,
"centerX": 85.0,
"width": 15.0,
"height": 45.0
}
}
],
"screenConfigVo": [],
"interactionRules": []
}
]
}
]
}
}
这是节选,删掉了素材、竞价、回调等和本文无关的字段。完整 JSON 见同目录的 5eplay_cloud_config.json。
几个值需要单独说明:
manager.openSensor=0。宿主如果自己禁用了传感器,云控把这个值设成 1 可以覆盖宿主的禁用。本次是 0,没有覆盖。
renderView[0].type=SKIPVIEW,skipText=跳过,skipViewTotalTime=5000,delayDisplaySkipButton=0,skipUnavailableTime=0。跳过按钮显示在右上角,位置是横向 85%、纵向 10%,大小 15% × 45%。
passPolicy=[]。本次没有跳过放行策略。
screenConfigVo=[]。本次没有往广告界面上叠加服务端图片图层。
interactionRules=[]。本次没有下发额外的互动规则。
fullScreenClick.randomClickNum 顶层是 0,但部分 orderData 里是 100。这个 100 表示对应订单的全屏点击概率拉满。
shakeView.randomClickTime=0、randomClickNum=0。本次没有开启伪造摇一摇的定时器。
- 摇一摇阈值在
orderData 里是 1.01、1.3、1.02、1.5,userProtectTime 是 12 小时或 24 小时。
6. 点“跳过”为什么可能打开广告
先看跳过按钮的点击监听。j4.java 里解析 SKIPVIEW 时,会读一个 randomClick 字段:
skipGateHit = percentRoll(node.randomClick)
function percentRoll(threshold):
roll = floor(random() * 100 + 1)
return roll <= threshold
randomClick=0 永远不命中,randomClick=100 每次都命中。点击监听里对应的分支是:
function onSkipClick(view):
if skipGateHit and not alreadyClicked:
skipGateHit = false
performSyntheticClick()
else:
closeSplashContainer()
reportCloseStatus(8)
命中时不会关闭开屏,而是调用 performSyntheticClick()。这个方法在 j4.java:1995-2008:
function performSyntheticClick():
if alreadyClicked: return
alreadyClicked = true
clickType = "regionalClick"
reportClickType(clickType)
width = adContainer.getWidth()
regionW = (int)(width * 0.1) / 2
regionH = (int)(height * 0.8)
point = randomPointNear(width * 0.9 / 2, dpToPx(65) / 2)
region = [point.x, point.y, point.x + regionW, point.y + regionH]
forwardClick(region, 0)
合成点击的区域在广告容器宽度 45% 附近,纵向从 32.5dp 开始,和用户手指实际点的位置没有关系。用户点的是右上角“跳过”,上报和后续打开的却是这个固定区域里的一次广告点击。
6.1 本次快照里的跳过字段
本次云控的 renderView 里没有 randomClick 字段,passPolicy 也是空数组。按代码里的默认值,跳过专用概率不会命中。所以不能把这次抓到的每一次“点跳过就跳广告”都归因于 randomClick。
但同一份云控里,fullScreenClick.orderData 有订单下发 randomClickNum=100。这段代码在 j4.java:518-535:
fullScreenClickBean = buyer.fullScreenClick
orderBean = findOrderData(fullScreenClickBean.orderData, currentOrder)
clickable = percentRoll(orderBean.fullScreenClick.randomClickNum)
log("beizi clickable = " + clickable)
setFullScreenClickable(clickable)
setFullScreenClickable(true) 不会给广告容器安装那个返回 true 的触摸拦截器,点击可以继续落到广告点击链路;setFullScreenClickable(false) 才会安装拦截器。换句话说,订单里的 100 表示“全屏点击可用”,它和跳过按钮的 randomClick 不是同一个字段,不能混为一谈。
跳过按钮是否会被全屏点击吃掉,还取决于视图层级。screenConfigVo 非空时,代码会在广告界面上按服务端坐标叠加可点击图片,图片的层级在跳过视图之上。本次 screenConfigVo 是空的,所以没有这一层。之前遇到“点跳过反而跳广告”,至少有三条可能:
- 当时下发的
SKIPVIEW.randomClick 命中了,第一次点击直接走 performSyntheticClick()。
- 当时下发了
screenConfigVo,一张可点击图片盖在跳过区域上,用户点到的是图片。
- 跳过按钮之外的全屏点击区域被打开,点击事件落到了广告容器。
第 1 条和第 3 条在代码里是确定的,第 2 条需要当时的下发配置才能确认。本文抓到的这一份快照只能证明第 3 条的条件存在,不能证明第 1 条在这次也发生了。
6.2 不点也会跳的定时器
除了点击,代码里还有两条定时触发路径:
function scheduleLandingTimer():
delay = lpStrategy.autoTime
type = lpStrategy.type
if delay <= 0 or type != 2: return
postDelayed(performSyntheticClick, delay)
function scheduleOptimizeTimer():
cfg = clickBridge.getOptimizeConfig()
if cfg is null or cfg.autoTime <= 0: return
postDelayed(performSyntheticClick, cfg.autoTime)
两条路径都不检查用户是否触摸过屏幕,只靠 alreadyClicked 保证一次展示只触发一次。广告展示成功后,倒计时结束、定时器到点,就会调用同一个合成点击方法。用户看到的是“广告自己跳了”,实际是展示阶段就已经排好的任务。
7. 没有触摸也会跳:透明 Activity 和链式启动
广告点击之后,最终打开的页面由 BZDpActivity 处理。这个 Activity 是透明的,onCreate 里直接调用 autoOpen():
BZDpActivity.onCreate:
super.onCreate()
setContentView(transparentLayout)
readIntentExtras()
init()
autoOpen()
enterTime = now()
autoOpen() 在 BZDpActivity.java:893-942,核心是根据 deeplink 拼 Intent:
function autoOpen():
if deeplink is empty: return
if deeplink starts with "bzopen://":
targetPackage = deeplink.host
targetClass = deeplink.path[0]
intent = new Intent(ACTION_MAIN)
intent.addCategory(CATEGORY_LAUNCHER)
intent.setComponent(targetPackage, targetClass)
intent.flags = FLAG_ACTIVITY_NEW_TASK
applyFlagsAndSourceBounds(intent, deeplink)
else if deeplink starts with "hwpps://landingpage":
intent = new Intent(Intent.ACTION_VIEW, deeplink)
intent.flags = FLAG_ACTIVITY_NEW_TASK
else if deeplink uses an apk scheme:
intent = Intent.parseUri(deeplink, 1)
intent.addFlags(FLAG_ACTIVITY_NEW_TASK)
else:
intent = new Intent(Intent.ACTION_VIEW, deeplink)
intent.flags = 0x30008000
launcher.start(intent, launchMode, reflectionRecipe)
bzopen:// 是显式组件启动,包名和类名都写在 deeplink 里,不依赖 queryIntentActivities 能不能看到目标应用。包可见性被限制时,这条路径仍然能拼出 Intent。
启动器 sg.java 支持三种模式:
function launcher.start(context, intent, launchMode, recipe):
if launchMode == 0:
context.startActivity(intent)
return
if recipe is null:
context.startActivity(intent)
return
if launchMode == 1:
if atmsDirectLauncher.start(context, intent, recipe):
return
context.startActivity(intent)
return
if launchMode == 2:
instrumentationHooker.replace(context, recipe)
context.startActivity(intent)
| 模式 |
行为 |
绕过点 |
| 0 |
普通 startActivity |
无 |
| 1 |
直连 IActivityTaskManager |
绕过进程内的 ATMS 动态代理 |
| 2 |
替换 mInstrumentation 后启动 |
绕过安全 SDK 对启动链的包装 |
模式 1 的实现是 BZAtmsDirectLauncher.java:28-88。它通过反射拿到 ActivityThread 和 IActivityTaskManager,再用 Proxy.getInvocationHandler 拆掉进程内动态代理,找到真实 binder,最后直接调用服务端的 startActivity 方法。模式 2 是 BZAntiHookInstrumentation.java:78-147,把 ActivityThread.mInstrumentation 替换成自己的代理,安全 SDK 看到的调用链就不是原始的了。
隐藏 API 限制由 BZHiddenApiBypass.java:85-118 处理:
function bypassAllHiddenApiRestrictions():
VMRuntime.setHiddenApiExemptions(["L"])
function getHiddenMethod(clazz, methodName, paramTypes):
methodArrayAddress = Unsafe.getLong(clazz, methodTableOffset)
for method in methodArray:
if method matches:
return method
"L" 会匹配所有成员,等于把隐藏 API 豁免打开。取不到的方法再用 Unsafe 读 ART 方法表,直接拿 ArtMethod 指针。
这里要区分清楚:这些代码绕过的是应用进程内的 Java Hook 和启动代理,不是 system_server 里的真实权限检查。目标组件本身必须允许启动,bzopen:// 才能成功。代码没有提权,也没有利用内核漏洞。
8. 传感器:没有“摇一摇权限”,但可以伪造
Android 原生没有“摇一摇权限”这道运行时授权。加速度计、陀螺仪、重力传感器通过 SensorManager.registerListener 注册,不会弹授权框。ACTIVITY_RECOGNITION 管的是计步和活动识别,不是原始传感器数据;HIGH_SAMPLING_RATE_SENSORS 只在采样率高于 200Hz 时才有要求。代码用的是 SENSOR_DELAY_GAME 和 SENSOR_DELAY_UI,低于这个上限。
ShakeUtil.java:219-231 注册传感器:
function ShakeDetector.start():
try:
if sensorManager is null: return
registerListener(listener, getDefaultSensor(4), SENSOR_DELAY_GAME)
registerListener(listener, getDefaultSensor(9), SENSOR_DELAY_UI)
registerListener(listener, getDefaultSensor(1), SENSOR_DELAY_GAME)
catch Throwable:
printStackTrace()
注册失败的返回值没有检查,异常也只打印堆栈。设备没有传感器、厂商隐私开关拦截、注册失败,用户都看不到提示。
传感器可用时,onSensorChanged 把加速度、陀螺仪、重力的数据累加,超过阈值就触发点击。用户不需要点广告,摇一下手机就会打开落地页。
传感器不可用时,美数路径没有降级,摇一摇不会触发。倍孜路径不一样。un.java:573-597 读取服务端参数:
function BeiziShake.configure(shakeBean):
randomClickTime = shakeBean.randomClickTime
randomClickNum = shakeBean.randomClickNum
if randomClickNum > 0:
armFakeShake()
function armFakeShake():
if percentRoll(randomClickNum) is false: return
delay = randomClickTime + randomPercent1To100() * 10
mainHandler.postDelayed(fakeShakeRunnable, delay)
function fakeShakeRunnable.run():
shakeDetector.c()
shakeDetector.c() 和真实传感器回调走同一个入口,最后进入 j4.onShakeHappened(),点击类型记为 shake,再走 deeplink。也就是说,设备没有传感器、传感器被关闭、注册失败,都不影响这条定时器路径。唯一例外是摇动动画视图存在但被判定为不可见时,c() 会提前返回。
本次云控里 randomClickTime=0、randomClickNum=0,伪造摇一摇没有开启。manager.openSensor=0 也没有覆盖宿主的传感器禁用。但代码路径是完整的,服务端下一次把这两个值改掉,行为就会变。
9. 权限被拒绝后还能走的路
自动跳转主链路不依赖危险权限。位置、电话、相机被拒绝,只影响设备画像、广告定向和互动创意,startActivity 照常执行。
几个容易被忽略的点:
QUERY_ALL_PACKAGES 被限制时,queryIntentActivities 看不到所有应用,但 bzopen:// 是显式组件启动,仍然可以拼出目标 Intent。目标组件本身要允许启动。
REQUEST_INSTALL_PACKAGES 被拒绝时,DownloadService 仍然拉起系统安装器。代码没有检查 canRequestPackageInstalls() 的结果,也没有处理用户拒绝后的回退。广告落地页已经打开,安装只是后续动作。
- 应用没有声明
SYSTEM_ALERT_WINDOW,也不需要悬浮窗权限。广告界面是 BZDpActivity 自己的前台 Activity,启动目标时不受后台启动限制。
- H5 页面的
shouldOverrideUrlLoading 遇到非 http/https 协议会直接 startActivity,没有触摸或手势参数。JavaScript 跳转、meta refresh、302 到自定义协议都会走这里。
10. 模拟器检测和这次广告的关系
倍孜有一个模拟器检测类 com.beizi.fusion.i7,环境信息在 EnvInfo。MuMu 的 ro.hardware 是 vbox,i7.a() 返回 true,EnvInfo.isSimulator 也是 true。这些标记确实被采集了。
但本次广告不展示的直接原因不是它。同一个模拟器,在修改 inID 和 ad_last_fail_time 之后,服务端返回了 adv_display=1 和 sigmob 广告位,倍孜云控也正常拉取。云控请求里报的是 root=no、developerMode=false,至少从请求参数看不是 root 或开发模式环境。
更准确的说法是:模拟器检测参与了设备画像,可能影响投放策略,但这次拦住广告的是新用户判断和失败间隔这两个服务端门槛。把它们分开看,才不会把“模拟器里没广告”直接解释成“模拟器检测生效”。
11. 证据和复现
本次留下的文件:
| 文件 |
内容 |
5eplay_cloud_config.json |
完整云控明文 JSON,23242 字节 |
cloud_evidence_full.flow |
原始 mitmproxy 流量,保留 7 个相关请求和完整请求头 |
config_response.json.request |
解密后的云控请求参数 |
adslot_exchange.txt |
广告接口请求头和响应原文 |
logcat_beizi.txt |
倍孜 SDK 运行日志 |
spUtils.xml |
宿主本地偏好,含 inID、ad_last_fail_time |
云控响应和本地 globalConfig 的 SHA-256 都是 689e47cbe468158964daa8d44551d4ea303eccf6e1fc610022075f575eb094e8。AES key、IV、HMAC secret 见 4.1 节。
12. 结论
这次分析里,最值得注意的不是某一个孤立的 API,而是整条链路的分层设计:
- 服务端先决定给不给广告,客户端本地状态也能影响这个判断。
- 广告下发后,云控决定跳过按钮、全屏点击、摇一摇和图层怎么工作。
- 跳过按钮的点击可以被转成固定区域的广告点击,合成坐标和用户手指位置无关。
- 没有触摸时,透明 Activity 在创建阶段就能打开 deeplink,启动器还准备了直连 ATMS 和替换
mInstrumentation 的路径。
- 传感器不需要运行时授权,注册失败被静默吞掉,云控还能用定时器伪造摇一摇。