<?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>VPN on Code is cheap, let&#39;s talk</title>
    <link>https://blog.ferstar.org/en/tags/vpn/</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/vpn/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>
    
  </channel>
</rss>
