吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 381|回复: 1
上一主题 下一主题
收起左侧

[Android 原创] 某E开屏广告分析:云控、跳过按钮和链式启动

[复制链接]
跳转到指定楼层
楼主
鬼魅王子 发表于 2026-10-27 14:08 回帖奖励

众所周知,某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 是空的,所以没有这一层。之前遇到“点跳过反而跳广告”,至少有三条可能:

  1. 当时下发的 SKIPVIEW.randomClick 命中了,第一次点击直接走 performSyntheticClick()。
  2. 当时下发了 screenConfigVo,一张可点击图片盖在跳过区域上,用户点到的是图片。
  3. 跳过按钮之外的全屏点击区域被打开,点击事件落到了广告容器。

第 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 的路径。
  • 传感器不需要运行时授权,注册失败被静默吞掉,云控还能用定时器伪造摇一摇。

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
m-chris 发表于 2026-10-28 09:17
大佬厉害,中午拜读一下
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-28 13:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表