From aec31c7b18a3257c9df792c666e7daf74f2610cd Mon Sep 17 00:00:00 2001 From: deepkicks <77412763+deepkicks@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:26:59 -0500 Subject: [PATCH] Add files via upload --- README.md | 97 +++++++- RESEARCH_POST.md | 170 +++++++++++++ analyze_dump.py | 412 ++++++++++++++++++++++++++++++++ bls.py | 98 ++++++++ crypto.py | 182 ++++++++++++++ firmware.py | 161 +++++++++++++ keys.py | 182 ++++++++++++++ main.py | 408 +++++++++++++++++++++++++++++++ ps5_key_extract.c | 593 ++++++++++++++++++++++++++++++++++++++++++++++ pup.py | 208 ++++++++++++++++ requirements.txt | 1 + 11 files changed, 2510 insertions(+), 2 deletions(-) create mode 100644 RESEARCH_POST.md create mode 100644 analyze_dump.py create mode 100644 bls.py create mode 100644 crypto.py create mode 100644 firmware.py create mode 100644 keys.py create mode 100644 main.py create mode 100644 ps5_key_extract.c create mode 100644 pup.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index 333fc35..95fa197 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ -# TitaniaPy -A Python CLI tool that parses, analyzes, and partially decrypts PS5 update (PUP) and BLS container files directly on PC—no console required. Features BLS metadata extraction, Layer 3 component decryption, PBKDF2/KMB derivation, and entropy-based key brute-forcing. Built as a modular framework ready for full PUP container decryption. +# PS5 PUP Decrypt Tool + +PC-based Python tool for parsing and decrypting PS5 PUP (PlayStation Update Package) files. No console needed. + +## Status + +The tool handles 3 of 4 crypto layers. **Layer 1 (PUP container encryption) is the one missing piece** — see [RESEARCH_POST.md](RESEARCH_POST.md) for the full technical write-up and what's needed. + +| Layer | Purpose | Status | +|-------|---------|--------| +| 0 | PBKDF2 master key derivation | Solved | +| 1 | PUP container encryption | **Blocked — key not public** | +| 2 | PUP segment-level encryption | Blocked (depends on Layer 1) | +| 3 | Inner firmware encryption | Solved | + +## Setup + +```bash +pip install -r requirements.txt +``` + +Requires Python 3.10+ and [pycryptodome](https://pypi.org/project/pycryptodome/). + +## Usage + +```bash +# Show PUP file structure (BLS container + PUP header) +python main.py info PS5UPDATE.PUP + +# Extract inner entries from BLS container +python main.py extract PS5UPDATE.PUP -o output_dir/ + +# Decrypt inner firmware blobs with known Layer 3 keys +python main.py decrypt firmware_blob.bin -k emc_ipl_header + +# Bruteforce-test an unknown blob against all known keys +python main.py bruteforce unknown_blob.bin + +# Analyze entropy of a binary blob +python main.py analyze blob.bin + +# Show all known keys and derivation results +python main.py keys + +# Decompress a zlib-compressed segment +python main.py decompress segment.bin -o decompressed.bin +``` + +## Files + +### Core Tool +| File | Description | +|------|-------------| +| `main.py` | CLI entry point — 7 commands | +| `keys.py` | All known PS5 keys (ROM, keyseeds, OTP/KDF, Titania, firmware) | +| `bls.py` | BLS (SLB2) outer container parser | +| `pup.py` | PUP inner structure parser | +| `crypto.py` | AES-128-CBC/ECB, PBKDF2, Layer 3 decrypt functions | +| `firmware.py` | Inner firmware decryptor with entropy analysis | + +### Key Extraction (for jailbroken PS5) +| File | Description | +|------|-------------| +| `ps5_key_extract.c` | C payload — 4 approaches to extract the missing PUP container key | +| `analyze_dump.py` | Parses the dump from `ps5_key_extract.c` and tests key candidates | + +### Documentation +| File | Description | +|------|-------------| +| `RESEARCH_POST.md` | Full technical write-up of findings | + +## Key Extraction Payload + +If you have a jailbroken PS5 on FW 13.60 or below, `ps5_key_extract.c` implements four extraction approaches: + +1. **CCP LSB Key Slot Dump** — Reads all 128 hardware key slots via MMIO +2. **EncryptService ioctl Handler Scan** — Finds and dumps the `pup_update0` decrypt handler code +3. **Instrumented PUP Decrypt** — Snapshots key slots before/after a decrypt call to catch the key being loaded +4. **Kernel Memory Scan** — Searches near crypto-related strings for key material + +All read-only. Output goes to `/data/pup_key_dump.bin`. Transfer to PC and run: + +```bash +python analyze_dump.py pup_key_dump.bin --pup14 PS5UPDATE14.00.PUP --pup14_1 PS5UPDATE14.10.PUP +``` + +**Requirements:** FreeBSD 11 amd64 cross-compiler, kernel R/W via Byepervisor Reborn + Relapse + etaHEN or similar. You'll need to provide your framework's `kernel_read64` / `kernel_copyout` functions. + +## Contributing + +The tool is one key away from full PUP decryption. If you have console access and can run the extraction payload or dump the EncryptService, open an issue or DM. + +--- + +*All research conducted on personally owned hardware for educational purposes. Referenced keys are publicly documented on psdevwiki.* diff --git a/RESEARCH_POST.md b/RESEARCH_POST.md new file mode 100644 index 0000000..810493e --- /dev/null +++ b/RESEARCH_POST.md @@ -0,0 +1,170 @@ +# I Built a PC-Based PS5 PUP Decrypt Tool — Need Someone to Dump the EncryptService Key So It Works Forever + +**TL;DR:** I built a Python tool that can parse and partially decrypt PS5 PUP files on PC — no console needed. It handles BLS containers, PUP structure analysis, and inner firmware decryption with known keys. I confirmed PBKDF2-HMAC-SHA256 as the Layer 0 derivation algorithm (exact match to Titania Rev 2 master key). Then I exhaustively tested every known key and derivation chain against real PUPs from **two different firmware versions (14.00 and 14.10)** and hit a wall: **the PUP container encryption key is not in the public key dump.** It lives inside the Titania Security Processor, used by `encsrv_decrypt_header()`. The key is almost certainly **burned into ROM or silicon** — it's universal across all consoles and has been there since FW 1.0. That means extracting it once = permanent solution for every firmware version, past and future. **I need someone with console access to either dump the EncryptService software or peek inside the Security Processor to find what key `encsrv_decrypt_header()` actually uses.** + +--- + +## Background + +After the PS5 bootrom key leak (Dec 31, 2025), a significant amount of key material became publicly documented — ROM key blocks, keyseeds (Key 2-9), OTP/KDF parameters, Titania master keys, and inner firmware AES keys. These are cataloged on psdevwiki. + +The existing on-console tool (`ps5-pup-decrypt`) works by calling the PS5's EncryptService via ioctl, meaning it requires a jailbroken console. No PC-based PUP decryption tool existed publicly. I don't have a PS5, so I set out to build a tool that works entirely on PC — something designed to last. The architecture is modular: once the missing key is found, it drops into the tool and every PUP from every firmware version becomes decryptable without a console. + +## What I Built + +A Python CLI tool (`pup-decrypt-tool`) that handles the layers we have keys for: + +- **BLS Parser** — Parses the outer SLB2 container (magic `0x32424C53`), extracts entry metadata +- **PUP Parser** — Reads the inner PUP structure (magic `0xEEF51454`), segment table, flags +- **Layer 3 Decryptor** — AES-128-CBC decryption of inner firmware blobs using known static keys (EMC IPL header/body, EAP KBL, EAP Kernel, EAP Kernel SELF, Titania Rev 2) +- **Key Derivation Module** — PBKDF2 + KMB_AES chain replication for research +- **Bruteforce Mode** — Tries all known keys against unknown blobs, uses Shannon entropy to score results + +Requires only Python 3 + pycryptodome. No console needed. + +## Finding 1: PBKDF2 Layer 0 Derivation Confirmed + +The psdevwiki documents OTP_AES_ROOT, KDF_SALT1, and 1000 iterations as KDF parameters, but doesn't specify the hash function or output length. + +I tested PBKDF2-HMAC with SHA-256, SHA-1, and SHA-512 at 16-byte and 32-byte output lengths: + +| Hash | dklen | Match? | +|------|-------|--------| +| SHA-256 | 16 | **YES — exact match to Titania Rev 2 master key** | +| SHA-256 | 32 | First 16 bytes match (as expected) | +| SHA-1 | 16 | No | +| SHA-512 | 16 | No | + +**Conclusion:** Layer 0 is PBKDF2-HMAC-SHA256 with 16-byte output. This is the algorithm that derives the Titania Rev 2 master key from OTP_AES_ROOT + KDF_SALT1. + +## Finding 2: Real PUP Structure (14.00 and 14.10) + +Tested against two retail PS5UPDATE.PUP files: +- **FW 14.00** — 1,260,600,832 bytes, BLS entry size 0x4B2339D5 +- **FW 14.10** — 1,261,408,256 bytes, BLS entry size 0x4B2F8A3D + +**BLS Container (plaintext):** +- Both: Version 3, 1 inner entry (`PS5UPDATE1.PUP`), offset 0x400 + +**PUP File Header (first 16 bytes — plaintext):** +- Both firmwares have **identical** plaintext headers: + - Magic: `0xEEF51454` (valid) + - Field at offset 0x04: `0x32010110` (likely format version, not firmware version) + - Header size: `0x5B0` (1456 bytes) — implies ~44 segments at 32 bytes each + - Hash/signature table size: `0xDE0` (3552 bytes) + +**Encrypted content comparison (14.00 vs 14.10):** +- 256 out of 256 bytes differ in the encrypted extended header — completely different ciphertext +- Confirms: different firmware content, but same encryption scheme and key hierarchy +- All encrypted regions: ~7.15-7.29 bits/byte entropy (near max 8.0) — confirmed encryption, not compression + +## Finding 3: Layer 1 Gap — Exhaustive Negative Result (Both Firmwares) + +This is the main research contribution. I replicated the KMB_AES derivation chain documented on psdevwiki and tested every resulting key against the encrypted PUP headers **from both 14.00 and 14.10** simultaneously. + +**Derivation chain tested:** + +``` +PBKDF2 master (slot 0) ──┬── CBC(KEY_4_KEYSEEDS) → slot 4 + ├── CBC(KEY_5_KEYSEEDS) → slot 5 + ├── CBC(KEY_7_KEYSEEDS) → slot 7 + │ ├── ECB(KEY_8_KEYSEEDS) → slot 8 + │ └── ECB(KEY_9_KEYSEEDS) → slot 9 + └── CBC/ECB(KEY_2_KEYSEEDS) → slot 2 +``` + +For each derived key, tested both first-16 and last-16 bytes of 32-byte results, in CBC, ECB, and CTR modes, with zeroed IV and all known Titania IVs. + +**Full 8-phase scan (all negative on BOTH firmwares):** +1. All 17 known static keys × 6 IV variants × 3 modes (CBC/ECB/CTR) +2. KMB_AES derivation chain: slots 4, 5, 7, 8, 9 (both halves, key+IV combos) +3. ROM key blocks: every 16-byte window + derived chains through key7 → key8/key9 +4. XOR combinations: master^OTP, master^SALT, OTP^SALT, master^ROM, master^K8/K9 +5. AES-256 with all 32-byte keys (ROM windows, PBKDF2-256, keyseeds raw) +6. PBKDF2 variants: SHA-256/SHA-1/SHA-512, 16+32 byte output, header-mixed salts +7. Sliding window: every possible 16-byte key from all keyseed material concatenated (257 windows × 2 PUPs × 2 modes) +8. AES encrypt (reversed direction) with master, EMC header key, and K8 keyseed + +**Validation method:** Decrypted the first 32 bytes of each PUP's extended header and checked if `file_size` falls within ~1MB of the known BLS entry size and `segment_count` is between 1-99. + +**Result: Zero matches across hundreds of combinations on both firmware versions.** + +## What This Means + +The PS5 crypto architecture has (at least) these distinct layers: + +| Layer | Purpose | Status | +|-------|---------|--------| +| 0 | PBKDF2 master key derivation | **Solved** — SHA-256, 16-byte output | +| 1 | PUP container encryption (header + segments) | **Blocked** — key not in public dump | +| 2 | PUP segment-level encryption | **Blocked** — depends on Layer 1 | +| 3 | Inner firmware encryption (EMC, EAP, etc.) | **Solved** — static keys known | + +The bootrom key leak provided Layer 0 parameters and Layer 3 static keys. The KMB_AES keyseeds (Key 2-9) appear to be for Layer 3 inner firmware operations, **not** for Layer 1 PUP container crypto. + +The `encsrv_decrypt_header()` ioctl (0xC0184402) used by the on-console tool accesses a key that is: +- Universal (all consoles decrypt the same PUP, so it's not per-console) +- Not in the public key dump +- Likely locked in the security processor's CCP hardware key slots +- Separate from the bootrom key hierarchy + +## Why This Key Is the Nail in the Coffin + +Here's why finding this one key is worth the effort: + +**The PUP container key is almost certainly burned into ROM or silicon.** Every PS5 ever made — launch day consoles running FW 1.0 through the latest — must be able to decrypt every PUP Sony distributes. There's no per-console provisioning for system updates. The key has to be: +- **Universal** — identical on every console +- **Permanent** — present since the very first firmware, can't be rotated without bricking existing consoles +- **Unpatchable** — if it's in ROM/silicon (which the evidence strongly suggests), Sony can't change it even if they wanted to + +Testing across two firmware versions (14.00 and 14.10) confirms this: both use identical plaintext header structures (`unknown_04=0x32010110`, `header_size=0x5B0`, `hash_size=0xDE0`) despite having completely different encrypted content. Same key hierarchy, same format — just different payloads. + +**Once extracted, this key works forever.** It decrypts every past PUP and every future PUP. It's not like a signing key that can be rotated — it's a decryption key baked into hardware that already shipped. A PC tool with this key makes on-console decryption unnecessary for all time. + +## What I Need — The Ask + +The tool is one key away from being a complete, permanent PC-based PUP decryptor. That key lives inside the Titania Security Processor and is used by `encsrv_decrypt_header()` (ioctl `0xC0184402`). It's universal — every PS5 uses the same one — so it only needs to be extracted once. + +**If you have a jailbroken PS5, here's what would crack this open:** + +1. **Dump the EncryptService** — Extract the code behind the `/dev/pup_update0` ioctls so we can see how it selects and uses the PUP header key. Even a disassembly of the handler for `0xC0184402` would tell us which key slot it reads from. + +2. **Peek inside the Security Processor during a PUP update** — Dump the CCP LSB slot contents while `encsrv_decrypt_header()` is running. The key is loaded into a hardware slot before the AES operation — if we can read the slots at that moment, we have it. + +3. **Reverse engineer the Titania firmware** — The security processor firmware contains the key hierarchy. Deeper RE of how it handles PUP operations would reveal where the container key sits relative to the keys we already know. + +Any one of these gives us the missing piece. Once that key is in the tool, it works for every PUP on every firmware — no console needed, forever. + +## I Already Built the Extraction Payload + +To make this as easy as possible for anyone with a jailbroken 13.60 PS5, I wrote a ready-to-compile C payload (`ps5_key_extract.c`) that does all three of the above automatically. It implements four approaches: + +1. **CCP LSB Key Slot Dump** — Maps the AMD CCP v5 MMIO region via `/dev/dmem0`, reads all 128 hardware key slots (8 LSBs x 16 slots x 32 bytes each). If the PUP key is in any slot, this grabs it. + +2. **EncryptService ioctl Handler Scan** — Searches kernel memory for the `pup_update0` device driver's `cdevsw` structure, follows the `d_ioctl` function pointer, and dumps 4KB of the handler code. Disassemble on PC with Ghidra/IDA to find which CCP key slot the decrypt function references. + +3. **Instrumented PUP Decrypt** — Snapshots all CCP LSB slots before and after a `DecryptPupHeader` ioctl call. Any slot that changes during the call = the key that was loaded for decryption. + +4. **Kernel Memory Scan** — Searches near strings like `encsrv`, `decrypt_header`, `Titania`, `ccp_key` and dumps surrounding context. + +All operations are **read-only** — nothing is written to kernel or CCP state. Output goes to `/data/pup_key_dump.bin`, which you transfer to PC and run through the companion `analyze_dump.py` script. That script automatically tests every captured key candidate against encrypted PUP headers from FW 14.00 and 14.10. + +**Requirements:** A PS5 on FW 13.60 or below with kernel R/W access (Byepervisor Reborn + Relapse + etaHEN or similar). Compile for FreeBSD 11 amd64 and load via payload loader. You'll need to plug in your framework's `kernel_read64` / `kernel_copyout` functions. + +## Tool Availability + +The tool works today for everything except the one missing key: +- Parse any PS5 PUP file's BLS container and show structure +- Estimate segment count from the plaintext header +- Decrypt inner firmware blobs if you have them extracted by other means +- Run the PBKDF2 derivation to verify/experiment with Layer 0 +- Bruteforce-test unknown blobs against all known keys with entropy scoring +- Tested against real retail PUPs from FW 14.00 and FW 14.10 +- **Ready to accept the Layer 1 key** — the crypto module just needs one more key added and the full pipeline connects +- **Extraction payload ready** — `ps5_key_extract.c` + `analyze_dump.py` for someone with console access + +Happy to publish the source on GitHub if there's interest. DMs open if you have console access and want to collaborate — this is a one-key problem with a permanent payoff. + +--- + +*All research conducted on personally owned hardware for educational purposes. No proprietary key values are reproduced in this post — all referenced keys are already publicly documented on psdevwiki. This work builds on research by the PS5 security community, the dayzerosec CCP architecture writeup, and fail0verflow's PS4 Aeolia documentation.* diff --git a/analyze_dump.py b/analyze_dump.py new file mode 100644 index 0000000..977f082 --- /dev/null +++ b/analyze_dump.py @@ -0,0 +1,412 @@ +""" +analyze_dump.py — Parse pup_key_dump.bin and test key candidates against PUP headers. + +Reads the structured dump from ps5_key_extract.c, extracts all key candidates +from CCP LSB slots, and tries AES-128-CBC/ECB decryption against the encrypted +PUP headers from two firmware files. + +Usage: + python analyze_dump.py pup_key_dump.bin + python analyze_dump.py pup_key_dump.bin --pup14 path/to/14.00.PUP --pup14_1 path/to/14.10.PUP +""" + +import argparse +import struct +import sys +from pathlib import Path + +try: + from Crypto.Cipher import AES +except ImportError: + print("ERROR: pycryptodome required. Install with: pip install pycryptodome") + sys.exit(1) + +# --- Dump format constants (must match ps5_key_extract.c) --- +DUMP_MAGIC = 0x505550444D50 # "PUPDMP\0\0" as uint64 LE +SECTION_LSB = 0x01 +SECTION_IOCTL = 0x02 +SECTION_SNAP_PRE = 0x03 +SECTION_SNAP_POST = 0x04 +SECTION_MEMSCAN = 0x05 + +SECTION_NAMES = { + SECTION_LSB: "CCP LSB Key Slots", + SECTION_IOCTL: "ioctl Handler Code", + SECTION_SNAP_PRE: "Pre-Decrypt LSB Snapshot", + SECTION_SNAP_POST: "Post-Decrypt LSB Snapshot", + SECTION_MEMSCAN: "Kernel Memory Scan", +} + +# LSB geometry +LSB_COUNT = 8 +SLOTS_PER_LSB = 16 +SLOT_SIZE = 32 +LSB_TOTAL_SIZE = LSB_COUNT * SLOTS_PER_LSB * SLOT_SIZE # 4096 + +# PUP header constants +PUP_HEADER_OFFSET = 0x410 +PUP_HEADER_READ_SIZE = 64 # read enough for testing + +# Known BLS entry sizes for validation +PUP_14_00_BLS_SIZE = 0x4B2339D5 +PUP_14_10_BLS_SIZE = 0x4B2F8A3D + +# Default PUP file paths (set to your local paths or pass via --pup14 / --pup14_1) +DEFAULT_PUP_14_00 = "PS5UPDATE14.00.PUP" +DEFAULT_PUP_14_10 = "PS5UPDATE14.10.PUP" + + +def parse_dump(data: bytes) -> dict: + """Parse the structured dump file.""" + if len(data) < 16: + print(f"[!] Dump file too small ({len(data)} bytes)") + return None + + magic, version, section_count = struct.unpack_from(" list: + """Extract all possible 16-byte key candidates from LSB sections.""" + candidates = [] + seen = set() + + for sec in sections: + if sec["type"] not in (SECTION_LSB, SECTION_SNAP_PRE, SECTION_SNAP_POST): + continue + + data = sec["data"] + # Extract each 32-byte slot, then take the first 16 bytes as AES-128 key + total_slots = len(data) // SLOT_SIZE + for slot_idx in range(total_slots): + slot = data[slot_idx * SLOT_SIZE:(slot_idx + 1) * SLOT_SIZE] + + if slot == b"\x00" * SLOT_SIZE: + continue + + # AES-128 key = first 16 bytes of the slot + key16 = slot[:16] + key_hex = key16.hex() + if key_hex not in seen: + seen.add(key_hex) + candidates.append({ + "key": key16, + "source": sec["name"], + "slot": slot_idx, + "full_slot": slot, + }) + + # Also try second 16 bytes (AES-128 key might be in upper half) + key16b = slot[16:32] + key_hex_b = key16b.hex() + if key_hex_b not in seen and key16b != b"\x00" * 16: + seen.add(key_hex_b) + candidates.append({ + "key": key16b, + "source": sec["name"] + " (upper half)", + "slot": slot_idx, + "full_slot": slot, + }) + + # Also do sliding 16-byte window over all section data + for sec in sections: + data = sec["data"] + if len(data) < 16: + continue + for i in range(0, len(data) - 15, 4): # 4-byte aligned + window = data[i:i + 16] + if window == b"\x00" * 16: + continue + whex = window.hex() + if whex not in seen: + seen.add(whex) + candidates.append({ + "key": window, + "source": f"{sec['name']} @ offset 0x{i:x}", + "slot": -1, + "full_slot": None, + }) + + return candidates + + +def find_lsb_diff(sections: list) -> list: + """Find LSB slots that changed between pre and post snapshots.""" + pre_data = None + post_data = None + + for sec in sections: + if sec["type"] == SECTION_SNAP_PRE: + pre_data = sec["data"] + elif sec["type"] == SECTION_SNAP_POST: + post_data = sec["data"] + + if pre_data is None or post_data is None: + return [] + + changed = [] + min_len = min(len(pre_data), len(post_data)) + total_slots = min_len // SLOT_SIZE + + for slot_idx in range(total_slots): + pre_slot = pre_data[slot_idx * SLOT_SIZE:(slot_idx + 1) * SLOT_SIZE] + post_slot = post_data[slot_idx * SLOT_SIZE:(slot_idx + 1) * SLOT_SIZE] + + if pre_slot != post_slot: + print(f"[!] LSB slot {slot_idx} CHANGED during decrypt:") + print(f" Before: {pre_slot.hex()}") + print(f" After: {post_slot.hex()}") + changed.append({ + "slot": slot_idx, + "before": pre_slot, + "after": post_slot, + }) + + return changed + + +def read_pup_header(pup_path: str, offset: int = PUP_HEADER_OFFSET, + size: int = PUP_HEADER_READ_SIZE) -> bytes | None: + """Read encrypted header bytes from a PUP file.""" + path = Path(pup_path) + if not path.exists(): + print(f"[!] PUP file not found: {pup_path}") + return None + + file_size = path.stat().st_size + if file_size < offset + size: + print(f"[!] PUP file too small: {file_size} bytes") + return None + + with open(path, "rb") as f: + f.seek(offset) + return f.read(size) + + +def validate_decrypted_header(decrypted: bytes, expected_bls_size: int, + pup_name: str) -> bool: + """ + Check if decrypted data looks like a valid PUP segment table entry. + Expected layout: + offset 0: uint64 LE — file_size (should be within 1MB of BLS entry size) + offset 8: uint16 LE — segment_count (should be 1-99) + """ + if len(decrypted) < 16: + return False + + file_size = struct.unpack_from(" bool: + """Try a key with AES-128-CBC (zero IV) and AES-128-ECB on each PUP header.""" + matched = False + + for pup_name, (header_data, bls_size) in pup_headers.items(): + if header_data is None: + continue + + enc_block = header_data[:32] # first 32 bytes (2 AES blocks) + + # AES-128-CBC with zero IV + try: + cipher = AES.new(key, AES.MODE_CBC, iv=b"\x00" * 16) + dec_cbc = cipher.decrypt(enc_block) + if validate_decrypted_header(dec_cbc, bls_size, f"{pup_name} (CBC)"): + matched = True + except (ValueError, KeyError): + pass + + # AES-128-ECB + try: + cipher = AES.new(key, AES.MODE_ECB) + dec_ecb = cipher.decrypt(enc_block) + if validate_decrypted_header(dec_ecb, bls_size, f"{pup_name} (ECB)"): + matched = True + except (ValueError, KeyError): + pass + + return matched + + +def hexdump_section(data: bytes, label: str, max_bytes: int = 256): + """Print a hex dump of section data.""" + print(f"\n--- {label} ---") + show = min(len(data), max_bytes) + for i in range(0, show, 16): + hex_part = " ".join(f"{b:02x}" for b in data[i:i + 16]) + ascii_part = "".join( + chr(b) if 32 <= b < 127 else "." for b in data[i:i + 16] + ) + print(f" {i:04x}: {hex_part:<48s} {ascii_part}") + if show < len(data): + print(f" ... ({len(data) - show} more bytes)") + + +def main(): + parser = argparse.ArgumentParser(description="Analyze PS5 PUP key dump") + parser.add_argument("dumpfile", help="Path to pup_key_dump.bin") + parser.add_argument("--pup14", default=DEFAULT_PUP_14_00, + help="Path to PS5UPDATE14.00.PUP") + parser.add_argument("--pup14_1", default=DEFAULT_PUP_14_10, + help="Path to PS5UPDATE14.10.PUP") + args = parser.parse_args() + + # Read dump file + dump_path = Path(args.dumpfile) + if not dump_path.exists(): + print(f"[!] Dump file not found: {args.dumpfile}") + sys.exit(1) + + print(f"[*] Reading dump: {args.dumpfile}") + dump_data = dump_path.read_bytes() + parsed = parse_dump(dump_data) + if parsed is None: + sys.exit(1) + + # Display ioctl handler sections + for sec in parsed["sections"]: + if sec["type"] == SECTION_IOCTL: + hexdump_section(sec["data"], "ioctl Handler Code (DecryptPupHeader)") + elif sec["type"] == SECTION_MEMSCAN: + hexdump_section(sec["data"], f"Kernel Memory Scan Match") + + # Check for LSB diff (approach 3) + changed_slots = find_lsb_diff(parsed["sections"]) + if changed_slots: + print(f"\n[+] {len(changed_slots)} LSB slots changed during decrypt — high-priority candidates") + + # Extract key candidates + candidates = extract_key_candidates(parsed["sections"]) + print(f"\n[*] Extracted {len(candidates)} unique key candidates") + + if not candidates: + print("[!] No key candidates found in dump. Possible issues:") + print(" - CCP LSB region not accessible via MMIO") + print(" - LSB contents cleared after use") + print(" - Key stored in PSP SRAM, not CCP LSB") + sys.exit(1) + + # Read PUP headers + print(f"\n[*] Reading PUP headers...") + pup_headers = {} + + header_14 = read_pup_header(args.pup14) + if header_14: + print(f" 14.00: {len(header_14)} bytes from offset 0x{PUP_HEADER_OFFSET:x}") + pup_headers["FW 14.00"] = (header_14, PUP_14_00_BLS_SIZE) + + header_14_1 = read_pup_header(args.pup14_1) + if header_14_1: + print(f" 14.10: {len(header_14_1)} bytes from offset 0x{PUP_HEADER_OFFSET:x}") + pup_headers["FW 14.10"] = (header_14_1, PUP_14_10_BLS_SIZE) + + if not pup_headers: + print("[!] No PUP files available for testing") + print("[*] Listing all key candidates instead:\n") + for i, cand in enumerate(candidates): + print(f" Candidate {i}: {cand['key'].hex()}") + print(f" Source: {cand['source']}, slot {cand['slot']}") + if cand['full_slot']: + print(f" Full: {cand['full_slot'].hex()}") + sys.exit(0) + + # Test each candidate + print(f"\n[*] Testing {len(candidates)} candidates against {len(pup_headers)} PUP files...") + matches = [] + + for i, cand in enumerate(candidates): + if i > 0 and i % 100 == 0: + print(f" ... tested {i}/{len(candidates)}") + + if test_key_against_pups(cand["key"], pup_headers): + matches.append(cand) + print(f" KEY FOUND: {cand['key'].hex()}") + print(f" Source: {cand['source']}, slot {cand['slot']}") + + # Priority test: changed slots from approach 3 + if changed_slots: + print(f"\n[*] Priority testing {len(changed_slots)} changed LSB slots...") + for cs in changed_slots: + for offset in (0, 16): + key = cs["after"][offset:offset + 16] + if key == b"\x00" * 16: + continue + print(f" Testing changed slot {cs['slot']} offset {offset}: {key.hex()}") + if test_key_against_pups(key, pup_headers): + matches.append({ + "key": key, + "source": f"Changed LSB slot {cs['slot']} (post-decrypt)", + "slot": cs["slot"], + "full_slot": cs["after"], + }) + + # Report + print(f"\n{'=' * 60}") + if matches: + print(f"[+] FOUND {len(matches)} MATCHING KEY(S)!") + for m in matches: + print(f" Key: {m['key'].hex()}") + print(f" Source: {m['source']}") + else: + print("[*] No matching keys found.") + print("[*] Possible reasons:") + print(" - Key is AES-256, not AES-128") + print(" - Different cipher mode (CTR, XTS, etc.)") + print(" - Header structure differs from expected layout") + print(" - Key not present in dumped LSB data") + print("[*] Next steps:") + print(" - Try AES-256 with 32-byte slot data") + print(" - Reverse the ioctl handler code to find the exact algorithm") + print(" - Use approach 3 with real DMA memory for live capture") + print(f"{'=' * 60}") + + +if __name__ == "__main__": + main() diff --git a/bls.py b/bls.py new file mode 100644 index 0000000..bbecf0e --- /dev/null +++ b/bls.py @@ -0,0 +1,98 @@ +""" +BLS (SLB2) Container Parser +Parses the outer BLS container of PS5 PUP files. +Magic: 0x32424C53 ("SLB2") +""" +import struct +from dataclasses import dataclass +from typing import BinaryIO + + +BLS_MAGIC = 0x32424C53 # "SLB2" little-endian + + +@dataclass +class BLSEntry: + index: int + block_offset: int # multiply by 512 for byte offset + size: int + name: str + + @property + def byte_offset(self) -> int: + return self.block_offset * 512 + + def __repr__(self) -> str: + return ( + f"BLSEntry(#{self.index} name='{self.name}' " + f"offset=0x{self.byte_offset:X} size=0x{self.size:X})" + ) + + +@dataclass +class BLSHeader: + magic: int + version: int + flags: int + file_count: int + block_count: int + entries: list + + def __repr__(self) -> str: + return ( + f"BLSHeader(magic=0x{self.magic:08X} version={self.version} " + f"flags=0x{self.flags:X} files={self.file_count} blocks={self.block_count})" + ) + + +def parse_bls_header(f: BinaryIO) -> BLSHeader: + """Parse a BLS (SLB2) header from the current file position.""" + data = f.read(32) + if len(data) < 32: + raise ValueError("File too small for BLS header") + + magic, version, flags, file_count, block_count = struct.unpack_from("<5I", data) + + if magic != BLS_MAGIC: + raise ValueError( + f"Invalid BLS magic: 0x{magic:08X} (expected 0x{BLS_MAGIC:08X})" + ) + + entries = [] + for i in range(file_count): + entry_data = f.read(48) + if len(entry_data) < 48: + raise ValueError(f"Truncated BLS entry at index {i}") + + block_offset, size = struct.unpack_from(" bytes: + """Read the raw data of a BLS entry.""" + f.seek(entry.byte_offset) + return f.read(entry.size) + + +def list_bls_entries(filepath: str) -> BLSHeader: + """Open a PUP file and list all BLS entries.""" + with open(filepath, "rb") as f: + return parse_bls_header(f) diff --git a/crypto.py b/crypto.py new file mode 100644 index 0000000..d4cde00 --- /dev/null +++ b/crypto.py @@ -0,0 +1,182 @@ +""" +PS5 Crypto Operations +- AES-128-CBC decryption with known keys (Layer 3) +- PBKDF2 key derivation (Layer 0 - experimental) +- Key derivation infrastructure for when Layer 1 gaps are filled +""" +import hashlib +import struct +from typing import Optional + +from Crypto.Cipher import AES + + +def aes_128_cbc_decrypt(data: bytes, key: bytes, iv: bytes = None) -> bytes: + """Standard AES-128-CBC decryption.""" + if iv is None: + iv = b"\x00" * 16 + if len(key) != 16: + raise ValueError(f"Key must be 16 bytes, got {len(key)}") + if len(iv) != 16: + raise ValueError(f"IV must be 16 bytes, got {len(iv)}") + # Pad data to 16-byte boundary if needed + padded = data + remainder = len(data) % 16 + if remainder: + padded = data + b"\x00" * (16 - remainder) + cipher = AES.new(key, AES.MODE_CBC, iv) + result = cipher.decrypt(padded) + return result[: len(data)] + + +def aes_128_ecb_decrypt(data: bytes, key: bytes) -> bytes: + """AES-128-ECB decryption (single block or multiple).""" + if len(key) != 16: + raise ValueError(f"Key must be 16 bytes, got {len(key)}") + padded = data + remainder = len(data) % 16 + if remainder: + padded = data + b"\x00" * (16 - remainder) + cipher = AES.new(key, AES.MODE_ECB) + result = cipher.decrypt(padded) + return result[: len(data)] + + +def pbkdf2_derive( + password: bytes, salt: bytes, iterations: int, key_len: int = 16 +) -> bytes: + """PBKDF2-HMAC-SHA256 key derivation.""" + return hashlib.pbkdf2_hmac("sha256", password, salt, iterations, dklen=key_len) + + +def decrypt_emc_ipl_header(data: bytes) -> bytes: + """ + Decrypt EMC IPL header using the known CP key. + Key: F0332357C8CFAE7E7E26E52BE9E3AED4 + AES-128-CBC with zeroed IV. + """ + from keys import EMC_IPL_HEADER_KEY + + return aes_128_cbc_decrypt(data, EMC_IPL_HEADER_KEY) + + +def decrypt_emc_ipl_body(data: bytes) -> bytes: + """ + Decrypt EMC IPL body (rev c0 only). + Key: D5C92E39759A3E5CE954E772B1C2B651 + AES-128-CBC with zeroed IV. + """ + from keys import EMC_IPL_CIPHER_KEY + + return aes_128_cbc_decrypt(data, EMC_IPL_CIPHER_KEY) + + +def decrypt_eap_kbl(data: bytes) -> bytes: + """ + Decrypt EAP KBL. + Key: 262555E3CF062B070B5AA2CDDF3A5D0E + AES-128-CBC with zeroed IV. + """ + from keys import EAP_KBL_KEY + + return aes_128_cbc_decrypt(data, EAP_KBL_KEY) + + +def decrypt_eap_kernel(data: bytes) -> bytes: + """ + Decrypt EAP Kernel (from KBL). + Key: CBCC1E53F42C1CB44D965E233CD792A8 + AES-128-CBC with zeroed IV. + """ + from keys import EAP_KBL_KERNEL_KEY + + return aes_128_cbc_decrypt(data, EAP_KBL_KERNEL_KEY) + + +def decrypt_eap_kernel_self(data: bytes) -> bytes: + """ + Decrypt EAP Kernel SELF modules. + Key: 32D00F27AE38FE4AC88A352313A2BFB4 + IV: 08FEA1ACC37A630999745386168812EC + AES-128-CBC. + """ + from keys import EAP_KERNEL_SELF_KEY, EAP_KERNEL_SELF_IV + + return aes_128_cbc_decrypt(data, EAP_KERNEL_SELF_KEY, EAP_KERNEL_SELF_IV) + + +def decrypt_with_titania_key(data: bytes, revision: int) -> Optional[bytes]: + """ + Decrypt using Titania master key for a given hardware revision. + Only revision 2 has a known key; 1 and 3 have only IVs. + """ + from keys import TITANIA_KEYS + + if revision not in TITANIA_KEYS: + return None + tk = TITANIA_KEYS[revision] + if "key" not in tk: + return None + return aes_128_cbc_decrypt(data, tk["key"], tk["iv"]) + + +# --- Experimental: PS4-style key derivation (adapted for PS5) --- + +def ps4_decrypt_with_seed( + rom_constant: bytes, key_seed: bytes, fuse_key: bytes +) -> bytes: + """ + PS4 Aeolia-style key derivation (fail0verflow). + Builds 32-byte input: key_seed[8] repeated twice + rom_constant[16] + AES-128-CBC decrypt with zeroed IV using fuse_key. + Returns second 16 bytes of result. + + NOTE: PS5 probably uses a different scheme (PBKDF2 + KMB_AES). + This is here for reference/experimentation. + """ + if len(key_seed) < 8: + key_seed = key_seed + b"\x00" * (8 - len(key_seed)) + plaintext = key_seed[:8] + key_seed[:8] + rom_constant[:16] + decrypted = aes_128_cbc_decrypt(plaintext, fuse_key) + return decrypted[16:32] + + +def experimental_pbkdf2_master_key() -> bytes: + """ + Experimental: derive master key using OTP_AES_ROOT + KDF_SALT1 + 1000 iters. + This is Layer 0 of the PS5 key derivation. + The exact parameters (hash function, key length) are not fully confirmed. + Trying PBKDF2-HMAC-SHA256 with 16-byte output. + """ + from keys import OTP_AES_ROOT, KDF_SALT1, KDF_ITERS + + return pbkdf2_derive(OTP_AES_ROOT, KDF_SALT1, KDF_ITERS, key_len=16) + + +def show_experimental_derivation(): + """Show experimental PBKDF2 derivation results for research.""" + from keys import OTP_AES_ROOT, KDF_SALT1, KDF_ITERS + + print("=== Experimental PBKDF2 Key Derivation ===") + print(f"OTP_AES_ROOT: {OTP_AES_ROOT.hex()}") + print(f"KDF_SALT1: {KDF_SALT1.hex()}") + print(f"KDF_ITERS: {KDF_ITERS}") + print() + + for hash_fn in ("sha256", "sha1", "sha512"): + for kl in (16, 32): + derived = hashlib.pbkdf2_hmac( + hash_fn, OTP_AES_ROOT, KDF_SALT1, KDF_ITERS, dklen=kl + ) + print(f"PBKDF2-HMAC-{hash_fn.upper()} (dklen={kl}): {derived.hex()}") + + print() + print("NOTE: The correct hash function and key length are not confirmed.") + print("These are candidates to test against known Titania master keys.") + print() + + from keys import TITANIA_KEYS + for rev, tk in TITANIA_KEYS.items(): + if "key" in tk: + print(f"Titania {rev} known key: {tk['key'].hex()}") + print(f"Titania {rev} known IV: {tk['iv'].hex()}") diff --git a/firmware.py b/firmware.py new file mode 100644 index 0000000..0f74330 --- /dev/null +++ b/firmware.py @@ -0,0 +1,161 @@ +""" +Inner Firmware Decryptor +Decrypts already-extracted firmware segments using known keys. +This is Layer 3 - works on raw firmware blobs, not on encrypted PUP segments. +""" +import os +import struct +from typing import Optional + +from crypto import ( + aes_128_cbc_decrypt, + decrypt_emc_ipl_header, + decrypt_emc_ipl_body, + decrypt_eap_kbl, + decrypt_eap_kernel, + decrypt_eap_kernel_self, + decrypt_with_titania_key, +) + + +def detect_firmware_type(data: bytes, filename: str = "") -> Optional[str]: + """Guess the firmware type from filename or content.""" + fn = filename.lower() + if "emc_salina" in fn or "emc_ipl" in fn: + return "emc_ipl" + if "eap_kbl" in fn: + return "eap_kbl" + if "eap_kernel" in fn or "eap_kern" in fn: + return "eap_kernel" + if "titania" in fn: + return "titania" + if fn.endswith(".self"): + return "eap_self" + return None + + +def decrypt_firmware_blob( + data: bytes, fw_type: str, titania_rev: int = 2 +) -> Optional[bytes]: + """ + Decrypt a firmware blob using the appropriate known key. + + Args: + data: raw encrypted firmware bytes + fw_type: one of 'emc_ipl', 'emc_ipl_header', 'eap_kbl', + 'eap_kernel', 'eap_self', 'titania' + titania_rev: Titania hardware revision (1, 2, or 3) + + Returns: + Decrypted bytes, or None if key not available for this type + """ + if fw_type == "emc_ipl_header": + return decrypt_emc_ipl_header(data) + elif fw_type == "emc_ipl": + return decrypt_emc_ipl_body(data) + elif fw_type == "eap_kbl": + return decrypt_eap_kbl(data) + elif fw_type == "eap_kernel": + return decrypt_eap_kernel(data) + elif fw_type == "eap_self": + return decrypt_eap_kernel_self(data) + elif fw_type == "titania": + return decrypt_with_titania_key(data, titania_rev) + return None + + +def try_all_decryptions(data: bytes, output_dir: str, base_name: str): + """ + Try decrypting data with all known keys and save results. + Useful for identifying which key works on unknown segments. + """ + os.makedirs(output_dir, exist_ok=True) + + attempts = [ + ("emc_ipl_header", "EMC IPL Header Key"), + ("emc_ipl", "EMC IPL Cipher Key (rev c0)"), + ("eap_kbl", "EAP KBL Key"), + ("eap_kernel", "EAP KBL Kernel Key"), + ("eap_self", "EAP Kernel SELF Key"), + ] + + # Also try Titania keys + for rev in (1, 2, 3): + attempts.append((f"titania_rev{rev}", f"Titania Rev {rev} Key")) + + results = [] + for key_name, description in attempts: + try: + if key_name.startswith("titania_rev"): + rev = int(key_name[-1]) + decrypted = decrypt_with_titania_key(data[:256], rev) + else: + decrypted = decrypt_firmware_blob(data[:256], key_name) + + if decrypted is None: + results.append((key_name, description, "NO KEY AVAILABLE")) + continue + + # Check if decrypted data looks reasonable + entropy = _estimate_entropy(decrypted[:64]) + has_nulls = b"\x00\x00\x00\x00" in decrypted[:64] + has_ascii = any(32 <= b <= 126 for b in decrypted[:32]) + + verdict = "HIGH_ENTROPY" + if entropy < 4.0: + verdict = "LOW_ENTROPY (likely correct!)" + elif has_nulls and has_ascii: + verdict = "POSSIBLE_MATCH" + + results.append((key_name, description, verdict)) + + # Save first 256 bytes for inspection + out_path = os.path.join(output_dir, f"{base_name}.{key_name}.bin") + with open(out_path, "wb") as f: + f.write(decrypted) + + except Exception as e: + results.append((key_name, description, f"ERROR: {e}")) + + return results + + +def _estimate_entropy(data: bytes) -> float: + """Rough Shannon entropy estimate (0-8 bits per byte).""" + import math + + if not data: + return 0.0 + freq = {} + for b in data: + freq[b] = freq.get(b, 0) + 1 + entropy = 0.0 + for count in freq.values(): + p = count / len(data) + if p > 0: + entropy -= p * math.log2(p) + return entropy + + +def analyze_emc_ipl(data: bytes) -> dict: + """ + Analyze an EMC IPL blob structure. + Based on PS4 Aeolia IPL header (fail0verflow), adapted for PS5. + """ + result = {"size": len(data)} + + # Try decrypting header with known key + if len(data) >= 0x80: + dec_header = decrypt_emc_ipl_header(data[:0x80]) + result["header_decrypted"] = dec_header + + # Check for PS4-style magic at start + magic = struct.unpack_from("= 0x20: + result["possible_hdr_len"] = struct.unpack_from(" {output}") + + # Show first 64 bytes hex dump + print("\nFirst 64 bytes of decrypted output:") + _hexdump(result[:64]) + + return 0 + + +def cmd_bruteforce(args): + """Try all known keys against a firmware blob.""" + filepath = args.file + output_dir = args.output or os.path.splitext(filepath)[0] + "_bruteforce" + + if not os.path.isfile(filepath): + print(f"Error: File not found: {filepath}") + return 1 + + with open(filepath, "rb") as f: + data = f.read() + + print(f"Trying all known keys against {os.path.basename(filepath)}...") + print(f"Output dir: {output_dir}/") + print() + + results = try_all_decryptions(data, output_dir, os.path.splitext(os.path.basename(filepath))[0]) + + print(f"{'Key':<20} {'Description':<30} {'Result'}") + print("-" * 80) + for key_name, description, verdict in results: + marker = ">>>" if "likely correct" in verdict.lower() or "POSSIBLE" in verdict else " " + print(f"{marker} {key_name:<20} {description:<30} {verdict}") + + print() + print("LOW_ENTROPY = likely correct decryption (structured data)") + print("POSSIBLE_MATCH = has null runs and ASCII (worth investigating)") + print("HIGH_ENTROPY = still looks encrypted or wrong key") + return 0 + + +def cmd_analyze(args): + """Analyze an EMC IPL blob in detail.""" + filepath = args.file + + if not os.path.isfile(filepath): + print(f"Error: File not found: {filepath}") + return 1 + + with open(filepath, "rb") as f: + data = f.read() + + print(f"=== EMC IPL Analysis: {os.path.basename(filepath)} ===") + print(f"Size: {len(data):,} bytes") + print() + + info = analyze_emc_ipl(data) + + if "header_magic" in info: + print(f"Decrypted header magic: {info['header_magic']}") + if "possible_hdr_len" in info: + print(f"Possible header length: 0x{info['possible_hdr_len']:X}") + if "possible_body_len" in info: + print(f"Possible body length: 0x{info['possible_body_len']:X}") + + if "header_decrypted" in info: + print("\nDecrypted header (first 0x80 bytes):") + _hexdump(info["header_decrypted"]) + + return 0 + + +def cmd_decompress(args): + """Decompress a zlib-compressed segment.""" + filepath = args.file + + if not os.path.isfile(filepath): + print(f"Error: File not found: {filepath}") + return 1 + + with open(filepath, "rb") as f: + data = f.read() + + try: + decompressed = zlib.decompress(data) + except zlib.error as e: + print(f"Decompression failed: {e}") + print("Data may not be zlib-compressed, or may still be encrypted.") + return 1 + + output = args.output + if not output: + base, ext = os.path.splitext(filepath) + output = f"{base}.decompressed{ext}" + + with open(output, "wb") as f: + f.write(decompressed) + + ratio = len(decompressed) / len(data) if data else 0 + print(f"Decompressed: {len(data):,} -> {len(decompressed):,} bytes ({ratio:.1f}x)") + print(f"Output: {output}") + return 0 + + +def cmd_keys(args): + """Show known keys and experimental derivation.""" + show_experimental_derivation() + + +def _hexdump(data: bytes, width: int = 16): + """Print a hex dump of data.""" + for offset in range(0, len(data), width): + chunk = data[offset:offset + width] + hex_part = " ".join(f"{b:02X}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in chunk) + print(f" {offset:08X} {hex_part:<{width * 3}} {ascii_part}") + + +def main(): + parser = argparse.ArgumentParser( + description="PS5 PUP Decrypt Tool - Partial PC-based firmware analyzer", + epilog=( + "Layer 3 (known keys) is fully functional.\n" + "Layer 1 (PUP segment decryption) requires key derivation research.\n" + "For security research on personally owned hardware only." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # info + p_info = subparsers.add_parser("info", help="Show PUP/BLS structure info") + p_info.add_argument("file", help="Path to PS5UPDATE.PUP or extracted PUP") + p_info.set_defaults(func=cmd_info) + + # extract + p_extract = subparsers.add_parser("extract", help="Extract BLS entries to disk") + p_extract.add_argument("file", help="Path to PS5UPDATE.PUP") + p_extract.add_argument("-o", "--output", help="Output directory") + p_extract.set_defaults(func=cmd_extract) + + # decrypt + p_decrypt = subparsers.add_parser("decrypt", help="Decrypt firmware blob with known key") + p_decrypt.add_argument("file", help="Path to extracted firmware blob") + p_decrypt.add_argument( + "-t", "--type", + choices=["emc_ipl_header", "emc_ipl", "eap_kbl", "eap_kernel", "eap_self", "titania"], + help="Firmware type (auto-detected from filename if omitted)", + ) + p_decrypt.add_argument("-o", "--output", help="Output file path") + p_decrypt.add_argument("--titania-rev", type=int, default=2, choices=[1, 2, 3], + help="Titania hardware revision (default: 2)") + p_decrypt.set_defaults(func=cmd_decrypt) + + # bruteforce + p_brute = subparsers.add_parser("bruteforce", help="Try all known keys against a blob") + p_brute.add_argument("file", help="Path to firmware blob") + p_brute.add_argument("-o", "--output", help="Output directory") + p_brute.set_defaults(func=cmd_bruteforce) + + # analyze + p_analyze = subparsers.add_parser("analyze", help="Analyze EMC IPL blob structure") + p_analyze.add_argument("file", help="Path to EMC IPL blob") + p_analyze.set_defaults(func=cmd_analyze) + + # decompress + p_decomp = subparsers.add_parser("decompress", help="Zlib-decompress a segment") + p_decomp.add_argument("file", help="Path to compressed segment") + p_decomp.add_argument("-o", "--output", help="Output file path") + p_decomp.set_defaults(func=cmd_decompress) + + # keys + p_keys = subparsers.add_parser("keys", help="Show known keys and derivation experiments") + p_keys.set_defaults(func=cmd_keys) + + args = parser.parse_args() + if not args.command: + parser.print_help() + return 0 + + try: + from Crypto.Cipher import AES + except ImportError: + print("Error: pycryptodome is required. Install it with:") + print(" pip install pycryptodome") + return 1 + + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/ps5_key_extract.c b/ps5_key_extract.c new file mode 100644 index 0000000..082a306 --- /dev/null +++ b/ps5_key_extract.c @@ -0,0 +1,593 @@ +/* + * ps5_key_extract.c — PUP header decryption key extractor for jailbroken PS5 + * + * Target: PS5 FW 13.60 or below with kernel R/W (e.g. Byepervisor Reborn + Relapse) + * Build: compile for FreeBSD 11 amd64, load via payload loader + * Output: /data/pup_key_dump.bin (structured binary with section headers) + * + * All operations are READ-ONLY. Nothing is written to kernel or hardware state. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ---------- kernel R/W primitives (framework-dependent) ---------- */ +extern uint64_t kernel_read64(uint64_t kaddr); +extern uint32_t kernel_read32(uint64_t kaddr); +extern int kernel_copyout(uint64_t kaddr, void *uaddr, size_t len); + +/* ---------- output format ---------- */ +#define DUMP_MAGIC 0x505550444D50ULL /* "PUPDMP\0\0" */ +#define SECTION_LSB 0x01 +#define SECTION_IOCTL 0x02 +#define SECTION_SNAP_PRE 0x03 +#define SECTION_SNAP_POST 0x04 +#define SECTION_MEMSCAN 0x05 + +typedef struct { + uint64_t magic; + uint32_t version; + uint32_t section_count; +} __attribute__((packed)) dump_header_t; + +typedef struct { + uint32_t type; + uint32_t size; + uint32_t offset; + uint32_t reserved; +} __attribute__((packed)) section_header_t; + +/* ---------- AMD CCP v5 constants ---------- */ +#define PCI_VENDOR_AMD 0x1022 +#define CCP_DEV_ID_1 0x1456 +#define CCP_DEV_ID_2 0x1468 +#define CCP_DEV_ID_3 0x15DF + +#define CCP_QUEUE_MMIO_BASE 0x3001000 +#define CCP_QUEUE_STRIDE 0x1000 + +/* LSB mask register offsets within a queue */ +#define CCP_PUBLIC_MASK_LO 0x18 +#define CCP_PUBLIC_MASK_HI 0x1C +#define CCP_PRIVATE_MASK_LO 0x20 +#define CCP_PRIVATE_MASK_HI 0x24 + +/* Candidate LSB region offsets from CCP BAR */ +#define LSB_OFFSET_A 0x8000 +#define LSB_OFFSET_B 0x10000 +#define LSB_OFFSET_C 0x4000 + +/* LSB geometry: 8 LSBs x 16 slots x 32 bytes = 4096 bytes */ +#define LSB_COUNT 8 +#define SLOTS_PER_LSB 16 +#define SLOT_SIZE 32 +#define LSB_TOTAL_SIZE (LSB_COUNT * SLOTS_PER_LSB * SLOT_SIZE) + +/* CCP engine type constants (from Linux ccp-dev.h) */ +#define CCP_ENGINE_AES 0 +#define CCP_ENGINE_XTS_AES 1 +#define CCP_ENGINE_DES3 2 /* 3DES */ +#define CCP_ENGINE_SHA 3 +#define CCP_ENGINE_RSA 4 +#define CCP_ENGINE_PASSTHRU 5 +#define CCP_ENGINE_ZLIB 6 +#define CCP_ENGINE_ECC 7 + +/* CCP AES modes */ +#define CCP_AES_MODE_ECB 0 +#define CCP_AES_MODE_CBC 1 +#define CCP_AES_MODE_OFB 2 +#define CCP_AES_MODE_CFB 3 +#define CCP_AES_MODE_CTR 4 +#define CCP_AES_MODE_CMAC 5 +#define CCP_AES_MODE_GHASH 6 +#define CCP_AES_MODE_GCTR 7 +#define CCP_AES_MODE_GCM 8 +#define CCP_AES_MODE_GMAC 9 + +/* ---------- PUP ioctl constants (from psdevwiki) ---------- */ +#define PUP_DEVICE_PATH "/dev/pup_update0" +#define PUP_IOCTL_DECRYPT_HEADER 0xC0184402 /* DecryptPupHeader */ +#define PUP_IOCTL_VERIFY_SEGMENT 0xC0184403 +#define PUP_IOCTL_DECRYPT_SEGMENT 0xC0184404 +#define PUP_HEADER_ENC_SIZE 0x5B0 + +/* ---------- PS5 kernel constants (FreeBSD 11 based) ---------- */ +#define KERNEL_BASE_PRIMARY 0xFFFFFFFF82200000ULL +#define KERNEL_BASE_FALLBACK 0xFFFFFFFF80000000ULL + +/* FreeBSD 11 cdevsw field offsets */ +#define CDEVSW_D_VERSION 0x00 +#define CDEVSW_D_NAME 0x08 +#define CDEVSW_D_OPEN 0x10 +#define CDEVSW_D_CLOSE 0x18 +#define CDEVSW_D_READ 0x20 +#define CDEVSW_D_WRITE 0x28 +#define CDEVSW_D_IOCTL 0x30 +#define CDEVSW_D_POLL 0x38 + +/* ioctl argument for DecryptPupHeader */ +typedef struct { + uint64_t src_paddr; + uint64_t dst_paddr; + uint32_t size; + uint32_t flags; +} __attribute__((packed)) pup_decrypt_arg_t; + +/* ---------- globals ---------- */ +static uint8_t g_output[256 * 1024]; +static uint32_t g_output_pos = 0; +static uint32_t g_section_count = 0; + +static section_header_t g_sections[32]; + +/* ---------- output helpers ---------- */ +static void output_begin(void) { + g_output_pos = sizeof(dump_header_t) + sizeof(g_sections); + g_section_count = 0; + memset(g_output, 0, sizeof(g_output)); +} + +static void output_add_section(uint32_t type, const void *data, uint32_t size) { + if (g_section_count >= 32) return; + if (g_output_pos + size > sizeof(g_output)) return; + + g_sections[g_section_count].type = type; + g_sections[g_section_count].size = size; + g_sections[g_section_count].offset = g_output_pos; + g_sections[g_section_count].reserved = 0; + + memcpy(g_output + g_output_pos, data, size); + g_output_pos += size; + g_section_count++; +} + +static int output_write(const char *path) { + dump_header_t hdr; + hdr.magic = DUMP_MAGIC; + hdr.version = 1; + hdr.section_count = g_section_count; + + memcpy(g_output, &hdr, sizeof(hdr)); + memcpy(g_output + sizeof(hdr), g_sections, sizeof(g_sections)); + + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + printf("[!] Failed to open %s for writing\n", path); + return -1; + } + write(fd, g_output, g_output_pos); + close(fd); + printf("[+] Wrote %u bytes to %s (%u sections)\n", + g_output_pos, path, g_section_count); + return 0; +} + +static void hexdump(const char *label, const uint8_t *data, size_t len) { + printf("[*] %s (%zu bytes):\n", label, len); + for (size_t i = 0; i < len; i++) { + if (i > 0 && (i % 16) == 0) printf("\n"); + if ((i % 16) == 0) printf(" %04zx: ", i); + printf("%02x ", data[i]); + } + printf("\n"); +} + +/* ================================================================ + * APPROACH 1: CCP LSB Key Slot Dump + * + * Map the AMD CCP v5 MMIO region and read all 128 LSB key slots. + * The CCP stores AES keys used by the security processor in its + * Local Storage Block (LSB). The PUP decrypt key should be in one + * of these slots. + * ================================================================ */ +static int approach1_ccp_lsb_dump(void) { + printf("\n=== APPROACH 1: CCP LSB Key Slot Dump ===\n"); + + int dmem_fd = open("/dev/dmem0", O_RDWR); + if (dmem_fd < 0) { + dmem_fd = open("/dev/mem", O_RDWR); + if (dmem_fd < 0) { + printf("[!] Cannot open /dev/dmem0 or /dev/mem\n"); + return -1; + } + printf("[+] Opened /dev/mem\n"); + } else { + printf("[+] Opened /dev/dmem0\n"); + } + + /* Find CCP BAR via PCI config space scan */ + uint64_t ccp_bar = 0; + printf("[*] Scanning PCI for AMD CCP (vendor 0x%04x, devices 0x%04x/0x%04x/0x%04x)...\n", + PCI_VENDOR_AMD, CCP_DEV_ID_1, CCP_DEV_ID_2, CCP_DEV_ID_3); + + /* + * PCI config space on x86: use I/O ports 0xCF8/0xCFC or ECAM. + * On PS5, the CCP is integrated into the SoC. Try known MMIO bases. + * The queue MMIO base 0x3001000 is relative to the CCP BAR. + * Typical PS5 CCP BAR candidates: + */ + uint64_t bar_candidates[] = { + 0xE0100000ULL, /* common AMD APU CCP BAR */ + 0xFED80000ULL, /* AMD FCH region */ + 0xFEC10000ULL, + 0x03000000ULL, /* from dayzerosec: queue base 0x3001000 implies BAR ~ 0x3000000 */ + }; + + uint32_t lsb_offsets[] = { LSB_OFFSET_A, LSB_OFFSET_B, LSB_OFFSET_C }; + uint8_t lsb_data[LSB_TOTAL_SIZE]; + int found_any = 0; + + for (int b = 0; b < (int)(sizeof(bar_candidates)/sizeof(bar_candidates[0])); b++) { + uint64_t bar = bar_candidates[b]; + + for (int lo = 0; lo < (int)(sizeof(lsb_offsets)/sizeof(lsb_offsets[0])); lo++) { + uint64_t lsb_phys = bar + lsb_offsets[lo]; + printf("[*] Trying BAR 0x%llx + LSB offset 0x%x = 0x%llx\n", + (unsigned long long)bar, lsb_offsets[lo], + (unsigned long long)lsb_phys); + + void *mapped = mmap(NULL, LSB_TOTAL_SIZE, + PROT_READ, MAP_SHARED, + dmem_fd, (off_t)lsb_phys); + if (mapped == MAP_FAILED) { + printf("[!] mmap failed for 0x%llx\n", (unsigned long long)lsb_phys); + continue; + } + + memcpy(lsb_data, mapped, LSB_TOTAL_SIZE); + munmap(mapped, LSB_TOTAL_SIZE); + + /* Check for non-zero slots */ + int nonzero_count = 0; + for (int slot = 0; slot < LSB_COUNT * SLOTS_PER_LSB; slot++) { + uint8_t *s = &lsb_data[slot * SLOT_SIZE]; + int is_zero = 1; + for (int j = 0; j < SLOT_SIZE; j++) { + if (s[j] != 0) { is_zero = 0; break; } + } + if (!is_zero) { + nonzero_count++; + printf("[+] Non-zero LSB slot %d (LSB %d, slot %d):\n", + slot, slot / SLOTS_PER_LSB, slot % SLOTS_PER_LSB); + hexdump(" slot data", s, SLOT_SIZE); + } + } + + if (nonzero_count > 0) { + printf("[+] Found %d non-zero slots at BAR 0x%llx + 0x%x\n", + nonzero_count, (unsigned long long)bar, lsb_offsets[lo]); + output_add_section(SECTION_LSB, lsb_data, LSB_TOTAL_SIZE); + found_any = 1; + } + } + + /* Also read the queue mask registers */ + for (int q = 0; q < 5; q++) { + uint64_t queue_base = bar + CCP_QUEUE_MMIO_BASE + (q * CCP_QUEUE_STRIDE); + void *qmap = mmap(NULL, 0x100, PROT_READ, MAP_SHARED, + dmem_fd, (off_t)queue_base); + if (qmap == MAP_FAILED) continue; + + uint32_t pub_lo = *(volatile uint32_t *)((uint8_t *)qmap + CCP_PUBLIC_MASK_LO); + uint32_t pub_hi = *(volatile uint32_t *)((uint8_t *)qmap + CCP_PUBLIC_MASK_HI); + uint32_t priv_lo = *(volatile uint32_t *)((uint8_t *)qmap + CCP_PRIVATE_MASK_LO); + uint32_t priv_hi = *(volatile uint32_t *)((uint8_t *)qmap + CCP_PRIVATE_MASK_HI); + munmap(qmap, 0x100); + + if (pub_lo || pub_hi || priv_lo || priv_hi) { + printf("[+] Queue %d masks: pub=0x%08x%08x priv=0x%08x%08x\n", + q, pub_hi, pub_lo, priv_hi, priv_lo); + } + } + } + + close(dmem_fd); + if (!found_any) { + printf("[!] No non-zero LSB data found via MMIO\n"); + } + return found_any ? 0 : -1; +} + +/* ================================================================ + * APPROACH 2: EncryptService ioctl Handler Scan + * + * Find the pup_update device's cdevsw in kernel memory, extract + * the d_ioctl handler pointer, dump 4KB of the handler code. + * This is the function called by ioctl 0xC0184402. + * ================================================================ */ +static int approach2_ioctl_handler_scan(void) { + printf("\n=== APPROACH 2: EncryptService ioctl Handler Scan ===\n"); + + uint64_t kernel_bases[] = { KERNEL_BASE_PRIMARY, KERNEL_BASE_FALLBACK }; + const char *search_str = "pup_update"; + size_t search_len = strlen(search_str); + + /* Scan range: first 32MB of kernel text/data */ + size_t scan_size = 32 * 1024 * 1024; + uint8_t *kbuf = (uint8_t *)malloc(scan_size); + if (!kbuf) { + printf("[!] malloc failed for kernel scan buffer\n"); + return -1; + } + + for (int kb = 0; kb < (int)(sizeof(kernel_bases)/sizeof(kernel_bases[0])); kb++) { + uint64_t kbase = kernel_bases[kb]; + printf("[*] Scanning kernel at base 0x%llx...\n", (unsigned long long)kbase); + + if (kernel_copyout(kbase, kbuf, scan_size) != 0) { + printf("[!] kernel_copyout failed for base 0x%llx\n", (unsigned long long)kbase); + continue; + } + + /* Search for "pup_update" string */ + for (size_t i = 0; i < scan_size - search_len; i++) { + if (memcmp(&kbuf[i], search_str, search_len) == 0 && kbuf[i + search_len] == '\0') { + uint64_t str_kaddr = kbase + i; + printf("[+] Found \"%s\" at kernel address 0x%llx\n", + search_str, (unsigned long long)str_kaddr); + + /* + * Now search for a pointer to this string in kernel memory. + * In FreeBSD 11 cdevsw, d_name is at offset 0x08. + * So cdevsw_base = ptr_location - 0x08. + */ + for (size_t j = 0; j < scan_size - 8; j += 8) { + uint64_t val; + memcpy(&val, &kbuf[j], 8); + if (val == str_kaddr) { + uint64_t ptr_kaddr = kbase + j; + uint64_t cdevsw_base = ptr_kaddr - CDEVSW_D_NAME; + + printf("[+] Pointer to string at 0x%llx\n", + (unsigned long long)ptr_kaddr); + printf("[+] Candidate cdevsw at 0x%llx\n", + (unsigned long long)cdevsw_base); + + /* Read d_ioctl at cdevsw + 0x30 */ + uint64_t d_ioctl = kernel_read64(cdevsw_base + CDEVSW_D_IOCTL); + printf("[+] d_ioctl handler at 0x%llx\n", + (unsigned long long)d_ioctl); + + /* Validate: should be in kernel space */ + if (d_ioctl < 0xFFFFFFFF80000000ULL) { + printf("[!] d_ioctl address looks invalid, skipping\n"); + continue; + } + + /* Also read other cdevsw entries for context */ + uint64_t d_open = kernel_read64(cdevsw_base + CDEVSW_D_OPEN); + uint64_t d_close = kernel_read64(cdevsw_base + CDEVSW_D_CLOSE); + printf("[*] d_open=0x%llx d_close=0x%llx\n", + (unsigned long long)d_open, + (unsigned long long)d_close); + + /* Dump 4KB of the ioctl handler code */ + uint8_t handler_code[4096]; + if (kernel_copyout(d_ioctl, handler_code, sizeof(handler_code)) == 0) { + printf("[+] Dumped ioctl handler (4096 bytes)\n"); + hexdump("ioctl handler first 128 bytes", handler_code, 128); + output_add_section(SECTION_IOCTL, handler_code, sizeof(handler_code)); + } else { + printf("[!] Failed to dump ioctl handler code\n"); + } + + /* Dump the cdevsw struct itself */ + uint8_t cdevsw_raw[0x80]; + if (kernel_copyout(cdevsw_base, cdevsw_raw, sizeof(cdevsw_raw)) == 0) { + hexdump("cdevsw struct", cdevsw_raw, sizeof(cdevsw_raw)); + } + + free(kbuf); + return 0; + } + } + } + } + } + + free(kbuf); + printf("[!] Could not locate pup_update cdevsw\n"); + return -1; +} + +/* ================================================================ + * APPROACH 3: Instrumented PUP Decrypt (LSB snapshot) + * + * Strategy: take a snapshot of all CCP LSB key slots BEFORE and + * AFTER issuing the DecryptPupHeader ioctl. Any slot that changed + * from zero to non-zero (or changed value) during the call was + * loaded by the security processor for the decryption — that's + * our key candidate. + * + * This requires a real PUP header blob to be present at a known + * physical address. The caller must arrange DMA-able memory. + * ================================================================ */ +static int approach3_instrumented_decrypt(void) { + printf("\n=== APPROACH 3: Instrumented PUP Decrypt ===\n"); + + int pup_fd = open(PUP_DEVICE_PATH, O_RDWR); + if (pup_fd < 0) { + printf("[!] Cannot open %s — device may not exist or access denied\n", + PUP_DEVICE_PATH); + printf("[*] Strategy: snapshot LSB before/after ioctl 0x%x\n", + PUP_IOCTL_DECRYPT_HEADER); + printf("[*] The ioctl argument is 24 bytes:\n"); + printf("[*] uint64_t src_paddr — physical address of encrypted header\n"); + printf("[*] uint64_t dst_paddr — physical address for decrypted output\n"); + printf("[*] uint32_t size — 0x5B0 (PUP header encrypted size)\n"); + printf("[*] uint32_t flags — typically 0\n"); + printf("[*] The key is loaded into a CCP LSB slot during the ioctl call.\n"); + printf("[*] Compare pre/post LSB snapshots to identify which slot changed.\n"); + return -1; + } + + printf("[+] Opened %s\n", PUP_DEVICE_PATH); + + /* Take pre-decrypt LSB snapshot via MMIO */ + int dmem_fd = open("/dev/dmem0", O_RDWR); + if (dmem_fd < 0) { + printf("[!] Cannot open /dev/dmem0 for LSB snapshot\n"); + close(pup_fd); + return -1; + } + + uint64_t lsb_phys = 0x03000000ULL + LSB_OFFSET_A; + uint8_t pre_lsb[LSB_TOTAL_SIZE]; + uint8_t post_lsb[LSB_TOTAL_SIZE]; + + void *mapped = mmap(NULL, LSB_TOTAL_SIZE, PROT_READ, MAP_SHARED, + dmem_fd, (off_t)lsb_phys); + if (mapped == MAP_FAILED) { + printf("[!] Cannot map LSB region for snapshot\n"); + close(dmem_fd); + close(pup_fd); + return -1; + } + + memcpy(pre_lsb, mapped, LSB_TOTAL_SIZE); + printf("[+] Pre-decrypt LSB snapshot captured\n"); + output_add_section(SECTION_SNAP_PRE, pre_lsb, LSB_TOTAL_SIZE); + + /* + * Issue the decrypt ioctl. + * NOTE: src/dst must be valid physical addresses containing + * a real encrypted PUP header. This is a placeholder — + * a real implementation must allocate contiguous physical + * memory (e.g. via contigmalloc or /dev/dmem0). + */ + printf("[*] To actually invoke the ioctl, provide valid src/dst paddrs.\n"); + printf("[*] Skipping actual ioctl call (would need DMA-able memory with PUP header).\n"); + + /* Take post-decrypt snapshot */ + memcpy(post_lsb, mapped, LSB_TOTAL_SIZE); + munmap(mapped, LSB_TOTAL_SIZE); + printf("[+] Post-decrypt LSB snapshot captured\n"); + output_add_section(SECTION_SNAP_POST, post_lsb, LSB_TOTAL_SIZE); + + /* Diff the snapshots */ + int changes = 0; + for (int slot = 0; slot < LSB_COUNT * SLOTS_PER_LSB; slot++) { + uint8_t *pre = &pre_lsb[slot * SLOT_SIZE]; + uint8_t *post = &post_lsb[slot * SLOT_SIZE]; + if (memcmp(pre, post, SLOT_SIZE) != 0) { + printf("[+] LSB slot %d CHANGED during decrypt!\n", slot); + hexdump(" before", pre, SLOT_SIZE); + hexdump(" after ", post, SLOT_SIZE); + changes++; + } + } + + printf("[*] %d LSB slots changed\n", changes); + close(dmem_fd); + close(pup_fd); + return (changes > 0) ? 0 : -1; +} + +/* ================================================================ + * APPROACH 4: Kernel Memory Scan + * + * Search kernel memory near known strings for key-like data. + * Strings: "encsrv", "encrypt_service", "pup_key", + * "decrypt_header", "ccp_key" + * Dump 512 bytes of surrounding context for each match. + * ================================================================ */ +static int approach4_kernel_memscan(void) { + printf("\n=== APPROACH 4: Kernel Memory Scan ===\n"); + + const char *needles[] = { + "encsrv", + "encrypt_service", + "pup_key", + "decrypt_header", + "ccp_key", + "dec_pup", + "Titania", + NULL + }; + + uint64_t kernel_bases[] = { KERNEL_BASE_PRIMARY, KERNEL_BASE_FALLBACK }; + size_t scan_size = 32 * 1024 * 1024; + uint8_t *kbuf = (uint8_t *)malloc(scan_size); + if (!kbuf) { + printf("[!] malloc failed\n"); + return -1; + } + + int total_found = 0; + + for (int kb = 0; kb < (int)(sizeof(kernel_bases)/sizeof(kernel_bases[0])); kb++) { + uint64_t kbase = kernel_bases[kb]; + printf("[*] Scanning kernel at base 0x%llx...\n", (unsigned long long)kbase); + + if (kernel_copyout(kbase, kbuf, scan_size) != 0) { + printf("[!] kernel_copyout failed for 0x%llx\n", (unsigned long long)kbase); + continue; + } + + for (int n = 0; needles[n] != NULL; n++) { + size_t nlen = strlen(needles[n]); + for (size_t i = 0; i < scan_size - nlen; i++) { + if (memcmp(&kbuf[i], needles[n], nlen) == 0) { + uint64_t match_addr = kbase + i; + printf("[+] Found \"%s\" at 0x%llx\n", + needles[n], (unsigned long long)match_addr); + + /* Dump 512 bytes of surrounding context */ + size_t ctx_start = (i >= 256) ? (i - 256) : 0; + size_t ctx_end = i + 256; + if (ctx_end > scan_size) ctx_end = scan_size; + size_t ctx_len = ctx_end - ctx_start; + + char label[128]; + snprintf(label, sizeof(label), "context around \"%s\"", needles[n]); + hexdump(label, &kbuf[ctx_start], ctx_len); + + output_add_section(SECTION_MEMSCAN, &kbuf[ctx_start], (uint32_t)ctx_len); + total_found++; + + /* Skip past this match to avoid duplicates */ + i += nlen; + } + } + } + + if (total_found > 0) break; + } + + free(kbuf); + printf("[*] Total string matches: %d\n", total_found); + return (total_found > 0) ? 0 : -1; +} + +/* ---------- main ---------- */ +int main(int argc, char **argv) { + printf("=== PS5 PUP Key Extractor ===\n"); + printf("Target: PUP header decryption key (AES-128)\n"); + printf("Output: /data/pup_key_dump.bin\n\n"); + + output_begin(); + + int r1 = approach1_ccp_lsb_dump(); + int r2 = approach2_ioctl_handler_scan(); + int r3 = approach3_instrumented_decrypt(); + int r4 = approach4_kernel_memscan(); + + printf("\n=== Summary ===\n"); + printf("Approach 1 (CCP LSB dump): %s\n", r1 == 0 ? "OK" : "FAILED"); + printf("Approach 2 (ioctl handler scan): %s\n", r2 == 0 ? "OK" : "FAILED"); + printf("Approach 3 (instrumented decrypt):%s\n", r3 == 0 ? "OK" : "FAILED"); + printf("Approach 4 (kernel mem scan): %s\n", r4 == 0 ? "OK" : "FAILED"); + + output_write("/data/pup_key_dump.bin"); + + printf("\n[*] Done. Transfer /data/pup_key_dump.bin to PC and run analyze_dump.py\n"); + return 0; +} diff --git a/pup.py b/pup.py new file mode 100644 index 0000000..bdf1b63 --- /dev/null +++ b/pup.py @@ -0,0 +1,208 @@ +""" +PUP Header and Segment Parser +Parses PS5 PUP inner structure (magic: 0xEEF51454) +Works on both encrypted PUPs (limited) and decrypted PUPs (full). +""" +import struct +from dataclasses import dataclass +from typing import BinaryIO, Optional +from keys import SEGMENT_NAMES + + +PUP_MAGIC = 0xEEF51454 + + +@dataclass +class PUPFileHeader: + magic: int + unknown_04: int + unknown_08: int + flags: int + unknown_0B: int + header_size: int + hash_size: int + + @property + def is_valid(self) -> bool: + return self.magic == PUP_MAGIC + + +@dataclass +class PUPHeader: + file_header: PUPFileHeader + file_size: int + segment_count: int + unknown_1A: int + unknown_1C: int + + +@dataclass +class PUPSegment: + index: int + flags: int + flags2: int + offset: int + compressed_size: int + uncompressed_size: int + + @property + def segment_id(self) -> int: + return (self.flags >> 20) & 0xFFF + + @property + def segment_type(self) -> int: + return (self.flags >> 28) & 0xF + + @property + def is_compressed(self) -> bool: + return bool(self.flags & 0x8) + + @property + def is_blocked(self) -> bool: + return bool(self.flags & 0x800) + + @property + def is_table(self) -> bool: + return bool(self.flags & 0x1) + + @property + def block_size(self) -> int: + return 1 << (((self.flags & 0xF000) >> 12) + 12) + + @property + def name(self) -> str: + sid = self.segment_id + if sid in SEGMENT_NAMES: + return SEGMENT_NAMES[sid] + return f"unknown_0x{sid:03X}" + + @property + def type_name(self) -> str: + t = self.segment_type + if t == 0xE: + return "additional_sig" + if t == 0xF: + return "watermark" + return f"type_{t:X}" + + def __repr__(self) -> str: + flags_str = [] + if self.is_compressed: + flags_str.append("COMPRESSED") + if self.is_blocked: + flags_str.append(f"BLOCKED(bs=0x{self.block_size:X})") + if self.is_table: + flags_str.append("TABLE") + flags_info = " ".join(flags_str) if flags_str else "SIMPLE" + + special = self.segment_type + if special in (0xE, 0xF): + return ( + f"PUPSegment(#{self.index} {self.type_name} " + f"flags=0x{self.flags:08X})" + ) + + return ( + f"PUPSegment(#{self.index} id=0x{self.segment_id:03X} " + f"'{self.name}' offset=0x{self.offset:X} " + f"compressed=0x{self.compressed_size:X} " + f"uncompressed=0x{self.uncompressed_size:X} " + f"{flags_info})" + ) + + +def parse_pup_file_header(data: bytes) -> PUPFileHeader: + """Parse the 16-byte PUP file header.""" + if len(data) < 16: + raise ValueError("Data too small for PUP file header") + + magic, unknown_04 = struct.unpack_from(" PUPHeader: + """Parse the 32-byte PUP header (file_header + extended fields).""" + fh = parse_pup_file_header(data) + + file_size = struct.unpack_from(" list: + """Parse segment table entries. Each entry is 32 bytes.""" + segments = [] + for i in range(segment_count): + pos = offset + (i * 32) + if pos + 32 > len(data): + break + flags, flags2 = struct.unpack_from(" Optional[dict]: + """ + Try to parse PUP data. Works fully on decrypted PUPs. + On encrypted PUPs, can only verify magic and show raw header bytes. + """ + if len(data) < 16: + return None + + magic = struct.unpack_from(" 0 and header.segment_count < 1000: + segments = parse_pup_segments(data, header.segment_count) + result["segments"] = segments + + # Sanity check: if segment offsets look reasonable, data is likely decrypted + for seg in segments: + if seg.segment_type in (0xE, 0xF): + continue + if seg.offset > len(data) or seg.offset < 0: + result["encrypted"] = True + break + else: + result["encrypted"] = True + except Exception: + result["encrypted"] = True + + return result diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..acdfd20 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pycryptodome