// HackTricks · Mobile

Firmware-level Android Backdoor via libandroidruntime Zygote Injection

Firmware-level Android Backdoor via libandroid_runtime Zygote Injection

Overview

Supply-chain tampering of /system/lib[64]/libandroid_runtime.so can hijack android.util.Log.println_native so that every app forked from Zygote executes attacker code. The Keenadu backdoor adds a single call inside println_native that drives a native dropper. Because all app processes run this code, Android sandbox boundaries and per-app permissions are effectively bypassed.[1]

The same post-root execution model also appears in multi-stage Android rootkits delivered from apparently benign Play-distributed apps. In McAfee’s Operation NoVoice analysis, the malware first landed in user space, obtained root with device-tailored exploits, and then replaced libandroid_runtime.so and libmedia_jni.so on the system partition so that every app spawned by zygote inherited the attacker hooks after reboot.[3]

Recent supply-chain patterns (2025+)

Recent Triada-linked investigations show the same “infect Zygote once, reach every app” outcome being delivered through counterfeit/pre-infected firmware and, in some cases, through OTA-related system components instead of a purely post-sale root step.[4] Two practical consequences:

  • Compare ro.build.fingerprint with vendor-published factory images. In recent counterfeit-device cases, near-match fingerprints were a useful clue that the device was not running a legitimate build.
  • Hunt beyond libandroid_runtime.so. Recent firmware families have also used privileged system APKs / services (launcher, facial-recognition utilities, OTA/update paths) as bootstrap or fallback footholds, so a “clean” shared library does not fully rule out a supply-chain compromise.

Pre-root staging: SDK init hijack + PNG tail polyglot

Before the libandroid_runtime.so replacement, NoVoice used an app-level bootstrap that is worth recognizing during APK triage:[3]

  • Auto-exec on first launch: bootstrap code ran from a tampered Facebook SDK init path, so no extra user interaction or suspicious permission prompt was required.
  • Payload smuggling in assets: the app shipped a valid PNG with an encrypted blob appended after the PNG IEND marker. Android image decoders ignore trailing bytes, but the loader carved the tail into enc.apk, decrypted it into h.apk, loaded it, and deleted the staging files.
  • Triage clue: if bytes immediately after IEND begin with a magic such as CAFEBABE, assume the image is acting as a polyglot carrier for a Java class / JAR / APK payload rather than as a pure media asset.

Quick checks:

pngcheck -v suspicious.png
tail -c +1 suspicious.png | xxd | tail
binwalk -e suspicious.png

Hunting notes:

  • Search APK assets/ for PNGs with unexpected trailing bytes or high entropy after IEND.
  • Trace early init paths such as Application.onCreate, third-party SDK bootstrap code, or native JNI_OnLoad handlers that open asset PNGs and then write *.apk / *.jar files into the app sandbox.

Dropper path: native patch → RC4 → DexClassLoader

  • Hooked entry: extra call inside println_native to __log_check_tag_count (injected static lib libVndxUtils.a).[1]
  • Payload storage: RC4-decrypt blob embedded in the .so, drop to /data/dalvik-cache/arm[64]/system@framework@vndx_10x.jar@classes.jar.
  • Load & execute: DexClassLoader loads the jar and invokes com.ak.test.Main.main. Runtime logs use tag AK_CPP (triage artifact).
  • Anti-analysis: aborts in Google/Sprint/T-Mobile system apps or if kill-switch files exist.
  • Zygote role split:
    • In system_server → instantiate AKServer.
    • In any other app → instantiate AKClient.

What changes on newer Android releases?

  • In AOSP, legitimate android.util.Log.println_native is a minimal JNI wrapper whose relevant job is calling __android_log_buf_write. When auditing a vendor build, any extra helper call or control-flow branch around this path deserves immediate review.
  • On Android 14+, apps using dynamic code loading must mark DEX / JAR / APK payloads read-only before loading. Older rootkit chains that drop a writable jar to disk therefore stand out more clearly on modern targets.[5]
  • A common adaptation is to keep the final payload off disk and switch the last stage to InMemoryDexClassLoader. If you confirm the zygote hook but never recover a stable classes.dex, assume the decrypted DEX may only exist in a ByteBuffer at runtime. For generic fileless DEX hunting, see this Android malware post-exploitation page.

Binder-based client/server backdoor

  • AKServer (running in system_server) sends protected broadcasts:[1]
    • com.action.SystemOptimizeService → binder interface for clients.
    • com.action.SystemProtectService → binder interface for downloaded modules.
  • AKClient (inside every app) receives the interface via broadcast and performs an attach transaction, handing an IPC wrapper so the server can load arbitrary DEX inside the current app process.
  • Exposed privileged operations (via SystemProtectService): grant/revoke any permission for any package, retrieve geolocation, and exfiltrate device info. This centralizes privilege bypass while still executing code in chosen target apps (Chrome, YouTube, launcher, shopping apps, etc.).

