12.5. Userspace boot — init, SELinux, A/B updates

This page picks up where the below-userspace boot chain (Boot chain and exception-level hand-off (PBL → XBL → ABL)) hands control to Linux: the first-stage ramdisk and init, the SELinux policy that is loaded before anything else runs, the dynamic-partition / A/B layout the system is mounted from, and the update_engine machinery that applies OTAs. It is the userspace counterpart to the bootloader-side slot-selection and verified-boot logic in AVB enforcement code path (LoadImageAndAuth / libavb).

Everything here is from the partition listings, the boot ramdisk and fstab.default, the extracted SELinux/init artifacts, and the AVB descriptor dumps in artifacts/avb. No code was executed.

12.5.1. Init and the first-stage ramdisk

The boot image’s ramdisk (a gzip’d cpio, ~2.4 MB uncompressed) carries first-stage init and the device-tree-derived first-stage fstab. Init then mounts the system and runs the second stage. The device is heavily init-driven: 103 .rc files in /vendor/etc/init, 67 in /system/etc/init and 14 in /system_ext — the stock Qualcomm/AOSP service set plus the Onyx additions (init.onyx.rc and friends, The Onyx/Boox platform layer, The e-ink software stack (Onyx EPDC)).

12.5.2. Dynamic partitions (super)

ro.boot.dynamic_partitions=true: system, system_ext, product, vendor and odm are logical partitions inside the physical super partition (they appear as logical entries in fstab.default and are the five images under artifacts/super/). This is the standard dynamic-partition layout; the physical partition table is in Partition map and checksums.

12.5.3. A/B and Virtual A/B

The device is seamless-update (A/B) with Virtual A/B:

  • every core partition is slotted _a / _babl, aop, bluetooth, boot, core_nhlos, devcfg, dsp, dtbo, featenabler, hyp, imagefv, keymaster, modem, qupfw, recovery, tz, uefisecapp, vbmeta, vbmeta_system, xbl, xbl_config (Partition map and checksums);

  • the updater is /system/bin/update_engine with the boot-control HAL android.hardware.boot@1.1 over libboot_control_qti.so — it writes the inactive slot and flips the active-slot flag;

  • ro.virtual_ab.enabled=true with snapuserd + gsid (and their .rc): updates are staged as snapshots (dm-snapshot / snapuserd copy-on-write on the dynamic partitions) rather than to a second physical copy, so super does not need doubled space;

  • recovery is a separate slotted partition (this is not a recovery-in-boot layout);

  • the userspace trigger is Onyx’s OnyxOtaService (The Onyx/Boox platform layer), which drives update_engine; init.onyx.rc clears a stale /data/local/assets/update.zip on boot.

12.5.4. OnyxOtaService decompiled: the AES layer around update_engine

OnyxOtaService.apk (system/priv-app/OnyxOtaService, package com.onyx.android.onyxotaservice) was decompiled with jadx (DEX only — its oat/arm64/OnyxOtaService.{odex,vdex} AOT copy was not separately examined) and its native helper, libota_jni.so (present at both system/lib64 (23,144 bytes, ~23 KB) and system/lib (the 32-bit copy, only 10,272 bytes, ~10 KB), stripped but with a full dynamic symbol table), was disassembled by hand (capstone, via artifacts/re_static/sodis.py). Five source files make up the whole app: OnyxOtaService (the exported Service), UpdateManager, PackageFiles, RsaUtil (the JNI bridge — the name is a misnomer, see below) and Util.

Control flow. OnyxOtaService is declared android:exported="true" with no android:permission attribute and listens for two actions, onyx.intent.action.OTA_START (extra path, an arbitrary string used verbatim as a filesystem path) and onyx.intent.action.OTA_CANCEL; a third action, onyx.intent.action.OTA_QUERY, is handled by a dynamically registered receiver. Because the service is exported with no permission check, any app on the device can send these intents, including OTA_START with a path of its choosing — the manifest’s one signature-level permission (…DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION) is the AGP-generated permission for the unexported dynamic receiver, not a guard on the service. startUpdate()UpdateManager.applyUpdate() checks free space and battery level, then calls decryptPackage(), which calls RsaUtil.decryptFile(path, "/data/local/assets/update.zip", cb).

