<?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>LSPosed on Code is cheap, let&#39;s talk</title>
    <link>https://blog.ferstar.org/en/tags/lsposed/</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 21:45:00 +0800</lastBuildDate>
    <ttl>60</ttl><atom:link href="https://blog.ferstar.org/en/tags/lsposed/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>It Doesn&#39;t Use the VPN, but You&#39;re Not Allowed to Either: How I Fixed Yadea&#39;s VPN Detection</title>
      <link>https://blog.ferstar.org/en/posts/how-to-avoid-yadea-vpn-detection/</link>
      <pubDate>Sun, 11 Oct 2026 21:45:00 +0800</pubDate>
      
      <guid isPermaLink="true">https://blog.ferstar.org/en/posts/how-to-avoid-yadea-vpn-detection/</guid>
      <description>Opening the Yadea app with a VPN on triggers a &#39;VPN environment detected&#39; warning, then the app exits outright; the app hides behind 360 Jiagu packing with anti-hook defenses, so the in-process diagnostics from my previous post all failed. The measured truth is even more annoying: Yadea&#39;s own traffic never goes through the VPN, yet it still polices your whole phone&#39;s network. VPN Hide finally solved it — filtering VPN traces at the system_server Binder layer and via a kernel module, for Yadea alone — and cold starts went back to normal. The detection was stopped at a layer it cannot see.</description><content:encoded><![CDATA[<blockquote><p>I am not a native English speaker; this article was translated by AI.</p>
</blockquote><p>Right after dealing with <a href="/en/posts/how-to-avoid-wps-root-detection/" >the WPS root warning</a>, it was Yadea’s turn. Opening the Yadea app with a VPN on shows “检测到VPN环境，请调整网络” (“VPN environment detected, please adjust your network”), and then the app exits. You can’t even get to the vehicle page.</p>
<p>WPS at least only showed a reminder; Yadea just refuses to work. What’s more absurd is that Yadea’s own traffic doesn’t go through the VPN at all. Whether my VPN is on has nothing to do with its network, but it insists on checking anyway.</p>

<h2 class="relative group">Symptoms
    <div id="symptoms" 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="#symptoms" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>logcat makes it clear: the main process logs “检测到VPN环境,请调整网络” and then exits on its own. The <code>:pushservice</code> push process shows the same message.</p>
<p>I wanted to unpack the APK, but Yadea 8.8.12 is packed with 360 Jiagu. Unpacking it only gives the shell code; the business logic isn’t visible.</p>

<h2 class="relative group">In-process hooks blocked by the packer
    <div id="in-process-hooks-blocked-by-the-packer" 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="#in-process-hooks-blocked-by-the-packer" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>For WPS, I used a temporary LSPosed diagnostic module that hooked the detection method inside the process and logged return values. On Yadea, the packer noticed during initialization and the app kept exiting before reaching the business code; removing the module brought back the original VPN warning. In-process hooking was a dead end. I also tried Frida, but it had version compatibility problems and never ran.</p>

<h2 class="relative group">Code dumped from memory
    <div id="code-dumped-from-memory" 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="#code-dumped-from-memory" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Since the static route was blocked, I let the app decrypt itself at runtime. After a normal start, I dumped the business DEX from memory and could finally read the code.</p>
<p>Following the network checks led to an interface-enumeration routine in the Getui push SDK that looks for VPN interfaces like <code>tun0</code> and <code>ppp0</code>. But there’s no evidence that this is what shows the warning, and I never caught which API triggers it, so I won’t draw a conclusion.</p>
<p>What I could confirm is something else. Yadea’s UID is <code>10450</code>. It’s not in the list of UIDs the VPN covers, and not in the VPN client’s per-app list either; its traffic was direct all along. So setting Yadea to bypass the VPN in the client doesn’t help: it checks whether the phone has a VPN at all, regardless of how its own traffic is routed. WPS checked which apps were installed; Yadea polices the whole phone’s network state, which reaches even further.</p>

<h2 class="relative group">Handling it outside the process
    <div id="handling-it-outside-the-process" 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="#handling-it-outside-the-process" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Since nothing can be done inside the process, the data has to be changed somewhere the app can’t see. <a href="https://github.com/okhsunrog/vpnhide"  target="_blank" rel="noreferrer">VPN Hide</a> does exactly this, on two layers:</p>
