<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>WPS on Code is cheap, let&#39;s talk</title>
    <link>https://blog.ferstar.org/en/tags/wps/</link>
    <description>Code is cheap, let&#39;s talk</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <copyright>© 2026 ferstar · [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)</copyright>
    <lastBuildDate>Sun, 11 Oct 2026 17:50:00 +0800</lastBuildDate>
    <ttl>60</ttl><atom:link href="https://blog.ferstar.org/en/tags/wps/index.xml" rel="self" type="application/rss+xml" /><image>
      <url>https://blog.ferstar.org/site-logo.png</url>
      <title>Code is cheap, let&#39;s talk</title>
      <link>https://blog.ferstar.org/</link>
    </image>
    
    <item>
      <title>You Turn Off the Switch, It Opens the List: How WPS Knew My Phone Was Rooted</title>
      <link>https://blog.ferstar.org/en/posts/how-to-avoid-wps-root-detection/</link>
      <pubDate>Sun, 11 Oct 2026 17:50:00 +0800</pubDate>
      
      <guid isPermaLink="true">https://blog.ferstar.org/en/posts/how-to-avoid-wps-root-detection/</guid>
      <description>Every launch of WPS on my rooted phone showed a &#39;device is rooted&#39; warning. I first wrote and published an LSPosed module that intercepts exactly that one toast; decompiling WPS revealed a six-check detection chain, and in-process instrumentation proved the hit was a KernelSU package-name query — one that succeeds even with the &#39;app list&#39; permission denied, thanks to a `` declaration in the Manifest. Hide My Applist finally made the query return &#39;not installed&#39;, killing the detection at its source.</description><content:encoded><![CDATA[<blockquote><p>I am not a native English speaker; this article was translated by AI.</p>
</blockquote><p>I bought this phone, but without root it felt like I was renting it, so I rooted it. Then WPS, starting from some version I can’t pin down, began caring whether my phone is rooted: every time I open it from the launcher, the home page shows “设备已Root，使用WPS Office可能存在安全风险。” (“This device is rooted; using WPS Office may pose security risks.”) It disappears after a second and leaves nothing in notification history. The worst part: I had denied its “read app list” permission, and it still detected KernelSU.</p>

<h2 class="relative group">Why the warning can’t be turned off
    <div id="why-the-warning-cant-be-turned-off" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-the-warning-cant-be-turned-off" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>It doesn’t go through the notification shade. It’s a Toast called directly from the app process, so notification permissions don’t apply, notification history keeps nothing, and WPS’s settings have no switch for it. Turning on KernelSU’s “unmount modules” for WPS didn’t help either.</p>
<p>Reading the detection code later explained why: this phone wasn’t being flagged by su files or mount points, so touching the file layer was pointless. At the time I didn’t know that, and just wanted the message gone.</p>

<h2 class="relative group">Hiding just this one message
    <div id="hiding-just-this-one-message" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hiding-just-this-one-message" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>I first tried the existing <a href="https://github.com/h465855hgg/toastblocker"  target="_blank" rel="noreferrer">ToastBlocker</a>, scoped to WPS only. The warning was gone, but so were normal toasts like “saved”.</p>
<p>To be more precise, MyAndroidTools’ component toggles were no use: the warning isn’t a separate Activity, Service, or Receiver. The call site is inside <code>HomeControllerImpl$1$1.run()</code> on the home page, which runs the check and calls Toast directly. Blocking only this sentence needs a method-level hook, which means LSPosed.</p>
<p>So I wrote a module with a single rule: exact-match the whole warning sentence (ignoring layout whitespace), let every other Toast through, and leave WPS’s detection result alone. It identifies the content through the public <code>Toast.makeText/setText</code>, marks the matching Toast object, and skips only its <code>show()</code>, without reading the hidden <code>Toast.mText</code> field that Android 16 restricts. The first version used the legacy API and was verified on the device; before release it was rewritten on libxposed API 102’s interceptor chain. Build, signing, and release run on GitHub Actions, and <a href="https://github.com/ferstar/wps-root-toast"  target="_blank" rel="noreferrer">wps-root-toast</a> 1.0.0 is now in the <a href="https://github.com/Xposed-Modules-Repo/io.github.ferstar.wpsroottoast"  target="_blank" rel="noreferrer">LSPosed module repository</a>.</p>
<p>The warning no longer shows, but the detection still runs; its result is just hidden.</p>

<h2 class="relative group">How WPS decides a device is rooted
    <div id="how-wps-decides-a-device-is-rooted" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-wps-decides-a-device-is-rooted" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>In WPS 26.9.1, the home-page check is <code>KSystemRoot.i(context)</code>. It runs six checks in order and returns true as soon as one hits.</p>
<pre class="not-prose mermaid">
flowchart TD
    A["KSystemRoot.i(context)"] --> B1{"su file exists in any PATH directory?"}
    B1 -->|hit| T["Rooted"]
    B1 -->|miss| B2{"persist.sys.root.status set and not 0?"}
    B2 -->|hit| T
    B2 -->|miss| B3{"ro.secure is 0?"}
    B3 -->|hit| T
    B3 -->|miss| B4{"Build.TAGS contains test-keys?"}
    B4 -->|hit| T
    B4 -->|miss| B5{"fingerprint or model shows emulator traits?"}
    B5 -->|hit| T
    B5 -->|miss| B6{"root manager package name found?"}
    B6 -->|hit| T
    B6 -->|miss| F["Not rooted"]
    T --> S["Home-page toast: device is rooted, security risk"]
    style B6 fill:#ffcdd2,stroke:#d32f2f,stroke-width:2px
</pre>