C2 staging, crypto, and gating

  • Host discovery: Base64 → gzip → AES-128-CFB decrypt with key MD5("ota.host.ba60d29da7fd4794b5c5f732916f7d5c"), IV "0102030405060708".[1]
  • Victim registration: collect IMEI/MAC/model/OS, encrypt with key MD5("ota.api.bbf6e0a947a5f41d7f5226affcfd858c"), POST to /ak/api/pts/v4 with params m=MD5(IMEI) and n=w|m (network type). Response data is encrypted identically.
  • Activation delay: C2 serves modules only after ~2.5 months from an “activation time” in the request, frustrating sandbox detonations.
  • Module container (proprietary):
struct KeenaduPayload {
    int32_t  version;
    uint8_t  padding[0x100];
    uint8_t  salt[0x20];
    KeenaduChunk config;   // size + data
    KeenaduChunk payload;  // size + data
    KeenaduChunk signature;// size + data
} __packed;
  • Integrity: MD5 file check + DSA signature (only operator with private key can issue modules).
  • Decryption: AES-128-CFB, key MD5("37d9a33df833c0d6f11f1b8079aaa2dc" + salt), IV "0102030405060708".

Post-root persistence: wrapper libraries + framework patching + self-heal

Once root is available, replacing libandroid_runtime.so is only one layer of persistence. NoVoice shows a more resilient pattern:[3]

  • Wrapper replacement instead of inline patching: the installer backed up the original system library and replaced it with an architecture-matched hook wrapper. The same campaign also replaced libmedia_jni.so, giving multiple code-execution choke points inside the framework.
  • Second-stage persistence in framework bytecode: after the library swap, a dedicated patcher modified pre-compiled Android framework bytecode on disk. This means restoring the original .so may still leave injected redirections active.
  • Self-healing watchdog: a daemon checked the installation roughly every 60 seconds, restored missing components, and could force a reboot if reinsertion kept failing. The malware also replaced the system crash handler / recovery flow so rebooting re-launched the rootkit.
  • Per-app payload assembly at runtime: after reboot, the replaced libandroid_runtime.so caused each spawned app to load attacker code. NoVoice stored secondary payloads as fragments inside the malicious library, assembled them in memory, and deleted the disk copies immediately after load.

Practical implications:

  • Factory reset is insufficient when the malware has modified the system partition or framework artifacts. Reflash the firmware instead.
  • Diff both /system/lib*/libandroid_runtime.so and framework oat/odex/vdex artifacts; checking only the shared library can miss the bytecode persistence layer.
  • If a device keeps restoring the malicious library after manual cleanup, look for a watchdog daemon, modified recovery scripts, or a replaced crash-handler path that is re-seeding the implant on boot.

Persistence & forensic tips

  • Supply chain placement: malicious static lib libVndxUtils.a linked into libandroid_runtime.so during build (e.g., vendor/mediatek/proprietary/external/libutils/arm[64]/libVndxUtils.a).[1]
  • Firmware auditing: firmware images ship as Android Sparse super.img; use lpunpack (or similar) to extract partitions and inspect libandroid_runtime.so for extra calls in println_native.[2]
  • On-device artifacts: presence of /data/dalvik-cache/arm*/system@framework@vndx_10x.jar@classes.jar, logcat tag AK_CPP, or protected broadcasts named com.action.SystemOptimizeService/com.action.SystemProtectService indicate compromise.[1]
  • For Android rootkits deployed from apps instead of factory supply chain, also inspect:[3]
    • APK assets/ for polyglot PNGs with appended data after IEND
    • Replaced /system/lib*/libandroid_runtime.so or /system/lib*/libmedia_jni.so
    • Modified framework oat/odex/vdex files that preserve hook redirections
    • Periodic watchdog processes that rewrite removed files or trigger forced reboots
  • Compare the device ro.build.fingerprint and hashes of privileged system APKs / libraries against vendor-published firmware. In recent counterfeit-device cases, subtle fingerprint drift was one of the few early hints that the image was not authentic.[4]
  • Because the legitimate println_native implementation is tiny, added action strings such as com.action.SystemOptimizeService, com.action.SystemProtectService, the AK_CPP log tag, or stray loader paths like system@framework@vndx_10x.jar@classes.jar are high-signal triage artifacts.[1]

Quick rooted-device triage

adb shell getprop ro.build.fingerprint
adb shell 'for f in /system/lib*/libandroid_runtime.so /system/lib*/libmedia_jni.so; do [ -e "$f" ] && sha256sum "$f"; done'
adb pull /system/lib64/libandroid_runtime.so . 2>/dev/null || adb pull /system/lib/libandroid_runtime.so .
strings -a libandroid_runtime.so | rg 'AK_CPP|com.action.SystemOptimizeService|com.action.SystemProtectService|vndx_10x|com.ak.test.Main'
adb shell logcat -d | rg 'AK_CPP|SystemOptimizeService|SystemProtectService'

If the firmware ships as super.img, extract the dynamic partitions first (simg2img when needed, then lpunpack) and diff the resulting system*.img contents against a trusted factory image.

References