<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: engnzdl</title>
    <description>The latest articles on DEV Community by engnzdl (@engnzdl).</description>
    <link>https://dev.to/engnzdl</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F368665%2Fcbf2e341-63bb-4341-8e4e-f3cfbf10f558.png</url>
      <title>DEV Community: engnzdl</title>
      <link>https://dev.to/engnzdl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/engnzdl"/>
    <language>en</language>
    <item>
      <title>Reversing TikTok's Request Signing: From Obfuscated Native Code to Pure Python</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Sun, 18 Oct 2026 20:26:43 +0000</pubDate>
      <link>https://dev.to/engnzdl/reversing-tiktoks-request-signing-from-obfuscated-native-code-to-pure-python-30e6</link>
      <guid>https://dev.to/engnzdl/reversing-tiktoks-request-signing-from-obfuscated-native-code-to-pure-python-30e6</guid>
      <description>&lt;h1&gt;
  
  
  Reversing TikTok's Request Signing: From Obfuscated Native Code to Pure Python
&lt;/h1&gt;

&lt;p&gt;TikTok's mobile app signs every API request with a stack of tokens: x-argus, x-gorgon, x-ladon, x-khronos. The server checks them before it returns a single byte. Get one wrong and you don't get a clean 403. You get an empty 200, or an error that looks like a network hiccup. The rejection is silent, and that silence is most of why this thing is hard to reverse.&lt;/p&gt;

&lt;p&gt;I reverse-engineered the full signature set for TikTok 47.0.3 and rebuilt it in pure Python. No emulator, no runtime instrumentation, no JVM, no loading native modules. A few Python dependencies and a signing facade.&lt;/p&gt;

&lt;p&gt;I'm leaving out the exact byte layouts, constant tables, and key-derivation steps. This post is about how to approach this class of problem, not instructions for reproducing the signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What you're up against
&lt;/h2&gt;

&lt;p&gt;Four moving parts, and they compose rather than stack independently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signature&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;x-argus&lt;/td&gt;
&lt;td&gt;request body + query&lt;/td&gt;
&lt;td&gt;the strict one, enforced on high-value endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;x-gorgon&lt;/td&gt;
&lt;td&gt;a header-level signature&lt;/td&gt;
&lt;td&gt;table-driven bit mixing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;x-ladon&lt;/td&gt;
&lt;td&gt;device / license attestation&lt;/td&gt;
&lt;td&gt;ties the request to an app identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;x-khronos&lt;/td&gt;
&lt;td&gt;timestamp&lt;/td&gt;
&lt;td&gt;tightly coupled to the others; same-timestamp consistency matters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Enforcement is risk-based and per-endpoint. Not every endpoint checks every signature. I probed live endpoints with deliberately broken signatures and found the anonymous pre-login surface is where the server is strictest. Once you hold a valid session, the checks relax a lot. That looks like a deliberate anti-bot posture, and it doubles as a development shortcut: test against the strictest endpoints first, because if those pass, the rest will too.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The approach
&lt;/h2&gt;

&lt;p&gt;The specific tools matter less than the order of thinking. Three principles carried the whole effort.&lt;/p&gt;

&lt;p&gt;Observe at the point of least resistance. The native signing code is control-flow-obfuscated, and it generates constants in registers at runtime. They are never written to disk, so a disassembler finds nothing to grep for. The breakthrough came from watching the code at the one place where its secrets are briefly in the clear: a full emulation environment where timing side effects vanish and short-lived intermediate state can be captured. In a real process that state lives for microseconds. In an emulator you can freeze it and read it back.&lt;/p&gt;

&lt;p&gt;Treat negative results as scope reduction. Instrumenting the higher-level runtime produced nothing but silence. The signature path was entirely native; the managed layer only handled string decryption and device queries. That silence was the finding. It told me precisely where not to look and saved weeks. A hook that returns nothing is still an answer, if you read it as a boundary.&lt;/p&gt;

&lt;p&gt;Prove the generator before you trust it. The turning point was building the inverse of the signing pipeline and requiring it to fully reverse a genuine token before I accepted the forward implementation. If your decoder can't explain a real signature byte for byte, your generator is a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The findings that mattered
&lt;/h2&gt;

&lt;p&gt;These are behavioral findings, not the algorithm:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;One byte in the argus header is a version/flag field that must be small. A random large value gets rejected before the body is even read. A whole class of plausible-looking forgeries dies on that single byte.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The trailing padding is not real PKCS7. It's length-prefixed random fill. Treating it as standard block padding produces a structurally plausible signature that gets silently rejected.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Not every field that looks like device attestation is actually checked. I found fingerprint-looking values that are client-generated and never verified by the server. The lesson isn't the specific field. It's to test every assumption: "the server checks this" is a hypothesis until you prove it with a live request.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Two error codes that look like siblings mean opposite things. One means "your signature is invalid." The other means "your signature is fine, but I want a challenge step first." I confirmed this by reproducing the app's own login flow: the challenge appears after the signature has already been accepted. Confusing the two sends you debugging the wrong layer for days.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Silent rejection is the real enemy. HTTP 200 with an empty body looks like success, but it's risk-control quietly dropping your action. A 403 at least tells you something. Treat an empty 200 as the red flag.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The pure Python port
&lt;/h2&gt;

