li r3, 1: An Xbox 360 Exploit Chain

25 min read ·

I loved Halo Reach growing up. It defined an entire era of my childhood in a way that no other game has since - nothing beats 10-year-old me screaming down a one-ear headset at teammates 2-3x my age. So when a friend gave me a Halo Reach Limited Edition Xbox 360 S, I knew I had to do something with it.

The console came with a 250GB HDD and dashboard version 2.0.17526.0. I planned to RGH3 (Reset Glitch Hack 3 - a hardware mod that glitches the CPU during boot to bypass signature checks) it later, but ended up exploiting it the same day with BadUpdate - a software-only hypervisor exploit requiring zero soldering and zero NAND (the flash memory chip that stores the console's firmware and bootloaders) modification. BadUpdate achieves unsigned code execution through a race condition against the hypervisor's LZX (Lempel-Ziv extended, Microsoft's compression algorithm) decompressor, using an encrypted memory side channel as a timing oracle.

The full chain runs from a stack overflow in an avatar name field to hypervisor shellcode, then through the patches XeUnshackle applies to the security chain.

Trinity

The Xbox 360 went through seven motherboard revisions over its lifespan - Xenon, Zephyr, Falcon, Jasper, Trinity, Corona and Winchester - where Xenon through Jasper are the original "phat" chassis and Trinity was the first slim board, the 360 S redesign that shipped in 2010 with a smaller chassis, integrated WiFi and a single centrifugal blower replacing the dual-fan setup. You can identify the board revision in code via XboxHardwareInfo->Flags & 0xF0000000, where Trinity returns 0x40000000.

The Reach Limited Edition is a Trinity; Trinity has stable glitch timing for RGH3 and falls inside the dashboard range BadUpdate targets. The console still had old user data on it, so I formatted the drive and created an offline profile from the Guide menu since it was greyed out on the main dashboard.

Dashboard 17559

BadUpdate works on dashboard version 2.0.17559.0, an Xbox 360 system update released November 2019; the version is load-bearing since each address in the exploit is hardcoded for this exact kernel build, including the ROP gadgets, syscall ordinals, function offsets and patch locations. KernelConfig_Retail_17559.asm in the BadUpdate source is a 270-line file of nothing but hardcoded addresses like stack_pivot at 0x81725378, call_func_preload at 0x8169CDDC and XPhysicalAlloc at the game-specific address.

My console was on 17526, so I grabbed the 17559 USB update from archive.org, put the $SystemUpdate folder on a FAT32 stick and let the console pick it up on boot.

BadUpdate

BadUpdate is a software-only hypervisor exploit targeting dashboard 17559, written by Grimdoomer, requiring no soldering, no NAND dumping and no hardware mods; you plug in a USB stick, boot the console and get unsigned code execution.

The same exploit has multiple entry points:

  • BadUpdate (original) - requires owning Tony Hawk's American Wasteland or Rock Band Blitz, where a crafted save file triggers a stack buffer overflow when loaded in-game. - ABadAvatar by shutterbug2000 - requires no game at all, since a crafted Xbox 360 avatar profile triggers a buffer overflow in the dashboard's avatar rendering pipeline from the sign-in screen. - ABadMemUnit / ABadAvatarHDD - the same avatar exploit targeting different storage devices.

I went with ABadAvatar since I didn't own either supported game; I used BadStick to automate the USB setup - a C# WinForms tool that formats the drive, downloads the exploit packages and homebrew from GitHub releases and extracts everything into the correct directory layout, serving as a provisioning tool rather than an exploit itself.

Encrypted Memory and the LZX Race

The Xbox 360's hypervisor provides a syscall called HvxKeysExecute (ordinal 0x42) that processes XKE (Xbox Kernel Extension) update payloads by taking a buffer of LZX-compressed data and decompressing it; this decompression happens in encrypted memory - memory that the CPU encrypts and decrypts via a transparent hardware encryption engine, where the hypervisor sees plaintext but usermode code with access to the physical backing pages can observe the ciphertext.

The encryption uses per-page "whitening" values that rotate through 1024 possible slots, so if you know what plaintext the hypervisor is about to write and you can observe the corresponding ciphertext, you can predict the ciphertext for any plaintext at that whitening value; you've built an oracle.

The LZX decompressor maintains internal state including a pointer called dec_output_buffer that controls where decompressed data gets written; that pointer lives in the same encrypted memory region. If you can replace the ciphertext for that pointer with ciphertext that decrypts to an address you control - say, a location inside the hypervisor's own code pages - the decompressor writes its output into the hypervisor. The whole exploit is a race condition against the decompressor, using an encrypted memory side channel as the timing oracle.

Stack Overflow to ROP

For the original BadUpdate, the entry point is a stack buffer overflow in Tony Hawk's American Wasteland's gap name parser, where the save file places crafted data at file offset 0xDF4 which lands on the heap at 0xB43B682E; the crafted data overflows the gap name buffer with 60 bytes of padding followed by controlled register values for r23-r31 and a link register value pointing to a stack_pivot gadget at 0x81725378 in xam.xex (the Xbox Auxiliary Module, the dashboard's core system library):

lwz  r1, 0(r1)    ; redirect the stack pointer to attacker-controlled data
lwz  r12, -8(r1)
mtlr r12
blr

This pivots execution into the ROP (Return-Oriented Programming - chaining together existing code fragments via their return instructions) chain; control of the instruction stream is now in attacker hands.

For ABadAvatar, which is the route I took, the entry point differs but the mechanism is identical; instead of a save file, a malformed avatar item whose name field overflows a fixed-size stack buffer during avatar rendering overwrites the same saved registers and link register, hits the same stack_pivot gadget and enters the same ROP pipeline. The avatar data lands at heap address 0x43AB9AC8, gets relocated to 0x43670000 from what the source comments call a "comically large allocation" by the avatar system; the oracle builder loads from there. ABadAvatar fires from the dashboard's profile selection screen the moment the console renders the crafted avatar, requiring no game disc and no user interaction.

Building the Ciphertext Oracle

The oracle builder constructs a lookup table mapping encrypted memory values to their replacements, so the race attack can swap a pointer in the decompressor's state before it reads it.

The oracle builder is a long ROP chain, where each operation chains together existing code fragments in xam.xex and the kernel, using the stack to control execution flow; there's no injected code yet because the exploit hasn't achieved that capability, so it's using the console's own code against itself.

The ROP chain builds the ciphertext oracle needed for the race attack through a three-step process:

  1. Load bootanim.xex (the boot animation) via XexLoadImage, whose code pages land at a predictable virtual address (0x90110000); capture 16 bytes of known plaintext from this address.

  2. Exhaust all 1024 whitening values by allocating and freeing encrypted memory regions in a loop at virtual address 0x8D000000 via HvxEncryptedReserveAllocation/HvxEncryptedReleaseAllocation. Each cycle increments the whitening counter and captures two ciphertexts: the LZX decoder's context header (signature 'CIDL', window size 0x8000, CPU type 1) - the canary - and a malicious dec_output_buffer pointer targeting 0x80000106.00030940 inside the hypervisor's last segment - the replacement. That target is the base at 0x80000106.00030000 plus 0x940 (HV_SEG3_OVERWRITE_OFFSET - BLOCK_14_TARGET_OFFSET), so that block 14's decompressed data starting at internal offset 0x15E8 lands at HV offset 0x1F28.

  3. Store everything in a lookup table indexed by the top 10 bits of the ciphertext, giving 1024 slots for canaries and 1024 slots for replacements.

Capturing the ciphertext for a known plaintext relies on the encrypted allocation's dual mapping: the physical page behind the encrypted virtual address is also reachable through an unencrypted alias. The exploit writes its plaintext through the encrypted address, flushes the cache with KeFlushCacheRange, then reads the alias back with an ordinary memcpy - that read is the raw ciphertext of whatever it just wrote. Same bytes, plaintext through one mapping and ciphertext through the other.

The ROP chain runs in a dual-buffer bounce loop to handle its indefinite runtime, where each iteration copies its gadget data to an alternate buffer and stack-pivots to it so the chain can keep loading and unloading bootanim.xex until the whitening value matches without overwriting itself across iterations.

Racing the Decompressor

With the lookup table built, the exploit launches two threads: one hammers HvxKeysExecute to trigger decompression, the other watches the ciphertext and swaps the decompressor's output pointer the moment the timing is right, redirecting decompressed data into hypervisor memory.

The race itself is actual PPC (PowerPC, the Xbox 360's CPU architecture) machine code rather than ROP; since GCC couldn't produce working output for this target, the author hand-assembled it. It runs at 0x90110000 in the boot animation's memory space, overwritten with the race payload using the ciphertext captured by the oracle builder.

Before starting the race, the code calls KeLockL2 twice to lock 256KB of L2 cache each time with trash data, forcing the encrypted memory's ciphertext to evict to main memory faster where the monitoring thread can observe changes. It also writes 0x66666666 to MmPhysical64KBMappingTable at 0x801C1000 to make the hypervisor's encrypted segments observable at virtual address 0xA0000000.

Then it launches two threads on separate hardware cores:

Thread 1 (hardware thread 1) runs HvxKeysExecute in a tight loop, copying clean payload data, calling the syscall and checking if the ciphertext at the target hypervisor location changed; each failed attempt returns 0xC8000012 (the corrupted pointer causes the LZX decompressor to abort); 0xC8000006 means a block was overwritten, which the code then verifies was block 14 by comparing ciphertexts.

Thread 0 (hardware thread 0) runs the tightest possible loop. The C reference implementation (BadUpdatePoc.cpp) describes a more elaborate version with a 1024-slot hash table lookup and a 1.5-million-cycle delay, but the hand-assembled binary simplifies this to a direct single-canary comparison with no delay loop:

loop:
    ld      r11, 0(r31)           ; load ciphertext from scratch buffer header
    cmpld   cr6, r11, r30         ; does it match the canary?
    bne     cr6, flush
        mtctr   r25               ; hammer 100,000 writes
overwrite:
        std     r29, 0x20(r26)    ; overwrite dec_output_buffer ciphertext
        std     r28, 0x28(r26)
        dcbst   r0, r26           ; flush cache line to main memory
        bdnz    overwrite
flush:
    dcbf    r0, r31               ; flush scratch buffer from cache
    b       loop

HvxKeysExecute starts decompressing and the LZX decoder writes its context header ('CIDL') into the scratch buffer early in the process. Thread 0 detects this by comparing the ciphertext against a pre-loaded canary in r30, then hammers 100,000 writes to replace the dec_output_buffer pointer at offset 0x2B28 in the scratch buffer with the pre-computed replacement that decrypts to the hypervisor address.

If the timing is right, the decompressor reads the attacker's pointer and writes its output into hypervisor code space.

The exploit targets block 14 of the compressed data because block 14 is the smallest block in the file at 0x1AD0 bytes, which gives the widest race window; its decompressed output at offset 0x15E8 contains a write-byte primitive:

stb     r4, 2(r6)
blr

This instruction sequence lands in the hypervisor at offset 0x1F28, giving the exploit the ability to write arbitrary bytes to arbitrary hypervisor addresses, one byte at a time with four calls for a 32-bit write; the +2 displacement in the stb instruction means every write address is offset by -2 to compensate.

The race doesn't hit every time. Grimdoomer's README cites a 30% success rate and up to 20 minutes per attempt - my console usually landed within the first few seconds, and when it didn't I'd wait about five minutes then restart.

Patching the Hypervisor

The exploit uses the write primitive to overwrite the syscall table entry for HvxPostOutput (syscall 0x0D) at 0x8000010200015FD0 + (0xD * 4) with the address of a mtctr r4; bctr gadget in the hypervisor, turning syscall 0x0D into an arbitrary-address jump where the hypervisor branches to whatever address you pass in r4.

The shellcode runs in hypervisor context. It restores the 64KB of hypervisor code the race corrupted by having HvpRelocateCacheLines copy clean data from a backup binary (Stage4_CleanHvData_Retail_17559.bin) to physical address 0x80000106.00030000. It patches RSA signature verification in the hypervisor by writing 0x38600001 (li r3, 1, meaning "load immediate: return true") to physical address 0x80000104.00029B04 to replace the call to XeCryptBnQwBeSigVerify in HvpImageSignatureVerification so the hypervisor reports all signatures as valid. It then patches the same function in the kernel by disabling RMCI (Real Mode Cache Inhibit) via HvpSetRMCI(0) to access encrypted kernel memory, writing the same li r3, 1 patch to 0x80000300.0007BFDC in XexpVerifyXexHeaders and re-enabling RMCI after.

It returns 0x41414141 as a success sentinel. The race code checks this value then calls XLaunchNewImage("PAYLOAD:\\default.xex", 0) to boot the unsigned payload from USB.

The entire chain, from a stack overflow in an avatar name field to unsigned code execution via hypervisor memory corruption, runs without touching the NAND flash - nothing is written to persistent storage, a power cycle returns it to a retail state.

XeUnshackle

XeUnshackle by Byrom90 is the payload that BadUpdate launches; it transforms the console into the functional equivalent of a JTAG (a permanent hardware exploit using the CPU's debug interface) or RGH-modded system in RAM by applying the same "freeboot" patch set that xeBuild uses when creating permanent NAND modifications, installing a hypervisor expansion for ongoing privileged access, loading DashLaunch and displaying the console's CPUKey (a unique per-console encryption key burned into the CPU's eFuses) and DVDKey (the key used to decrypt game disc content, stored in the keyvault).

Expansion Install Bypass

XeUnshackle's first move is to use BadUpdate's HvxPostOutput backdoor (the hijacked syscall 0x0D) to run shellcode that patches HvxExpansionInstall (syscall 0x72) at three locations: HV offset 0x3089C gets changed to bne cr6, +8 to skip the signature check failure branch, 0x308A0 becomes li r29, 0 to clear the check result, and 0x308A4 is NOPed out; together these disable RSA signature verification on hypervisor expansions and allow XeUnshackle to install its own unsigned expansion. The full freeboot patch set later rewrites the same handler, NOPing 0x308A8 as well.

Peek/Poke via HV Expansion

With expansion checks disabled, XeUnshackle installs a custom hypervisor expansion (ID 0x48565050 / HVPP) via HvxExpansionInstall that provides read/write access to any hypervisor memory address from usermode through HvxExpansionCall (syscall 0x73), implementing a dispatch table where modes 0-3 read byte/halfword/word/doubleword, modes 5-8 write them, modes 4 and 9 do bulk transfers with cache coherency (dcbst/icbi/sync/isync) and modes 10-11 read/write Special Purpose Registers.

The SPR accessor needs a JIT. PPC's mfspr/mtspr instructions encode the SPR number into the opcode itself, meaning you can't use a register to specify which SPR to access; the expansion solves this by JIT-compiling the instruction at runtime, reading its own code address via bl .+4; mflr, computing a pointer 0x30 bytes ahead, inserting the SPR number from r5 into the opcode template using rlwimi, writing the modified instruction, flushing the instruction cache with dcbst/icbi/sync/isync and branching to it - self-modifying hypervisor code generated per call.

Freeboot Patches

With the peek/poke expansion installed, XeUnshackle applies the full freeboot patch set in two phases.

Phase 1 - Primary HV Patches go in first because they include the memory protection disable; the most important patch is at HV offset 0xB510, where 288 bytes replace HvxGetVersion (syscall 0) with a multi-function backdoor that checks for magic value 0x72627472 in r3 and dispatches on r4 (mode 2 disables hypervisor memory protections by writing ori r6, r6, 7 at the protection handler to force RWX permissions, mode 3 re-enables them, mode 4 does bulk memory copies), falling through to the real HvxGetVersion if the magic doesn't match.

After these primary patches, Hvx::ToggleMemProtect(FALSE) calls HvxGetVersion(0x72627472, 2) to use the backdoor it installed to disable HV memory protections, making the remaining address space writable.

Phase 2 - Secondary HV Patches dismantle the security chain with protections disabled. Most of these follow the same pattern: overwrite the target function with li r3, 1; blr or li r3, 0; blr depending on what the caller expects. RSA signature verification (XeCryptBnQwBeSigVerify, HvpPkcs1Verify), security violation detection and activation (four separate get/set functions), key validation, image loading checks, import resolution, hash verification, image key transforms - all patched to return success unconditionally.

The mechanically interesting patches:

WhatWhereEffect
HvpImageSignatureVerificationHV 0x29B0814-DWORD replacement of the signature verification logic (not a simple li r3, 1 - this is a full rewrite of the function)
Fuse blow handlerHV 0xA560Returns success without blowing eFuses (one-time-programmable hardware fuses the console burns to track update history)
Devkit XEX AES keyHV 0x00F0Zeroed out (16 null bytes) - disables devkit encryption validation
Machine check exceptionsHV 0x72B4-0x72ECThree NOPs + li r11, 1 - suppresses hardware exception reporting

Between the signature verification rewrite, the fuse blow suppression, the devkit key wipe and the machine check NOPs, the hypervisor no longer has any mechanism to detect, record or act on the fact that it's running unsigned code.

Kernel Patches use the same primitive, applied to kernel virtual addresses via memcpy with cache flush (__dcbst/__sync/__isync). The bulk are the same li r3, 1; blr pattern across XEX (Xbox Executable - the 360's signed binary format) error handling, media type verification, version checks, RSA verification, revocation checks, HDD and DVD drive authentication and USB device security. The notable ones:

WhatWhereEffect
SataCdRomAuthenticationExInitialize0x800998D0DVD drive auth threshold set to 0xFF (accepts any drive)
VdDisplayFatalError (E66)0x800992B4E66 error screen disabled
SataDiskAuthenticateDevice0x8015D9D8Third-party HDDs accepted

These three open up the hardware: any DVD drive works, the E66 fatal error that would brick the UI on a drive mismatch is gone; non-original HDDs are accepted without authentication.

At 0x8010BF40, XeUnshackle injects 176 bytes into the now-unused XeKeysConsoleSignatureVerification function body containing the DashLaunch boot loader with a polling delay loop and the string \Device\Flash\launch.xex, then redirects Phase1Initialization (0x800613CC), XexLoadExecutable (0x8007D7F8) and XeKeysGetKeyProperties (0x80108E70) into this injected code to ensure DashLaunch loads at boot.

Reverting BadUpdate

After patching and loading DashLaunch, XeUnshackle reverts BadUpdate's original patches: it restores the original HvxPostOutput syscall dispatch entry (0x00002540) at HV 0x16004 to close the exploit's initial backdoor, restores the original branch in HvpImageSignatureVerification at HV 0x29B04 since the freeboot patches at 0x29B08 handle signature verification with more granularity; it also restores the original branch in XexpVerifyXexHeaders at kernel 0x8007BFDC.

Freeboot's more nuanced signature handling replaces BadUpdate's crude li r3, 1 bypasses, which DashLaunch depends on for loading patched retail-signed XEX files; the peek/poke expansion and the custom HvxGetVersion handler provide all the privileged access the system needs from here, making the HvxPostOutput backdoor redundant.

One more patch: HvpProtectedFlags at HV 0x16618 gets zeroed because these flags accumulate security violation records during boot before XeUnshackle patches anything; without clearing them the DVD drive checks would still report violations and cause "disc unreadable" errors.

CPUKey, DVDKey, 1BL

XeUnshackle then displays a screen showing four things: the CPUKey (read from fuse lines 3 and 5 via the peek/poke expansion at 0x8000020000020000 + ((fuse * 0x40) << 3)); the DVDKey (read from the keyvault pointer at HV 0x00000002000163C0, offset +0x100); the console type (derived from XboxHardwareInfo->Flags & 0xF0000000, where Trinity returns 0x40000000); and a 1BL dump (First Bootloader - the first code that runs on the CPU at power-on, burned into ROM - 32KB from 0x8000020000000000, saved as Trinity-1bl.bin). It plays a success animation. You press Back to exit and the console is the functional equivalent of a JTAG/RGH system until you turn it off.

Semi-Untethered Boot

BadUpdate is semi-untethered because all of XeUnshackle's patches live in DRAM, where the hypervisor, kernel and all system code run from memory loaded fresh from NAND flash on each boot; none of the exploit touches the flash, so power off and the DRAM contents are gone, the console boots from its unmodified NAND image as if nothing happened.

Each power cycle requires re-running the exploit. The boot flow:

  1. Power on without touching the controller. 2. ABadAvatar triggers from the sign-in screen, where the front panel LEDs cycling between segments means the race is running with Thread 0 monitoring ciphertext and Thread 1 hammering HvxKeysExecute. 3. LEDs go solid green once XeUnshackle has patched the hypervisor and kernel. 4. Press Back; DashLaunch reads launch.ini, loads plugins, boots Aurora, signs in the profile.

On my console most boots hit within the first few seconds; when they didn't, the five-minute restart usually got there - the trade-off for not soldering anything.