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
- What I was actually looking for
- First mistake: I put the headset online
- The gate was the patch itself
- So was the Bluetooth thing a real bug?
- Forty minutes too late
- The fuse does not care about your flags
- Two error codes that were hiding a GPU
- The write that went nowhere
- 1,340 kernel commits later
- Binder: the best-looking dead end of the night
- What
adb shellcan really do - One more pass: sockets, perfetto, and a real privilege crossing
- So which bugs did I actually find?
- The bootloader question I never asked
- USB-C: the one place the attacker is on the other end
- The tool: quest2_hunt.py
- The hardware: what the board gives you, and what it does not
- What I would do next
- 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:
- Fingerprint the firmware precisely enough that every claim keys off a build string.
- Map what
u:r:shell:s0(uid 2000) can actually reach — syscalls, socket families, device nodes,
binder services,cmdimplementations, settings. - 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. - 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:
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 answeringDeveloperMode: 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:
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:
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 aSecureRandom, so it is not even consistent between attempts.
Three things follow, and they are worth separating:
A self-integrity check inside the attacker's own client protects nothing. Anyone with
apktoolremoves it in four lines of smali. Mine ended up being: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.The device side was right. The
DeviceKeyis 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.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:
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:
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 theabl 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)
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:
bics wzr, w8, w23 sets flags on w8 & ~w23, so the branch fires when flags is missing any bit of0x12 — 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.
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:
An unregistered ioctl cannot produce EACCES: dispatch would miss and the syscall layer would returnENOTTY. 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); andkgsl_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)
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 setsKGSL_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 andIB3 & 3 == 3.
Then I went looking for the other half — a kernel address to aim at — and there wasn't one:
- every
getpropertytype dumped, - all 42 kernel→user copy sites in kgsl enumerated mechanically and each shown to clamp,
- the one
*(kernel_ptr)+8disclosure site found dead because both slots are NULL at runtime, useraddrcompiled to a literal zero,- sysfs/proc/tracefs swept for pointer-shaped values (none),
- and
/proc/kallsymsdenied 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.
Two of the best candidates died on config lines rather than arguments, for the price of one zcat:
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:
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 throughcall_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 andfixup_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:
I then spent a day believing they were unreachable, because "shell cannot own an addressablebinder_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)
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 isregisterUserSwitchListener, and uid 1000 — system_server — calls straight back into my binder:
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:
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.
- 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. - No kernel-address disclosure exists anywhere on the reachable surface (see above).
- 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,164onTransactimplementations: 37 interfaces take a binder and return
one, 13 are shell-reachable, and every single one is a factory (register()returnsnew Binder("UidObserver-…"), owned by system_server). The only genuine store-and-retrieve pairs are
ownership-gated, andattachApplicationLockedkills 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 withParcel: 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; andBC_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, includingsignature|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" (enforceCallingOrSelfPermissioninstead ofenforceCallingPermission). Measured: the permission issignature|privilegedanddumpsys package com.android.shellshowsgranted=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
productionuserbuild). 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 readsallowwithinstallerPackageName=null. No dialog, no user action. device_configas 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 aspersist.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 callsenforceTrustedCaller() 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:
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 returnsEPROTOTYPE, 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:
Two vendor-side defects, neither a lever
crcs(/system_ext/bin/crcs, a system daemon) is permanently deniedreadonu: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 --printis readable from shell (44 entries here). That is a useful crash oracle
for uid-1000 daemons: it showssystem_app_crashforcom.oculus.presence, a pile ofsoft_error
records from the vrshell guardian,SYSTEM_BOOTandSYSTEM_FSCK. No native tombstones, andlogcat -b crashis 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
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:
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:
- 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 theXrspBrokermissing check and the accessibility path are hardening notes rather than vulnerabilities. - 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 anormal-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:
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:
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 noflashing/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:
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 andoem 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:
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.
Three findings, in descending order of how confident I am about them:
xrspdruns as uid 1000 and parses attacker-supplied USB descriptors in host mode. Itsusb_device_addedwalks descriptors looking for interface class0x8a, protocol1, and reads fixed
offsets without an obviousbLengthpre-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.XrspBrokerhands out nothing. I wanted the FD handoff to be reachable from the ungatedacquireUsb*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_hdrFDE table showed that no ungated path ever calls theIXrspStreamingClient::onSessionStartedvtable slot. The FD handoff stays behind the gated session.
Missing-authorization inconsistency; not a capability transfer.- EDL is a first-class ABL feature and I do not know whether it is authenticated.
oem reboot-edl
andoem enable-dload-modeare registered and dispatch;device-infosaysDownload 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.
| 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:
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
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 forzig 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
Part 3 — plumbing and the offline parsers
python264 linesexpandcollapse
Part 4 — the checks
python514 linesexpandcollapse
Part 5 — reporting and the CLI
python105 linesexpandcollapse
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:
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 reportslinux-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:
- 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. - 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. - 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. - Settle
/dev/usb-ffs. Onecat /proc/mounts. Ifxrspis not a mountpoint, the shell-writable
parent is a file-confusion primitive aimed at a uid-1000 daemon. - Trace the
device_configconsumers. Pure static work, no device risk, and one re-reading consumer
turns a writable knob into a real permission bypass. - Build the Pi that impersonates an XRSP peer and fuzz
xrspd's descriptor walk. - Test the console hypothesis —
oem enable-linux-console 1, power-cycle, watch the debug connector
(owner decision; state-changing but reversible). - 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.
