Friend's WinGet LLVM has clang + llvm-objcopy but NOT llvm-readelf, so the build died on the readelf step. The run path already parses ELF by hand for the carrier splice, so I removed the external binutils entirely: - elfutil.py: obj_section_and_syms() extracts a section's bytes + its symbol offsets from the assembled .o in pure Python. - orchestrate.assemble() and build_credmod: use it instead of `llvm-objcopy -O binary` + `llvm-readelf -s`. Verified byte-identical — merged carrier 281 diffs, inject stub fits, poison replays to the exact carrier. - require_toolchain()/build_credmod now check ONLY clang. toolconf keeps readelf/nm for port.py. - SETUP.md/README: running needs only clang; readelf/nm are port.py-only. Net: `python orchestrate.py ...` needs just Python + adb + clang. Co-Authored-By: Claude Opus 4.8 <[email protected]>
DirtyFrag-LPE — unprivileged SELinux Enforcing→Permissive + root (Quest 3 & Quest Pro)
Single orchestrator that runs the full zero-root chain: an unprivileged (uid 2000 shell / any
app) Dirty-Frag page-cache poison → unsigned kernel module load → selinux_state.enforcing = 0.
First time? See SETUP.md for external-dependency install (Linux & Windows) and a one-shot environment check. Tool paths live in
toolconf.py(env-overridable).
python3 orchestrate.py --target targets/<name>.json [--device SERIAL] [--settle N] [--verify-root]
[--no-restore | --leave-disabled | --postex | --adb-root]
--adb-root — root adb shells without Magisk
Cred-patches the running adbd to uid 0 + all caps + kernel SELinux context and leaves SELinux
Permissive, so every new adb shell is full root — no Magisk, no Zygisk. Use it to get a
clean root shell (e.g. to isolate Magisk/Zygisk problems). Works on both device families (merged
carrier on Quest Pro, usbip cred carrier on Quest 3); needs kernel.cred_security_off in the target.
python3 orchestrate.py -t targets/<name>.json --adb-root
# then open a NEW shell: adb shell -> id # uid=0
Transient (RAM-only): a reboot fully clears it. Existing shells stay uid 2000; only shells forked after the patch are root.
One file, both devices. The target JSON's kernel.merged flag picks the flow — no code changes:
- Quest 3 (5.10) — two-carrier flow. Validated on 5234532 (unit B, ~18 s) and 5243367 (unit A, locked retail, ~20 s).
- Quest Pro (4.19) — merged single-carrier flow (
targets/QPro_51483620027600340.json); see Quest Pro below.
Quest Pro (merged single-carrier)
Quest Pro's 4.19 kernel differs (selinux_state.enforcing @ +1, task_struct.cred @ 0x7e8) and,
critically, its only not-loaded module with a big-enough init is rdbg (680 B) — llcc_perfmon
is already loaded, and the small USB-net modules (≤168 B init) can't hold the 196 B cred-patch. So a
single carrier does everything: build_credmod diff-patches rdbg into
enforcing=0 + status-page-sync + cred-patch(waiting-shell pid), and it is loaded once →
Permissive and root together. Because rdbg is vendor_file (shell can't read it under
Enforcing) it's poisoned by a 1-file init_array ctor injected into libgralloc.qti.so (a
same_process_hal_file lib mapped by trackingservice, 3.7 KB code gap); the cfg is shell-readable
under Enforcing here, so shell poisons it directly. All values (deltas, offsets) verified statically
from the firmware + BTF. Run:
python3 orchestrate.py -t targets/QPro_51483620027600340.json --postex # -> Permissive + uid 0 + Magisk
Status: on-device run #1 confirmed the chain executes end-to-end — rdbg loaded and our patched
init_moduleran (panic log). It panicked because 4.19 routes the module'sbl __platform_driver_registerthrough a PLT veneer, so decoding the bl gave the veneer, not the symbol. Fixed: the anchor is now an R_AARCH64_ABS64 slot the loader fills with the real address (no CALL26/veneer). Remaining on-device unknowns:module.sig_enforceoff (rdbg loaded, so likely off), and Magisk setup. Dirty-Frag itself is confirmed on 4.19.
Post-exploitation (--postex) — unprivileged → uid 0 root → Magisk
After reaching Permissive, roots a shell and sets up Magisk, no per-kernel compilation:
- shell writes a diff-patched cred carrier (
usbip-vudc.ko, not-loaded, 408 B init) to/data/local/tmp/uv.ko— its init reads&__platform_driver_registerfrom an ABS64 anchor and cred-patches a waiting shell'stask->credto uid 0 + all caps (anchor-relativefind_vpid/pid_task/selinux_state, per-kernel deltas from the target JSON); - shell poisons the cfg (permissive ⇒ DAC read) to
insmod|/data/local/tmp/uv.ko; ctl.start insmod_shloads uv.ko → the waiting shell becomes uid 0 (polled to confirm);- that root shell runs
postex/postex.sh: drop_caches → rmmod both carriers →singularity_magisk.sh(Singularity Magisk v30.7 fork, frompostex/assets/) →setenforce 1.
python3 orchestrate.py -t targets/<name>.json --postex [--skip-magisk]
Proven on locked retail A (~40 s): a fresh unprivileged adb shell → su -c id = uid 0,
u:r:magisk:s0, under SELinux Enforcing. Magisk (Singularity v30.7 fork) daemon + manager live.
Two things that made it work:
- SELinux status-page sync — the carrier flips
enforcing=0by a direct memory write, which updates the kernel AVC but not the/sys/fs/selinux/statuspage userspace reads, so init kept enforcing (setprop/ctl.stop/magiskpolicy denied). The usbip carrier now also callsselinux_status_update_setenforce(&selinux_state,0)(anchor-relative) → userspace truly permissive → magiskpolicy applies → after the finalsetenforce 1, headlesssuworks under enforcing. - Payload =
singularity_magisk.sh(Quest-tuned v30.7 fork,SL_RESTART_ZYGOTE=0) instead of the AVDlive_setup.sh: no zygote restart (the AVD script'sstop/startwas ~181 s of the old 219 s total) and nomemfd_filemagiskpolicy error. Zygisk isn't injected into already-running procs (setSL_RESTART_ZYGOTE=1for that), butsuworks.
Both carriers are still diff-patched existing modules (llcc_perfmon = enforcing; usbip-vudc = cred-patch + status-page sync), so a new kernel needs only the JSON deltas — no compilation.
Cleanup modes (what happens after Permissive is reached)
- default —
setenforce 1; rmmod; reboot→ device fully clean (Enforcing). Needs root only on a rooted validation unit; on a locked unit the reboot alone restores. --leave-disabled— reverts the code-injection poisons (libeva ctor + libandroid dump) via shell, no reboot, SELinux stays Permissive. For handing off to a post-exploitation script. Residue left (benign, cleared later once root): the carrier/cfg page-cache poison + the loadedllcc_perfmon. (--postexdoes this whole hand-off automatically; use--leave-disabledonly to drive post-ex by hand.)--no-restore— leave everything poisoned & permissive (debugging).
--settle N (default 8) is how long to wait for the ctor's carrier/cfg poison to finish after the
trackingservice restart, before firing insmod_sh. Do not lower it — re-triggering would revert
the poison.
Chain (all steps as uid-2000 shell, no su)
- stage attacker-keyed AES-GCM ESP SA via IpSecService (
q3.Stager) - poison
libeva.so: redirectinit_array[0](RELR slot) → a 2-file poison stub planted in libeva's ~1.8 KB code gap - poison
libandroid_servers.so::dump→ actl.restart trackingservicestub dumpsys input→ system_server restarts trackingservice → the libeva ctor runs inhal_tracking_defaultand page-cache-poisons the carrier.ko(48-byte enforcing=0 patch) +init.insmod.cfg(adds aninsmodline)setprop ctl.start insmod_sh→init-insmod-shfinit_modules the poisoned carrier → kernel loads the unsigned module →enforcing = 0
Key optimization: diff-injection — the target module is the real carrier with a 52-byte
in-place init patch (the enforcing carrier decodes its own bl anchor; the cred/merged carrier uses
an ABS64 anchor the loader fills — veneer-proof — see cred_patch.S.tmpl),
so only ~48 bytes need poisoning and the whole IV table fits libeva's existing gap (no 78 KB table).
Files
orchestrate.py— the whole pipeline (build + stage + poison + trigger + verify + post-ex/cleanup); dispatches two-carrier (run) vs merged (run_merged) onkernel.mergedSETUP.md— external-dependency install for Linux & Windows + environment self-checkrequirements.txt— running needs NO pip packages (built-in pure-Python AES; pycryptodome optional speedup); add-target extra inrequirements-port.txtelfutil.py— tiny pure-Python ELF reader so running needs only clang (no objcopy/readelf)toolconf.py— ONE place for all external tool paths (clang/objcopy/readelf/nm, NDK, payload-dumper, debugfs, vmlinux-to-elf); env vars override. Imported by the scripts below.port.py— gather a new firmware's values → drafttargets/<name>.json(+ binaries);--mergedemits the single-carrier shape (Quest Pro)build_credmod.py— diff-patch the cred carrier (usbip-vudc on Q3, rdbg on QPro); takesenf_off/cred_offso one template covers 5.10 and 4.19PORTING.md— how to add a new firmware (automated + manual, both device families)targets/*.json— per-firmware constants/offsets/paths (q3_, QPro_ — named after the OTA zip)targets/<name>/— that firmware's gathered binaries: carrier.ko(llcc_perfmon on Q3, rdbg on QPro),init.insmod.cfg(+usbip-vudc.koon Q3)asm/—patch_init.S(llcc enforcing patch, embedded in orchestrate.py), the injection ctor stub (generated in-code bybuild_inject_stub, N-file / dynamic tables),libas_restart.S.tmpl(system_server restart stub),cred_patch.S.tmpl(cred-patch + status-page sync;@ENF_OFF@/@CRED_OFF@substituted per target)java/Stager.java,java/Writer.java→e2e.dex— firmware-independent Dirty-Frag primitivespostex/postex.sh+postex/assets/{singularity_magisk.sh, singularity-Magisk.apk}— the root payload
Adding a new firmware
One command (see PORTING.md for details + the manual equivalent):
python3 port.py --zip q3_<build>.zip # OTA-only; name defaults to the zip basename
python3 orchestrate.py -t targets/q3_<build>.json
port.py extracts the carrier/cfg/vmlinux from the exact-build zip, computes DELTA, and derives
libeva/libandroid offsets from the device's own libs. No code changes — the orchestrator auto-derives
the carrier ELF layout and repoints its relocations. Only vermagic + delta_selinux_from_anchor
have differed between builds so far (userspace libs unchanged).
Authorized bug-bounty research only.
--no-restoreleaves the device permissive; the default restores (setenforce 1; rmmod; reboot).