&lt;p&gt;The runtime is deliberately boring, and that's the point. A TLS layer that matches the app's own fingerprint, a pure Python implementation of the core hash primitive, and a standard library for the symmetric layer. No native modules, no subprocess, no emulator, no runtime instrumentation. It runs anywhere Python runs.&lt;/p&gt;

&lt;p&gt;A single signing facade produces the entire header set from one timestamp, which guarantees the x-khronos consistency the server checks. Around it sits a thin client: device registration, session management, and a typed API layer for feed, search across 7 tabs, profile, video download, and DM read. The session store is sqlite-backed with restricted permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verification
&lt;/h2&gt;

&lt;p&gt;I validated against the endpoints that actually enforce the signatures, not the lenient ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The anonymous feed endpoint, one of the strictest, returned a real feed.&lt;/li&gt;
&lt;li&gt;The like endpoint returned is_digg: 1, meaning the server accepted the signature and applied the action.&lt;/li&gt;
&lt;li&gt;The login endpoint returned the same challenge response the real app gets. That's the strongest signal: the server read the signature as valid and moved on to the next auth step.&lt;/li&gt;
&lt;li&gt;A 38 MB video pulled clean through the signing path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The read path holds. What's left is write actions, which hit risk-control silent rejections rather than signature failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What I'm not showing
&lt;/h2&gt;

&lt;p&gt;This covers methodology and behavioral findings. The exact byte layouts, constant tables, and key-derivation chain stay private. If you want to take on a system like this yourself, the reusable part is the order of operations: observe where the secrets are briefly exposed, treat dead ends as scope reduction, and prove your generator with a reverse-verifier before you trust it.&lt;/p&gt;

&lt;p&gt;The signature rotates with every app release anyway. The methodology is what lasts.&lt;/p&gt;

</description>
      <category>reverseengineering</category>
      <category>cybersecurity</category>
      <category>python</category>
      <category>tiktok</category>
    </item>
    <item>
      <title>How apps spot an Android emulator (part 2): sensors, telephony, and the GPU</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Sat, 17 Oct 2026 13:34:17 +0000</pubDate>
      <link>https://dev.to/engnzdl/how-apps-spot-an-android-emulator-part-2-sensors-telephony-and-the-gpu-4o4g</link>
      <guid>https://dev.to/engnzdl/how-apps-spot-an-android-emulator-part-2-sensors-telephony-and-the-gpu-4o4g</guid>
      <description>&lt;h1&gt;
  
  
  How apps spot an Android emulator (part 2): sensors, telephony, and the GPU
&lt;/h1&gt;

&lt;p&gt;Part 1 covered the props layer and the kernel tells. Those are the cheap checks: a file you can edit, and a boot command line you can't. This part is about the expensive ones. The hardware and behavior layer, where the tells stop being strings and become things the VM simply does not have.&lt;/p&gt;

&lt;p&gt;I measured everything below on a stock emulator this month, the same way I measured part 1. The values are real, from &lt;code&gt;adb shell&lt;/code&gt;, not from memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sensors: the vendor string is the giveaway
&lt;/h2&gt;

&lt;p&gt;Android exposes its sensors through a HAL, and every sensor carries a vendor string. On a stock emulator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell dumpsys sensorservice | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Accelerometer|Gyroscope|Proximity|Light"&lt;/span&gt;
0x00000000&lt;span class="o"&gt;)&lt;/span&gt; Goldfish 3-axis Accelerometer | The Android Open Source Project | ver: 1 | ...
0x00000001&lt;span class="o"&gt;)&lt;/span&gt; Goldfish 3-axis Gyroscope | The Android Open Source Project | ver: 1 | ...
0x00000005&lt;span class="o"&gt;)&lt;/span&gt; Goldfish Proximity sensor | The Android Open Source Project | ver: 1 | ...
0x00000006&lt;span class="o"&gt;)&lt;/span&gt; Goldfish Light sensor | The Android Open Source Project | ver: 1 | ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tells in one line. The sensor names all start with "Goldfish", the codename of the emulator's virtual platform. And the vendor on every sensor is "The Android Open Source Project".&lt;/p&gt;

&lt;p&gt;A real phone does not look like this. Its accelerometer comes from Bosch or STMicroelectronics, its gyroscope from InvenSense, its proximity sensor from AMS. The vendor field names the silicon. Nobody ships a phone whose sensors are all "The Android Open Source Project", because that vendor string means "synthesized in software, no chip behind it".&lt;/p&gt;

&lt;p&gt;The list is also thinner than a real device's. The emulator exposes the standard set: accelerometer, gyroscope, magnetometer, light, proximity, pressure, temperature, humidity. It can even stream fake events into them. But the characteristics of the ones it does expose look wrong to an app that has seen real hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telephony: the SIM says LOADED, the RIL says emulator
&lt;/h2&gt;

&lt;p&gt;The emulator goes to real effort here. It simulates a full telephony stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop gsm.sim.state
LOADED

&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop gsm.operator.alpha
T-Mobile

