PS3 · Cell BE · 4.93 CFW — a field report in three parts
Starting from the most privileged software position on the machine — hypervisor read/write, courtesy of CFW — I signed my own code, planted it in the boot chain, and made the mask‑ROM run it inside the SPU isolation. I got in. I ran. As a proof of concept I reached for the master key — and at first believed it already gone. It is not — see the correction below.
Correction — 2026‑09‑01. The headline conclusion below is too strong, and later measurement — at the very same patch point — disproved it. Everything measured here still stands: the key's original location, Local‑Store 0:0x40, really is scrubbed to zero, proven three ways. What was wrong was the leap from that to “the key is gone.” The loader keeps an un‑scrubbed working copy of the key elsewhere in Local Store, and code running inside isolation can read it. So “execution is not extraction” does not hold in general: execution can recover the key, through that copy.
Scope, so it isn't overstated the other way: the read is still gated behind the metldr loader‑signing key and a hypervisor write — it runs only on an already‑CFW console, and it exposes nothing such a console doesn't already expose through lv1. It is a defense‑in‑depth correction (the scrub is incomplete), not a new break of a retail machine. The specifics are held back on purpose: this note corrects the claim, it is not a how‑to. And it is not a new idea — nwert documented in 2012 (psdevwiki Per_Console_Keys) that the loader buffers the key before clearing sector 0, obtainable without pwning a loader; what's new here is only the on‑silicon confirmation, and the correction of my own earlier overreach.
Background
The Cell processor has one PPE (a PowerPC core) and eight SPEs (the SPU vector cores). One SPE can enter isolation mode: it runs entirely from its 256 KB Local Store, its LS is hidden from the rest of the system, and it will only execute code the on‑die mask‑ROM has cryptographically verified. This is where the console does the work it trusts nobody with — deriving per‑console secrets, running Sony's signed SPU modules. It is the last thing on the PS3 the scene never fully owned.
The chain is short and each link only trusts the next:
boot chain into isolation mask-ROM (bootldr, on-die, unbroken) └─ metldr — the isolation root loader; verifies & runs loaders └─ isoldr — loads & runs the isolated SPU modules └─ sv_iso / appldr / sc_iso / … (the modules)
I start on a 4.93 CFW console — the foothold this whole report builds on. CFW gives me two things retail never would: the ability to run my own homebrew, and a hypervisor read/write primitive (lv1 peek/poke). That's the most privileged software position on the machine. The question this report answers is what that position is worth against the isolation — and the answer turns out to be "a great deal, but not the key."
Two facts make the rest possible. First, metldr verifies loaders with an ECDSA key the scene recovered back in 2010 — so a loader I sign myself is one metldr will run. Second, the per‑console eid_root_key sits in the isolated Local Store at offset 0x00 when a loader first gets control — so, in principle, a loader that can read its own LS could read it. That's the theory a proof of concept would test. What follows is the fight to make it real, and the wall at the end.
Part 0 · The instruments
The isolated SPE is close to the worst RE target imaginable: no debugger, no single‑step, a Local Store that reads back as noise from the outside, and a hardware failure mode — the red screen — that costs a full reboot for every wrong guess. You cannot poke at it interactively. So the honest truth is that most of this project was tools. I built a bench that let me do nearly everything offline, and treated the console as a place to confirm, not to explore.
The engine is anergistic, the scene's open‑source SPU emulator — but stock, it doesn't model the MFC: DMA commands were printed and dropped. Isolated modules are DMA machines; they receive their arguments and their payloads over DMA, so a no‑DMA emulator can't run them at all. I gave it a real DMA engine backed by a memory store, and three instruments on top:
[LR‑TAINT] the instant tainted bytes reach the saved link register. That one report is what turned "I think there's an overflow" into "here is the exact instruction that overwrites the return address."isoldr won't run past its crypto (SRVK) barrier without the right metadata blocks, so I inject them and let execution reach the logic that matters.sc_iso prints "SC Iso SPU module: Hello!" down a run of one‑byte DMAs, so you learn the packet format from the module rather than guessing at it.Emulation tells you what a program does; to reason about why, you have to read it — and the SPU is a nasty read. It's a 128‑bit vector machine with no scalar registers: every value lives in a 16‑byte register, and the "scalar" you want is one lane of it, selected by shuffles and rotates. Off‑the‑shelf SPU tooling gets those lane semantics subtly wrong, and subtly‑wrong on a cryptographic verifier is worse than useless. So I generated my own Ghidra SLEIGH model of the SPU, correct at lane granularity — and validated it the only way that means anything:
differential validation — decompiler vs. emulator run the same block in the SLEIGH model AND in anergistic diff the FULL 128-bit register file after every instruction any divergence → a decompiler bug, fixed at the lane level this loop killed a string of lane bugs and made the metldr / isoldr verify stack readable with confidence — every listing here came through it
The plant, the mailbox writes, the isolate_load — all of that is driven from the PPE through lv1, and a wrong guess about a hypervisor structure doesn't throw an exception, it red‑screens the console. To stop paying a reboot for every hypothesis, I built an offline PPC64 interpreter that runs lv1's own code — the real hypercall paths — against a snapshot of its memory. Guesses that would have cost a reboot got tested in milliseconds; only the survivors were ever fired at hardware. It's what broke the endless red‑screen loop the hypervisor work had turned into.
Last, the isoldr harness: the real isoldr, running under the instrumented emulator with a container in a DMA backing store — the loader doing its actual job with no console in the loop. This is where I watched it consume metldr's version handshake, compare it to a constant baked into its own data, and stop 0x30 on a mismatch — the version gate that had been quietly eating the overflow the whole time:
isoldr's version gate, reversed on the bench 0x34df8 ch_read wrch $ch64, 0x10000 ; read words from $ch73 0x34f10 read_version two words → (w0 << 32) | w1 0x272d0 compare vs. a constant in isoldr's OWN data (0x360c0) mismatch → stop 0x30 ← the gate that swallowed my fires
One discipline ran through all of it: trust nothing, differential everything. The emulator's metldr model was checked against silicon and matched 32/32 words. The decompiler was checked against the emulator. And it earned its keep — over the project a whole run of tools produced clean‑looking results that were quietly wrong, each caught only because a second, independent instrument disagreed. The rule became: every "success" gets a control that must fail, and every read gets checked against ground truth. Part III's key result survived exactly that gauntlet.
the overflow, proven offline before a single hardware shot sv_iso 4.20 (vuln) arg3=0x200: [LR-TAINT] saved link register = attacker data stop 0x00000000 pc=0x3bee0 ← my marker → return-address hijack sv_iso 4.93 (patched) same input: stop 0x10a pc=0x2c5c ← bounds fixed, no hijack
The whole bench is open source — github.com/eth-man/ps3-cell-re-tools: the extended anergistic, the SLEIGH SPU decompiler and its differential harness, the offline lv1 executor, and the isoldr harness. Tooling only — no keys, no firmware, no payload.
Part I
The original plan was a memory‑corruption bug. sv_iso, one of the isolated modules, takes an argument that flows into a DMA‑GET whose length is attacker‑controlled; push it past the destination buffer and you smash the saved link register. I had it nailed offline — in the SPU emulator, an argument length of 0x200 lands the return address exactly on my marker at pc=0x3bee0. A clean hijack. The idea isn't mine: it traces straight back to Mathieulh — one of the reverse engineers who uncovered a huge part of the PS3's secrets in the early days — and the page he left behind, psdevwiki — SPU LS Overflow Exploit. It had never been landed on hardware.
On silicon it did nothing. The isolated module would load, run, and complete normally — every time, no matter the marker. The overflow was real, but the machinery around it (a version gate, the way isoldr stages modules) never let my data reach the SPE intact. Days of fires, all swallowed. The wiki had warned me, too: "I cannot see the code in the first place, and it will not give a clean dump."
The turn came from a question — had I actually solved the SPU's open mysteries? — which sent me back to a wiki mirror I'd been carrying the entire time. The overflow was a known idea nobody had ever realized. And the documented way to run code inside isolation wasn't a bug at all — it was spelled out on psdevwiki — Making Isolated SPU Modules and Loaders:
You don't exploit metldr. You feed it a loader it will run. The mask‑ROM's whole job is to load, verify, and execute loaders. Give it one signed with the key it trusts, and it runs your code by design.
metldr verifies a loader's header with ECDSA over a curve baked into metldr's own image. ECDSA has one catastrophic failure mode: the per‑signature nonce k must be secret and unique. Sony reused a constant k. With two signatures sharing the same r (same k), the private key falls straight out — the fail0verflow "Epic Fail" from CCC 2010:
the 2010 ECDSA nonce-reuse recovery (Segher / fail0verflow) same k ⇒ same r across two signatures k = (z₁ − z₂) · (s₁ − s₂)⁻¹ (mod n) d = (s₁·k − z₁) · r⁻¹ (mod n) ← the private key you don't even need the public key — two signatures is enough
geohot supplied the other half — code execution on metldr itself, dumping its keys off a live core with a payload that ran right after metldr copied the root keys into low LS. The technique is still written down: psdevwiki — Dumping Metldr. Between the two, the loader‑signing keyset went public. I verified my copy is genuine by matching its public half, byte‑for‑byte, against the key I pulled from real metldr at LS 0xec00. I hold metldr's loader private key — and the tool to use it already exists:
signing a loader metldr accepts (scetool, LDR keyset) scetool -0 SELF -5 LDR -2 00 -3 <auth_id> -4 01000002 \ -A 0001000000000000 -6 0004009300000000 \ -e loader.elf loader.self # round-trips: `scetool -d loader.self` decrypts with the metldr keyset → valid
The reframe that made the session. None of this was cleverness. The key had been public for over a decade; the method sat in a wiki page I'd already mirrored. I found it only because someone asked the right question — the reframe was reading what was already on the desk.
Part II
Before it enters isolation, lv1 (the hypervisor) keeps the loader images resident in low memory and hands metldr their addresses through a descriptor object at effective address 0x10100. I dumped it with my hypervisor read primitive:
| sel | address | size | what it is |
|---|---|---|---|
| 0 | 0x65780 | 0xE890 | metldr container (matches my NOR dump byte-for-byte) |
| 4 | 0x50780 | 0x146F0 | isoldr — an lv0-embedded SELF, 83,696 bytes |
| 2 / 3 | 0x10200 / 0x28D00 | — | other lv0 embedded blobs |
Selector 4 is the prize. isoldr lives at 0x50780 as a plain SELF of 0x146F0 bytes, and my own signed loader (0x138E8 bytes) fits inside that slot. The attack is not subtle: stream my loader over the resident isoldr, one doubleword at a time, and let metldr load mine instead.
planting my loader (PPU side, via the CFW lv1 write primitive) // overwrite the resident isoldr in place for (off = 0; off < selflen; off += 8) lv1poke(0x50780 + off, dword_from_our_signed_self); // point metldr at it, then run the normal isolation entry: mailbox(spe=4, 0x50780); // tells metldr where the loader is isolate_load(spe=4, 0x65780); // run metldr → it loads OUR bytes
How do I know metldr is verifying my loader and not a cached original? Three differentials, each its own fire on the console:
silicon — the signature gate is live valid signature → loader runs, SPU_Status low word = 0x00000081 (isolated+running) flip one sig bit → console dies (a cached original would just read 0x081) code at the entry → console dies (metldr requires isoldr's exact ch64/ch73 handshake there)
The third line taught me where I can and can't touch. metldr decrypts the loader, pulls the entry point out of the parsed image, and jumps to it — not to my ELF's e_entry, which cost me a run of dead fires until I read the handoff:
metldr — the handoff (annotated disasm) 700: ai $4, $1, 32 ; $4 = SP+0x20 (output slot) 704: brsl 0xdf8 ; parse loader header → entry into [SP+0x20] 708: brnz $3, 0x740 ; on error → stop-code table (0x1f..0x23) ... 734: lqd $3, 32($1) ; $3 = [SP+0x20] = the payload entry point 738: brsl 0x410 ; self-erase (Part III) 73c: br 0x7c0 ; → bi $3 : jump into the loader's own entry
metldr faults.Accepted is not executing. From outside, my loader and the genuine one both sat at status 0x081 — indistinguishable. So I planted a tell in the one region I control: the deliver‑wait loop the loader idles in. I issue an MFC DMA to an address the isolation doesn't map, then wait on the tag for a completion that can never come:
my patch at isoldr 0x26494 — the "tell" ; issue an MFC PUT to an unmapped EA (metldr's own DMA calling convention) wrch $ch16, lsa ; MFC_LSA wrch $ch17, 0 ; MFC_EAH wrch $ch18, 0x64400 ; MFC_EAL — not mapped in isoldr's context wrch $ch19, 16 ; MFC_Size wrch $ch20, 4 ; MFC_TagID wrch $ch21, 0x10020 ; MFC_Cmd = (class 1 << 16) | PUT(0x20) ; wait for it — forever wrch $ch22, 0x10 ; MFC_WrTagMask (tag 4) wrch $ch23, 2 ; MFC_WrTagUpdate = ALL rdch $ch24 ; MFC_RdTagStat — BLOCKS: DMA never completes → SPE hangs
rdch $ch24 here. The SPE hung. That hang is that instruction executing — so my code ran inside the isolation.Running is one thing; getting a value out is another. The isolation welds shut every window a spectator has, and I confirmed each on silicon:
base+0x44004) — machine‑checks the whole consoleThe way out was a door the isolation itself relies on. Isolated modules report status to the hypervisor over the outbound mailbox (channel 28); lv1 has a privileged accessor that reads it into memory I can peek — the same path it uses on every boot. My raw MMIO read machine‑checked; the privileged accessor does not:
the exfil path — SPE mailbox → lv1 accessor → readable memory SPU side : wrch $ch28, <word> ; write SPU_Out_Mbox (+0x44004) lv1 fn : FUN_002b321c(spe=4, u32* out) ; reads Out_Mbox → *out (lv1 memory) PPU side : call_lv1(0x2b321c, 4, &scratch); read scratch
But the mailbox is one slot deep, and isoldr stuffs its own status word into it during init. There is exactly one wrch $ch28 in the whole loader, and it checks for a free slot first:
isoldr's mbox_write (0x34d34) — the contention 34d38: rchcnt $2, $ch28 ; free slots in Out_Mbox 34d3c: il $4, -1 ; default return: full/fail 34d40: brz $2, 0x34d4c ; no slot → skip the write 34d44: wrch $ch28, $3 ; write the status word ( == 0x1 ) 34d48: il $4, 0 ; success
My word kept losing the race — the drain returned isoldr's 0x1, or nothing. The fix pairs a looping write on the SPE with a paced drain on the PPE: my code keeps refilling the one slot; my reader frees it, waits ~4 ms for the refill, and reads again:
silicon — the drained mailbox (looping write + paced drain) drain[0] 0x00000001 ← isoldr's own status, in front of mine drain[1] 0xBABECAFE drain[2] 0xBABECAFE drain[3] 0xBABECAFE … ×7, every read a success
Part III
Code execution, a faithful read, a reliable way out — every tool to lift the crown jewel. The prize is the eid_root_key, the per‑console master secret. When metldr hands off it wipes its own code and keys from Local Store — but look at the range:
metldr self-erase (0x410) — annotated disasm 410: il $17, 0 ; zero source 414: ila $14, 0x630 ; ptr = 0x630 ← starts HERE 418: ila $15, 0x12bf0 ; end = 0x12bf0 41c: clgt $16, $14, $15 ; ptr > end ? 420: brnz $16, 0x430 ; done → zero all registers 424: stqd $17, 0($14) ; *ptr = 0 428: ai $14, $14, 16 ; ptr += 16 42c: bra 0x41c ; loop
metldr erases 0x630 → 0x12bf0. The key at 0x00 is below the start of the wipe. metldr leaves it alive.That was the moment I thought I had it. The mask‑ROM doesn't erase the key — so whatever does comes later, and "later" is my loader's own code, which I sign and control. I pointed my reader at the key's address and folded it to a word:
silicon — reading the key region at the deliver-wait fold of LS 0x00..0x40 (the key) → 0x00000000 dump of LS 0x40, 0x50 → 0000 0000 0000 0000
At first the failures looked like a read block — a hardware lock on the live key, which would have been a clean story. It was wrong twice over. Once because the silence was the mailbox race, not the read; and once because of an SPU footgun that cost me a false mismatch: lqa masks its address to 16‑byte alignment, so lqa 0x26494 actually reads 0x26490. To be sure I'd trust the zero, I made the vault read something I could check against ground truth — its own loaded code, whose bytes I knew exactly — once I accounted for the alignment:
silicon — the reads are faithful expected fold of my own code (16-byte aligned) → 0x19cbb980 read back from inside isolation → 0x19cbb980 ← exact match
By the time my code can run — after the mandatory handshake, at the only points metldr still accepts — the loader has already scrubbed the key's slot to zero. Not one word: the whole immediate neighborhood, 0x00, 0x40, 0x50, all zero. That measurement is correct and holds. My mistake, corrected at the top, was to read those 64 bytes and conclude the key was gone from Local Store entirely — the loader had already copied it elsewhere and left the copy behind.
I broke the seal, ran my program in the most protected core in the machine, and read its memory out through its own throat. And the one thing I came for had been wiped clean, on purpose, in the gap between the door and the desk.
The prior chapter was decryption is not authentication. This one is its mirror: execution is not extraction. You can own the code and still not own the secret.
Findings
Chasing the key meant instrumenting the isolation from the inside for the first time. Several things fell out that the scene has wondered about for years — each one measured, not guessed:
Read the isolated Local Store from the PPE and you get noise — the long‑standing "you can't get a clean dump." But from inside, the SPU's own reads are faithful, byte‑for‑byte (I proved it with 0x19cbb980). The scramble is a boundary on external inspection, not on the core itself.
Reading SPU_Out_Mbox as raw MMIO machine‑checks — but lv1's own privileged accessor (0x2b321c) reads an isolated core's mailbox just fine, because the real modules use it to report home. One slot deep, and isoldr pre‑loads it with 0x1; a looping write plus a paced drain wins the race reliably.
metldr's self‑erase runs 0x630 → 0x12bf0 and leaves 0x00 untouched. The eid_root_key survives the mask‑ROM handoff intact. It's isoldr, running later, that scrubs low LS to zero — a defense in the loader, not the ROM.
An MFC DMA from inside only lands on the addresses lv1 authorized for that context (the 0xa000_xxxx staging region) — and that region machine‑checks on every read path I have. Readable addresses aren't mapped; the mapped one isn't readable. The mailbox, not DMA, is the way data leaves.
e_entryThe handoff reads the entry from the decrypted image at [SP+0x20] and jumps there — so a loader's ELF e_entry is ignored, and the loader's real entry (with its ch64/ch73 version handshake) must stay byte‑intact or metldr faults. This is why code can only be injected past the handshake.
If your injected code stops the SPE to signal, the console dies — reading any register of a halted isolated core machine‑checks, and the halt itself breaks metldr's protocol. Every observation has to come from a running core, which is why the mailbox‑while‑spinning trick matters.
The ledger
metldr on a 4.93 CFW console.0:0x40) — scrubbed to zero before any code I place runs, proven three ways. But the loader keeps an un‑scrubbed copy elsewhere that in‑isolation code can read (see the correction at the top): the slot is erased, the key is not gone.When first written, this section concluded the opposite of what I now know: that a fully‑privileged attacker running code inside the isolation still comes up empty, because the design erases the secret first. The erase is real — for the key's original slot. But it is incomplete: the loader's own working copy of the key survives elsewhere in Local Store and is readable from inside. So the honest result is not “a defense that holds” but a defense that mostly holds with one gap — one I later found in my own earlier measurement (see the correction at the top). It remains a study of the mechanism, written so it is understood; it is not a recipe for extracting anyone's per‑console keys.
In that spirit the full chain is public as a research PoC — github.com/eth-man/ps3-isolation-fullchain: the lv1 payload, the GameOS driver that plants the signed loader and reads the marker back, and the in‑isolation SPU logic. Source only, and it runs only on a console that is already CFW — it is not a new entry point, and it needs a loader key you already have on a console you already own. (On what it can read from inside isolation, see the correction at the top.) The signed‑loader blob and the (2010‑public) loader key are left for you to supply.