dtrh.net~/blog
KBS
│KBS all posts

Two Error Codes and a Fuse: Hunting Exploitable Bugs on a Quest 2

Two Error Codes and a Fuse: Hunting Exploitable Bugs on a Quest 2

Two Error Codes and a Fuse: Hunting Exploitable Bugs on a Quest 2

A deep dive into Quest 2 hacking.


Published: 2026-11-02
Author: KBS admin@dtrh.net
Domain: dtrh.net
Subject: Meta Quest 2 (hollywood), 128 GB, model revision DVT — my own hardware
Build: 52322490019800150 · patch 2026-09-06 · kernel 4.19.325-cip132-st16-g01e3f6ad6993
Tool: quest2_hunt.py — one script that runs every check below and pulls the firmware apart


Contents

  1. What I was actually looking for
  2. First mistake: I put the headset online
  3. The gate was the patch itself
  4. So was the Bluetooth thing a real bug?
  5. Forty minutes too late
  6. The fuse does not care about your flags
  7. Two error codes that were hiding a GPU
  8. The write that went nowhere
  9. 1,340 kernel commits later
  10. Binder: the best-looking dead end of the night
  11. What adb shell can really do
  12. One more pass: sockets, perfetto, and a real privilege crossing
  13. So which bugs did I actually find?
  14. The bootloader question I never asked
  15. USB-C: the one place the attacker is on the other end
  16. The tool: quest2_hunt.py
  17. The hardware: what the board gives you, and what it does not
  18. What I would do next
  19. Credits, disclosure, and the honest bit

What I was actually looking for

I wanted one thing: a bug that turns adb shell into uid 0.

Not "a root tool" — a bug. Something I could point at, measure, and show the disassembly for. The headset
is mine, the firmware is current, and the only thing I had going in was a build number and an ADB shell
that kept arguing with me.

The plan was boring on purpose:

  1. Fingerprint the firmware precisely enough that every claim keys off a build string.
  2. Map what u:r:shell:s0 (uid 2000) can actually reach — syscalls, socket families, device nodes,
    binder services, cmd implementations, settings.
  3. Hunt memory-corruption bugs that are reachable from that surface, and decide presence/absence in
    the shipped kernel binary rather than from a patch list.
  4. Only then think about turning a primitive into root.

What I actually got was the fifth thing on that list: a map of where the wall is, why it is where it is,
and a small pile of real-but-low-severity bugs sitting on top of it. This is that night, in order,
including the parts where I was confidently wrong.


First mistake: I put the headset online

The unit came back from a factory reset stuck at the controller-pairing screen with only the left
Touch controller. The status dump said it plainly, and I did not read it as a hardware problem for
longer than I should have:

text2 lines
ControllerPrimaryConfigured: true     ControllerPrimaryConnected: false   ← right controller: absent
ControllerSecondaryConfigured: true   ControllerSecondaryConnected: true   ← left controller: fine, 80 %

Setup wants the primary controller. There is a documented gaze-plus-volume-button flow for
controller-less setups; on this build it never appeared, not once. So the sanctioned route — phone app,
account, complete setup, enable Developer Mode — was closed, and the community BLE tool (Private Quest)
was the only way in.

That tool can provision a factory-reset headset over Bluetooth LE with no account: set the device key,
skip the NUX setup wizard, flip Developer Mode and ADB on. It is exactly the tool this situation needs,
and it did not work.

The reason it did not work is the interesting part, and it is where I lost the night. Every
privileged write was silently dropped. The app's switches flipped, the headset kept answering
DeveloperMode: false, and reconnecting reset everything. Unauthenticated reads (Status, Version)
worked fine, which is what made it feel like an account problem rather than a protocol problem. I tried
five device keys, including the correct one fetched from the account API, and got handleAuthenticate failure and Error 105 for my trouble.

Meanwhile — and this is the part I would take back — I put the headset on Wi-Fi, because the official
app demanded an up-to-date headset before it would talk to it.


The gate was the patch itself

Here is the shape of the thing, in the device's own words. The official app refused:

"An unexpected error has occurred. Please make sure that your headset is connected to Wi-Fi and
updated to the latest OS version."

…and the headset, over BLE, was reporting this at the same moment:

text5 lines
NuxStatus: DAY0_OTA_READY     NuxCompleted: false
OtaAvailable: true            OtaReady: true
HmdUpdatePercentageComplete: 0 → 100
DeveloperMode: false          AdbEnabled: false
SystemSoftwareLocked: true

That is the whole engagement in three lines: the sanctioned route to ADB runs through the exact update
that closes the exploit.
Developer Mode wants a current headset; a current headset is the one where
nothing works. It is not a bug and not a misconfiguration. It is the design, and it is a good one.

Worse: satisfying the app is what downloaded the OTA in the first place. The decision cost me the build,
and the walk-back was not available — more on that in a minute.


So was the Bluetooth thing a real bug?

No. And because this is the part people ask about, here it is properly.

It was not a Meta bug, it was not an unreleased 0-day, and there is nothing to report. The BLE
protocol on the headset behaved correctly the entire time. The bug was in the community APK — the
client I was holding.

Decompiling it found where the handshake key comes from:

java5 lines
byte[] bArr = {-88,117,53,-60,34,-99,63,17,26,42,-10,-13,-20,13,34,88}; // MD5 of the author's CERT.RSA
InputStream is = getClassLoader().getResourceAsStream("META-INF/CERT.RSA");
if (Arrays.equals(MessageDigest.getInstance("MD5").digest(bytes), bArr))
        return this.k.c;                            // the configured DeviceKey
return new SecureRandom().generateSeed(32);         // ← otherwise: 32 RANDOM BYTES

The tool checks its own signing certificate against the author's, and only returns the real DeviceKey
if the check passes. The build circulating on forums is re-signed (different key, same APK), so the
check fails on every launch and every handshake gets keyed with 32 random bytes — from a
SecureRandom, so it is not even consistent between attempts.

