不碰 dex、不脱壳、不重打包,直接在 View 层把广告认出来并隐藏。 对爱加密 / 360 / 梆梆 / zxprotect 这类加固包同样有效。
在 Xiaomi 11 Lite 5G (renoir) + 澎湃OS 4 移植包 (Android 17) 上实测跑通, 配合 LSPosed v2.1.1 (7790) + Zygisk Next 1.4.5。
主流去广告模块(AdClose / FuckApp 那一类)的思路是拦住广告 SDK 的初始化——
hook TTAdSdk.init(),让它起不来,广告自然没有。这条路遇到加固包就死了。
实测目标 App com.zjwh.android_wh_physicalfitness,导出 base.apk(330MB)后
dexdump -f 读 dex header:
file_size : 71061232 # 单个 dex 71MB
string_ids_size : 1061
class_defs_size : 30 # 但只有 30 个类
data_size : 50244 # 真实 dex 只有 50KB
配合 lib/arm64-v8a/libijiami_1011.so、assets/ijiami_1011.VData、libzxprotect.so
—— 爱加密 + zxprotect 加固。那 71MB 是运行时才解密的加密载荷。
后果:handleLoadPackage 发生在 Application 创建之前,那一刻只有壳的 30 个类,
findClass("com.bytedance.sdk.openadsdk.TTAdSdk") 必然返回 null,日志里只剩一句
no ad sdk found。想改包就得先脱壳(dump 运行时 dex → 修复 → 重打包 → 过签名校验),
而且每次 App 更新都要重做一遍。
广告最终一定要变成屏幕上的 View。而 View 上有三样东西加固壳改不了:
| 线索 | 怎么拿 | 为什么壳改不了 |
|---|---|---|
| 资源 id 属于哪个包 | Resources.getResourcePackageName(id) |
SDK 资源编译在 resources.arsc,壳只处理 dex |
| 实现类的名字 | v.javaClass.name |
运行时拿到的就是真名;混淆只混淆 App 自己的代码 |
| 广告位 id 的名字 | Resources.getResourceName(id) 切词 |
UI 开发给广告位起名不会回避 ad / banner |
三条线索互不依赖,命中任一即可。整套逻辑不碰 dex,所以对加固包天然有效。
顺带解释一个现象:同一台机器上,定位伪造的 hook 一直在生效,广告 hook 全部失败。 原因就在这——
Location/GnssStatus/WifiManager是 boot classpath 上的系统类, 任何时刻都可见;广告 SDK 的类不是。
| 线索 | 覆盖的投放形态 | 实测例子 |
|---|---|---|
| 资源包名 | SDK 自己 inflate 的布局 | com.kwad.dy.sdk:id/ksad_container |
| 类名 | App 用自家布局包住 SDK 控件(id 变成 App 的包名) | com.bytedance.sdk.openadsdk...TTNativeAdView |
| 广告位 id | 容器是 App 写的、素材是网络的,前两条都落空 | com.zjwh...:id/home_banner |
实测这台机器上落到的完整清单:
com.kwad.dy.sdk:id/ksad_container 快手联盟插屏(资源包名锚点)
com.kwad.dy.sdk:id/ksad_tk_view 「扭一扭」摇一摇视图
com.zjwh...:id/ad_container 开屏/插屏容器(id 锚点)
com.zjwh...:id/home_banner 首页横幅,会员卡推广(id 锚点)
com.zjwh...:id/sport_banner 运动页横幅
com.zjwh...:id/fl_native 信息流原生广告卡
AdDragFrameLayout ...:id/home_ad 可拖拽广告位
第三条线索只在白名单包名内启用(SLOT_RULE_PKGS)。别的 App 里 ad 可能是
address、adapter 的缩写,全量放开会误杀正常界面。用之前把 SLOT_RULE_PKGS
换成你自己的目标 App。
认出来只是第一步,难点在「什么时候认」。
| 层 | 入口 | 处理 |
|---|---|---|
| Dialog | Dialog.show |
广告面积 ≥ 50% → dismiss();否则只隐藏广告子树 |
| PopupWindow | showAtLocation / showAsDropDown |
内容根即广告 → dismiss() |
| 窗口 | WindowManagerImpl.addView |
所有窗口的唯一入口,延时多次扫 |
| View 树 | ViewGroup.addView |
命中广告祖先 → GONE + 高度归零 |
| 兜底 | WindowManagerGlobal.mViews 轮询(1.2s) |
全局扫所有窗口 + 必要时移除窗口 |
第 5 层(轮询)是必须的,不是保险:前四层都依赖「某个事件发生」,
而实测这个 App 的广告长期复用同一个窗口、只换里面的内容 ——
事件在第一次之后就再也不触发了,于是广告换个素材又冒出来。
WindowManagerGlobal.mViews 是所有窗口 root view 的全局列表,直接遍历它绕开一切时机问题。
1.2s 一次的代价可以忽略:目标界面不到 100 个节点,单次遍历在微秒级。 递归深度另有 40 层上限,不会把主线程拖死。
移除会打断 SDK 的 View 引用计数,广告 SDK 普遍不做空判断,remove 之后容易
NPE 崩在它自己的回调里。GONE + 高度 0 既看不见也不占位,SDK 侧完全无感。
唯一例外是「整窗广告」:那类窗口带 FLAG_DIM_BEHIND,dim 是 WMS 画的(见下),
只 GONE 内容不够,必须整窗移除。
目标 App 一加载 WebView,LSPosed 会额外回调一次 com.google.android.webview。
最初写成「记录当前包名、再和目标包名比较」,那次回调把包名覆盖成 webview,
之后 App 主进程里所有判定都因为「包名不匹配」而静默返回 false——
表现就是规则完全失效,而且日志里一点错误都没有。
改成单向布尔开关(一次命中即永久开启)才对。这个 bug 花了最长时间,因为它没有报错。
首页 Banner 的 ViewPager2 每次翻页/自动轮播都会走一遍 setVisibility / setLayoutParams,
所以 scrub 日志里每次都 hid 1,界面上横幅却一直在。
解法:记住被隐藏的 View(WeakHashMap,父容器回收后自动释放),
再 hook View.setVisibility 把后续的「显示」请求压回 GONE。
只对已判定的对象生效,对正常界面的开销接近于零。
广告窗口带 FLAG_DIM_BEHIND,那层半透明黑由 WindowManagerService 绘制,
不属于任何 View —— GONE 内容影响不到它,实测就是这样。
两条修法都实现了:
dimOff():改窗口属性把dimAmount清零、去掉 dim flag,再updateViewLayout。比移除安全。removeViewImmediate():直接摘掉窗口。仅限非 Activity 基础窗口 (type != TYPE_BASE_APPLICATION),误删主窗口会让用户直接黑屏。
另外,面积判据在整窗广告上算不出来:这些容器在 addView 阶段就被 hide 了,
等 sweeper 扫到时 width/height 已经是 0,cover 恒为 0.0。
所以全屏广告位改用「这窗口里装的是不是已知的全屏广告位 id」判定。
cd android-adcleaner
gradle assembleDebug -Dorg.gradle.java.home="<你的 JDK 17+ 路径>"- JDK 17+(Gradle 8.11 / AGP 8.7 要求),
gradle.properties里注释了org.gradle.java.home,按本机路径改或命令行传 - Xposed API 是
compileOnly,运行期由框架注入 - 装不上时(MIUI 拦
adb install):adb push到/data/local/tmp再su -c 'pm install -r -d /data/local/tmp/xxx.apk'
激活:LSPosed 里启用模块 → 勾选目标 App 作用域 → force-stop 目标 App(不用重启机器)。 不需要勾「系统框架」。
装完第一次打开时看日志确认装上了:
adb logcat | grep "AdCleaner"
# [AdCleaner] armed: com.xxx slotRule=true
# [AdCleaner] hooked Dialog.show / hooked PopupWindow / hooked WindowManagerImpl.addView# 总开关(默认开)
adb shell su -c 'settings put secure adcleaner_on 0' # 关
adb shell su -c 'settings put secure adcleaner_on 1' # 开
# 调试:把整棵 View 树打到日志,每行一个节点,命中广告的标 AD
adb shell su -c 'settings put secure adcleaner_dump 1'
adb logcat | grep "AdCleaner"
adb shell su -c 'settings put secure adcleaner_dump 0'dump 开关是这个模块最重要的自愈能力。 广告 SDK 会换布局、换资源前缀,
硬编码规则迟早漏;漏了直接开 dump 抓一次日志,就能看到漏掉的那个节点长什么样
(类名 + id + 尺寸),把 id 名补进 APP_AD_ID_NAMES 即可,不用重新反编译。
dump 输出长这样:
---- dump HomeActivity 63 nodes ----
FrameLayout com.zjwh...:id/content 1080x2160 v
LinearLayout com.zjwh...:id/home_banner 1080x180 v
AD RelativeLayout com.kwad.dy.sdk:id/ksad_container 1080x1920 v
AD KSAdWebView com.kwad.dy.sdk:id/ksad_web 1080x1800 v
另外 isAppAdSlot 对「形状可疑但没命中」的 id 会打 slot-miss name=[...] parts=[...],
方便判断该不该补规则(限 10 条,避免刷爆日志)。
目标 App com.zjwh.android_wh_physicalfitness(爱加密 + zxprotect 加固):
- 千问插屏、小米应用商店插屏、「扭动手机」摇一摇广告、快手极速版插屏 —— 全部消失
- 首页会员卡推广横幅(
home_banner)—— 消失 - 连开 3 轮无崩溃、无 ANR
- 与定位伪造模块并存时互不影响
唯一残留:偶尔某一轮会有一层极淡的灰(dim 处理晚于截图), 再等 1.2s 的下一轮 sweeper 自动清掉。
GodMode(包名 com.viewblocker.jrsen,作者 jrsen)是同类思路的先行者:
它让用户手动在界面上框选要屏蔽的控件,按 View 的 id / 类名生成屏蔽规则。
差别:
| GodMode | 本模块 | |
|---|---|---|
| 规则来源 | 用户手动框选 | 自动按资源包名 / 类名 / id 名识别 |
| 新增广告 | 需要用户再框一次 | 三条线索自动命中 |
| 加固包 | 同样不依赖 dex,可用 | 同样可用 |
| 维护状态 | 原仓库已删除,只剩 2023 年停更的 fork,无 release | 本文代码可直接编译 |
因为 GodMode 原仓库已下线、网上流传的都是来路不明的二手 APK,所以自己实现了一份, 并把「怎么认」这件事写成可维护的规则而不是一次性配置。
- 只处理 View 层。SDK 的网络请求、素材下载不受影响——
AdDns那层只是顺带省点流量, 挡不住界面广告(实测挡了 DNS 但广告还在)。 - 整窗移除有风险边界。Activity 基础窗口一律不动,宁可留着广告窗口也不能让界面黑掉。
- id 名切词是启发式,不同 App 的命名习惯差异很大。换目标 App 时先用 dump 抓一次 View 树,再决定补哪些 id。
- 不屏蔽加速度计。目标 App 靠加速度计计步,屏蔽了步数就归零;「扭一扭」广告只要 Dialog 被 dismiss 就不存在了,不需要动传感器。别的应用若靠摇一摇触发,需要另加处理。
自用去广告属于对自己设备上软件的修改。把去广告后的 App 重新分发, 或用于绕过付费/激励内容,责任在操作者。