<ul>
<li><strong>Java layer</strong>: the LSPosed module hooks system_server. At Binder’s <code>writeToParcel</code>, it removes VPN information from <code>NetworkCapabilities</code>, <code>NetworkInfo</code>, and <code>LinkProperties</code>, so the network data the app gets over IPC contains no VPN. Nothing is injected into Yadea’s process, so the packer’s anti-hook and memory-integrity checks don’t notice.</li>
<li><strong>Native layer</strong>: a kernel module (kmod, based on kprobe/kretprobe) hides the <code>tun</code> interface in the kernel. Interface-enumeration ioctls, <code>getifaddrs</code>, and netlink route dumps are all filtered, so native code can’t find it either. The backend can be kmod, KPM, or Zygisk. Zygisk does inline hooks inside the process and may be caught by the packer; this phone’s kernel supports kmod, so I used kmod.</li>
</ul>
<pre class="not-prose mermaid">
flowchart TD
    Y["Yadea app (360 Jiagu pack + anti-hook)"]
    Y -->|"in-process hook injection"| X["Packer detects it, repeated exits ✗"]
    Y -->|"Java API network queries"| S["system_server<br/>VPN Hide: Binder-layer filtering ✓"]
    Y -->|"native interface/route enumeration"| K["Kernel<br/>VPN Hide kmod: tun0 hidden ✓"]
</pre>

<p>kmod doesn’t require flashing the kernel or modifying the boot image; the KernelSU module loads a <code>.ko</code> into the existing kernel at boot. But it does change the running kernel’s behavior: if it’s incompatible, the system may crash, and after uninstalling it you need another reboot to fully restore things.</p>
<p>The configuration enables Java and native hiding for <code>com.yadea.smartmoto</code> only, with the LSPosed scope set to the system framework, not Yadea.</p>

<h2 class="relative group">Result
    <div id="result" 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="#result" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>After rebooting to load the kernel module, with NekoBox connected, Yadea cold-started twice in a row straight into the vehicle home page. No VPN warning, no exit.</p>
<p>VPN Hide has a statistics page. I recorded for 43 seconds, cold-starting Yadea once during that window, and it logged 156 interceptions:</p>
<table>
  <thead>
      <tr>
          <th>Query type</th>
          <th>Count</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Native ioctl network interface queries</td>
          <td>130</td>
      </tr>
      <tr>
          <td>IPv4 / IPv6 address enumeration</td>
          <td>3 each</td>
      </tr>
      <tr>
          <td>Java <code>NetworkInfo</code></td>
          <td>12</td>
      </tr>
      <tr>
          <td>Java <code>Network</code> handles</td>
          <td>5</td>
      </tr>
      <tr>
          <td>Java <code>ConnectivityService</code></td>
          <td>3</td>
      </tr>
  </tbody>
</table>
<p>Most of it is native ioctl interface queries. That path returns the interface list, addresses, and up/down state, which is enough to spot VPN interfaces like <code>tun0</code>. Filtering only at the Java layer can’t block these queries, which is why kmod has to be enabled as well. These counts also include normal network calls, though, so they don’t mean Yadea deliberately checked for a VPN 156 times, and the stats alone can’t identify which call triggered the warning.</p>
<p>VPN Hide’s own status page shows “no check needed”, which looks like it isn’t working. It’s actually because VPN Hide itself doesn’t go through the VPN, so that’s the expected display.</p>
<p>VPN Hide only handles VPN traits that can be queried on the phone. It doesn’t hide root or deal with Play Integrity. Server-side checks such as DNS leaks, IP blacklists, and TLS fingerprinting are out of its reach.</p>

<h2 class="relative group">Compared with WPS
    <div id="compared-with-wps" 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="#compared-with-wps" aria-label="Anchor">#</a>
    </span>
    
</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>WPS</th>
          <th>Yadea</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>What it checks</td>
          <td>whether a root manager is installed</td>
          <td>whether the phone has a VPN</td>
      </tr>
      <tr>
          <td>Is the check protected</td>
          <td>no</td>
          <td>packing + anti-hook</td>
      </tr>
      <tr>
          <td>After a hit</td>
          <td>shows a toast</td>
          <td>shows a warning, then exits</td>
      </tr>
      <tr>
          <td>Where it was fixed</td>
          <td>package visibility (HMA)</td>
          <td>system_server + kernel (VPN Hide)</td>
      </tr>
  </tbody>
</table>
<p>WPS’s check sits in the app layer without protection, so hiding the package name with HMA was enough. Yadea’s check is behind a packer and can’t be touched in-process, so the only option was to go down to system_server and the kernel and change the data it receives.</p>
<p>Unpacking, dumping memory, reading decompiled code, picking a kernel module: in the past, even a professional wouldn’t have found it worth this much effort for one vendor app’s unreasonable restrictions, and ordinary users just had to put up with it. With AI’s help, ordinary people can get this done too, and finally decide for themselves how to use their own phones.</p>
]]></content:encoded>
      
    </item>
    
    <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>
