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/_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(Partition map and checksums);the updater is
/system/bin/update_enginewith the boot-control HALandroid.hardware.boot@1.1overlibboot_control_qti.so— it writes the inactive slot and flips the active-slot flag;ro.virtual_ab.enabled=truewithsnapuserd+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, sosuperdoes not need doubled space;recoveryis 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 drivesupdate_engine;init.onyx.rcclears a stale/data/local/assets/update.zipon 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.apkdecompiled withjadx1.5.1 intoartifacts/re_apk/OnyxOtaService/sources/com/onyx/android/onyxotaservice/;artifacts/system_a/system/lib64/libota_jni.sodisassembled withartifacts/re_static/sodis.py(capstone) from file offset0x2100(nativeDecryptFile,0x988bytes) through its two string-constructor helpers at0x2c9cand0x2f6c; the XOR mask and the two 32-byte obfuscated literals were read directly from.rodataat file offsets0x0f60,0x107dand0x109eand decoded with a standalone Python XOR against the (also file-derived) mask.artifacts/system_a/system/bin/update_engineexamined withartifacts/re_static/elfdis.py(--segs) plus a standalone capstone ADRP/ADR string-xref scan for references to the.rodatastrings at file offsets0x36651(/system/etc/security/otacerts.zip) and0x30f5c-0x30f64(payload_verifier.cc/x509.pem);artifacts/system_a/system/etc/security/otacerts.zipextracted with7zand read withopenssl x509; its modulus diffed againstbuild/target/product/security/testkey.x509.pemfetched live fromandroid.googlesource.comfor comparison.- Method:
static decompilation and disassembly;
update.zip/payload.binthemselves were not observed anywhere in the dump (only theupdate_enginebinary, its embedded verifier strings, and theotacerts.zipkey it loads), so the metadata-hash-vs-signature comparison insidepayload_verifier.ccwas 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/selinux—vendor_sepolicy.ciland thevendor_*_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.cilpolicy sources and context files are present;/system/etc/selinux—plat_sepolicyat version 202404 (Android 15), withmapping/{29,30,31,32,33,34}.0.cil+202404.cilcompatibility 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_contextsconfirms thekeystore2key 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(logicaldynamic 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/selinuxmappings),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 providedvbmetadumps. 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).