<p>There are also two separate checks: <code>LogoutTracker.h()</code> looks for <code>su</code> at two fixed paths, and <code>GetDeviceInfoHandler.a()</code> checks <code>/system/bin/su</code> and <code>/system/xbin/su</code>, puts the result into the <code>root</code> field of the device info, and returns it through a JS callback. The code alone doesn’t show whether this is uploaded to a server, so I won’t draw a conclusion.</p>
<p>The code only shows that these six checks exist, not which one my phone hits. So I made a temporary diagnostic build that hooks the detection method itself and only logs each return value, installed it, and did one cold start:</p>
<table>
  <thead>
      <tr>
          <th>Check</th>
          <th>Measured result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>su</code> file in PATH directories</td>
          <td>false</td>
      </tr>
      <tr>
          <td><code>persist.sys.root.status</code> property</td>
          <td>false</td>
      </tr>
      <tr>
          <td><code>ro.secure=0</code></td>
          <td>false</td>
      </tr>
      <tr>
          <td><code>Build.TAGS</code> contains <code>test-keys</code></td>
          <td>false</td>
      </tr>
      <tr>
          <td>Emulator traits (<code>goldfish</code>, <code>ranchu</code>, <code>sdk_gphone</code>, etc.)</td>
          <td>false</td>
      </tr>
      <tr>
          <td>Query for <code>me.weishu.kernelsu</code></td>
          <td><strong>true</strong></td>
      </tr>
      <tr>
          <td>Final verdict</td>
          <td><strong>true</strong></td>
      </tr>
  </tbody>
</table>
<p>The first five were false; the hit was number six, the package-name query for the KernelSU manager. That’s also why hiding su and changing properties didn’t help: none of them touched this check. After the test I removed the diagnostic build and reinstalled the release module.</p>

<h2 class="relative group">The permission was denied, so why does the query work?
    <div id="the-permission-was-denied-so-why-does-the-query-work" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-permission-was-denied-so-why-does-the-query-work" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>I really hadn’t given WPS the “read app list” permission, and it still found KernelSU, because these are two separate mechanisms.</p>
<p>The vendor’s “read app list” setting controls enumerating all apps on the device. Package visibility, introduced in Android 11, lets an app name packages in the Manifest’s <code><queries></code>, and for those named packages it can check whether they’re installed without any runtime permission. WPS’s <code><queries></code> lists KernelSU, Magisk, and APatch, and the system’s visibility records show the two packages as mutually visible. So tapping “deny” in the permission panel does nothing against these named queries.</p>
<p>This check also doesn’t justify the words “security risk”: having a manager app installed counts as a hit, without checking whether WPS could actually get root; emulator traits count as rooted too, which emulator users probably wouldn’t expect.</p>

<h2 class="relative group">Making the query return “not installed”
    <div id="making-the-query-return-not-installed" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#making-the-query-return-not-installed" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Since the check is a package-name query, WPS just needs to not find the package. There were three options:</p>
<table>
  <thead>
      <tr>
          <th>Option</th>
          <th>Approach</th>
          <th>Cost</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>App-list hiding module</td>
          <td>Hide chosen package names from WPS</td>
          <td>Mostly older APIs; needs on-device testing on new Android</td>
      </tr>
      <tr>
          <td>Extend my module</td>
          <td>Intercept package queries in WPS’s process, return “not installed”</td>
          <td>Write and maintain it myself</td>
      </tr>
      <tr>
          <td>Patch the APK, strip <code><queries></code></td>
          <td>Re-sign and install</td>
          <td>Breaks updates and signature checks; not for daily use</td>
      </tr>
  </tbody>
</table>
<p>I was planning to write another API 102 package-visibility filter module, then found that <a href="https://modules.lsposed.org/module/com.tsng.hidemyapplist/"  target="_blank" rel="noreferrer">Hide My Applist</a> (HMA) already does exactly this, so there was no need.</p>
<p>The configuration has two entries: a blacklist template containing only <code>me.weishu.kernelsu</code>, and an app rule applying it to WPS only, with aggressive filtering and verbose logging off. To verify, I turned off the “WPS Root 提示隐藏” module, force-stopped WPS, and reopened it: no warning. The package query was blocked and all six checks returned false. Other entry points using the same check fail the same way, while the original module only covered the home page.</p>
<p>HMA 3.8.3 targets libxposed API 101 and requires the framework to allow loading native libraries in system services. It works on my Android 16 + LSPosed 2.2.1 setup.</p>

<h2 class="relative group">Was the module a waste?
    <div id="was-the-module-a-waste" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#was-the-module-a-waste" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Looking back, I should have searched for existing solutions first. But without digging out the six checks, I wouldn’t have known the trigger was a package-name query, let alone that one HMA template would fix it.</p>
<p>The <a href="https://github.com/ferstar/wps-root-toast"  target="_blank" rel="noreferrer">wps-root-toast repository</a> is now archived. Its README includes the tested HMA configuration, and the source and released APK remain available for reference.</p>
<p>The module isn’t entirely wasted. If you only want this warning gone without changing package visibility, it still works on its own, and the diagnostic build was made from its code.</p>
<p>Decompiling and hooking were only done on the WPS on my own phone. The module only hides the toast and doesn’t change detection results, and the repository contains no WPS code. The release notes only list the tested combination: Android 16 + WPS 26.9.1 + LSPosed 2.2.1.</p>
<p>Later Yadea started policing VPNs too, with its check hidden behind a packer. That one is in the <a href="/en/posts/how-to-avoid-yadea-vpn-detection/" >next post</a>.</p>
]]></content:encoded>
      
    </item>
    
  </channel>
</rss>
