买了个手机,不 root 感觉跟租的一样,所以我 root 了。结果 WPS 不知道从哪个版本开始,手越伸越长,连我 root 没 root 都要管:每次从桌面打开它,首页先弹一句「设备已Root,使用WPS Office可能存在安全风险。」一秒就消失,连通知历史都不留。最恶心的是,我明明拒绝了它的「读取应用列表」权限,它还是检测到了我装的 KernelSU。
这句警告为什么关不掉 #
这东西压根没走通知栏,是进程里直接调用的 Toast,通知权限管不着,通知历史不留痕,WPS 设置里也翻不到开关。把 KernelSU 对 WPS 的「卸载模块」打开,照弹不误。
后来看了检测代码才知道为什么没用:这台手机根本不是因为 su 文件或挂载点被查出来的,动文件层自然白搭。不过当时还不知道,只想先让这句话闭嘴。
先让这一句消失 #
先试了现成的 ToastBlocker,作用域只勾 WPS。警告是没了,「保存成功」这类正常提示也跟着没了。
想拦得准一点,写轮眼(MyAndroidTools)那套组件开关用不上,这句警告不是单独的 Activity、Service 或 Receiver,调用点在首页 HomeControllerImpl$1$1.run() 里,检测完直接调 Toast。只拦这一句,就只能上方法级 Hook,也就是 LSPosed。
于是写了个模块,规则只有一条:整句精确匹配那句警告(忽略排版空白),其他 Toast 照常显示,WPS 的检测结果也不动。实现上通过公开的 Toast.makeText/setText 认内容,标记命中的 Toast 对象,只跳过它的 show(),不去读 Android 16 已经限制访问的隐藏字段 Toast.mText。第一版用 legacy API 在实机上验证过,发布前又迁到 libxposed API 102 的 interceptor chain 重写了一遍。构建、签名、发布都走 GitHub Actions,wps-root-toast 1.0.0 已经进了 LSPosed 模块仓库。
警告是不弹了,但检测还在跑,只是看不见结果。
WPS 怎么判定「已 Root」 #
把 WPS 26.9.1 拆开,首页用的判定在 KSystemRoot.i(context),六项检查按顺序走,任何一项命中就返回 true。
flowchart TD
A["KSystemRoot.i(context)"] --> B1{"PATH 各目录下存在 su 文件?"}
B1 -->|命中| T["判定已 Root"]
B1 -->|未命中| B2{"persist.sys.root.status 非空且不为 0?"}
B2 -->|命中| T
B2 -->|未命中| B3{"ro.secure 为 0?"}
B3 -->|命中| T
B3 -->|未命中| B4{"Build.TAGS 含 test-keys?"}
B4 -->|命中| T
B4 -->|未命中| B5{"指纹或型号含模拟器特征?"}
B5 -->|命中| T
B5 -->|未命中| B6{"查询到 Root 管理器包名?"}
B6 -->|命中| T
B6 -->|未命中| F["判定未 Root"]
T --> S["首页 Toast:设备已Root,使用WPS Office可能存在安全风险"]
style B6 fill:#ffcdd2,stroke:#d32f2f,stroke-width:2px
另外还有两处独立检测:LogoutTracker.h() 查两个固定路径的 su;GetDeviceInfoHandler.a() 查 /system/bin/su 和 /system/xbin/su,结果放进设备信息的 root 字段,通过 JS 回调返回。光看代码看不出会不会上传到服务器,这里不下结论。
代码只能说明有这六项,看不出我这台手机命中的是哪一项。于是临时改了个诊断版,hook 检测方法本身,只记录每一项的返回值,装上冷启动一次:
| 检测项 | 本机实测 |
|---|---|
PATH 各目录下的 su 文件 |
false |
persist.sys.root.status 属性 |
false |
ro.secure=0 |
false |
Build.TAGS 含 test-keys |
false |
模拟器特征(goldfish、ranchu、sdk_gphone 等) |
false |
查询 me.weishu.kernelsu |
true |
| 最终判定 | true |
前五项都是 false,命中的是第六项,查 KernelSU 管理器的包名。前面藏 su、改属性之所以没用,也是因为碰的都不是这一项。测完把诊断版卸了,换回正式模块。
权限被拒,为什么还查得到 #
我确实没给 WPS「读取应用列表」权限,它照样查到了 KernelSU,因为这是两套机制。
厂商设置里的「读取应用列表」管的是枚举设备上的全部应用。Android 11 之后的包可见性机制允许 App 在 Manifest 的 <queries> 里点名一批包名,点了名的包不需要任何运行时权限就能查装没装。WPS 的 <queries> 里就列着 KernelSU、Magisk、APatch,系统的可见性记录也显示这两个包互相可见。所以权限面板上点的「拒绝」,对这种点名查询根本不起作用。
这套判定也配不上「安全风险」这个说法:装了管理器 App 就算命中,根本不看 WPS 自己能不能拿到 root;模拟器特征也算 Root,用模拟器的人大概也想不到会被这么提醒一句。
让查询返回「未安装」 #
既然查的是包名,那让 WPS 查不到这个包就行。可选的做法有三种:
| 方案 | 做法 | 代价 |
|---|---|---|
| 应用列表隐藏类模块 | 对 WPS 隐藏指定包名 | 多数基于旧 API,新系统兼容性要实测 |
| 扩展现有模块 | 在 WPS 进程里拦截包查询,返回「未安装」 | 要自己写、自己维护 |
改 APK 删 <queries> |
重签名后安装 | 影响更新和签名校验,不适合日常 |
本来打算再写一个 API 102 的包可见性过滤模块,后来发现 Hide My Applist(HMA)干的就是这事,就没必要自己写了。
配置只有两条:建一个黑名单模板,里面只放 me.weishu.kernelsu;应用规则只对 WPS 生效,激进过滤和详细日志都不开。验证时先把「WPS Root 提示隐藏」模块关掉,强停后重开 WPS,没再弹。说明包名查询直接被挡住了,六项检测都返回 false。其他入口如果也用同一套判定,一样会失效,原来的模块只能遮住首页这一处。
HMA 3.8.3 用的是 libxposed API 101,还要求框架允许在系统服务里加载原生库,我这套 Android 16 + LSPosed 2.2.1 上实测正常。
模块白写了吗 #
回头看,确实该先找找现成方案再动手。不过不把六项检测挖出来,也不知道触发点是包名查询,更想不到 HMA 一个模板就能解决。
wps-root-toast 仓库现已归档,README 保留了 HMA 的实测配置,源码和已发布 APK 也保留供查阅。
模块也不算完全白写。只想去掉这句提醒、不想改包可见性的话,它还能单独用;诊断版也是在它的代码上改出来的。
反编译和 Hook 都只在自己手机上的 WPS 里做,模块只隐藏提示、不改检测结果,仓库里没有 WPS 的代码。发布说明里也只写了实测过的 Android 16 + WPS 26.9.1 + LSPosed 2.2.1。
后来雅迪也开始管 VPN,而且检测藏在加固后面,处理过程写在下一篇。