The “RSA” layer is actually static-key AES-128-CFB, and the key is recoverable from the binary. Despite the class name, RsaUtil does no RSA: nativeDecryptFile() (Java_com_onyx_android_onyxotaservice_RsaUtil_ nativeDecryptFile at file offset 0x2100 in libota_jni.so) opens path as a C++ ifstream, opens /data/local/assets/update.zip as an ofstream, and stream-decrypts one into the other in 0x9000-byte chunks with BoringSSL’s AES_cfb128_encrypt (enc=0, i.e. decrypt), reporting progress via the JNI callback after each chunk. The key and IV are two function-local static const std::string C++ objects (compiler thread-safe-init “magic statics”, guarded at .bss+0x18/.bss+0x68); each is materialized once by copying a 32-byte literal out of .rodata (0x107d for the key, 0x109e for the IV) into a .bss scratch buffer and XOR-ing it in place, 16 bytes at a time, against a fixed, repeating 8-byte mask also stored in .rodata at 0x0f60 (69 2b 9b d3 b4 d2 a5 5a, repeated to 16 bytes) — this is libc++’s short-string “hardened” scrambling of the SSO buffer, not a device-specific secret. Decoding it by hand (XOR the two 32-byte .rodata literals against the mask, then read the result as a NUL-terminated string) recovers, in plain ASCII:

AES-128 key: [REDACTED]
AES IV     : [REDACTED]

(AES_set_encrypt_key is called with bits=0x80, confirming AES-128; these 16-character ASCII strings are copied verbatim, 16 bytes, into the AES_KEY/ivec buffers — there is no further key derivation.) These are identical across every device running this exact libota_jni.so build: the “encryption” is not per-device, per-package, or keyed by anything outside the binary itself, so it is a confidentiality/obfuscation step, not an authentication one — any party with this .so (i.e. anyone who has dumped this device’s system partition, as this project has) can decrypt an official update.zip, or wrap an arbitrary payload so that nativeDecryptFile accepts it as “correctly encrypted”.

What this does and does not buy an attacker. PackageFiles confirms the decrypted /data/local/assets/update.zip is a stock AOSP streaming A/B OTA package — it is opened as an ordinary ZipFile and expected to contain META-INF/com/android/metadata, payload.bin and payload_properties.txt, the standard update_engine payload layout. UpdateManager never checks a signature or hash itself; it parses the offset/size of payload.bin out of the zip metadata and hands them straight to UpdateEngine.applyPayload() (android.os.UpdateEngine, the stock AOSP/Qualcomm update_engine binary + boot-control HAL from the section above). That means the AES layer documented here is not the security boundary for flashing a slot — that boundary is inside update_engine itself, which is disassembled below: it appears, based on the reachable call path traced there, to perform a real payload-signature check, against a real, locally-generated (not the public AOSP default) key.

12.5.4.1. update_engine’s own payload verification, disassembled