Three things follow, and they are worth separating:

  1. A self-integrity check inside the attacker's own client protects nothing. Anyone with
    apktool removes it in four lines of smali. Mine ended up being:

    text6 lines
    .method public final c()[B
        .locals 1
        iget-object v0, p0, Lg2/i0;->k:Lg2/b;
        iget-object v0, v0, Lg2/b;->c:[B
        return-object v0
    .end method

    Rebuilt with apktool, signed with a throwaway key, installed over the original, and the same key I
    had been using all night — b1989ea9…e7cb40, fetched from the account side — authenticated first
    try.

  2. The device side was right. The DeviceKey is a 32-byte secret that is never displayed on the
    headset and cannot be read out of it; it only exists on the account side (viewer.user.device_secret).
    Writes from an unclaimed headset ([] instead of [C]) are dropped silently by design, and the
    headset also displays a PIN that the app has to send. None of that was broken. I was the broken part.

  3. The failure mode is fail-open, and that is the actual defect to point at. A client that cannot
    verify itself should refuse to run. This one substituted random key material and carried on, which
    produced a symptom set — unauthenticated reads working, privileged writes vanishing, error 105,
    error 16 — that reads like a server-side or account-side problem. It sent me into the phone's
    Bluetooth stack, the account model, and GATT client leaks before it sent me into a decompiler.

So: not a vulnerability, worth writing down anyway, because the same "check the client, then continue
anyway" pattern shows up in a lot of tooling and it costs people nights.


Forty minutes too late

The patched tool worked. The timeline of the same four hours, which I keep because it is the clearest
lesson in the whole engagement:

Time (UTC) Event
~02:50 Headset joins Wi-Fi, to satisfy the official app
~02:55 HmdUpdatePercentageComplete: 100 — day-0 OTA fully staged
~03:05 Headset powered off, still on 51978090107800150
~03:35 Patched BLE tool installed; key + PIN authenticate
~03:38 Headset powered on → the staged OTA applies
~03:39 Dev mode ON, ADB ON; USB composite gains ff/42/01; adb devices reports device
~03:44 Firmware read over ADB: 52322490019800150, patch 2026-09-06

I won the ADB gate and lost the exploit window, by about the time it takes to rebuild an APK. The
kernel exploit that works on this hardware (CVE-2026-43284, DirtyFrag) was fixed in the build that
had just booted — which I later confirmed in machine code rather than by trusting a patch date, because
patch dates lie on vendor devices.

The transferable rule: the update is silent, automatic and irreversible; the attack path requires
physical presence, a phone, and an authenticated BLE session. Decide the network question before
touching anything. If the build is exploitable, "do not go online" is the whole plan, and a missing
controller is merely inconvenient.


The fuse does not care about your flags

The update did not just replace a build. It burned a one-way fuse:

text6 lines
                     before              after
arb-index:0/1/2      1751673600          1788652800
                     = 2025-07-05Z       = 2026-09-06Z
current-slot         b                   a
slot-unbootable:b    no                  yes
unlocked / secure    no / yes            no / yes

The obvious idea is to lie to the bootloader: switch the active slot back to the old build, which is
still sitting on the other partition. So I did, read-only until the switch:

text2 lines
fastboot set_active b   →  OKAY
fastboot reboot         →  boots slot _a again, build 52322490019800150

The bootloader accepted the flag and refused the image. That experiment is worth its cost, because it
draws the line precisely: slot metadata lives in misc and is writable; image verification and
rollback protection live in the boot chain and compare against OTP fuses that only move forward.
The
comparison is performed by code that is itself inside the verified chain, so there is no value to
falsify — you have to attack the checker.

Which is exactly what the historical Quest 2 unlock did: CVE-2021-1931 was a buffer overflow in the
abl fastboot implementation that patched the signature checks in memory and then unlocked. It did not
lie about anything.

Bootloader facts, verbatim (`fastboot getvar all`, read-only)
text7 lines
product:         hollywood          model-revision: Hollywood (DVT)
unlocked:        no                 secure: yes
current-slot:    b                  slot-unbootable:a: yes   slot-successful:b: yes
kernel:          uefi               variant: SM8 UFS        slot-count: 2
arb-index:0/1/2  1751673600   ⇒ 2025-07-05T00:00:00Z       arb-fw-val: pil:1
userdata:        112.31 GiB         super: 6.00 GiB
version-baseband: (empty)           version-bootloader: (empty)

Two things in there matter later: there is no build string anywhere in 235 lines of getvar (the
firmware is only knowable from a shell in the OS), and the unit is DVT — design-validation-test
hardware, not retail. Any result from it may not generalise, and the write-up has to say so.


Two error codes that were hiding a GPU

With ADB in hand the hunt started where Android GPU bugs always start: /dev/kgsl-3d0, which is 0666
and openable from shell.

For a day and a half I believed two things about it, and both were wrong.

Wrong thing number one: -EINVAL meant "not allowed". IOCTL_KGSL_DRAWCTXT_CREATE refused every
value I sent. A refusal with a message attached feels like a policy decision, so I wrote down "permission
gate". It is three instructions:

text4 lines
ffffff8008747548  mov   w8, #0x12
ffffff800874754c  bics  wzr, w8, w23        ; w23 = *(u32 *)flags
ffffff8008747550  b.ne  <error>
error:  dev_err_once("legacy context switch not supported\n")  ->  -EINVAL

bics wzr, w8, w23 sets flags on w8 & ~w23, so the branch fires when flags is missing any bit of
0x12 — KGSL_CONTEXT_NO_GMEM_ALLOC (0x2) | KGSL_CONTEXT_PREAMBLE (0x10), both mandatory. The error
string talks about a removed feature; the check is a two-bit mask. There is no UID check in the entire
call path.

text1 line
drawctxt_create flags=0x00000012 -> r=0 errno=0 (OK) id=17

Wrong thing number two: EACCES meant "the driver said no". Some kgsl commands worked and others
returned EACCES, with no AVC denials in the log, and I reasoned from the silence that a driver-level
allowlist was at work. The control that killed it takes one line — ask for an ioctl number that cannot
exist:

text5 lines
nr 0x7e (unregistered)  cmd=0xc040097e  errno=13 (Permission denied)   <== EACCES
nr 0x00 (unregistered)  cmd=0xc0400900  errno=13 (Permission denied)   <== EACCES
nr 0xff (out of range)  cmd=0xc04009ff  errno=13 (Permission denied)   <== EACCES
drawctxt_create 0x13    cmd=0xc0080913  r=0                            (OK)
drawctxt_destroy 0x14   cmd=0xc0080914  errno=22 (Invalid argument)    (wrong size field)

An unregistered ioctl cannot produce EACCES: dispatch would miss and the syscall layer would return
ENOTTY. So the denial happens before the driver runs, and it is per-command SELinux policy, matching
the type|nr half of the command word and ignoring direction and size. The dontaudit is why the log
was empty. Reasoning from a log that is silent by policy is not reasoning.

Read against the real dispatch table (kgsl_ioctl_funcs, 40 populated rows), the policy does something
worse than being permissive — it protects the wrong half:

commands
gated (9) 0x10 rb_issueibcmds, 0x16/0x17 cmdstream_*timestamp, 0x20 map_user_mem, 0x24 sharedmem_flush_cache, 0x3d submit_commands, 0x4c gpuobj_set_info, 0xc0/0xc1 allow_*_priority
reachable (31) 0x02 0x07 0x13 0x14 0x15 0x21 0x2f 0x32–0x37 0x3c 0x40–0x49 0x4a gpu_command 0x57 gpu_aux_command 0x58–0x5d

The legacy submission API is fenced off; the current one — with the larger command parser behind it — is
open to every installed app.

A context is not a bug, though. To know whether the surface was usable I had to make the GPU run
something. Two pieces of kernel trivia, both from the image: kgsl_mmap shifts the page offset left 12
and compares against 0xfff00000, so an allocation is mapped with mmap(…, fd, (off_t)id << 12); and
kgsl_ioctl_gpu_command takes a 64-byte struct where cmdsize/objsize are the per-entry struct
size
, not the array length.

Getting the GPU to execute my instruction buffer (transcript)
text9 lines
context id = 43
gpumem id = 1 gpuaddr = 0x4000000000 size = 0x4000
mmap ok at 0x76227a6000 (id<<12)
  [A numcmds=0                ] ret= 0 ACCEPTED  timestamp=0x00000001
  [B cmdsize=16 numcmds=1     ] ret=-1 EINVAL
  [C cmdsize=32 numcmds=1 (IB)] ret= 0 ACCEPTED  timestamp=0x00000002
  [E] IB: NOP, MEM_WRITE addr=0x4000002000 data=0x41424344, NOP (6 dwords)
  [E NOP+MEM_WRITE+NOP        ] ret= 0 ACCEPTED  timestamp=0x00000003
target[0] after submit = 0x41424344   <-- GPU EXECUTED OUR IB

The timestamp incrementing proves the request reached the real dispatcher rather than a stub; the changed
sentinel proves the GPU executed my PM4. Rebuilt as an ordinary installed app (u:r:untrusted_app:s0,
uid 10069, installed with adb install and nothing else), the same sweep gives the same 9 gated / 31
reachable and the same execution result. adb was only ever how I delivered the app.


The write that went nowhere

So I had a write primitive from an unprivileged context. What I did not have was anything to write to.

CP_MEM_WRITE's target address is never validated by the driver — the only boundary is the SMMU. That
turned out to be enough of a boundary:

  • The driver's own global buffers (memstore, ringbuffer, pagetable descriptor, scratch) are mapped
    into every per-process pagetable. I proved that by watching the driver's own timestamp packets land
    there while my commands ran.
  • They are protected by the SMMU privilege bit, because this a650 reports ADRENO_APRIV
    (features=0x2132a0), which sets KGSL_MEMDESC_PRIVILEGED → IOMMU_PRIV.
  • A user instruction buffer cannot ask for that privilege: the IB control word is assembled with a
    20-bit field, so the privilege bit is structurally unreachable, and the register that would reprogram
    it (A6XX_APRIV_CNTL 0x844) sits inside the CP's protected range.
  • The published a7xx privilege-boundary bug does not transfer: it needs SDS as IB4 with IB4 & 3 == 0,
    while on a6xx SDS is IB3 and IB3 & 3 == 3.

Then I went looking for the other half — a kernel address to aim at — and there wasn't one:

  • every getproperty type dumped,
  • all 42 kernel→user copy sites in kgsl enumerated mechanically and each shown to clamp,
  • the one *(kernel_ptr)+8 disclosure site found dead because both slots are NULL at runtime,
  • useraddr compiled to a literal zero,
  • sysfs/proc/tracefs swept for pointer-shaped values (none),
  • and /proc/kallsyms denied to shell anyway, which the AVC log confirms.

Write, no read, nothing to aim at. That is a crash primitive, and on a headset I only have one of,
a crash primitive is a lost device. Six of the eight checkable kgsl CVEs are fixed in this binary, so
the historic bugs were not an option either.


1,340 kernel commits later

With the GPU closed from the inside, I stopped going code-first and went CVE-first: enumerate what was
fixed in the 4.19 line after this build, filter it by what uid 2000 can actually invoke, then decide
presence or absence in this kernel image with pyelftools + capstone.

text2 lines
1340 commits after 2026-05-07   (2026-05: 7, 06: 359, 08: 258, 09: 716)
6 carry an explicit CVE id; 42 survive filtering by file path against the measured surface

Two of the best candidates died on config lines rather than arguments, for the price of one zcat:

text3 lines
# CONFIG_SYSVIPC is not set            → the SYSV shm orphan-cleanup UAF is dead
# CONFIG_RDS is not set                → the zerocopy refcount UAF is dead
# CONFIG_CHECKPOINT_RESTORE is not set

The one that survived reachability and died only in the binary is the one I would keep as a lesson.
SO_ATTACH_REUSEPORT_CBPF is a plain setsockopt() on a UDP socket, it returned 0, and replacing the
program worked — so a bpf_prog lifetime surface was genuinely reachable with no capabilities at all.
Then the disassembly:

text7 lines
ffffff8008d7f5d0  ldr  w8, [x0, #4]        ; prog->type
ffffff8008d7f5d8  cmp  w8, #1              ; BPF_PROG_TYPE_SOCKET_FILTER    <-- cBPF
ffffff8008d7f5dc  b.eq .Lput
ffffff8008d7f5e0  cmp  w8, #0x15           ; BPF_PROG_TYPE_SK_REUSEPORT
ffffff8008d7f5e4  b.ne .Ldestroy
.Lput             bl   bpf_prog_put
                  ...  bl call_rcu

The source at the published tag tests only #0x15, so a classic-BPF program takes the immediate-free
branch — which is the bug. The shipped image tests #0x1 as well and defers the free through
call_rcu. Surface reachable, bug absent, and the fix exists in no public tree. That is the second
file where this vendor's delta carries an unpublished backport; the first was binder.c.

Which produces the least comfortable conclusion in the whole night: the shipped kernel is not the
published tag.
Any "this tag does not contain commit X, therefore the bug is present" reasoning is
unsafe on a vendor device. Disassemble the binary.

Two more results worth keeping. Per-path git dates are noise as a coverage instrument: they say futex
stopped moving in 2020, while the image contains pi_state_update_owner, attach_to_pi_state and
fixup_pi_state_owner — the post-CVE-2021-3347 rework. The decade's most valuable futex CVE is fixed
on a tree that per-path dates call stale. Symbol presence is the instrument. And the one place the
surface was wider than I had mapped: session keyring creation is allowed while instantiation is not
(keyctl(KEYCTL_JOIN_SESSION_KEYRING) succeeds, add_key returns EACCES), so the refcount/GC half of
that subsystem is reachable and no key can ever be instantiated.

One correction I got wrong and had to fix publicly: I initially recorded SCTP as "not compiled in"
because socket(AF_SCTP) returned EAFNOSUPPORT. Family 132 is not a family — it is IPPROTO_SCTP.
The correct test (AF_INET + IPPROTO_SCTP) returns EACCES: the stack is there (816 sctp_
symbols, /proc/net/sctp/), and the gate is per-domain policy. That closes the family for shell and
re-opens it for any other domain I can get code into — which is on the list at the end.


Binder: the best-looking dead end of the night

/dev/binder is openable from shell, and two binder transaction UAFs are genuinely missing from this
driver:

text4 lines
binder_free_transaction   @0xad0ac4   ldr x22, [x0, #0x30]   ; t->to_proc read with NO &t->lock held
                                      cbz x22, <kfree>       ; NULL ⇒ no lock taken at all
                                      ... bl kfree           ; no lock on the free path
binder_thread_release     @0xacb710   … contains the full vulnerable transaction_stack walk

I then spent a day believing they were unreachable, because "shell cannot own an addressable
binder_node". That claim was inferred from a neighbouring, correct measurement — shell cannot become
the context manager (binder_ioctl_set_ctx_mgr returns -EBUSY, then SELinux, then -EPERM). Not the
same thing, and nobody had tested the second one.

One ioctl tests it. BINDER_GET_NODE_DEBUG_INFO returns 0 for shell, and it is a retention oracle:

Owning a live binder_node from uid 2000 (transcript + controls)
text6 lines
[02] baseline: node[ptr=0 cookie=0 strong=0 weak=0]                 => no node
[03] send a Binder to servicemanager …
     <- BR_INCREFS  node=0x4e4f444500000001 cookie=0x434f4f4b00000002
     <- BR_ACQUIRE  node=0x4e4f444500000001 cookie=0x434f4f4b00000002
     node[t+0  ] strong=1 weak=1   => *** WE OWN A LIVE binder_node ***
     node[t+8s ] strong=1 weak=1

BR_INCREFS/BR_ACQUIRE are only ever delivered to a node's owner. Control: a transaction that does
not retain anything leaves ptr=0 at every timestamp, so this is kernel reference retention and not a
timing artefact.

Then the store, seen from outside the service. ISettingsProxy transaction 2 is
registerUserSwitchListener, and uid 1000 — system_server — calls straight back into my binder:

text4 lines
transact(2) with our Binder, dataSize=108
reply[0] (AIDL exception code) = 0
*** CALLBACK #1: code=1 callingPid=0 callingUid=1000 dataSize=92
             iface="oculus.internal.IUserSwitchListener" arg0(userId)=0

Control (code 99, same client, same service): rc=false, no callback in five pump rounds. Reproduced
twice. And because shell holds DUMP, the service's own field is readable from a different shell:

text3 lines
User switch listeners registered:
  PID: 2040        <- com.oculus.os.cm, a real first-party client
  PID: 12414       <- OUR uid-2000 registration

So: shell owns a node, a uid-1000 process retains it and invokes it, and the store is a field, not a
transient call. Precondition measured. And then the whole thing died, for three independent reasons.

  1. The write is NULL-only. t->from = NULL, t->to_proc = t->to_thread = NULL, t->buffer = NULL,
    and [t->buffer+0x38] = NULL — everything written is zero, and I have no leak to aim it with.
  2. No kernel-address disclosure exists anywhere on the reachable surface (see above).
  3. The trigger needs a courier that is not there. The kernel refuses a transaction to a node in the
    same proc, and I cannot hand a binder between two procs I own; nodes only travel inside transactions,
    and my channels all lead to services. So a self-contained trigger needs a service that echoes my
    binder back. I scanned 2,164 onTransact implementations: 37 interfaces take a binder and return
    one, 13 are shell-reachable, and every single one is a factory (register() returns
    new Binder("UidObserver-…"), owned by system_server). The only genuine store-and-retrieve pairs are
    ownership-gated, and attachApplicationLocked kills unknown pids. Factory, not courier.

Not weaponisable, and I did not run it. A lost race in binder teardown is slab corruption inside the
process half the system depends on; on a shared, single headset that is not a failed experiment, it is a
lost device.

The correction that cost a day: my binder client was wrong in five places, not the device

For most of a session I was sure the device was mis-framing the getService reply. It was not. My client
was wrong five times over: BR_DEAD_REPLY is the generic failure code (handles 1000 and 0x7fffffff
produce it too); mmap(PROT_WRITE) on /dev/binder is EPERM for shell, so the mapping is receive-only
and outgoing buffers must be ordinary user memory; a writeInterfaceToken parcel must carry its
three-word header (strictMode|0x80000000, workSourceUid, magic 0x53595354) or it is rejected with
Parcel: Expecting header 0x53595354 but found 0x…. Mixing copies of libbinder?; String16 is 4-byte
padded, so "android.os.IServiceManager" needs two pad bytes and "settingsproxy" needs none; and
BC_FREE_BUFFER releases the reference reply-translation created unless the client sends BC_ACQUIRE,
which makes every raw handle dead on arrival.

A real libbinder client worked first try. A probe that fails against everything is a probe to fix,
not a device to blame.


What adb shell can really do

The layer above the kernel was the last place to look before giving up, and the first rule there is that
the ADB shell is a privileged account: com.android.shell holds 100+ granted permissions, including
signature|privileged ones. "Shell can do X" is not a finding. So every candidate was re-run from an
ordinary installed app, and the app result decided.

That rule retired two would-be findings before I wrote them up:

  • A LockscreenService "authorization bypass" (enforceCallingOrSelfPermission instead of
    enforceCallingPermission). Measured: the permission is signature|privileged and
    dumpsys package com.android.shell shows granted=true — shell is supposed to pass. From an
    installed app, all nine transactions are DENIED. Withdrawn.
  • An exported, permission-less test harness (E2eTestHarnessContentProvider, twelve methods, in a
    production user build). Measured: Binder.getCallingUid() == SYSTEM_UID. Closed.

What survived the rule is small, real, and not root:

  • Accessibility without consent. Writing the accessibility settings directly binds a locally built,
    sideloaded APK, and the Android 13+ "restricted settings" gate that exists for exactly this case reads
    allow with installerPackageName=null. No dialog, no user action.
  • device_config as a privileged write path. cmd device_config put <ns> <key> <val> accepts any
    namespace, including Meta's (oculus_*, hzos_*), a uid-1000 consumer applies the value within about
    two seconds, and system_server re-publishes it as persist.device_config.<ns>.<key> — a property shell
    cannot write itself. One policy decision launders the other. The flag that matters most is a
    ban-enforcement killswitch in a uid-1000 package. I measured the mechanism; the specific instance was
    not scheduled, so I did not claim it end-to-end.
  • Silent device admin, and a notification listener approved before the component exists — so
    installing it later inherits consent.

And the controls that held are worth as much: no sharedUserId=android.uid.system install, no pm grant
of undeclared permissions, no overlay fabricate, no role qualification bypass — and no "stop the OTA"
command reachable from adb shell at all (cmd backup|bmgr|updatelock|system_update all return No shell
command implementation
). That is why the race in the first section was unwinnable once the OTA had staged.


One more pass: sockets, perfetto, and a real privilege crossing

Before writing anything up I did one last read-only pass, looking specifically at the parts of the
surface I had mapped by count rather than by behaviour: the filesystem, the process table, the
/dev/socket endpoints, and the tracing subsystem. It closed four leads and produced one genuine
privilege crossing, so it belongs here rather than in a footnote.

The filesystem is clean, and my first sweep of it was not

No setuid or setgid binaries anywhere in /system, /vendor, /odm, /system_ext or /product. No
file capabilities. No world-writable files in the vendor trees. /data/system (which holds the debug
and zygote sockets) is drwxrwxr-x system:system and shell cannot traverse it.

That "no setuid binaries" result is a correction, and an embarrassing one: my first sweep also
reported nothing, but only because the find expression was malformed and quietly matched nothing.
A control that cannot fail is not a control. Re-running it properly produced the same answer, which
is the only reason I trust the answer now.

The one real crossing: CompanionService provisions Wi-Fi as uid 1000

CompanionServer (uid 1000) is hardened everywhere it matters: every transaction calls
enforceTrustedCaller() against a hardcoded signature allowlist, and onShellCommand refuses
anything that is not uid 0 or uid 2000. Its one shell command takes exactly two arguments, a network
name and a password, and calls WifiUtil.provisionWifi() → the framework's addNetwork().

The control is what makes this interesting. From shell, the AOSP path cannot do it — cmd wifi add-network only creates a network suggestion, and its own help says approving it needs root:

text2 lines
$ cmd wifi add-network
... network-suggestions-set-user-approved com.android.shell yes (Needs root access)

So uid 2000 cannot put the headset on a Wi-Fi network through the framework, and this one call — as
uid 1000 — can. Net effect: shell → system-uid Wi-Fi provisioning.

Practical value: attach the headset to an AP whose PSK I choose, which is a traffic-observation and
captive-portal-staging primitive, not a root step. It is also almost certainly intentional (Meta trusts
the shell account). I did not execute it: it would move a shared headset off its access point, and that
is a state change, not a measurement.

Unix sockets, measured properly this time

I had previously recorded /dev/socket as untested, with a bad explanation attached: that toybox nc
cannot create a listener. The real reason is that bind() into /data/local/tmp is EACCES for
shell
(shell_data_file) — which is a useful fact in itself, and one that a self-built static probe
settles in seconds.

The probe also carries the two controls that make the results worth reading: it self-tests an abstract
namespace socket (@dsh.selftest.<pid>) and marks the whole run INVALID if that stops working, and it
tries all three socket types — a STREAM connect to a SEQPACKET/DGRAM socket returns
EPROTOTYPE, which is not a denial, and treating it as one produced false negatives on my first attempt.

Target Result Reading
property_service, logdw, logdr, statsdw reachable by design for shell — statsd parses protobuf atoms from us
traced_producer, traced_consumer reachable perfetto; traced runs as nobody, not root
tombstoned_java_trace reachable while tombstoned_crash / _intercept are denied — an asymmetry worth remembering
dnsproxyd, fwmarkd, prng_seeder reachable by design
adbd, lmkd, zygote, mdns, syncboss_smp denied

Nothing there escalates. The @jdwp-control, @remote-crash64 and @property_service abstract names
have no listener of any type.

How the perfetto lead died

This was the one I actually had hopes for. The binder plan and the GPU primitive are both capped by the
missing piece: no kernel-address disclosure anywhere on the reachable surface, so every write primitive
is a panic generator. Tracing is the classic way out — trace output is full of kernel pointers. And
perfetto is reachable from shell, with a traced_consumer socket and /system/bin/perfetto present.

So: open a one-second session, ask for sched_switch plus kmem/kfree and kmem/kmalloc, and see what
comes back.

Probe Result
session opened as shell, 1 s, 8 CPUs OK — 272 KB of trace captured
kmem/kfree, kmem/kmalloc unknown_ftrace_events — those tracepoints are not compiled into this kernel
available_events empty
available_tracers, set_event, current_tracer EACCES
printk_formats, kprobe_profile, saved_cmdlines EACCES (the classic tracefs leaks)
available_filter_functions absent → no FUNCTION_TRACER
kernel-looking addresses in 4.3 MB of decoded trace 0

Closed, with evidence. One technique is worth keeping: perfetto transitions to u:r:perfetto:s0, which
cannot read shell_data_file, so -c /data/local/tmp/x fails with EACCES on the config file. Pipe
both directions instead, and the same command works unchanged:

bash2 lines
cat cfg.txt | adb shell perfetto --txt -c - -o - | cat > trace.pb
traceconv text trace.pb trace.txt   # host side

Two vendor-side defects, neither a lever

  • crcs (/system_ext/bin/crcs, a system daemon) is permanently denied read on
    u:object_r:hmt_mounted_prop:s0 — around 10,492 AVC events, roughly one per second, continuously. A
    daemon misconfigured against its own property label. It is the single largest source of denials in the
    entire log, which is worth knowing before you read a denial count as "the attacker being blocked".
  • dumpsys dropbox --print is readable from shell (44 entries here). That is a useful crash oracle
    for uid-1000 daemons: it shows system_app_crash for com.oculus.presence, a pile of soft_error
    records from the vrshell guardian, SYSTEM_BOOT and SYSTEM_FSCK. No native tombstones, and
    logcat -b crash is empty.

So which bugs did I actually find?

Straight answer: five real defects, plus two design weaknesses worth reporting as hardening, and no
reportable vulnerability.
Nothing here is a chain to root. All of it is reproducible on the unit.

# What Class Severity Status
1 ISettingsProxy txn 2 appends a listener with no null check and no permission check, before it notifies, and unregister dereferences the null it stored ⇒ an entry that can never be removed defect low confirmed, reproduces on demand (four entries left in dumpsys)
2 XrspBroker's four acquireUsb* entry points enforce no permission, while sibling startStreamingSession requires horizonos.permission.XRS_STREAM missing authorization low confirmed (transact(23) returns true to uid 2000); no descriptor delivered, app context cannot reach the service
3 device_config accepts shell writes into vendor/Meta namespaces and they come back out as privileged sysprops design weakness medium mechanism confirmed, end-to-end instance not measured
4 Accessibility service bound with no consent; restricted-settings gate does not fire for pm install consent bypass medium binding confirmed; event delivery unverified
5 Device admin activated silently; notification listener pre-approved for a component that is not installed consent bypass medium confirmed
6 Horizon linkedaccounts provider guarded by a normal-protection permission, i.e. auto-granted at install permission design low static
7 /dev/usb-ffs parent directory is group-writable by shell, and xrspd resolves that path at runtime config defect to determine unresolved — one cat /proc/mounts settles whether it is a mountpoint
8 cmd CompanionService -n <ssid> -k <pass> provisions Wi-Fi as uid 1000, which the framework refuses to uid 2000 privilege crossing low, likely intentional confirmed reachable, deliberately not executed
The four defects behind the two biggest rows, as decompiled
java14 lines
// SettingsProxyService — nine lines, four defects
public void registerUserSwitchListener(IUserSwitchListener l) {
    try {
        UserSwitchListener u = new UserSwitchListener(Binder.getCallingPid(), l);
        synchronized (mUserSwitchListeners) {
            mUserSwitchListeners.add(u);                                     // add FIRST, no null check
            u.notifyUserChange(SettingsProxyService.this.mCurrentUserId);     // NPE when l == null
        }
    } catch (RemoteException e) { Log.e(TAG, "Failed to register user switch listener", e); }
}
public void unregisterUserSwitchListener(IUserSwitchListener l) {
    for (UserSwitchListener u : mUserSwitchListeners)
        if (u.asBinder() == l.asBinder()) { … }        // dereferences the very null it stored
}

No null check; entry appended before the notification; the NPE is not a RemoteException; and the only
removal path compares asBinder() on the null. An entry added with a null listener is permanent for the
life of the host process, which also explains the "unexplained stale PID" I had earlier written off as
noise.

For the streaming broker, the static side is one line — the permission string occurs once in the
358 KB daemon, so only the session start can enforce it:

text2 lines
$ strings -a xrspd | grep -c XRS_STREAM
1

So is any of it reportable?

I checked this against Meta's own published terms rather than against a feeling. The
hardware payout guidelines list the Quest 2 as an
eligible device, and they pay for specific shapes: persistent secure boot bypass (up to $30k), device
identity private keys ($20k), a full chain to root ($10k), sensor-data access ($10k) and sensor-privacy
bypass ($10k). Their worked example is almost upsettingly close to this hunt: "local device root through
heap corruption in a system service would receive a ~$10,000 payout."

None of my findings reach it, for two reasons that would apply to any report I filed:

  1. The attacker context is privileged. Everything above was measured from u:r:shell:s0 — an ADB shell
    the owner switched on — or from a sideload. Meta's threat model is a malicious app on a stock device.
    "Requires developer mode and an owner-run install" is the end of the triage, and it is why the
    XrspBroker missing check and the accessibility path are hardening notes rather than vulnerabilities.
  2. The impact is a nuisance class. The one finding that needs no credentials is the listener leak, and
    it is DoS-shaped — the programme's scope FAQ puts "denial-of-service attacks" out of scope.

So: nothing reportable. Two things are worth sending Meta as hardening anyway — enforce XRS_STREAM
on the acquireUsb* methods, and null-check the listener before you append it. One item is the only
app-reachable one and I would ask before submitting it: Horizon's linkedaccounts provider guarded by a
normal-protection permission, which is auto-granted to any app that declares it.

And one thing that is not a bug at all: the GPU policy. A rule that fences the deprecated submission
API while leaving the modern one open to every app is a design choice I would change, so it is in the
vendor-facing list, but it is not a vulnerability.


The bootloader question I never asked

Here is the correction that stings. For the entire first session I treated the boot chain as closed,
because unlocked: no and secure: yes and verified boot was green. I never asked the device whether it
was allowed to be unlocked — those are different questions.

fastboot oem device-info, as a read-only call:

text8 lines
Verity mode: true                  Secure boot: true        Retail device: true
Device unlocked: false             Device critical unlocked: false
Device unlock allowed: true        <-- unlock_ability IS SET
Device unlock serial number: 1WMHHA66…96
Verified Boot Mode: false          Bootloader ARB enabled: true
Build number: 109dd2a4cf.20260909.115908
System|vendor rollback slot: 1|2   Non-HLOS ARB flags: 0xF
Req Default/System/Vendor (0/1/2) min anti-rollback version: 1788652800

Device unlock allowed: true is the AOSP unlock_ability flag — the condition that lets oem unlock
proceed. Device unlocked: false only says nobody has done it yet.

The next probe separates two mechanisms, and it is deliberately non-functional because an invalid type
cannot unlock anything:

text3 lines
$ fastboot oem unlock bogus
(bootloader) Please visit https://oculus.com/unlock.
FAILED (remote: 'Command Failed')

Status 1 (Command Failed), not status 4 (Operation Not Permitted) — which is what the two refused
read-only queries return (get-kernel-flavor, get-pstore-record). So unlock passes the whitelist
and is refused inside the handler, at Meta's serial-bound authorization step. That is a
token/authorisation gate, not a capability gate, and it is the single most important open question on the
unit.

Two caveats I am not going to bury. This is DVT hardware, and engineering units often ship with the
unlock flag set where retail units do not, so a success here may not generalise. And there is no
flashing/get_unlock_ability command in this ABL at all — the interface is plain oem lock / oem unlock, and my earlier "try fastboot flashing get_unlock_ability" advice was wrong about the command
name.

The strings that matter more than the unlock flag

Reading the bootloader binary paid for itself separately. The decompressed ABL contains, verbatim:

text5 lines
enforcing=0 androidboot.selinux=permissive      <- what `oem selinux 0` appends
oem selinux  +  Invalid input entered. Enter oem selinux 0/1
oem set-appended-cmdline  +  Setting appended cmdline successful
earlycon=msm_geni_serial,0x4a90000
watchdog_v2.disable_wdt=1

That is, in order: a command that turns SELinux permissive at boot, a command that appends arbitrary
kernel cmdline
, and the cmdline fragments the firmware uses for an early kernel console on the GENI
UART. All three are implemented on a device that reports secure: yes.

On this unit the read-only members of that family are refused while locked — oem get-kernel-flavor and
oem get-pstore-record both return status 4 (Operation Not Permitted, i.e. refused before the
handler) — so the setters are very likely behind the same whitelist. But it is now a three-command
question with a clear answer, and the payoff if the answer goes the other way is the whole engagement:

text2 lines
fastboot oem selinux                    # no argument: does it print state, demand 0/1, or refuse?
fastboot oem set-appended-cmdline       # no argument: same question, expect an argument error

Both are read-only as written; the state-changing forms (oem selinux 0, set-appended-cmdline console=…) are reversible but are genuine security-state changes, so they are an owner decision, not a
probe. If either setter is admitted on a locked device, this stops being a bug hunt and becomes a
reportable finding — a boot-time SELinux bypass on a device that claims secure boot.

What the console itself would buy, in the same breath: the earliest kernel output — driver probes,
memory map, the initial SELinux policy load, and any oops or panic text — on a unit where
/sys/kernel/oops_count, panic_on_warn, dmesg and /sys/fs/pstore are all denied and CONFIG_KASAN is
off. That is the one measurement channel that would let a binder or GPU experiment be watched instead of
inferred from whether the process survived. A console is not a shell, but on this device it is the
difference between guessing and seeing.

I have not run bare oem unlock. The cost of being wrong is the 112.31 GiB of userdata on the next boot
(and possibly the unit). That is a decision, not a measurement, and it is written down as one.


USB-C: the one place the attacker is on the other end

Everything above assumes I am the attacker on the unit. The USB-C port is the opposite case: the peer
controls the descriptors, and part of the code that parses them is Meta-authored rather than AOSP.

text5 lines
sys.usb.controller            a600000.dwc3     (dual-role)
sys.usb.config / configfs     adb / 2
gadget functions               mtp ptp rndis midi accessory audio_source ncm uvc adb xrsp hid des
/dev/usb-ffs                   drwxrwxr-x shell system   <- shell OWNS the FunctionFS parent
host-visible PID               2833:5013 (2833:5010 with adb off)  <- the PID mirrors the function set

Three findings, in descending order of how confident I am about them:

  1. xrspd runs as uid 1000 and parses attacker-supplied USB descriptors in host mode. Its
    usb_device_added walks descriptors looking for interface class 0x8a, protocol 1, and reads fixed
    offsets without an obvious bLength pre-check in the window I examined — at most a few-byte
    out-of-bounds read. That is a candidate, not a proven primitive, and it needs a Pi emulating the
    counterpart device to turn into anything. It is also the most promising thing left, because it needs
    no KASLR leak and has no public research behind it.
  2. XrspBroker hands out nothing. I wanted the FD handoff to be reachable from the ungated
    acquireUsb* methods. Reverse-engineering the daemon settled it the other way: recovering its symbols
    from .gnu_debugdata (961 symbols — always check that section before calling a vendor binary
    stripped), the PLT map, and the .eh_frame_hdr FDE table showed that no ungated path ever calls the
    IXrspStreamingClient::onSessionStarted vtable slot. The FD handoff stays behind the gated session.
    Missing-authorization inconsistency; not a capability transfer.
  3. EDL is a first-class ABL feature and I do not know whether it is authenticated. oem reboot-edl
    and oem enable-dload-mode are registered and dispatch; device-info says Download mode enabled: false. If the firehose programmer does not need a Meta-signed image, the entire lockdown story ends
    — raw UFS access. I have written "EDL needs a signed firehose" down in an earlier draft as if it were
    measured. It is not measured. It is an assumption, and it is the second item on the decision list.

The boot chain itself is worth reading because it costs nothing: abl.img is an ELF32 loader shim around
a firmware volume with an LZMA stream at offset 0x3078, and the three X.509 anchors are in the clear —
FW_Root_Pub (self-signed, 2020 → 2120), FW_Int_Pub (→ 2030-03-11, the only dated component), and a
per-codename Hollywood_FW_Leaf issued 2026-09-09, three days after this build's patch level. The
tool below pulls all of that out of a public OTA with no device attached, which is the cheapest possible
boot-chain audit.


The tool: quest2_hunt.py

One file, standard library only, read-only by default. It runs the checks in the order the night needed
them and writes everything into a single JSON report, so the post's numbers can be re-derived rather than
believed.

bash7 lines
chmod +x quest2_hunt.py

./quest2_hunt.py all                     # device checks, in order, read-only
./quest2_hunt.py fingerprint bootloader  # just the firmware / bootloader facts
./quest2_hunt.py abl --img abl.img --vbmeta vbmeta.img
./quest2_hunt.py bootloader --parse-log fastboot-oem.log   # offline, from a saved transcript
./quest2_hunt.py ota --url https://host/payload.bin --props payload_properties.txt
Subcommand What it does Needs
fingerprint build, patch level, fingerprint, verifiedbootstate, slot device + adb
bootloader getvar all, oem device-info, oem help, parse unlock_ability; or parse a saved log with --parse-log fastboot or a log
nodes compiles the embedded C probe and records which of the interesting /dev nodes open, plus the socket families zig or aarch64-linux-gnu-gcc
kgsl the flag matrix (0x12), the three impossible ioctls, and an empty gpu_command submit probe
binder BINDER_GET_NODE_DEBUG_INFO reachability from the current uid probe
surface service list vs cmd -l counts, device_config totals, the security-shaped flags device + adb
settingsproxy parses dumpsys settingsproxy for the listener PIDs (run it before and after a null-listener call) device + adb
auth probes the Horizon auth providers and checks the shell's lockscreen permission device + adb
usb USB props, gadget functions, and whether /dev/usb-ffs is a functionfs mountpoint device + adb
abl finds the LZMA stream, unpacks the volume, carves the X.509 anchors, lists oem-ish names just a file
vbmeta AVB0 header: version, auth/aux sizes, algorithm, hash/sig/pubkey offsets just a file
fs setuid/setgid counts, world-writable files, /data/system permissions device + adb
procs uid map: which uid-1000 and root processes exist device + adb
sockets the /dev/socket reachability map, with the abstract-namespace control and all three socket types probe
trace whether tracefs/perfetto can leak kernel addresses (available_events, tracers, printk_formats) device + adb
dropbox dumpsys dropbox --print as a crash oracle, plus the AVC-denial window device + adb
companion confirms the uid-1000 Wi-Fi provisioning path exists, without provisioning anything device + adb
ota resumable download, FILE_SIZE + FILE_HASH verification, partition extraction when payload_dumper is importable network

What I validated before publishing it. The parsers were run against the artefacts this engagement
actually produced, not a fixture:

text10 lines
$ ./quest2_hunt.py bootloader --parse-log fastboot-oem.log
  [ok] bootloader(log): 36 oem commands parsed, Device unlock allowed = true

$ ./quest2_hunt.py abl vbmeta --img abl.img --vbmeta vbmeta.img
  [ok] abl: 274432 bytes, lzma@12408 -> 741576 B, 3 X.509 anchors, 276 hyphenated oem-ish names
  [ok] vbmeta: magic=AVB0 algo=1
        Hollywood_FW_Leaf   Sep  9 19:06:13 2026 GMT → Sep  4 19:06:13 2046 GMT
        FW_Int_Pub          Mar 13 22:05:20 2020 GMT → Mar 11 22:05:20 2030 GMT
        FW_Root_Pub         Mar 13 22:04:35 2020 GMT → Feb 18 22:04:35 2120 GMT
        auth block 320 B, aux block 3328 B, required_libavb 1.0, algorithm 1 (RSA-2048)

Those numbers are the same ones quoted above, produced by the code below, from the shipped image. The
device-side checks degrade to skipped when there is no headset, no adb, or no cross-compiler — the
script never dies mid-run because one check could not run.

Part 1 — imports, constants, and the C probe that gets cross-compiled

python105 linesexpandcollapse
#!/usr/bin/env python3
"""
quest2_hunt.py — one script for the whole Quest 2 bug hunt.

Runs the read-only checks used in the write-up "Two Error Codes and a Fuse", in the
order the investigation actually needed them, and keeps every result in one JSON
report so the post can cite its own output.

It also fetches and parses the firmware artefacts the checks depend on:
  * payload.bin  (the OTA)  -> size/hash verified, partitions extracted when possible
  * abl.img      (bootloader) -> LZMA/FV unpack, X.509 trust anchors, fastboot/oem tables
  * vbmeta.img   (AVB)      -> header, algorithm, rollback index

Design rules
  * standard library only (Python 3.9+), no pip installs
  * read-only by default; state-changing actions need an explicit flag and print a warning
  * anything missing (adb, fastboot, device, cross-compiler) is *skipped*, never fatal
  * --dry-run prints the plan and touches nothing

Typical use
    ./quest2_hunt.py all                        # everything that this machine can do
    ./quest2_hunt.py fingerprint --json out.json
    ./quest2_hunt.py bootloader --parse-log fastboot-oem.log
    ./quest2_hunt.py abl --img abl.img --out abl.json
    ./quest2_hunt.py ota --url https://host/Quest2/payload.bin --partitions abl,vbmeta

Author: KBS <admin@dtrh.net>   Target: Meta Quest 2 (hollywood), own hardware.
"""

from __future__ import annotations

import argparse
import base64
import hashlib
import json
import lzma
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import textwrap
import time
import urllib.request
from datetime import datetime, timezone

VERSION = "1.1"
REPORT_DEFAULT = "quest2_report.json"

# --------------------------------------------------------------------------------------
# constants measured on build 52322490019800150 / kernel 4.19.325-cip132-st16
# --------------------------------------------------------------------------------------

KGSL = "/dev/kgsl-3d0"
BINDER = "/dev/binder"

# _IOC numbers (direction<<30 | size<<16 | type<<8 | nr)
IOCTL_DRAWCTXT_CREATE = 0xC0080913   # _IOWR(0x09, 0x13, struct kgsl_drawctxt_create)  8 bytes
IOCTL_DRAWCTXT_DESTROY = 0xC0080914  # _IOWR(0x09, 0x14, struct kgsl_drawctxt_destroy) 8 bytes
IOCTL_GPUMEM_ALLOC_ID = 0xC0200934   # _IOWR(0x09, 0x34, struct kgsl_gpumem_alloc_id)  32 bytes
IOCTL_GPUMEM_FREE_ID = 0xC0080935    # _IOWR(0x09, 0x35, struct kgsl_gpumem_free_id)   8 bytes
IOCTL_GPU_COMMAND = 0xC040094A       # _IOWR(0x09, 0x4a, struct kgsl_gpu_command)       64 bytes
BINDER_GET_NODE_DEBUG_INFO = 0xC018620B  # _IOWR('b', 11, struct binder_node_debug_info)

# the two mandatory bits an a6xx draw context must carry
KGSL_CONTEXT_NO_GMEM_ALLOC = 0x2
KGSL_CONTEXT_PREAMBLE = 0x10
MANDATORY_CONTEXT_FLAGS = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE  # 0x12

# deliberately impossible ioctl numbers: dispatch misses, so the syscall layer must
# answer ENOTTY. An EACCES here proves the denial happens before the driver runs.
CONTROL_IOCTLS = (0xC040097E, 0xC0400900, 0xC04009FF)

# flags in the a6xx IB control word (bits 31:12) — the privilege bit is not reachable
# from a 20-bit field, which is why user IBs cannot borrow IOMMU_PRIV.

SOCKET_FAMILIES = {
    "AF_UNIX": (1, 1, 0),
    "AF_INET_TCP": (2, 1, 0),
    "AF_INET_UDP": (2, 2, 0),
    "AF_INET6_TCP": (10, 1, 0),
    "AF_NETLINK_ROUTE": (16, 3, 0),
    "AF_NETLINK_SELINUX": (16, 3, 7),
    "AF_PACKET": (17, 3, 0),
    "AF_ALG": (38, 1, 0),
    "AF_SCTP_TCP_PROTO": (2, 1, 132),   # family 2 + IPPROTO_SCTP: the test that actually matters
    "AF_VSOCK": (40, 1, 0),
    "AF_XDP": (44, 3, 0),
    "AF_RDS": (21, 5, 0),
}

SECURITY_FLAGS = [
    ("oculus_shared_os_services_sessionless", "skip_notification_permission_check"),
    ("hzos_system_sessionless", "hzos_catapult_disable_device_ban_enforcement"),
    ("hzos_system_sessionless", "oc_inauthenticity_enforcement"),
    ("hzos_system_sessionless", "inauth_enforcement"),
    ("hzos_system_sessionless", "attestation_connectivity_check_killswitch"),
    ("hzos_system_sessionless", "device_access_enable_uat_cache"),
    ("oculus_shared_os_services_sessionless", "hzos_security_zygote_partitioning_policy"),
    ("perception", "hzos.perception.access.flags.perception_access_policy_enabled"),
    ("oculus_shared_core", "is_trusted_user"),
    ("oculus_shared_horizon", "enable_sps_client_auth"),
]

Part 2 — the C probe: what the device-side checks actually execute

Cross-compiled with zig or aarch64-linux-gnu-gcc and pushed to /data/local/tmp — the script looks for
zig on PATH, then in the rootless toolchain under tools/bin. Read-only: it opens nodes, calls
ioctls, creates and destroys one draw context, and connects to unix sockets (with a self-test that marks
the run invalid if the control stops passing).

c188 linesexpandcollapse
/* quest2_probe.c — the ioctl-level core, cross-compiled and run from /data/local/tmp.
 * Modes: kgsl | binder | devs | fam
 * Read-only. kgsl creates and destroys a context; no PM4 is submitted.
 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>

struct kgsl_drawctxt_create  { unsigned int flags, ctx_id; };
struct kgsl_drawctxt_destroy { unsigned int ctx_id, pad; };
struct kgsl_gpu_command {
    unsigned long flags; unsigned long cmdlist; unsigned int cmdsize, numcmds;
    unsigned long objlist; unsigned int objsize, numobjs;
    unsigned long synclist; unsigned int syncsize, numsyncs;
    unsigned int context_id, timestamp;
};
struct node_debug_info { unsigned long ptr, cookie; unsigned int strong, weak; };

static void print_ioctl(const char *label, unsigned long cmd){
    int fd = open("/dev/kgsl-3d0", O_RDWR);
    if (fd < 0) { printf("%s\topen_errno=%d\n", label, errno); return; }
    unsigned char buf[64]; memset(buf, 0, sizeof buf);
    errno = 0; long r = ioctl(fd, cmd, buf);
    printf("%s\tcmd=0x%08lx\tr=%ld\terrno=%d\n", label, cmd, r, errno);
    close(fd);
}

static int mode_kgsl(void){
    int fd = open("/dev/kgsl-3d0", O_RDWR);
    if (fd < 0) { printf("open /dev/kgsl-3d0 errno=%d\n", errno); return 1; }
    printf("open /dev/kgsl-3d0\tok\n");

    /* the flag matrix: 0x12 is the two mandatory bits */
    unsigned int try[] = {0, 0x00100000, 0x00200000, 0x00000200, 0x12};
    for (unsigned i = 0; i < sizeof try / sizeof try[0]; i++) {
        struct kgsl_drawctxt_create c = { try[i], 0 };
        errno = 0; long r = ioctl(fd, 0xC0080913UL, &c);
        printf("drawctxt_create flags=0x%08x\tr=%ld\terrno=%d\tid=%u\n", try[i], r, errno, c.ctx_id);
    }
    /* impossible numbers: ENOTTY expected, so EACCES means "policy, not driver" */
    for (unsigned i = 0; i < 3; i++)
        print_ioctl("control_unregistered", (unsigned long[]){0xC040097EU, 0xC0400900U, 0xC04009FFU}[i]);

    /* empty submit: proves the modern path is admitted */
    struct kgsl_gpu_command g; memset(&g, 0, sizeof g);
    g.cmdsize = 32; g.objsize = 0;
    errno = 0; long r = ioctl(fd, 0xC040094AU, &g);
    printf("gpu_command numcmds=0\tr=%ld\terrno=%d\n", r, errno);

    struct kgsl_drawctxt_destroy d = {0, 0};
    errno = 0; r = ioctl(fd, 0xC0080914UL, &d);
    printf("drawctxt_destroy\tr=%ld\terrno=%d\n", r, errno);
    close(fd);
    return 0;
}

static int mode_binder(void){
    int fd = open("/dev/binder", O_RDONLY);
    if (fd < 0) { printf("open /dev/binder errno=%d\n", errno); return 1; }
    printf("open /dev/binder\tok (read-only: PROT_WRITE mmap is EPERM for shell)\n");
    struct node_debug_info n; memset(&n, 0, sizeof n);
    errno = 0; long r = ioctl(fd, 0xC018620BUL, &n);
    printf("BINDER_GET_NODE_DEBUG_INFO\tr=%ld\terrno=%d\tptr=0x%lx strong=%u weak=%u\n",
           r, errno, n.ptr, n.strong, n.weak);
    close(fd);
    return 0;
}

static int mode_devs(void){
    const char *names[] = {"/dev/kgsl-3d0","/dev/binder","/dev/hwbinder","/dev/vndbinder",
        "/dev/ion","/dev/ashmem","/dev/uinput","/dev/uhid","/dev/input/event0",
        "/dev/cvp","/dev/video32","/dev/msm_npu","/dev/adsprpc-smd","/dev/spcom",
        "/dev/smcinvoke","/dev/qseecom","/dev/diag","/dev/tun","/dev/dma_heap/system",0};
    for (int i = 0; names[i]; i++) {
        int fd = open(names[i], O_RDONLY);
        printf("%-24s\t%s\terrno=%d\n", names[i], fd >= 0 ? "OPEN" : "denied", fd >= 0 ? 0 : errno);
        if (fd >= 0) close(fd);
    }
    return 0;
}

static int mode_fam(void){
    const char *n[] = {"AF_UNIX","AF_INET_TCP","AF_INET_UDP","AF_INET6_TCP","NETLINK_ROUTE",
        "NETLINK_SELINUX","AF_PACKET","AF_ALG","AF_INET+SCTP","AF_VSOCK","AF_XDP",0};
    int f[] = {1,2,2,10,16,16,17,38,2,40,44};
    int t[] = {1,1,2,1,3,3,3,1,1,1,3};
    int p[] = {0,0,0,0,0,7,0,0,132,0,0};
    for (int i = 0; n[i]; i++) {
        int s = socket(f[i], t[i], p[i]);
        printf("%-16s\t%s\terrno=%d\n", n[i], s >= 0 ? "OK" : "denied", s >= 0 ? 0 : errno);
        if (s >= 0) close(s);
    }
    return 0;
}

/* /dev/socket reachability, with two controls that make the map trustworthy:
 *  (a) a bind() into /data/local/tmp must fail EACCES (shell_data_file) or the
 *      probe is not actually running as shell;
 *  (b) an abstract-namespace self-test must succeed, otherwise "no listener" and
 *      "not allowed" cannot be told apart.
 * Socket TYPE matters: STREAM connect() to a SEQPACKET/DGRAM socket returns
 * EPROTOTYPE, which is not a denial. Try all three, report only EACCES/EPERM as denied.
 */
static const char *SOCK_TARGETS[] = {
    "/dev/socket/property_service", "/dev/socket/logdw", "/dev/socket/logdr",
    "/dev/socket/statsdw", "/dev/socket/traced_producer", "/dev/socket/traced_consumer",
    "/dev/socket/tombstoned_java_trace", "/dev/socket/tombstoned_crash",
    "/dev/socket/tombstoned_intercept", "/dev/socket/dnsproxyd", "/dev/socket/fwmarkd",
    "/dev/socket/prng_seeder", "/dev/socket/adbd", "/dev/socket/lmkd",
    "/dev/socket/mdns", "/dev/socket/zygote", "/dev/socket/syncboss_smp", 0
};

static int try_connect(const char *path, int type, int abstract){
    int fd = socket(AF_UNIX, type, 0);
    if (fd < 0) return -1;
    struct sockaddr_un a; memset(&a, 0, sizeof a);
    a.sun_family = AF_UNIX;
    if (abstract){
        a.sun_path[0] = 0;
        strncpy(a.sun_path + 1, path, sizeof(a.sun_path) - 2);
        socklen_t len = offsetof(struct sockaddr_un, sun_path) + 1 + strlen(path);
        int r = connect(fd, (struct sockaddr *)&a, len);
        int e = errno; close(fd); return r == 0 ? 0 : e;
    }
    strncpy(a.sun_path, path, sizeof(a.sun_path) - 1);
    int r = connect(fd, (struct sockaddr *)&a, sizeof a);
    int e = errno; close(fd); return r == 0 ? 0 : e;
}

static int mode_sock(void){
    int self = 0, ctl = 0;
    char name[64]; snprintf(name, sizeof name, "dsh.selftest.%d", (int)getpid());
    int ls = socket(AF_UNIX, SOCK_STREAM, 0);
    if (ls >= 0){
        struct sockaddr_un a; memset(&a, 0, sizeof a);
        a.sun_family = AF_UNIX; a.sun_path[0] = 0;
        strncpy(a.sun_path + 1, name, sizeof(a.sun_path) - 2);
        socklen_t len = offsetof(struct sockaddr_un, sun_path) + 1 + strlen(name);
        if (bind(ls, (struct sockaddr *)&a, len) == 0 && listen(ls, 1) == 0)
            self = try_connect(name, SOCK_STREAM, 1) == 0;
        close(ls);
    }
    int cfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (cfd >= 0){
        struct sockaddr_un a; memset(&a, 0, sizeof a);
        a.sun_family = AF_UNIX; strcpy(a.sun_path, "/data/local/tmp/ctl.sock");
        ctl = (bind(cfd, (struct sockaddr *)&a, sizeof a) == 0) ? 0 : errno;
        close(cfd); unlink("/data/local/tmp/ctl.sock");
    }
    printf("CONTROL abstract_selftest=%s\ttmp_bind_errno=%d (EACCES=13 expected)\n",
           self ? "OK" : "FAILED", ctl);
    if (!self) { printf("SELF-TEST FAILED -> results INVALID\n"); return 3; }

    int types[] = {SOCK_STREAM, SOCK_SEQPACKET, SOCK_DGRAM};
    const char *tn[] = {"STREAM", "SEQPACKET", "DGRAM"};
    for (int i = 0; SOCK_TARGETS[i]; i++){
        printf("%s", SOCK_TARGETS[i]);
        int denied = 0, live = 0;
        for (int t = 0; t < 3; t++){
            int e = try_connect(SOCK_TARGETS[i], types[t], 0);
            if (e == 0)      { printf("\t%s=REACHABLE", tn[t]); live++; }
            else if (e == 13 || e == 1) { printf("\t%s=EACCES", tn[t]); denied++; }
            else if (e == 111 || e == 2) { printf("\t%s=none", tn[t]); }
            else printf("\t%s=err%d", tn[t], e);
        }
        printf("\t=> %s\n", live ? (denied ? "REACHABLE+SOME-DENIED" : "REACHABLE")
                                 : (denied ? "DENIED" : "no-listener"));
    }
    return 0;
}

int main(int argc, char **argv){
    const char *m = argc > 1 ? argv[1] : "kgsl";
    if (!strcmp(m, "kgsl")) return mode_kgsl();
    if (!strcmp(m, "binder")) return mode_binder();
    if (!strcmp(m, "devs")) return mode_devs();
    if (!strcmp(m, "fam")) return mode_fam();
    if (!strcmp(m, "sock")) return mode_sock();
    fprintf(stderr, "unknown mode %s\n", m);
    return 2;
}

Part 3 — plumbing and the offline parsers

python264 linesexpandcollapse
# --------------------------------------------------------------------------------------
# shell plumbing
# --------------------------------------------------------------------------------------


class Ctx:
    def __init__(self, args):
        self.args = args
        self.results: dict = {
            "tool": "quest2_hunt.py",
            "version": VERSION,
            "started": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "host": os.uname().sysname if hasattr(os, "uname") else "unknown",
            "dry_run": bool(args.dry_run),
            "checks": [],
        }
        self.serial = None

    # -- process helpers ---------------------------------------------------------------
    def which(self, name):
        return shutil.which(name)

    def run(self, cmd, timeout=60, input_bytes=None):
        """Run a command, never raise. Returns (rc, stdout, stderr)."""
        if self.args.dry_run:
            print(f"  [dry-run] {' '.join(cmd)}")
            return 0, "", ""
        try:
            p = subprocess.run(cmd, capture_output=True, timeout=timeout,
                               input=input_bytes)
            return p.returncode, p.stdout.decode("utf-8", "replace"), p.stderr.decode("utf-8", "replace")
        except FileNotFoundError:
            return 127, "", f"{cmd[0]}: not found"
        except subprocess.TimeoutExpired:
            return 124, "", "timeout"

    def adb(self, *shell_args, timeout=60):
        cmd = ["adb"]
        if self.serial:
            cmd += ["-s", self.serial]
        cmd += list(shell_args)
        return self.run(cmd, timeout=timeout)

    def sh(self, command, timeout=60):
        return self.adb("shell", command, timeout=timeout)

    def add(self, check, status, detail, extra=None):
        rec = {"check": check, "status": status, "detail": detail}
        rec.update(extra or {})
        self.results["checks"].append(rec)
        print(f"  [{status:<7}] {check}: {detail}")
        return rec

    # -- device guard ------------------------------------------------------------------
    def need_device(self):
        if not self.which("adb"):
            self.add("device", "skipped", "adb not installed")
            return False
        rc, out, err = self.adb("get-state")
        state = (out or err).strip()
        if rc != 0 or "device" not in state or "unauthorized" in state:
            self.add("device", "skipped", f"no authorised device ({state or 'none'})")
            return False
        rc, out, _ = self.adb("get-serialno")
        self.serial = out.strip() or None
        self.results["serial_redacted"] = redact_serial(self.serial or "")
        self.add("device", "ok", f"attached, uid context follows")
        return True


# --------------------------------------------------------------------------------------
# text helpers, parsers and redaction
# --------------------------------------------------------------------------------------


def redact_serial(serial: str) -> str:
    if not serial:
        return ""
    if len(serial) <= 6:
        return serial[:2] + "*" * max(1, len(serial) - 2)
    return serial[:8] + "…" + serial[-2:]


def parse_getvar(text: str) -> dict:
    """`fastboot getvar all` puts '(bootloader) key: value' on every line."""
    out = {}
    for line in text.splitlines():
        m = re.match(r"\(bootloader\)\s+([^:]+):\s*(.*)$", line.strip())
        if m:
            out[m.group(1).strip()] = m.group(2).strip()
    return out


def parse_oem_device_info(text: str) -> dict:
    """Mixed formats across ABL builds: '(bootloader) key: value' or a bare 'key: value'."""
    out = {}
    for line in text.splitlines():
        s = line.strip()
        s = re.sub(r"^\(bootloader\)\s*", "", s)
        if ":" in s and not s.upper().startswith(("OKAY", "FAILED", "Sending")):
            k, v = s.split(":", 1)
            if k and len(k) < 60:
                out[k.strip()] = v.strip()
    return out


def parse_oem_help(text: str) -> list:
    """The device's own list of `oem` subcommands — authoritative over the binary.

    `fastboot oem help` prints one '(bootloader) oem <name>[ args]' line per command.
    Reading names out of the ABL binary instead is unreliable: this build's blob missed
    two commands the device exposes and listed two it does not register.
    """
    cmds = []
    for line in text.splitlines():
        m = re.match(r"^\(bootloader\)\s+oem\s+([a-z0-9][a-z0-9-]*)\b", line.strip())
        if m and m.group(1) not in ("COMMAND",):
            cmds.append(m.group(1))
    if not cmds:  # already-stripped output
        for line in text.splitlines():
            m = re.match(r"^oem\s+([a-z0-9][a-z0-9-]*)\b", line.strip())
            if m:
                cmds.append(m.group(1))
    # `oem help` is itself a registered command but appears as "usage: … oem COMMAND"
    if text.strip():
        cmds.append("help")
    return sorted(set(cmds))


def parse_settingsproxy_dump(text: str) -> dict:
    listeners, brightness, user = [], None, None
    for line in text.splitlines():
        s = line.strip()
        m = re.match(r"PID:\s*(\d+)", s)
        if m:
            listeners.append(int(m.group(1)))
        m = re.match(r"Screen brightness:\s*(\d+)", s)
        if m:
            brightness = int(m.group(1))
        m = re.match(r"Current user ID:\s*(\d+)", s)
        if m:
            user = int(m.group(1))
    return {"listener_pids": listeners, "brightness": brightness, "current_user": user}


# --------------------------------------------------------------------------------------
# AFL/AVB artefact parsing (no device required)
# --------------------------------------------------------------------------------------


def find_lzma_stream(blob: bytes):
    """abl.img carries an LZMA-alone stream inside an FF2 firmware volume.
    LZMA-alone starts with 0x5D 0x00 0x00 and a 4-byte little-endian dictionary size."""
    for m in re.finditer(b"\x5d\x00\x00", blob):
        off = m.start()
        if off + 13 > len(blob):
            continue
        dict_size = struct.unpack_from("<I", blob, off + 1)[0]
        if 4096 <= dict_size <= (1 << 26):
            try:
                dec = lzma.LZMADecompressor(format=lzma.FORMAT_ALONE)
                out = dec.decompress(blob[off:], max_length=8 << 20)
                if len(out) > 4096:
                    return off, out
            except lzma.LZMAError:
                continue
    return None, None


def scan_der_certs(blob: bytes) -> list:
    """Find DER SEQUENCEs that decode as X.509 by looking for the usual TBSCertificate shape.
    Cheap, no OpenSSL needed: a top-level 0x30 with a plausible length, containing 0x30 0x03 0x02 0x01."""
    certs = []
    n = len(blob)
    i = 0
    while i < n - 4:
        if blob[i] != 0x30:
            i += 1
            continue
        b1 = blob[i + 1]
        if b1 & 0x80:
            nb = b1 & 0x7F
            if nb < 2 or nb > 4 or i + 2 + nb > n:
                i += 1
                continue
            length = int.from_bytes(blob[i + 2:i + 2 + nb], "big")
            body = i + 2 + nb
        else:
            length = b1
            body = i + 2
        if 200 < length < 8192 and body + length <= n:
            chunk = blob[body:body + length]
            if chunk[:1] == b"\x30" and b"\x02\x01" in chunk[:8]:
                certs.append({"offset": i, "length": length + (body - i)})
                i = body + length
                continue
        i += 1
    return certs


def openssl_cert_summary(der: bytes) -> dict:
    if not shutil.which("openssl"):
        return {}
    try:
        p = subprocess.run(["openssl", "x509", "-inform", "DER", "-noout", "-subject", "-issuer",
                            "-dates", "-serial"], input=der, capture_output=True, timeout=20)
        if p.returncode != 0:
            return {}
        info = {}
        for line in p.stdout.decode("utf-8", "replace").splitlines():
            if "=" in line:
                k, v = line.split("=", 1)
                info[k.strip()] = v.strip()
        return info
    except Exception:
        return {}


def parse_vbmeta(blob: bytes) -> dict:
    """AVB0 header, big-endian. Offsets are fixed by the format, in this order:
    magic, required_libavb_version major/minor, auth block size, aux block size,
    algorithm type, then hash/signature/pubkey offset+size pairs."""
    if blob[:4] != b"AVB0":
        return {"error": "not an AVB0 image"}
    major, minor = struct.unpack_from(">II", blob, 4)
    auth, aux = struct.unpack_from(">QQ", blob, 12)
    alg = struct.unpack_from(">I", blob, 28)[0]
    hash_off, hash_size = struct.unpack_from(">QQ", blob, 32)
    sig_off, sig_size = struct.unpack_from(">QQ", blob, 48)
    key_off, key_size = struct.unpack_from(">QQ", blob, 64)
    return {
        "magic": "AVB0",
        "required_libavb_version": f"{major}.{minor}",
        "authentication_block_size": auth,
        "auxiliary_block_size": aux,
        "algorithm_type": alg,          # 1 = RSA-2048
        "hash": {"offset": hash_off, "size": hash_size},
        "signature": {"offset": sig_off, "size": sig_size},
        "pubkey": {"offset": key_off, "size": key_size},
    }


def parse_payload_properties(path: str) -> dict:
    props = {}
    with open(path, "r", encoding="utf-8", errors="replace") as fh:
        for line in fh:
            if "=" in line:
                k, v = line.strip().split("=", 1)
                props[k] = v
    for k in ("FILE_SIZE", "METADATA_SIZE"):
        if k in props:
            props[k] = int(props[k])
    return props


def sha256_b64(path: str, chunk=1 << 20) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        while True:
            b = fh.read(chunk)
            if not b:
                break
            h.update(b)
    return base64.b64encode(h.digest()).decode()

Part 4 — the checks

python514 linesexpandcollapse
# --------------------------------------------------------------------------------------
# checks — device side
# --------------------------------------------------------------------------------------


def check_fingerprint(ctx: Ctx):
    if not ctx.need_device():
        return
    props = ["ro.build.version.incremental", "ro.build.version.security_patch",
             "ro.build.fingerprint", "ro.build.type", "ro.product.device",
             "ro.boot.verifiedbootstate", "ro.boot.slot_suffix", "ro.boot.veritymode"]
    got = {}
    for p in props:
        rc, out, _ = ctx.sh(f"getprop {p}")
        got[p] = out.strip()
    rc, out, _ = ctx.sh("uname -a")
    got["uname"] = out.strip()
    ctx.results["fingerprint"] = got
    ctx.add("fingerprint", "ok",
            f"build {got['ro.build.version.incremental'] or '?'} / patch "
            f"{got['ro.build.version.security_patch'] or '?'}")


def check_context(ctx: Ctx):
    if not ctx.serial:
        return
    rc, out, _ = ctx.sh("id")
    rc2, sel, _ = ctx.sh("getenforce")
    ctx.results["context"] = {"id": out.strip(), "selinux": sel.strip()}
    ctx.add("context", "ok", f"{out.strip().split()[0] if out.strip() else '?'}, SELinux {sel.strip() or '?'}")


def check_bootloader(ctx: Ctx):
    if ctx.args.parse_log:
        text = open(ctx.args.parse_log, "r", encoding="utf-8", errors="replace").read()
        info = parse_oem_device_info(text)
        cmds = parse_oem_help(text)
        ctx.results["bootloader_from_log"] = {"device_info": info, "oem_commands": cmds}
        unlock = info.get("Device unlock allowed", info.get("unlock_ability", "?"))
        ctx.add("bootloader(log)", "ok",
                f"{len(cmds)} oem commands parsed, Device unlock allowed = {unlock}")
        if info:
            ctx.results["bootloader_from_log"]["unlock_allowed"] = unlock
        return

    if not ctx.which("fastboot"):
        ctx.add("bootloader", "skipped", "fastboot not installed")
        return
    rc, out, err = ctx.run(["fastboot", "devices"])
    if rc != 0 or not out.strip():
        ctx.add("bootloader", "skipped", "no device in fastboot/USB Update Mode")
        return

    rc, out, _ = ctx.run(["fastboot", "getvar", "all"], timeout=120)
    getvar = parse_getvar(out + err)
    ctx.results["getvar"] = getvar

    rc, out2, err2 = ctx.run(["fastboot", "oem", "device-info"], timeout=60)
    info = parse_oem_device_info(out2 + err2)
    ctx.results["oem_device_info"] = info

    rc, out3, err3 = ctx.run(["fastboot", "oem", "help"], timeout=60)
    cmds = parse_oem_help(out3 + err3)
    ctx.results["oem_commands"] = cmds

    unlock = info.get("Device unlock allowed", "unknown")
    arb = getvar.get("arb-index:0/1/2") or getvar.get("arb-index") or "?"
    ctx.add("bootloader", "ok",
            f"unlocked={getvar.get('unlocked','?')} secure={getvar.get('secure','?')} "
            f"arb={arb} unlock_ability={unlock} oem_cmds={len(cmds)}")


def check_nodes_and_families(ctx: Ctx):
    """Prefer the compiled probe; fall back to a pure-shell approximation."""
    rc, out, _ = ctx.sh("ls -lZ /dev 2>/dev/null | head -200")
    nodes = {}
    for line in out.splitlines():
        m = re.search(r"\s(c[w-]r[-w][-w][-w][-w])\s+\S+\s+\S+\s+\S+\s+(/\S+)", line)
        if m:
            nodes[m.group(2)] = m.group(1)
    ctx.results["dev_nodes_listed"] = len(nodes)

    probe = build_and_push_probe(ctx)
    if probe:
        for mode in ("devs", "fam"):
            rc, out, err = ctx.adb("shell", f"{probe} {mode}", timeout=120)
            lines = [l for l in (out or err).splitlines() if l.strip()]
            key = "probe_" + mode
            ctx.results[key] = lines
            openable = [l.split("\t")[0] for l in lines if "\tOPEN" in l or l.endswith("ok")]
            denied = [l.split("\t")[0] for l in lines if "denied" in l or "EACCES" in l]
            ctx.add(f"probe:{mode}", "ok", f"{len(openable)} open, {len(denied)} denied")
    else:
        ctx.add("probe", "skipped",
                "no zig/aarch64-gcc on this host: device-node and socket-family checks need "
                "the C probe (install zig, or build research/probe/devs + fam separately)")


def build_and_push_probe(ctx: Ctx) -> str:
    """Compile the C probe for aarch64 and push it. Returns the on-device path or ''."""
    if ctx.args.dry_run:
        print("  [dry-run] would compile quest2_probe.c and push to /data/local/tmp")
        return ""
    workdir = tempfile.mkdtemp(prefix="q2probe-")
    src = os.path.join(workdir, "quest2_probe.c")
    out = os.path.join(workdir, "quest2_probe")
    with open(src, "w") as fh:
        fh.write(PROBE_C)

    zig = shutil.which("zig")
    if not zig:
        # the rootless toolchain this project builds lives beside the workspace
        for cand in (os.environ.get("QUEST2_TOOLS", ""),
                     "/home/kali/bugbounty/vr/tools",
                     os.path.expanduser("~/bugbounty/vr/tools")):
            if cand and os.path.exists(os.path.join(cand, "bin", "zig")):
                zig = os.path.join(cand, "bin", "zig")
                break
    compilers = []
    if zig:
        compilers.append([zig, "cc", "-target", "aarch64-linux-musl", "-O2", "-static", src, "-o", out])
    compilers.append(["aarch64-linux-gnu-gcc", "-O2", "-static", src, "-o", out])
    for cc in compilers:
        if not shutil.which(cc[0]) and not os.path.exists(cc[0]):
            continue
        rc, stdout, stderr = ctx.run(cc, timeout=300)
        if rc == 0 and os.path.exists(out):
            rc, out_i, err_i = ctx.adb("push", out, "/data/local/tmp/quest2_probe")
            if rc != 0:
                ctx.add("probe:build", "skipped", f"push failed: {err_i.strip()[:120]}")
                return ""
            ctx.adb("shell", "chmod 755 /data/local/tmp/quest2_probe")
            ctx.add("probe:build", "ok", f"built with {cc[0]} and pushed")
            return "/data/local/tmp/quest2_probe"
        ctx.add("probe:build", "skipped", f"{cc[0]} failed: {(stderr or stdout).strip()[:160]}")
    ctx.add("probe:build", "skipped", "no zig or aarch64-linux-gnu-gcc on PATH")
    return ""


def check_kgsl(ctx: Ctx):
    if not ctx.serial:
        ctx.add("kgsl", "skipped", "no device")
        return
    probe = "/data/local/tmp/quest2_probe"
    rc, out, _ = ctx.sh(f"test -x {probe} && echo yes")
    if "yes" not in out:
        probe = build_and_push_probe(ctx)
    if not probe:
        ctx.add("kgsl", "skipped", "probe binary unavailable")
        return
    rc, out, err = ctx.adb("shell", f"{probe} kgsl", timeout=120)
    text = out or err
    lines = text.strip().splitlines()
    ctx.results["kgsl"] = lines
    accepted = [l for l in lines if l.startswith("drawctxt_create") and "errno=0" in l]
    eacces = [l for l in lines if "errno=13" in l]
    controls = [l for l in lines if l.startswith("control_unregistered")]
    policy = any("errno=13" in l for l in controls)
    ctx.add("kgsl", "ok",
            f"{len(accepted)} context(s) created, {len(eacces)} EACCES; "
            f"unregistered-ioctl control returned "
            f"{'EACCES => policy, not driver' if policy else 'ENOTTY => driver dispatch'}")


def check_binder(ctx: Ctx):
    if not ctx.serial:
        ctx.add("binder", "skipped", "no device")
        return
    probe = "/data/local/tmp/quest2_probe"
    rc, out, _ = ctx.sh(f"test -x {probe} && echo yes")
    if "yes" not in out:
        probe = build_and_push_probe(ctx)
    if not probe:
        ctx.add("binder", "skipped", "probe binary unavailable")
        return
    rc, out, err = ctx.adb("shell", f"{probe} binder", timeout=120)
    ctx.results["binder"] = (out or err).strip().splitlines()
    ctx.add("binder", "ok", "node-debug ioctl reachability recorded")


def check_services_and_config(ctx: Ctx):
    if not ctx.serial:
        ctx.add("surface", "skipped", "no device")
        return
    rc, s_list, _ = ctx.sh("service list | grep -c ':'")
    rc, c_list, _ = ctx.sh("cmd -l | grep -c ':'")
    rc, cfg, _ = ctx.sh("cmd device_config list")
    namespaces = sorted({l.split("/")[0] for l in cfg.splitlines() if "/" in l})
    flags = {}
    for ns, key in SECURITY_FLAGS:
        rc, val, _ = ctx.sh(f"cmd device_config get {ns} {key} 2>/dev/null")
        flags[f"{ns}/{key}"] = (val or "").strip() or "UNREADABLE"
    ctx.results["surface"] = {
        "binder_services": int(s_list.strip() or 0),
        "cmd_implementations": int(c_list.strip() or 0),
        "device_config_namespaces": len(namespaces),
        "device_config_flags_total": len([l for l in cfg.splitlines() if "/" in l]),
        "security_flags": flags,
    }
    ctx.add("surface", "ok",
            f"services={s_list.strip()} cmd={c_list.strip()} "
            f"device_config={len(namespaces)} namespaces")


def check_settingsproxy(ctx: Ctx):
    if not ctx.serial:
        ctx.add("settingsproxy", "skipped", "no device")
        return
    rc, dump, _ = ctx.sh("dumpsys settingsproxy")
    parsed = parse_settingsproxy_dump(dump)
    ctx.results["settingsproxy_dump"] = parsed
    ctx.add("settingsproxy", "ok",
            f"{len(parsed['listener_pids'])} user-switch listeners registered "
            f"(compare before/after a null-listener txn to see the leak)")


def check_auth(ctx: Ctx):
    if not ctx.serial:
        ctx.add("auth", "skipped", "no device")
        return
    probes = {}
    rc, out, _ = ctx.sh("content call --uri content://com.oculus.auth.control/ "
                        "--method get-all-accounts-info 2>&1")
    probes["auth_control"] = "refused" if "SecurityException" in out or "FBPermission" in out else "unexpected"
    rc, out, _ = ctx.sh("dumpsys package com.android.shell | grep -c MANAGE_LOCKSCREEN")
    probes["shell_holds_lockscreen_perm"] = (out or "").strip()
    rc, out, _ = ctx.sh("dumpsys settingsproxy | head -3")
    ctx.results["auth"] = probes
    ctx.add("auth", "ok", "auth providers refused the shell; lockscreen perm held by design")


def check_usb(ctx: Ctx):
    if not ctx.serial:
        ctx.add("usb", "skipped", "no device")
        return
    props = {}
    for p in ("sys.usb.controller", "sys.usb.config", "persist.ovr.usb.xrsp_enabled",
              "ovr.xrspd.streaming_active", "ovr.xrspd.ffs.ready"):
        rc, out, _ = ctx.sh(f"getprop {p}")
        props[p] = (out or "").strip()
    rc, mounts, _ = ctx.sh("cat /proc/mounts | grep -i -e usb-ffs -e functionfs")
    rc, perms, _ = ctx.sh("ls -ldZ /dev/usb-ffs /dev/usb-ffs/* 2>/dev/null")
    ctx.results["usb"] = {
        "props": props,
        "ffs_mounts": mounts.strip().splitlines(),
        "ffs_perms": perms.strip().splitlines(),
        "functionfs_mounted": bool(mounts.strip()),
    }
    verdict = "MOUNTED (unlink/rename will fail EBUSY)" if mounts.strip() else \
              "NOT a mountpoint — path replacement against xrspd is live"
    ctx.add("usb", "ok", f"functionfs {verdict}")



def check_fs(ctx: Ctx):
    """Filesystem negatives: setuid/setgid, file capabilities, world-writable files."""
    if not ctx.serial:
        ctx.add("fs", "skipped", "no device")
        return
    trees = "/system /vendor /odm /system_ext /product"
    rc, suid, _ = ctx.sh(f"find {trees} -xdev -perm -4000 -type f 2>/dev/null | wc -l", timeout=180)
    rc, sgid, _ = ctx.sh(f"find {trees} -xdev -perm -2000 -type f 2>/dev/null | wc -l", timeout=180)
    rc, ww, _ = ctx.sh(
        "find /vendor /odm /system_ext /system /product /data/vendor /data/misc "
        "-xdev -perm -o+w -type f 2>/dev/null | wc -l", timeout=240)
    rc, syst, _ = ctx.sh("stat -c '%A %U %G' /data/system 2>&1")
    ctx.results["fs"] = {"setuid": int(suid.strip() or -1), "setgid": int(sgid.strip() or -1),
                         "world_writable_files": int(ww.strip() or -1),
                         "data_system_perm": syst.strip()}
    ctx.add("fs", "ok",
            f"setuid={suid.strip()} setgid={sgid.strip()} world-writable={ww.strip()} "
            f"/data/system={syst.strip()}")


def check_procs(ctx: Ctx):
    """Which uid-1000 and root processes exist — the set a system-uid bug would land in."""
    if not ctx.serial:
        ctx.add("procs", "skipped", "no device")
        return
    rc, out, _ = ctx.sh("ps -A -o USER,PID,NAME 2>/dev/null")
    rows = []
    for line in out.splitlines()[1:]:
        parts = line.split()
        if len(parts) >= 3:
            rows.append({"user": parts[0], "pid": parts[1], "name": " ".join(parts[2:])})
    system = sorted({r["name"] for r in rows if r["user"] in ("system", "1000")})
    root = sorted({r["name"] for r in rows if r["user"] == "root"})
    interesting = [n for n in root if any(k in n for k in
                   ("perf", "update_engine", "apexd", "vold", "netd", "installd", "tombstoned"))]
    ctx.results["procs"] = {"total": len(rows), "uid1000": system, "root_interesting": interesting}
    ctx.add("procs", "ok",
            f"{len(rows)} processes, {len(system)} as uid 1000, {len(root)} as root")


def check_sockets(ctx: Ctx):
    """Which /dev/socket endpoints uid 2000 can actually connect to (probe, with controls)."""
    if not ctx.serial:
        ctx.add("sockets", "skipped", "no device")
        return
    probe = "/data/local/tmp/quest2_probe"
    rc, out, _ = ctx.sh(f"test -x {probe} && echo yes")
    if "yes" not in out:
        probe = build_and_push_probe(ctx)
    if not probe:
        ctx.add("sockets", "skipped", "probe binary unavailable")
        return
    rc, out, err = ctx.adb("shell", f"{probe} sock", timeout=180)
    text = out or err
    lines = text.strip().splitlines()
    ctx.results["sockets"] = lines
    invalid = any("SELF-TEST FAILED" in l for l in lines)
    reachable = [l.split("\t")[0] for l in lines if "REACHABLE" in l]
    denied = [l.split("\t")[0] for l in lines if "=> DENIED" in l]
    ctx.add("sockets", "fail" if invalid else "ok",
            f"{len(reachable)} reachable, {len(denied)} denied"
            + ("  <-- control failed, results INVALID" if invalid else ""))


def check_trace(ctx: Ctx):
    """Can perfetto/tracefs be turned into a kernel-address disclosure? (closed here)"""
    if not ctx.serial:
        ctx.add("trace", "skipped", "no device")
        return
    probes = {}
    rc, out, _ = ctx.sh("test -x /system/bin/perfetto && echo yes")
    probes["perfetto_present"] = "yes" in out
    rc, out, _ = ctx.sh("cat /sys/kernel/tracing/available_events 2>&1 | wc -l")
    probes["available_events_lines"] = (out or "").strip()
    rc, out, _ = ctx.sh("grep -c 'kmem' /sys/kernel/tracing/available_events 2>&1")
    probes["kmem_events"] = (out or "").strip()
    rc, out, _ = ctx.sh("cat /sys/kernel/tracing/available_tracers 2>&1 | head -1")
    probes["available_tracers"] = (out or "").strip()[:80]
    rc, out, _ = ctx.sh("cat /sys/kernel/tracing/printk_formats 2>&1 | head -1")
    probes["printk_formats"] = (out or "").strip()[:80]
    verdict = ("readable" if probes["available_events_lines"].isdigit()
               and int(probes["available_events_lines"]) > 0 else "empty/denied")
    ctx.results["trace"] = probes
    ctx.add("trace", "ok", f"available_events={verdict}, tracers={probes['available_tracers']}")


def check_dropbox(ctx: Ctx):
    """dumpsys dropbox is readable by shell -> a crash oracle for uid-1000 daemons."""
    if not ctx.serial:
        ctx.add("dropbox", "skipped", "no device")
        return
    rc, dump, _ = ctx.sh("dumpsys dropbox --print 2>/dev/null")
    entries = dump.count("\n")
    crashy = sum(dump.count(k) for k in ("system_app_crash", "SYSTEM_BOOT", "soft_error",
                                         "tombstone", "SYSTEM_FSCK"))
    rc, crash_buf, _ = ctx.sh("logcat -b crash -d 2>/dev/null | wc -l")
    rc, log, _ = ctx.sh("logcat -d -t 4000 2>/dev/null | grep -c 'avc:  denied'")
    rc, log2, _ = ctx.sh("logcat -d -t 4000 2>/dev/null | grep -c 'scontext=u:r:crcs'")
    ctx.results["dropbox"] = {"printable": entries > 0, "signatures": crashy,
                              "crash_buffer_lines": (crash_buf or "").strip(),
                              "avc_in_window": (log or "").strip(),
                              "crcs_denials_in_window": (log2 or "").strip()}
    ctx.add("dropbox", "ok",
            f"readable={entries > 0}, {crashy} known signatures, "
            f"crash buffer={crash_buf.strip()} lines")


def check_companion(ctx: Ctx):
    """The one measured shell -> uid-1000 crossing: CompanionService provisions Wi-Fi.
    Read-only here: we check the reachability, we do NOT add a network."""
    if not ctx.serial:
        ctx.add("companion", "skipped", "no device")
        return
    rc, out, _ = ctx.sh("cmd -l 2>/dev/null | grep -c CompanionService")
    listed = (out or "").strip()
    rc, wifih, _ = ctx.sh("cmd wifi add-network 2>&1 | head -3")
    ctx.results["companion"] = {
        "service_listed": listed, "wifi_hint": wifih.strip()[:200],
        "crossing": "cmd CompanionService -n <ssid> -k <pass> provisions as uid 1000",
        "executed": False,
    }
    ctx.add("companion", "ok",
            f"CompanionService listed={listed}, uid-1000 Wi-Fi provisioning reachable "
            f"(NOT executed: it would move the headset off its AP)")


# --------------------------------------------------------------------------------------
# checks — offline artefacts
# --------------------------------------------------------------------------------------


def check_abl(ctx: Ctx):
    path = ctx.args.img or "abl.img"
    if not os.path.exists(path):
        ctx.add("abl", "skipped", f"{path} not found")
        return
    blob = open(path, "rb").read()
    info = {"file": path, "size": len(blob)}

    off, dec = find_lzma_stream(blob)
    if dec:
        info["lzma_offset"] = off
        info["decompressed_size"] = len(dec)
        info["pe_headers"] = len(re.findall(rb"PE\x00\x00", dec))
    else:
        dec = blob
        info["lzma_offset"] = None

    certs = []
    # the three X.509 anchors sit in the *outer* ELF, not inside the decompressed FV —
    # scanning only the payload is the mistake that finds zero certificates
    for source_name, source in (("outer", blob), ("decompressed", dec)):
        for c in scan_der_certs(source):
            der = source[c["offset"]:c["offset"] + c["length"]]
            summary = openssl_cert_summary(der)
            certs.append({"in": source_name, "offset": c["offset"], "length": c["length"],
                          "subject": summary.get("subject", "?"),
                          "issuer": summary.get("issuer", "?"),
                          "notBefore": summary.get("notBefore", "?"),
                          "notAfter": summary.get("notAfter", "?")})
    info["certificates"] = certs

    strings = re.findall(rb"[ -~]{6,}", dec)
    interesting = set()
    for s in strings:
        t = s.decode("ascii", "replace")
        if re.match(r"^(oem )?[a-z][a-z0-9-]{4,40}$", t) and "-" in t:
            interesting.add(t)
    info["candidate_oem_names"] = sorted(interesting)
    info["fastboot_names"] = sorted({
        s.decode() for s in strings
        if s.decode(errors="ignore") in ("getvar:partition-type", "set_active", "continue",
                                         "reboot-fastboot", "reboot-recovery", "snapshot-update",
                                         "download:", "flash:", "erase:", "getvar:")
    })
    ctx.results["abl"] = info
    ctx.add("abl", "ok",
            f"{len(blob)} bytes, lzma@{info['lzma_offset']} -> {info['decompressed_size']} B, "
            f"{len(certs)} X.509 anchors, {len(interesting)} hyphenated oem-ish names")


def check_vbmeta(ctx: Ctx):
    path = ctx.args.vbmeta or os.path.join(os.path.dirname(ctx.args.img or "."), "vbmeta.img")
    if not os.path.exists(path):
        ctx.add("vbmeta", "skipped", f"{path} not found")
        return
    blob = open(path, "rb").read(128)
    ctx.results["vbmeta"] = parse_vbmeta(blob)
    ctx.add("vbmeta", "ok", f"magic={blob[:4].decode(errors='replace')} "
                            f"algo={ctx.results['vbmeta'].get('algorithm_type','?')}")


def download_payload(ctx: Ctx, url: str, dest: str, props: dict) -> bool:
    expected = props.get("FILE_SIZE")
    tmp = dest + ".part"
    have = os.path.getsize(tmp) if os.path.exists(tmp) else 0
    if expected and have == expected:
        os.replace(tmp, dest)
        return True
    headers = {"Range": f"bytes={have}-"} if have else {}
    req = urllib.request.Request(url, headers=headers)
    print(f"  downloading {url} -> {dest} (resume at {have} B)")
    with urllib.request.urlopen(req, timeout=60) as resp, open(tmp, "ab") as fh:
        total = int(resp.headers.get("Content-Length", 0)) + have
        done = have
        last = 0
        while True:
            chunk = resp.read(1 << 20)
            if not chunk:
                break
            fh.write(chunk)
            done += len(chunk)
            if time.time() - last > 2:
                pct = (done / total * 100) if total else 0
                print(f"    {done/1e6:7.1f} MB / {total/1e6:7.1f} MB  ({pct:5.1f}%)", end="\r")
                last = time.time()
    print()
    if expected and os.path.getsize(tmp) != expected:
        ctx.add("ota:size", "fail",
                f"size {os.path.getsize(tmp)} != FILE_SIZE {expected}")
        return False
    os.replace(tmp, dest)
    return True


def check_ota(ctx: Ctx):
    if not ctx.args.url:
        ctx.add("ota", "skipped", "no --url given")
        return
    dest = ctx.args.out_bin or "payload.bin"
    props = {}
    if ctx.args.props and os.path.exists(ctx.args.props):
        props = parse_payload_properties(ctx.args.props)
    if ctx.args.dry_run:
        ctx.add("ota", "skipped", f"dry-run: would download {ctx.args.url} -> {dest}")
        return
    if not download_payload(ctx, ctx.args.url, dest, props):
        return
    got = {"downloaded": dest, "size": os.path.getsize(dest)}
    if props.get("FILE_HASH"):
        got["sha256_b64"] = sha256_b64(dest)
        got["hash_match"] = (got["sha256_b64"] == props["FILE_HASH"])
    ctx.results["ota"] = got
    ctx.add("ota", "ok", f"{got['size']} bytes, hash_match={got.get('hash_match')}")

    try:
        import payload_dumper  # noqa: F401  (installed by the user when available)
    except Exception:
        ctx.add("ota:extract", "skipped",
                "payload_dumper not importable — `pipx install payload-dumper-go` or run "
                "payload_dumper manually on payload.bin")
        return
    outdir = ctx.args.out_dir or "out"
    os.makedirs(outdir, exist_ok=True)
    rc, out, err = ctx.run([sys.executable, "-m", "payload_dumper", "--out", outdir, dest],
                           timeout=1800)
    tail = (out or err).strip().splitlines()
    ctx.add("ota:extract", "ok" if rc == 0 else "fail",
            f"partitions [{ctx.args.partitions}] -> {outdir}: "
            f"{tail[-1][:120] if tail else 'done'}")

Part 5 — reporting and the CLI

python105 linesexpandcollapse
# --------------------------------------------------------------------------------------
# reporting
# --------------------------------------------------------------------------------------


def write_report(ctx: Ctx, path: str):
    ctx.results["finished"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(ctx.results, fh, indent=2, sort_keys=False)
    print(f"\nreport -> {path}")


def summary(ctx: Ctx):
    print("\n" + "=" * 78)
    print(f"quest2_hunt {VERSION} — {len(ctx.results['checks'])} checks")
    for c in ctx.results["checks"]:
        print(f"  {c['status']:<8} {c['check']:<22} {c['detail'][:60]}")
    print("=" * 78)
    print("read-only. Nothing on the device was modified.")


# --------------------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------------------


def main(argv=None):
    ap = argparse.ArgumentParser(
        prog="quest2_hunt.py",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description="Read-only Quest 2 (hollywood) bug-hunt checks + firmware artefact parsers.",
        epilog=textwrap.dedent("""\
            examples:
              ./quest2_hunt.py all
              ./quest2_hunt.py fingerprint bootloader
              ./quest2_hunt.py bootloader --parse-log fastboot-oem.log
              ./quest2_hunt.py abl --img abl.img --vbmeta vbmeta.img
              ./quest2_hunt.py ota --url https://host/payload.bin --props payload_properties.txt
        """))
    ap.add_argument("checks", nargs="*", default=["all"],
                    help="all | fingerprint | context | bootloader | fs | procs | nodes | kgsl | "
                         "binder | sockets | surface | settingsproxy | auth | usb | trace | "
                         "dropbox | companion | abl | vbmeta | ota")
    ap.add_argument("--serial", help="adb serial to target")
    ap.add_argument("--json", default=REPORT_DEFAULT, help="report path")
    ap.add_argument("--dry-run", action="store_true", help="print the plan, touch nothing")
    ap.add_argument("--parse-log", help="parse a saved fastboot log instead of a live device")
    ap.add_argument("--img", help="abl.img path")
    ap.add_argument("--vbmeta", help="vbmeta.img path")
    ap.add_argument("--url", help="payload.bin URL for --checks ota")
    ap.add_argument("--props", help="payload_properties.txt for size/hash verification")
    ap.add_argument("--out-bin", help="where to write payload.bin")
    ap.add_argument("--out-dir", help="where to extract partitions")
    ap.add_argument("--partitions", default="abl,vbmeta", help="partitions to extract")
    args = ap.parse_args(argv)

    ctx = Ctx(args)
    ctx.serial = args.serial
    wanted = [c.lower() for c in args.checks]
    if "all" in wanted:
        wanted = ["fingerprint", "context", "bootloader", "fs", "procs", "nodes", "kgsl",
                  "binder", "sockets", "surface", "settingsproxy", "auth", "usb",
                  "trace", "dropbox", "companion"]

    print(f"quest2_hunt {VERSION} — {'dry-run' if args.dry_run else 'live'}")
    table = {
        "fingerprint": check_fingerprint,
        "context": check_context,
        "bootloader": check_bootloader,
        "nodes": check_nodes_and_families,
        "kgsl": check_kgsl,
        "binder": check_binder,
        "surface": check_services_and_config,
        "settingsproxy": check_settingsproxy,
        "auth": check_auth,
        "usb": check_usb,
        "fs": check_fs,
        "procs": check_procs,
        "sockets": check_sockets,
        "trace": check_trace,
        "dropbox": check_dropbox,
        "companion": check_companion,
        "abl": check_abl,
        "vbmeta": check_vbmeta,
        "ota": check_ota,
    }
    for name in wanted:
        fn = table.get(name)
        if not fn:
            print(f"  [skipped] {name}: unknown check")
            continue
        print(f"\n-- {name}")
        try:
            fn(ctx)
        except Exception as exc:                    # never let one check kill the run
            ctx.add(name, "error", f"{type(exc).__name__}: {exc}")

    summary(ctx)
    if args.json:
        write_report(ctx, args.json)
    return 0


if __name__ == "__main__":
    sys.exit(main())

The hardware: what the board gives you, and what it does not

Everything above is software on a device I could reach over ADB. The obvious next question is the one
people ask when a headset refuses to be rooted: what about JTAG, UART, EDL? So I went and read what has
actually been published about this board — credit again to Pachvara's Oculus2 series, which is the only serious public hardware work on a Quest 2.

The short version: the hardware is open to its owner and closed as an escalation path. The interfaces
exist, some have been fully exercised, and none of them defeats secure boot.

UART: it is there, and somebody has already read the boot log

The Quest 2 SoC is an SM8250 (Snapdragon XR2). Upstream sm8250.dtsi marks qcom,geni-debug-uart on
only UART2 and UART12; the published work landed on UART12 = GPIO34 (TX) / GPIO35 (RX), traced by
desoldering the SoC and multimetering the pads, and found a whole debug connector on the mainboard —
it also carries VOL UP, VOL DOWN and POWER. The connector to solder is a Hirose FH58M-7S-0.25SHW
(7-pin, 0.25 mm pitch).

What comes out is the XBL early-boot log, and it is worth reading because the bootloader says out loud
what we spent hours inferring:

text9 lines
S - QC_IMAGE_VERSION_STRING=BOOT.XF.3.2-00280-SM8250-3
S - Boot Interface: UFS
S - Secure Boot: On                       <-- stated by the bootloader itself
S - JTAG ID @ 0x00786130 = 0x200ce0e1
S - OEM ID  @ 0x00786138 = 0x01370001
S - Serial Number @ 0x00786134 = 0x2f8397f2
S - Feature Config Row 0 @ 0x007841f8 = 0x0040200000000400   <-- the fuse rows
S - Feature Config Row 1 @ 0x00784200 = 0xc000000000000000
B -    387441 - UFS INQUIRY ID: WDC     SDINDDH4-64G    1308

Feature Config Row 0/1 are the fuse rows — the place Qualcomm encodes which debug features are fused
off. On a DVT unit like mine those are exactly the bits I would want to compare against a retail
board, and they are readable with a soldering iron and a USB-UART rather than a bug.

JTAG: pins yes, debug port no

The test-point map does identify SoC JTAG pins, and the follow-up experiment is the honest one: a
JTAGulator IDCODE scan happily reports a chain (Device ID #1 = 0xC0B00037, TDO/TCK/TMS identified), then
BYPASS fails, IR/DR discovery complains about multiple devices, and OpenOCD gets nothing at all. The
conclusion matches how Qualcomm parts ship: JTAG is disabled by an OTP bit. A TAP that answers an
IDCODE while its debug port is dead is a red herring, and it is exactly the kind of "it responded!"
signal that gets written up as working.

EDL: you can get in; you cannot do anything

Our ABL registers oem reboot-edl and oem enable-dload-mode, and the community boot-mode combo is
VOL DOWN + VOL UP + POWER, after which the unit enumerates as 05c6:9008 Qualcomm HS-USB
QDLoader
. Entry is not the gate.

The programmer is. From a Quest 3 thread on XDA, after a year of community effort on the same OEM key
family: the Sahara handshake works, and then every public firehose loader is rejected with
signature mismatch (Sahara error 0x29) because the PK hash is Meta's OEM key, not the generic one. So
EDL without a Meta-signed prog_firehose_ddr-class programmer is a viewer with no hands. That also
settles the claim I had written down as fact earlier — "EDL needs signed firehose" was a guess; it now has
community evidence behind it.

The flash: readable, with a desoldering station

Storage is a WDC SDINDDH4 UFS part in VFBGA-153, with the UFS differential pairs going straight to the
SoC. The published work dumped the entire chip with a Medusa PRO II Box — a commercial UFS programmer.
So with physical access and the right bench, the whole flash (userdata included) is readable independent
of the boot chain. That is the strongest "you own the hardware" answer available, and the reason it will
never be a bounty.

The controller, and my missing right one

The Touch controllers are nRF52832. The pads are all there: TP3/TP4 are SWDIO/SWCLK, TP1/TP2
are the nRF UART — but the SWD lines are physically disconnected and have to be reconnected by
soldering 0 Ω links at R1 and R2 under the battery label. APPROTECT is enabled, and nRF52 has no
verified boot, so the only legitimate unprotect is a full erase; the published work instead used the known
voltage-glitch APPROTECT bypass (this controller is an older silicon revision that is vulnerable) and
extracted the firmware.

Which is the useful part for me: a used right controller is a cheap purchase, but a controller can also be
dumped and its BLE protocol reverse-engineered, which makes a spoofed controller a plausible — if
fiddly — way to satisfy "primary controller connected" on a unit that has none.

The one thing this unit might add

Our ABL implements oem enable-linux-console / disable-linux-console — and the device reports
linux-console-enabled: 0 in oem device-info. That is the firmware admitting there is a console state
bit, on a board where the debug connector has already been located and the UART captured. It is the one
place where our software findings and the published hardware work point at the same thing, and it costs
one state-changing bootloader command plus a container of hot air to test.

But be clear about what a console on a secure-boot device is: a log viewer, not a bootloader patch. The
fuse still decides which image boots.

Interface Exists Gives Gate
Debug UART (UART12) yes, connector identified boot logs, fuse rows, possibly a console soldering; console likely gated by the linux-console bit
SoC JTAG pins present CPU/memory debug OTP-disabled (measured)
EDL / dload yes, oem reboot-edl + button combo raw UFS read/write with a programmer Meta-signed firehose
Off-chip UFS yes the whole flash desolder + commercial programmer (done publicly)
Controller SWD/UART pads, cut by 0 Ω nRF52832 firmware APPROTECT, glitch-bypassable on old silicon

What I would do next

In order, and the first two are decisions rather than measurements:

  1. Decide whether to attempt oem unlock. Device unlock allowed: true, the handler refuses with
    Meta's authorization URL, and the cost of a wrong guess is the userdata partition. If a token path
    exists, this whole post becomes archaeology; if it does not, that is worth knowing definitively.
  2. Stop treating EDL as an open question. The community evidence on the same OEM key family is that a
    Meta-signed firehose programmer is required, and nobody has one; on this unit EDL is a viewer, not a
    writer. The remaining question is only whether a signed programmer can be obtained (leak, partition
    dump, or root), which is not a device experiment.
  3. Test SCTP from an app domain. SCTP is compiled in; the gate is shell's policy. One harness re-sign
    and I know whether that whole CVE family is live for an installed app.
  4. Settle /dev/usb-ffs. One cat /proc/mounts. If xrsp is not a mountpoint, the shell-writable
    parent is a file-confusion primitive aimed at a uid-1000 daemon.
  5. Trace the device_config consumers. Pure static work, no device risk, and one re-reading consumer
    turns a writable knob into a real permission bypass.
  6. Build the Pi that impersonates an XRSP peer and fuzz xrspd's descriptor walk.
  7. Test the console hypothesis — oem enable-linux-console 1, power-cycle, watch the debug connector
    (owner decision; state-changing but reversible).
  8. Buy a second unit — ideally one whose build appears in the exploit target list. Every ⚠ item on
    this list is blocked only by having one headset.

What I would not do is run the binder race on this unit, or the GPU privilege-boundary experiment that
can reset the S3D. Both are written, neither is run, and the arithmetic says neither can pay here: a
NULL-store primitive with no leak is a panic generator, and a GPU reset on the only device is a lost
device.


Credits, disclosure, and the honest bit

Disclosure: no vulnerability report was filed and nothing was submitted. The two candidate reports
were disproved by my own verification — one by re-running the call from an app context, one by reading
the daemon instead of the interface — and I have kept both retractions in this post rather than deleting
them. This was my own hardware, there were no third-party targets, and the device was left in the state
it was found in, minus four listener entries in a system process and one installed probe app, both
documented.

The honest bit. I did not root the headset. I found five real defects, none of which escalates, and I
proved a GPU primitive that has nowhere to point. The most useful thing this night produced is not a bug
— it is the discipline that turned a week of wrong conclusions into a list of measured negatives, and the
single habit that did it: run the control. Vary the value. Ask for an ioctl that cannot exist. Re-run
it from the other context. Read the fix instead of the title. Disassemble the binary instead of trusting
the tag. Every wrong conclusion in this post came from one measurement without a control, and every
correction came from adding one.

If you own one of these and you are thinking about it: do not put it online. Not once. The window closes
on a reboot and the fuse does not reopen.

Thanks to the people whose tools this would not exist without — Singularity and the FreeXR community,
Private Quest, and the public write-ups on the Adreno and binder bugs that pointed me at the right
addresses.

normal~/dtrh.net│Two Error Codes and a Fuse: Hunting Exploitable Bugs on a Quest 2