&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop gsm.operator.numeric
310260
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has a SIM in state LOADED, an operator name, a network code, even an IMEI. Any app that checks "is there a SIM and a network" gets a plausible answer.&lt;/p&gt;

&lt;p&gt;Two more props are the ones that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop gsm.version.ril-impl
android reference-ril 1.0

&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop gsm.version.baseband
1.0.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RIL is the radio interface layer, the software that talks to the actual modem. On a real phone that string names the vendor's RIL binary: Qualcomm, MediaTek, Samsung, whatever the SoC is. The emulator reports &lt;code&gt;android reference-ril 1.0&lt;/code&gt;, which is the reference implementation that ships with AOSP for bring-up and emulators. It is the RIL you get when there is no modem.&lt;/p&gt;

&lt;p&gt;The baseband version is the same story. A real phone reports a modem firmware build like &lt;code&gt;MPSS.DE.2.0.c3-00122-M8996FAAAANAZM-1&lt;/code&gt;. The emulator reports &lt;code&gt;1.0.0.0&lt;/code&gt;, a placeholder nobody ships.&lt;/p&gt;

&lt;p&gt;So the telephony tell is not "no SIM". It is a perfectly ordinary SIM attached to a reference RIL and a placeholder baseband. The emulator fakes the part apps check casually, and leaves the part that requires real silicon with a string that gives it away.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPU: the renderer string and the ANDROID_EMU extensions
&lt;/h2&gt;

&lt;p&gt;This is the strongest one. The graphics stack advertises itself in more than one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop ro.hardware.egl
emulation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EGL implementation property is literally the string "emulation". No real device says that. A real phone reports &lt;code&gt;adreno&lt;/code&gt;, &lt;code&gt;mali&lt;/code&gt;, &lt;code&gt;powervr&lt;/code&gt;, or similar.&lt;/p&gt;