/system/bin/update_engine (2.9 MB, stripped ARM64 PIE, dynamically links BoringSSL and statically links a copy of libavb) embeds the literal source-file string system/update_engine/payload_consumer/payload_verifier.cc in its .rodata — consistent with this being the stock AOSP payload-verifier translation unit rather than a vendor rewrite — immediately followed by the literal x509.pem (the conventional suffix AOSP’s verifier looks for inside a certificate zip). The dynamic symbol table shows BoringSSL’s certificate/signature primitives (PEM_read_bio_PUBKEY, X509_get_pubkey, EVP_PKEY_get0_RSA, EVP_PKEY_get0_EC_KEY, ECDSA_verify) all listed U (undefined) against NEEDED libcrypto.so — i.e. BoringSSL is dynamically linked, not compiled in — alongside a fully-linked-in copy of libavb (avb_vbmeta_image_verify, avb_rsa_verify, etc. are local, non-UND symbols in this binary — i.e. compiled in, not just imported at link time — used for the Virtual-A/B post-apply slot re-verification rather than for the OTA payload signature itself (inferred from this symbol set and its linkage, not from a traced call site).

The literal path string /system/etc/security/otacerts.zip is also embedded in .rodata (file offset 0x36651), and is a genuine, reachable reference: an adr instruction loads its address directly at three separate call sites (file offsets 0xe8980, 0xefb8c, 0xefc34), one of which (0xefb8c) sits in a small function that constructs a std::string from it and then calls into what is, by control flow, a zip-open/entry-scan routine — consistent with AOSP’s PayloadVerifier::GetPublicKeysFromZip() reading the same on-device otacerts.zip that legacy recovery uses for whole-package ZIP signature checks. That file exists in this dump at system_a/system/etc/security/otacerts.zip and contains exactly one entry, testkey.x509.pem — a self-signed, RSA-2048, SHA256-with-RSA certificate:

Issuer/Subject: C=ZH, ST=ShenZhen, L=Mountain View, O=Onyx, OU=Android,
                CN=Android, emailAddress=onyx-international.com
Validity: 2024-07-24 -- 2051-12-10

Despite the AOSP-convention filename, this is not the public AOSP default test key: its RSA modulus was diffed byte-for-byte against the real build/target/product/security/testkey.x509.pem fetched from android.googlesource.com and the two do not match (different modulus, different Subject — AOSP’s default says O=Android, this one says O=Onyx). So this is a certificate/key pair Onyx generated themselves for this product (they configured PRODUCT_DEFAULT_DEV_CERTIFICATE to point at their own key while keeping AOSP’s default testkey file basename — a common carry-over convention; whether it constitutes a weakness depends on how the corresponding private key is handled, which was not assessed here), and update_engine appears, based on the reachable call path traced above, to perform a genuine RSA signature check against it before accepting a payload — the actual comparison was not stepped through (see below). Two things are not established here: whether the corresponding private key has ever leaked or is reused elsewhere (no evidence either way was found in this dump), and the exact byte-level verification routine itself (the metadata-hash-vs-signature comparison inside payload_verifier.cc) was not disassembled instruction by instruction — the finding here is the presence of a real key and a real, reachable call path to load it, not a full audit of the comparison logic. Taken together with the AES-key finding above: (1) the app-level “encryption” adds no real confidentiality or authenticity beyond obscuring the payload from casual inspection, since its key is static and extractable exactly as done here, and (2) the exported, unguarded OnyxOtaService lets any local app on the device invoke UpdateEngine.applyPayload() with an update.zip of its choosing (subject to whatever update_engine itself then verifies) and, on completion, triggers a full device reboot (PowerManager.reboot() on any non-zero- exit-code-zero finish) — a low-effort local DoS/reboot-loop primitive regardless of the payload-verification question.

12.5.4.2. Provenance

Source:

artifacts/system_a/system/priv-app/OnyxOtaService/OnyxOtaService.apk decompiled with jadx 1.5.1 into artifacts/re_apk/OnyxOtaService/sources/com/onyx/android/onyxotaservice/; artifacts/system_a/system/lib64/libota_jni.so disassembled with artifacts/re_static/sodis.py (capstone) from file offset 0x2100 (nativeDecryptFile, 0x988 bytes) through its two string-constructor helpers at 0x2c9c and 0x2f6c; the XOR mask and the two 32-byte obfuscated literals were read directly from .rodata at file offsets 0x0f60, 0x107d and 0x109e and decoded with a standalone Python XOR against the (also file-derived) mask. artifacts/system_a/system/bin/update_engine examined with artifacts/re_static/elfdis.py (--segs) plus a standalone capstone ADRP/ADR string-xref scan for references to the .rodata strings at file offsets 0x36651 (/system/etc/security/otacerts.zip) and 0x30f5c-0x30f64 (payload_verifier.cc/x509.pem); artifacts/system_a/system/etc/security/otacerts.zip extracted with 7z and read with openssl x509; its modulus diffed against build/target/product/security/testkey.x509.pem fetched live from android.googlesource.com for comparison.

Method:

static decompilation and disassembly; update.zip / payload.bin themselves were not observed anywhere in the dump (only the update_engine binary, its embedded verifier strings, and the otacerts.zip key it loads), so the metadata-hash-vs-signature comparison inside payload_verifier.cc was confirmed reachable but not disassembled instruction-by-instruction, and no real OTA payload was available to test the check against.

Cross-refs:

The Onyx/Boox platform layer (app inventory), AVB enforcement code path (LoadImageAndAuth / libavb) (the AVB chain this still ultimately rests on for anything that touches vbmeta-hashed partitions).

12.5.5. SELinux

Full Treble split-policy SELinux is present and loaded at first stage (enforcing — there is no androidboot.selinux=permissive override in the properties):

  • /vendor/etc/selinuxvendor_sepolicy.cil and the vendor_*_contexts (file, service, hwservice, property, seapp) at vendor policy version 30.0 (Android 11); precompiled_sepolicy (the binary policy actually loaded on device) was not captured in this dump — only the .cil policy sources and context files are present;

  • /system/etc/selinuxplat_sepolicy at version 202404 (Android 15), with mapping/{29,30,31,32,33,34}.0.cil + 202404.cil compatibility shims that let the Android-15 platform policy speak to the Android-11 vendor policy — the SELinux expression of the version split in The Onyx/Boox platform layer;

  • plat_keystore2_key_contexts confirms the keystore2 key labelling (Security and DRM userspace).

By AOSP convention, a precompiled_sepolicy on vendor (not present in this dump) would only be used if its …sha256 hashes match the plat/product/system_ext policy; otherwise init recompiles from the .cil sources at boot — which, absent a captured precompiled_sepolicy here, is what this device’s dump evidences.

12.5.6. Verified boot (AVB / dm-verity)

Verified boot is AVB 2.0. vbmeta_a (SHA256_RSA4096, rollback index 0) is the root: it chains to vbmeta_system (its own key, rollback-index location 2), carries hash descriptors for the small partitions (boot, dtbo, recovery) and a hashtree descriptor (dm-verity v1) for the large read-only images. So system/vendor are dm-verity-protected and boot/dtbo are whole-partition hashed, all anchored in vbmeta. The bootloader-side verification and the key/fuse state are in AVB enforcement code path (LoadImageAndAuth / libavb); the images are test-key signed with secure boot un-fused, which is what makes replacement feasible.

12.5.7. Relevance to a custom bootloader

A replacement bootloader has to reproduce the parts of this that happen before Linux: select the active slot from the boot-control metadata (and honour the _a/_b suffix when loading boot/dtbo/vbmeta), verify or explicitly pass on vbmeta (or userspace fs_mgr will fail to set up dm-verity), and expose androidboot.slot_suffix and the dynamic-partition / Virtual-A/B boot flags on the kernel command line — otherwise init cannot mount super or bring up the correct slot. The actual A/B apply (snapshots, slot flip) is all userspace (update_engine / snapuserd) and needs nothing from the bootloader beyond a correct slot and command line.

12.5.8. Provenance

Source:

artifacts/ramdisk (first-stage init/fstab), artifacts/boot_a/fstab.default (logical dynamic partitions, FBE), artifacts/super/vendor_a.img (etc/selinux/*, etc/init/*.rc, etc/fstab.qti), system_a.img (bin/update_engine, bin/snapuserd, bin/gsid, etc/selinux mappings), artifacts/notes/props (ro.boot.dynamic_partitions, ro.virtual_ab.enabled, sepolicy/build versions), artifacts/avb/vbmeta_a.txt (AVB descriptors), _READONLY/rawprogram*.xml (A/B slot labels).

Method:

images/ramdisk listed and sampled read-only with 7z / text reads; AVB descriptors from the provided vbmeta dumps. No code executed.

Cross-refs:

Boot chain and exception-level hand-off (PBL → XBL → ABL) (kernel handoff), AVB enforcement code path (LoadImageAndAuth / libavb) (bootloader-side AVB, slots, fuses), Partition map and checksums (physical layout), The Onyx/Boox platform layer (OnyxOtaService, version split), Security and DRM userspace (keystore2 contexts, FBE), The e-ink software stack (Onyx EPDC) (Onyx init).