li r3, 1: An Xbox 360 Exploit Chain
A Halo Reach console, a crafted avatar name, and the one instruction that makes every signature valid.
A console that only runs signed code has to do the checking somewhere. On the Xbox 360 it happens in the hypervisor, 64KB of code living in memory the CPU keeps encrypted, which nothing in usermode is supposed to read or write. One function in there verifies an RSA signature and returns a boolean. Overwrite it with li r3, 1 and every signature on the machine is valid.
I loved Halo Reach growing up. It defined an entire era of my childhood in a way no other game has since, and nothing beats 10-year-old me screaming down a one-ear headset at teammates two or three times 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 2.0.17526.0. The plan was RGH3 later, a hardware mod that glitches the CPU during boot so the signature checks never complete, which means soldering. I never got that far, because the same day I ran BadUpdate instead: no soldering, no NAND modification, nothing written to persistent storage at all.
Getting from a text field to that one instruction takes six rungs, and the only reason each one exists is to buy the next. That's the whole shape of it. Nothing in the chain is interesting on its own.
The board
The Xbox 360 shipped on seven motherboard revisions Xenon, Zephyr, Falcon, Jasper, Trinity, Corona and Winchester. Xenon through Jasper are the original "phat" chassis. and the Reach Limited Edition is a Trinity, the first slim board, the 360 S redesign from 2010 with integrated WiFi and a single centrifugal blower in place of the dual-fan setup. Code identifies it through XboxHardwareInfo->Flags & 0xF0000000, where Trinity returns 0x40000000.
That matters twice. Trinity has stable glitch timing if I ever do the hardware mod, and it sits inside the dashboard range BadUpdate targets. The console still had someone else's data on it, so I formatted the drive and made an offline profile from the Guide menu, since the option is greyed out on the main dashboard.
17559
BadUpdate works on dashboard 2.0.17559.0 and nothing else. Every address in the exploit is hardcoded against that exact kernel build: the ROP gadgets, the syscall ordinals, the function offsets, the patch locations. KernelConfig_Retail_17559.asm in the source is 270 lines of nothing but constants, stack_pivot at 0x81725378 and call_func_preload at 0x8169CDDC among them.
Mine was on 17526, one update behind. So I pulled the 17559 USB update from archive.org, dropped the $SystemUpdate folder on a FAT32 stick and let the console find it on boot. Updating a console to make it exploitable is a funny thing to have to do.
The entry point
BadUpdate is Grimdoomer's, and the version everyone quotes needs you to own Tony Hawk's American Wasteland or Rock Band Blitz, because the overflow is in a save file those games load. I own neither. The variant I used is ABadAvatar by shutterbug2000, which needs no game at all: a malformed Xbox 360 avatar whose name field overruns a fixed-size stack buffer while the dashboard renders it, fired from the profile selection screen with no disc and no input from me.
I used BadStick to lay the USB stick out, a C# WinForms tool that formats the drive, pulls the exploit packages and the homebrew from GitHub releases and puts them where the console expects. It isn't part of the exploit, it just saves you doing the directory layout by hand.
The avatar data lands at 0x43AB9AC8 and gets relocated to 0x43670000 by what the source comments call a "comically large allocation" from the avatar system. The overflow overwrites the saved registers and the link register, so when the rendering function returns it returns into a gadget in xam.xex, the dashboard's core system library.
lwz r1, 0(r1)lwz r12, -8(r1)mtlr r12blrThe first load replaces the stack pointer with attacker-controlled data. The second pulls a value out of that new stack, moves it into the link register and branches to it. Four instructions, and the machine is now executing whatever sequence of return addresses I put on the stack. That's return-oriented programming, and for the rest of this rung there is no injected code anywhere, only the console's own code fragments chained by their returns.
Encrypted memory
Rung one bought instruction control in usermode. It bought nothing against the hypervisor, which is where the signature check lives.
The way in is a syscall called HvxKeysExecute, ordinal 0x42, which takes a buffer of LZX-compressed data and decompresses it. That decompression runs in encrypted memory: the CPU encrypts and decrypts transparently in hardware, so the hypervisor reads and writes plaintext, and usermode code holding the physical backing pages sees only ciphertext. The decompressor keeps its state in there, including a pointer called dec_output_buffer that says where the output goes.
So the attack writes itself. Swap that pointer for one aimed at the hypervisor's own code pages and the decompressor does the writing for you.
Except you can't write a pointer. You can reach the physical page, but everything you put there is read back through the decryption engine as noise, and there is no instruction that says "encrypt this address for me". Knowing exactly which eight bytes you want to change and having write access to them is not the same as being able to set them.
What saves it is that the encryption is weak in a specific way. It uses a per-page "whitening" value that rotates through 1024 slots, and within one slot the mapping from plaintext to ciphertext is fixed. Observe the ciphertext of a plaintext you already know, at a given whitening value, and you can produce the ciphertext for any plaintext at that same value. That is an oracle, and building one is the entire next rung.
The oracle
Capturing a ciphertext relies on one property of an encrypted allocation: the physical page behind the encrypted virtual address is also reachable through a plain unencrypted alias.
memcpy(encrypted, plaintext, 16);KeFlushCacheRange(encrypted, 16);memcpy(captured, alias, 16);table[captured[0] >> 22] = captured;The known plaintext goes in through the encrypted address, the cache is flushed so the bytes actually reach the page, and an ordinary read of the alias hands back the raw ciphertext of what was just written. Same bytes, plaintext through one mapping and ciphertext through the other. The top ten bits index a 1024-slot table, one slot per whitening value.
The known plaintext comes from bootanim.xex, the boot animation, loaded through XexLoadImage because its code pages land at a predictable virtual address. Loading and unloading it in a loop at 0x8D000000, allocating and freeing an encrypted region each time, walks the whitening counter through all 1024 values. Every cycle captures two things: the ciphertext of the LZX decoder's context header, signature 'CIDL' with a 0x8000 window, which becomes the canary the race watches for, and the ciphertext of a poisoned dec_output_buffer pointing at 0x80000106.00030940 inside the hypervisor's last segment, which becomes the replacement.
All of this is ROP, and a ROP chain that runs for an indefinite number of iterations has a problem: it overwrites its own gadget data. The fix is a dual-buffer bounce, where each pass copies its gadgets to the alternate buffer and stack-pivots into it, so the chain never stands where it is writing.
The race
Two ciphertexts per slot, and now the hard part is time. The window is between the decompressor writing its context header and reading its output pointer back.
Two things happen before the threads start. KeLockL2 is called twice to lock 256KB of L2 cache each time with rubbish, so the ciphertext under observation evicts to main memory quickly enough to be seen. Then 0x66666666 goes into MmPhysical64KBMappingTable at 0x801C1000, which makes the hypervisor's encrypted segments visible from usermode at 0xA0000000.
Then two threads, one per hardware core. Thread 1 calls HvxKeysExecute in a tight loop and reads the return code: 0xC8000012 means the corrupted pointer made the decompressor give up, and 0xC8000006 means a block got overwritten, which it then verifies by comparing ciphertexts. Thread 0 runs the tightest loop the author could write. The C reference in BadUpdatePoc.cpp describes a hash table lookup and a 1.5-million-cycle delay; the hand-assembled version that actually ships has neither.
loop:
ld r11, 0(r31) cmpld cr6, r11, r30 bne cr6, flush
mtctr r25
overwrite:
std r29, 0x20(r26) std r28, 0x28(r26)
dcbst r0, r26
bdnz overwrite
flush:
dcbf r0, r31 b loop
It loads eight bytes from the scratch buffer's header and compares them against the canary sitting in r30. A match means the decoder has just written its context and the state is live. Then it stores the replacement pointer a hundred thousand times over offset 0x2B28, flushing each line to memory, because it has no idea when in that window the decompressor will read. On a miss it flushes the header out of cache so the next read comes from memory rather than telling it the same stale answer forever.
- Thread 1 to Scratch buffer: Decompress
- Thread 0 to Scratch buffer: Swap the pointer
- Scratch buffer to Hypervisor segment
Block 14 is the target because it is the smallest block in the compressed file at 0x1AD0 bytes, which gives the widest window. The race does not hit every time. Grimdoomer's README quotes a 30% success rate and up to 20 minutes per attempt. Mine usually landed within a few seconds, and when it didn't I'd wait about five minutes and restart.
The write primitive
What lands in the hypervisor is not shellcode. It is whatever block 14 happens to decompress to, aimed so that its contents at internal offset 0x15E8 come to rest at hypervisor offset 0x1F28. At that exact spot in the decompressed output there are two instructions.
stb r4, 2(r6)blrStore the low byte of r4 at r6 plus two, then return. That is the whole of it, and it is enough, because the hypervisor is now holding a routine that writes one attacker-chosen byte to one attacker-chosen address. A 32-bit write is four calls. Every address is passed with two subtracted, to cancel the displacement.
Getting at it needs a way in. The exploit uses the byte-writer to overwrite the syscall table entry for HvxPostOutput, syscall 0x0D, with the address of a mtctr r4; bctr gadget already sitting in the hypervisor. Syscall 0x0D now branches to whatever address you hand it in r4, from usermode, in hypervisor context.
From there the shellcode cleans up after itself and patches the two things that matter. The race left 64KB of hypervisor code corrupted, so HvpRelocateCacheLines copies a clean copy back from a bundled binary. Then 0x38600001 goes over the call to XeCryptBnQwBeSigVerify inside HvpImageSignatureVerification, which is li r3, 1, return true. The kernel copy of the same check needs RMCI turned off to reach encrypted kernel memory, so HvpSetRMCI(0) runs, the same four bytes go into XexpVerifyXexHeaders, and RMCI goes back on.
The shellcode returns 0x41414141 as a sentinel. The race code checks for it and calls XLaunchNewImage on the payload sitting on the USB stick.
XeUnshackle
The payload is XeUnshackle by Byrom90, and its job is to turn a console that can run one unsigned binary into a console that behaves like a permanently modified one.
Its first move is the BadUpdate backdoor it was launched from. Syscall 0x0D runs shellcode that patches three instructions inside HvxExpansionInstall, syscall 0x72: a branch at HV 0x3089C flipped to skip the failure path, a li r29, 0 to clear the check result, and a NOP. Hypervisor expansions are meant to be RSA-signed, and after those three writes they aren't.
So XeUnshackle installs its own. The expansion is a small dispatch table reached through HvxExpansionCall, syscall 0x73, where modes zero to three read a byte, halfword, word or doubleword of hypervisor memory, modes five to eight write them, and two more do bulk copies with the cache maintenance that makes written instructions actually execute. Arbitrary hypervisor peek and poke from usermode, which is the byte-writer promoted to something you can use.
Two of the modes read and write special purpose registers, and those can't be done the ordinary way, because PowerPC encodes the register number into the mfspr and mtspr opcodes rather than taking it in a register. The expansion writes the instruction at runtime instead: it finds its own address with bl .+4; mflr, uses rlwimi to drop the number from r5 into an opcode template 0x30 bytes ahead, flushes the instruction cache and branches into it. Self-modifying code, inside the hypervisor, regenerated on every call.
With peek and poke available, the freeboot patch set goes in, the same one xeBuild writes into NAND on a permanently modified console. The first phase has to include the memory protection disable, and the important patch is 288 bytes at HV 0xB510 that replace HvxGetVersion, syscall zero, with a multi-function backdoor. It checks r3 for the magic value 0x72627472 and dispatches on r4: one mode forces RWX on hypervisor pages, one puts the protections back, one does bulk copies. Anything without the magic falls through to the real HvxGetVersion, so the syscall keeps working for everyone else. Calling it with the magic is what makes the rest of the address space writable.
The second phase is mostly one move repeated. Overwrite the target function with li r3, 1; blr, or li r3, 0; blr where the caller reads zero as success. RSA verification, security violation detection, the getters and setters around it, key validation, image loading checks, import resolution, hash verification: all of them unconditionally agree with you now.
Four are worth naming because they aren't that pattern. HvpImageSignatureVerification at HV 0x29B08 gets a 14-doubleword rewrite rather than a stub, because the callers need more granularity than "yes". The fuse blow handler at HV 0xA560 returns success without burning anything, so the one-time-programmable fuses the console uses to record its update history stay where they are.
The devkit XEX AES key at HV 0x00F0 is zeroed. And the machine check exception handler between HV 0x72B4 and 0x72EC becomes three NOPs and a li r11, 1. Between them the hypervisor loses every mechanism it had to notice, record or act on the fact that it is running unsigned code.
The kernel patches are the same primitive aimed at kernel addresses. Most are the li r3, 1 stub again, across XEX error handling, media type checks, version checks, revocation, and drive authentication. Three of them open up the hardware: SataCdRomAuthenticationExInitialize at 0x800998D0 has its DVD drive threshold set to 0xFF so any drive passes, SataDiskAuthenticateDevice at 0x8015D9D8 accepts third-party hard drives, and VdDisplayFatalError at 0x800992B4 kills the E66 screen that would otherwise brick the interface on a drive mismatch.
Then DashLaunch. 176 bytes go into the body of XeKeysConsoleSignatureVerification at 0x8010BF40, a function nothing calls any more, containing a polling loop and the string \Device\Flash\launch.xex. Three kernel functions are redirected into it, so whichever one runs first at boot, DashLaunch loads.
The last thing XeUnshackle does is undo BadUpdate. It restores the original HvxPostOutput dispatch entry, closing the syscall 0x0D backdoor it arrived through, and puts back the original branches that BadUpdate's crude li r3, 1 patches replaced, because freeboot's version of the same check is more careful and DashLaunch depends on the careful one to load patched retail-signed binaries. The peek and poke expansion and the HvxGetVersion backdoor are all the privileged access it needs from here, so the door it came in by gets shut behind it.
One more, easy to miss and annoying to debug: HvpProtectedFlags at HV 0x16618 is zeroed. Those flags collect security violations during boot, before any of this has run, and leaving them set means the DVD checks keep reporting violations and every disc comes back unreadable.
Then it prints the things you actually came for: the CPUKey out of fuse lines 3 and 5, the DVDKey from the keyvault, the board revision, and a 32KB dump of the first bootloader. It plays a success animation. You press Back, DashLaunch reads launch.ini, and Aurora comes up.
The end
None of it touches the NAND. The hypervisor, the kernel and everything above them are loaded into DRAM from flash on every boot, and every patch here is written to DRAM. Power off and the console is retail again, which also means power on and you start at rung one: boot without touching the controller, let the avatar render, watch the front panel LEDs cycle while the race runs, wait for solid green, press Back.
Usually a few seconds. Sometimes five minutes and a restart. That's the price of not soldering anything.
I've skipped the LZX format itself, why block 14's decompressed output happens to contain a useful instruction pair at all, and everything about RGH3, which is the same destination reached by putting a wire on the board instead. Grimdoomer's BadUpdate and Byrom90's XeUnshackle repositories are where the real detail lives, and both are more readable than they have any right to be.