&lt;p&gt;The OpenGL renderer string is worse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell dumpsys SurfaceFlinger | &lt;span class="nb"&gt;grep &lt;/span&gt;GLES
GLES: Google &lt;span class="o"&gt;(&lt;/span&gt;Google Inc. &lt;span class="o"&gt;(&lt;/span&gt;Google&lt;span class="o"&gt;))&lt;/span&gt;, Android Emulator OpenGL ES Translator &lt;span class="o"&gt;(&lt;/span&gt;ANGLE &lt;span class="o"&gt;(&lt;/span&gt;Google, Vulkan 1.3.0 &lt;span class="o"&gt;(&lt;/span&gt;SwiftShader Device &lt;span class="o"&gt;(&lt;/span&gt;LLVM 10.0.0&lt;span class="o"&gt;)&lt;/span&gt; ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Android Emulator OpenGL ES Translator". "SwiftShader Device". SwiftShader is Google's software renderer, a CPU implementation of Vulkan and GL. A real phone renders with a hardware GPU and reports something like "Adreno (TM) 730" or "Mali-G78". The emulator reports a software translator, because that is what it is.&lt;/p&gt;

&lt;p&gt;Then there are the extensions. OpenGL lets an app query the list of supported extensions with &lt;code&gt;glGetString(GL_EXTENSIONS)&lt;/code&gt;. A stock emulator returns, alongside the standard ones, a family that only the Android emulator emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ANDROID_EMU_checksum_helper_v1
ANDROID_EMU_native_sync_v2
ANDROID_EMU_dma_v1
ANDROID_EMU_direct_mem
ANDROID_EMU_host_composition_v1
ANDROID_EMU_host_composition_v2
ANDROID_EMU_vulkan
ANDROID_EMU_YUV_Cache
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are more than twenty of them, all prefixed &lt;code&gt;ANDROID_EMU_&lt;/code&gt;. They exist to let the guest and the host coordinate rendering through QEMU, and they are not present on any physical device, because no physical device runs on QEMU. A single &lt;code&gt;glGetString&lt;/code&gt; call, a substring search for "ANDROID_EMU_", and the app knows.&lt;/p&gt;

&lt;p&gt;This is the hardest one to fake, for the same reason as the kernel tells in part 1. The renderer string and the extension list come from the graphics driver, and the driver is part of the guest image. Changing them means shipping a different driver, not editing a property.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this layer is different
&lt;/h2&gt;

&lt;p&gt;The props from part 1 are strings in a file. The kernel tells are strings from the boot command line. The hardware tells are behavior. A sensor HAL, a radio interface, a graphics driver. Each one is a piece of software that exists to talk to silicon the VM does not have, and each one reports its synthetic origin in its own way.&lt;/p&gt;

&lt;p&gt;That is also why faking them is a different category of work. You cannot patch a vendor string and call it done, because the app reading that string is also asking the sensor for data, and the shape of that data has to match a real chip's. This is reimplementing a driver or a HAL, not editing a config file.&lt;/p&gt;

&lt;p&gt;Across both parts the ladder is: props (editable), kernel (needs a custom ROM), hardware (needs a reimplemented driver). An app that checks all three is drawing the real line, and the line is not "is it an emulator", it is "how much work went into hiding it".&lt;/p&gt;

&lt;p&gt;I have been measuring all of this while building &lt;a href="https://github.com/engnzdl/enmulator" rel="noopener noreferrer"&gt;enmulator&lt;/a&gt;, an open source Android emulator manager in Tauri 2 + Rust.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Educational security research. No working bypass, keys, or implementation details published.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you build device detection, or you run automation that keeps getting flagged, this is the kind of problem I work on. DM me on X or reach me at enginozdal.com.&lt;/p&gt;

</description>
      <category>android</category>
      <category>reverseengineering</category>
      <category>security</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How apps spot an Android emulator (part 1): build props and kernel tells</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Sat, 17 Oct 2026 13:24:01 +0000</pubDate>
      <link>https://dev.to/engnzdl/how-apps-spot-an-android-emulator-part-1-build-props-and-kernel-tells-ng1</link>
      <guid>https://dev.to/engnzdl/how-apps-spot-an-android-emulator-part-1-build-props-and-kernel-tells-ng1</guid>
      <description>&lt;h1&gt;
  
  
  How apps spot an Android emulator (part 1): build props and kernel tells
&lt;/h1&gt;

&lt;p&gt;I spent a few days this month poking at a stock Android emulator to answer one question: what actually gives it away? Not the obvious stuff everyone lists, but the signals an app reads when it decides "this is not a phone". This is part one of what I found. It covers the two layers you can touch from userspace and the one you can't. Part two covers hardware and behavior.&lt;/p&gt;

&lt;p&gt;Quick context on why this matters at all. Emulators are used for legit test automation and for bot farms that install an app thousands of times to farm bonuses. The app's defense side wants to separate a real user on a phone from a VM in a rack. Everything below is that detection, looked at from the inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The props layer: a file you can edit
&lt;/h2&gt;

&lt;p&gt;Android exposes device identity through &lt;code&gt;Build.*&lt;/code&gt; and the &lt;code&gt;ro.*&lt;/code&gt; system properties. Most of them live in one file, &lt;code&gt;build.prop&lt;/code&gt;. A stock emulator reports this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop ro.build.product
emulator64_arm64

&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop ro.product.model
sdk_gphone64_arm64

&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop ro.build.fingerprint
google/sdk_gphone64_arm64/emu64xa:12/SGR1.210910.001/7793123:userdebug/dev-keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things jump out. The model name &lt;code&gt;sdk_gphone64_arm64&lt;/code&gt; is not a device anyone has ever bought. And the fingerprint ends in &lt;code&gt;userdebug/dev-keys&lt;/code&gt;, which is a build signing that does not ship to consumers. Every real phone in the wild runs &lt;code&gt;user&lt;/code&gt; builds with &lt;code&gt;release-keys&lt;/code&gt;. Those two strings alone are enough for a cheap first filter.&lt;/p&gt;

&lt;p&gt;Here's what this layer actually is. An app reads these values with &lt;code&gt;SystemProperties.get()&lt;/code&gt;, which is a string lookup into a shared memory region that init fills at boot from &lt;code&gt;build.prop&lt;/code&gt;. It is a file, loaded into memory, read back as strings. With root you can rewrite any of it at runtime and it will happily lie until the next reboot.&lt;/p&gt;

&lt;p&gt;I changed it the persistent way: boot the emulator with &lt;code&gt;-writable-system&lt;/code&gt;, remount the system partition read-write, edit &lt;code&gt;build.prop&lt;/code&gt;, reboot. The new values stuck. &lt;code&gt;ro.build.product&lt;/code&gt; went from &lt;code&gt;emulator64_arm64&lt;/code&gt; to a real device's product name and stayed that way.&lt;/p&gt;

&lt;p&gt;One flag is worth knowing about. The emulator documents &lt;code&gt;-prop&lt;/code&gt; as a way to set properties at boot, and it worked on older versions. On recent versions it's dead: it sets nothing. The reliable path is editing &lt;code&gt;build.prop&lt;/code&gt; directly, which is what I used for the rest of this.&lt;/p&gt;

&lt;p&gt;So the props layer is fakeable, and everyone knows it. An app that checks only &lt;code&gt;Build.MODEL&lt;/code&gt; is catching the VMs whose owners did not bother. That is the point of this layer in a detection stack: cheap, catches the sloppy cases, nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The kernel layer: the part that does not lie
&lt;/h2&gt;

&lt;p&gt;The second layer is where it gets harder. These values do not come from a file you can edit. They come from the kernel and its boot command line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell getprop ro.kernel.qemu
1

&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/cmdline
... androidboot.hardware&lt;span class="o"&gt;=&lt;/span&gt;ranchu androidboot.hardware.platform&lt;span class="o"&gt;=&lt;/span&gt;ranchu ... &lt;span class="nv"&gt;qemu&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 ...

&lt;span class="nv"&gt;$ &lt;/span&gt;adb shell &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-m1&lt;/span&gt; &lt;span class="s2"&gt;"Hardware"&lt;/span&gt; /proc/cpuinfo
Hardware : goldfish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three signals pointing the same direction. &lt;code&gt;ro.kernel.qemu&lt;/code&gt; is set from a &lt;code&gt;qemu=1&lt;/code&gt; flag on the kernel command line when QEMU boots the guest. &lt;code&gt;/proc/cpuinfo&lt;/code&gt; reports &lt;code&gt;goldfish&lt;/code&gt;, the codename of the virtual platform the Android emulator has used for years. The kernel itself is a &lt;code&gt;ranchu&lt;/code&gt; build, a fork of goldfish compiled for QEMU's virtual hardware, not for any shipping SoC.&lt;/p&gt;

&lt;p&gt;I wanted to know whether these could be faked the way props can, so I tried. First I patched &lt;code&gt;build.prop&lt;/code&gt; to change &lt;code&gt;ro.hardware&lt;/code&gt; from &lt;code&gt;ranchu&lt;/code&gt; to a real device's hardware name, rebooted, and checked. Still &lt;code&gt;ranchu&lt;/code&gt;. Then I passed &lt;code&gt;androidboot.hardware=...&lt;/code&gt; on the QEMU command line to try to override it at boot. Still &lt;code&gt;ranchu&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reason is that &lt;code&gt;ro.hardware&lt;/code&gt; is not a &lt;code&gt;build.prop&lt;/code&gt; key. init derives it at boot from &lt;code&gt;androidboot.hardware&lt;/code&gt; on the kernel command line, and QEMU sets that when it starts the guest. My edit was ignored because the key I edited was not the key that set the value. To change it you need a custom kernel or a custom ROM, one recompiled with a different hardware identity or one that does not advertise QEMU at all. That is a build system problem, not a config tweak.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ro.board.platform&lt;/code&gt; behaved the same way, but for a different reason. I edited it in &lt;code&gt;build.prop&lt;/code&gt; and it stayed empty after boot. That one is restricted by SELinux property contexts, so init refuses to accept the value from that file.&lt;/p&gt;

&lt;p&gt;The kernel layer is the line. A spoofed &lt;code&gt;build.prop&lt;/code&gt; gets caught here because the kernel keeps telling the truth. &lt;code&gt;/proc/cpuinfo&lt;/code&gt; is the kernel reporting its own identity, and a userspace process asking for it gets the kernel's answer. This is what a serious detector checks after the props pass, and it is why the props layer alone was never going to be enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where that leaves the first two layers
&lt;/h2&gt;

&lt;p&gt;Props are cosmetic. They are rewritable and any detector worth the name treats them as such. The kernel layer is the real first filter, and it is where a VM leaves marks it cannot edit away from userspace without a custom ROM.&lt;/p&gt;

&lt;p&gt;That split matters more than it sounds like it should. These signals look like the same kind of thing from the outside, just strings you could rewrite. They are not. Some of them come from a file, some of them come from the kernel, and the difference determines what you have to build if you want them to say something else.&lt;/p&gt;

&lt;p&gt;Next part: the hardware and behavior layer. Sensors, telephony, the GPU renderer string, and why faking those convincingly means reimplementing drivers instead of editing files.&lt;/p&gt;

&lt;p&gt;I have been measuring all of this while building &lt;a href="https://github.com/engnzdl/enmulator" rel="noopener noreferrer"&gt;enmulator&lt;/a&gt;, an open source Android emulator manager in Tauri 2 + Rust.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Educational security research. No working bypass, keys, or implementation details published.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you build device detection, or you run automation that keeps getting flagged, this is the kind of problem I work on. DM me on X or reach me at enginozdal.com.&lt;/p&gt;

</description>
      <category>android</category>
      <category>reverseengineering</category>
      <category>security</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Two real bugs in aiograpi: a race condition and a dropped auth token</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:34:00 +0000</pubDate>
      <link>https://dev.to/engnzdl/two-real-bugs-in-aiograpi-a-race-condition-and-a-dropped-auth-token-20l4</link>
      <guid>https://dev.to/engnzdl/two-real-bugs-in-aiograpi-a-race-condition-and-a-dropped-auth-token-20l4</guid>
      <description>&lt;p&gt;aiograpi is the async Instagram private-API library (475★ on GitHub). A version bump of our vendored copy surfaced two real bugs. Both are fixed, tested, and PR'd upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. photo_upload extracts media from the wrong response
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;photo_upload&lt;/code&gt; retries &lt;code&gt;photo_configure&lt;/code&gt; up to 10 times, then pulls the uploaded media out of &lt;code&gt;self.last_json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;photo_configure&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="n"&gt;media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_configured_media_or_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# shared mutable state
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;self.last_json&lt;/code&gt; is the client's shared "last response" field. In a single-upload flow it happens to equal the configure response. Run two uploads concurrently and another coroutine's response can land in &lt;code&gt;self.last_json&lt;/code&gt; between the configure call and the read — you extract media from the wrong request, or fail outright.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;photo_configure&lt;/code&gt; already returns the configured dict. The retry loop was discarding it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;configure_result&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;photo_configure&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="n"&gt;media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_configured_media_or_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;configure_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Race gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Instagram's token rotation gets silently dropped
&lt;/h2&gt;

&lt;p&gt;Instagram rotates the bearer token mid-session and returns it in the &lt;code&gt;ig-set-authorization&lt;/code&gt; response header. aiograpi parsed that header in exactly one place — the login flow. A rotation returned on any other private request was dropped, so &lt;code&gt;authorization_data&lt;/code&gt; went stale and every later request rebuilt a stale &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;The fix moves the parse into the shared private-request handler, so any response carrying the header updates the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ig-set-authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_authorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authorization_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Shipped with tests
&lt;/h2&gt;

&lt;p&gt;Each fix has a regression test (mocked transport / mocked configure) and passes the project's ruff + pytest gates.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Race fix: &lt;a href="https://github.com/subzeroid/aiograpi/pull/511" rel="noopener noreferrer"&gt;https://github.com/subzeroid/aiograpi/pull/511&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Auth fix: &lt;a href="https://github.com/subzeroid/aiograpi/pull/512" rel="noopener noreferrer"&gt;https://github.com/subzeroid/aiograpi/pull/512&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>debugging</category>
      <category>opensource</category>
      <category>instagram</category>
    </item>
    <item>
      <title>Upgrading a vendored anti-bot library across a major version: the patch ledger</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Thu, 15 Oct 2026 14:14:51 +0000</pubDate>
      <link>https://dev.to/engnzdl/upgrading-a-vendored-anti-bot-library-across-a-major-version-the-patch-ledger-2ekf</link>
      <guid>https://dev.to/engnzdl/upgrading-a-vendored-anti-bot-library-across-a-major-version-the-patch-ledger-2ekf</guid>
      <description>&lt;p&gt;I run the Instagram side of a social-automation platform, and it depends on aiograpi — the async port of instagrapi, the library people use to talk to Instagram's private API without the official Graph API. We vendor it (we have to, because we patch it), and recently we took it from 0.9.6 to 2.0.15.&lt;/p&gt;

&lt;p&gt;That's not a minor bump. 2.0.15 is the instagrapi 3.x lineage — a rewrite underneath. And when you've been patching a vendored library for months, a major upgrade isn't &lt;code&gt;pip install&lt;/code&gt;. It's an accounting exercise: which of my local patches does upstream now have, and which are still mine to carry forward by hand?&lt;/p&gt;

&lt;h2&gt;
  
  
  The patch ledger
&lt;/h2&gt;

&lt;p&gt;Over the life of our 0.9.6 fork, five local patches had accumulated. Going in, the job was to sort them into two buckets.&lt;/p&gt;

&lt;p&gt;Three had been absorbed upstream. The XDT media types (XDTGraphImage / XDTGraphVideo / XDTGraphSidecar — Instagram's newer Graph response shapes), a null guard on empty &lt;code&gt;video_versions&lt;/code&gt;, a normalize on carousel-children pk extraction. Those just disappear on upgrade.&lt;/p&gt;

&lt;p&gt;Two hadn't. Those are the ones worth describing, because they're the kind of bug that only surfaces in production, and only in an async client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch one: the walrus-operator fix
&lt;/h2&gt;

&lt;p&gt;The photo upload path retries its configure step up to ten times. The original code checked success by reading &lt;code&gt;self.last_json&lt;/code&gt; — a shared field the client overwrites on every response. In a synchronous client that's already fragile. In an async client, where another coroutine's response can land between your configure call and your read, it's actively wrong: you're checking whether your upload succeeded by looking at some &lt;em&gt;other&lt;/em&gt; request's response.&lt;/p&gt;

&lt;p&gt;The fix is one line, and it's the whole lesson:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configure_result&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;photo_configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upload_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)):&lt;/span&gt;
    &lt;span class="n"&gt;media&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_configured_media_or_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configure_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The walrus captures configure's return value into a local, so you test the response you actually received — not whatever last touched the shared field. This is the bug that makes photo uploads flake intermittently in ways that are miserable to reproduce: it only bites when two requests race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch two: the authorization header Instagram sends you
&lt;/h2&gt;

&lt;p&gt;Instagram hands the session's auth token back in a response header, &lt;code&gt;ig-set-authorization&lt;/code&gt;. The code parsed its sibling — &lt;code&gt;ig-set-x-mid&lt;/code&gt;, the machine id — but never parsed the authorization header into the client's &lt;code&gt;authorization_data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the session looked logged in while the client quietly worked with an empty authorization, and the first privileged request failed in a way that pointed at everything except the real cause. The fix is a straight header parse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ig-set-authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_authorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authorization_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took a failing login to trace it there, because the symptom — "auth required" on the first action — is indistinguishable from a dozen other auth problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the upgrade didn't cause, but the test run caught
&lt;/h2&gt;

&lt;p&gt;None of this counts until it's exercised against a real account. The end-to-end run was: session login, reads (user info, search, friendship status), then writes — follow, like, unlike, unfollow.&lt;/p&gt;

&lt;p&gt;The unlike action 404'd with "not-logged-in". It turned out &lt;code&gt;_action_unlike&lt;/code&gt; only called the &lt;em&gt;web&lt;/em&gt; client's &lt;code&gt;unlike()&lt;/code&gt;, while the session we were holding was a mobile session — so the web client had no cookie and got a clean 404. The like path already had the right shape (mobile first, web fallback); the unlike path didn't. Fixed by mirroring it: try &lt;code&gt;media_unlike&lt;/code&gt; on the mobile client, fall back to web.&lt;/p&gt;

&lt;p&gt;After that, every write verified against the real account — follow reflected as &lt;code&gt;following: true&lt;/code&gt; on Instagram, like / unlike / unfollow all clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm writing this
&lt;/h2&gt;

&lt;p&gt;Because this is the unglamorous reality of building on top of reverse-engineered, unofficial APIs: the library is one platform change away from breaking, so you vendor it, you patch it, and when upstream finally catches up you run the ledger — what's absorbed, what's still yours, and what only shows up when you run it against a live account.&lt;/p&gt;

&lt;p&gt;If your product sits on a private-API library and you're tired of the treadmill — the upgrades, the patch accounting, the 404s that mean something else entirely — this is the work I do. DM me on X (&lt;a href="https://x.com/engnzdl" rel="noopener noreferrer"&gt;@engnzdl&lt;/a&gt;) or reach me at &lt;a href="https://enginozdal.com" rel="noopener noreferrer"&gt;enginozdal.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reverseengineering</category>
      <category>python</category>
      <category>api</category>
      <category>instagram</category>
    </item>
    <item>
      <title>I built a cross-platform Android emulator manager with Tauri 2 + Rust — no Android Studio required</title>
      <dc:creator>engnzdl</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:55:38 +0000</pubDate>
      <link>https://dev.to/engnzdl/i-built-a-cross-platform-android-emulator-manager-with-tauri-2-rust-no-android-studio-required-52c5</link>
      <guid>https://dev.to/engnzdl/i-built-a-cross-platform-android-emulator-manager-with-tauri-2-rust-no-android-studio-required-52c5</guid>
      <description>&lt;p&gt;I got tired of three things at once: the Android emulator CLI is tedious, Android Studio is a multi-gigabyte install for just running AVDs, and every decent alternative is either closed-source or expensive. So I built &lt;a href="https://github.com/engnzdl/enmulator" rel="noopener noreferrer"&gt;Enmulator&lt;/a&gt; — a desktop app that manages Android Virtual Devices end to end, ships on Windows/macOS/Linux, and doesn't need Android Studio installed.&lt;/p&gt;

&lt;p&gt;It's Tauri 2 on the backend (Rust, 13 modules), React 18 on the frontend, and it's GPLv3.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Create / start / stop / clone / delete AVDs, plus batch operations across multiple devices&lt;/li&gt;
&lt;li&gt;66 pre-loaded device profiles across 16 brands (Pixel, Samsung, Xiaomi, OnePlus, realme…)&lt;/li&gt;
&lt;li&gt;Device identity spoofing: IMEI, operator, phone number, WiFi MAC, timezone, locale&lt;/li&gt;
&lt;li&gt;Root: &lt;code&gt;adb root&lt;/code&gt; for google_apis images, and Magisk via rootAVD for everything else&lt;/li&gt;
&lt;li&gt;Per-device proxy, GPS mocking, screen recording, clipboard sync, live logcat, a split-panel file explorer&lt;/li&gt;
&lt;li&gt;A headless REST API (Actix-web) so you can drive it from CI or a script&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole point was to make emulator setup feel like a product, not a shell-script chore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tauri instead of Electron
&lt;/h2&gt;

&lt;p&gt;A tool that spawns emulator processes shouldn't eat 300 MB of RAM on its own. Tauri uses the OS webview instead of bundling Chromium, so the installer is small and idle memory is low. The backend being Rust mattered more than I expected: adb calls are blocking &lt;code&gt;Command&lt;/code&gt; invocations, and being able to move them off the main thread with plain &lt;code&gt;std::thread&lt;/code&gt; — without async ceremony — kept the UI responsive for free.&lt;/p&gt;

&lt;p&gt;The tradeoff is real too: the macOS/Windows/Linux build matrix means three toolchains and three sets of platform quirks (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The Rust side is a flat set of modules, each owning one concern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sdk.rs&lt;/code&gt; — locate the Android SDK, &lt;code&gt;adb&lt;/code&gt;, &lt;code&gt;emulator&lt;/code&gt;, &lt;code&gt;avdmanager&lt;/code&gt; binaries&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;avd_manager.rs&lt;/code&gt; — create/delete AVDs, write &lt;code&gt;config.ini&lt;/code&gt; (resolution, DPI, hardware keyboard)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;emulator.rs&lt;/code&gt; — process management + port allocation (each emulator gets a console port)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adb_bridge.rs&lt;/code&gt; — the &lt;code&gt;adb shell / install / push / pull&lt;/code&gt; wrappers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fingerprint.rs&lt;/code&gt; — the identity-spoofing engine&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bypass.rs&lt;/code&gt; — build.prop patching for detection bypass&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;extras.rs&lt;/code&gt; — GPS, clipboard, screen record, logcat streams&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api_server.rs&lt;/code&gt; — the REST layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend is a few focused components: a create wizard, per-device cards, an identity panel, the file explorer, quick actions. Nothing clever — state lives in the backend, the UI just calls commands over Tauri's IPC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that were actually hard
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fingerprint spoofing is a moving target
&lt;/h3&gt;

&lt;p&gt;The naive version is &lt;code&gt;adb shell setprop&lt;/code&gt; with the IMEI/operator values. That works for &lt;em&gt;some&lt;/em&gt; properties and silently fails for others — and "silently fails" is the worst outcome because the user thinks they're spoofed when they aren't.&lt;/p&gt;

&lt;p&gt;The reality on modern Android:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ro.product.*&lt;/code&gt; (brand, model, manufacturer) are &lt;strong&gt;immutable&lt;/strong&gt; — set at boot by the system image, &lt;code&gt;setprop&lt;/code&gt; can't touch them.&lt;/li&gt;
&lt;li&gt;IMEI/SIM fields (&lt;code&gt;persist.radio.imei&lt;/code&gt;, etc.) stopped being settable via &lt;code&gt;setprop&lt;/code&gt; around API 29.&lt;/li&gt;
&lt;li&gt;The reliable route for read-only props is &lt;strong&gt;Magisk's &lt;code&gt;resetprop&lt;/code&gt;&lt;/strong&gt;, which patches the property store in memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the identity engine is now Magisk-aware: if the device is rooted with Magisk, it uses &lt;code&gt;resetprop&lt;/code&gt; for &lt;code&gt;ro.product.*&lt;/code&gt; and &lt;code&gt;ro.build.fingerprint&lt;/code&gt;; otherwise it falls back to &lt;code&gt;setprop&lt;/code&gt; for the fields that still allow it. The app also reports &lt;em&gt;which fields applied and which failed&lt;/em&gt;, instead of one big "success" that might be a lie.&lt;/p&gt;

&lt;h3&gt;
  
  
  Magisk integration without Android Studio
&lt;/h3&gt;

&lt;p&gt;Rooting an emulator normally means patching the ramdisk. Enmulator bundles &lt;a href="https://gitlab.com/newbit/rootAVD" rel="noopener noreferrer"&gt;rootAVD&lt;/a&gt;, which does exactly that: it grabs &lt;code&gt;ramdisk.img&lt;/code&gt; from the system image, patches it with Magisk, and pushes it back.&lt;/p&gt;

&lt;p&gt;The gotcha I learned: &lt;strong&gt;the ramdisk is shared per system image, not per AVD&lt;/strong&gt;. Root one device on a given image and every AVD using that image is rooted. That's worth documenting loud, because it surprises people.&lt;/p&gt;

&lt;p&gt;Also &lt;code&gt;adb root&lt;/code&gt; only works on &lt;code&gt;google_apis&lt;/code&gt; (non-Play-Store) images — Play Store images reject it outright. So the app offers both paths and tells you which one applies to your image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows wants to show you console windows
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;adb&lt;/code&gt; / &lt;code&gt;emulator&lt;/code&gt; spawn on Windows popped a black console window. The fix is &lt;code&gt;CREATE_NO_WINDOW&lt;/code&gt; (0x08000000) in the process creation flags — one line, but it has to wrap &lt;em&gt;every&lt;/em&gt; command invocation, so it went into a shared &lt;code&gt;sdk::new_command()&lt;/code&gt; helper used everywhere.&lt;/p&gt;

&lt;p&gt;Windows also likes to prepend &lt;code&gt;\\?\&lt;/code&gt; to long paths, which broke resolving the rootAVD submodule. &lt;code&gt;dunce::canonicalize&lt;/code&gt; fixed it by stripping the prefix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shipping three platforms from one repo
&lt;/h3&gt;

&lt;p&gt;The release pipeline is a GitHub Actions matrix (ubuntu / windows / macos) that produces a &lt;code&gt;.deb&lt;/code&gt;, &lt;code&gt;.AppImage&lt;/code&gt;, &lt;code&gt;.msi&lt;/code&gt;, and &lt;code&gt;.dmg&lt;/code&gt; with stable filenames, then uploads them to the release. The thing that bit me: the default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; can't &lt;em&gt;update&lt;/em&gt; a release — it needs &lt;code&gt;permissions: contents: write&lt;/code&gt; in the workflow. Took a failed run to learn that.&lt;/p&gt;

&lt;p&gt;I also did a git history cleanup before open-sourcing: the repo's &lt;code&gt;.git&lt;/code&gt; was 483 MB because old &lt;code&gt;target/&lt;/code&gt; and &lt;code&gt;node_modules&lt;/code&gt; blobs were stuck in history. &lt;code&gt;git filter-repo&lt;/code&gt; brought it to 6.7 MB. Same code, 72× smaller clone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;ARM64 system images only run natively on Apple Silicon — x86_64 everywhere else.&lt;/li&gt;
&lt;li&gt;The detection-bypass and fingerprint features are for testing on software you own/are authorized to test. There's a disclaimer in the README for a reason.&lt;/li&gt;
&lt;li&gt;This is a solo project, so some edges (snapshot management, exotic device configs) are rougher than a team-built tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/engnzdl/enmulator" rel="noopener noreferrer"&gt;github.com/engnzdl/enmulator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Installers for macOS / Windows / Linux are on the releases page. If you poke at it and hit something, open an issue — and if it's useful, a star helps others find it.&lt;/p&gt;

&lt;p&gt;Built with Tauri 2, Rust, and React. GPLv3.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>react</category>
      <category>android</category>
    </item>
  </channel>
</rss>
