============================================= Userspace boot — init, SELinux, A/B updates ============================================= This page picks up where the below-userspace boot chain (:doc:`/boot/boot-chain`) 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 :doc:`/abl/verified-boot`. 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. 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, :doc:`onyx-platform`, :doc:`/display/stack`). 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 :doc:`/partition-map`. A/B and Virtual A/B =================== The device is **seamless-update (A/B)** with **Virtual A/B**: - every core partition is slotted ``_a`` / ``_b`` — ``abl``, ``aop``, ``bluetooth``, ``boot``, ``core_nhlos``, ``devcfg``, ``dsp``, ``dtbo``, ``featenabler``, ``hyp``, ``imagefv``, ``keymaster``, ``modem``, ``qupfw``, ``recovery``, ``tz``, ``uefisecapp``, ``vbmeta``, ``vbmeta_system``, ``xbl``, ``xbl_config`` (:doc:`/partition-map`); - 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`` (:doc:`onyx-platform`), which drives ``update_engine``; ``init.onyx.rc`` clears a stale ``/data/local/assets/update.zip`` on boot. ``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: .. code-block:: text 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. ``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: .. code-block:: text 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. 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: :doc:`onyx-platform` (app inventory), :doc:`/abl/verified-boot` (the AVB chain this still ultimately rests on for anything that touches ``vbmeta``-hashed partitions). 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/selinux`` — ``vendor_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/selinux`` — ``plat_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 :doc:`onyx-platform`; - ``plat_keystore2_key_contexts`` confirms the ``keystore2`` key labelling (:doc:`security`). 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. 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 :doc:`/abl/verified-boot`; the images are test-key signed with secure boot un-fused, which is what makes replacement feasible. 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. 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: :doc:`/boot/boot-chain` (kernel handoff), :doc:`/abl/verified-boot` (bootloader-side AVB, slots, fuses), :doc:`/partition-map` (physical layout), :doc:`onyx-platform` (OnyxOtaService, version split), :doc:`security` (keystore2 contexts, FBE), :doc:`/display/stack` (Onyx init).