diff --git a/.gitignore b/.gitignore index 2930595..68fa54d 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,7 @@ _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML *.vshost.* #ignore vshost files... + +# Python (tools/) +__pycache__/ +*.pyc diff --git a/Latest Compiled Version/PS3DumpChecker.exe b/Latest Compiled Version/PS3DumpChecker.exe index 72932e8..638d2d6 100644 Binary files a/Latest Compiled Version/PS3DumpChecker.exe and b/Latest Compiled Version/PS3DumpChecker.exe differ diff --git a/Latest Compiled Version/PS3DumpChecker.exe.md5 b/Latest Compiled Version/PS3DumpChecker.exe.md5 index 29c210c..8eb1084 100644 --- a/Latest Compiled Version/PS3DumpChecker.exe.md5 +++ b/Latest Compiled Version/PS3DumpChecker.exe.md5 @@ -1 +1 @@ -0CAC8E2164D7AF81CEBB66ECEEBFB697 \ No newline at end of file +486A0D9A65E3E02409300E2DC2FDA409 \ No newline at end of file diff --git a/Latest Compiled Version/PS3DumpChecker.exe.version b/Latest Compiled Version/PS3DumpChecker.exe.version index 4e7095f..e28fc92 100644 --- a/Latest Compiled Version/PS3DumpChecker.exe.version +++ b/Latest Compiled Version/PS3DumpChecker.exe.version @@ -1 +1 @@ -1.0.505.0 +1.0.506.0 diff --git a/Latest Compiled Version/changelog b/Latest Compiled Version/changelog index 23ea453..4cacd48 100644 --- a/Latest Compiled Version/changelog +++ b/Latest Compiled Version/changelog @@ -1,3 +1,7 @@ +Build 506 2026-08-27: + - Changed: The embedded 4.92 patch replaced by a 4.93 patch built from Evilnat's 4.93 custom firmware CoreOS. + - Added: OFW and Patched 4.93 ROS hashes. + - Added: tools/ to decrypt CoreOS from a PUP, build ROS hashes, and validate the data files. Build 505 2025-03-07: - Changed: The embedded 4.91 patch replaced by a 4.92 patch built from Evilnat's 4.92 custom firmware CoreOS. - Added: OFW and Patched 4.92 ROS hashes. diff --git a/Latest Compiled Version/default.hashlist b/Latest Compiled Version/default.hashlist index fd792e0..6878817 100644 --- a/Latest Compiled Version/default.hashlist +++ b/Latest Compiled Version/default.hashlist @@ -9,6 +9,12 @@ 7C0020 + + 505788E2430D1573D925E713EA58CA23 + + + 7C54192073537CD1C448971547086423 + 36BD44795F06B59EECBDAAD6982BE426 diff --git a/Latest Compiled Version/default.hashlist.md5 b/Latest Compiled Version/default.hashlist.md5 index cddb75d..fdcf250 100644 --- a/Latest Compiled Version/default.hashlist.md5 +++ b/Latest Compiled Version/default.hashlist.md5 @@ -1 +1 @@ -81CD7ECFF0F7AF98158F1BD2CE61C8DD \ No newline at end of file +3DB50FC7A2EE7F244F429A09212B158C \ No newline at end of file diff --git a/src/PS3DumpChecker/Patches/patch.bin b/src/PS3DumpChecker/Patches/patch.bin index b10d98e..af37dab 100644 Binary files a/src/PS3DumpChecker/Patches/patch.bin and b/src/PS3DumpChecker/Patches/patch.bin differ diff --git a/src/PS3DumpChecker/Patches/patch_info.txt b/src/PS3DumpChecker/Patches/patch_info.txt index 48bdb0c..6644675 100644 --- a/src/PS3DumpChecker/Patches/patch_info.txt +++ b/src/PS3DumpChecker/Patches/patch_info.txt @@ -1 +1 @@ -noFSM 4.92 (Evilnat based) \ No newline at end of file +noFSM 4.93 (Evilnat based) \ No newline at end of file diff --git a/src/PS3DumpChecker/Properties/AssemblyInfo.cs b/src/PS3DumpChecker/Properties/AssemblyInfo.cs index e5ebf7a..f41c4b6 100644 --- a/src/PS3DumpChecker/Properties/AssemblyInfo.cs +++ b/src/PS3DumpChecker/Properties/AssemblyInfo.cs @@ -35,5 +35,5 @@ // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.505.0")] -[assembly: AssemblyFileVersion("1.0.505.0")] \ No newline at end of file +[assembly: AssemblyVersion("1.0.506.0")] +[assembly: AssemblyFileVersion("1.0.506.0")] \ No newline at end of file diff --git a/src/PS3DumpChecker/hashlist.xml b/src/PS3DumpChecker/hashlist.xml index c280e5b..2c82a74 100644 --- a/src/PS3DumpChecker/hashlist.xml +++ b/src/PS3DumpChecker/hashlist.xml @@ -9,6 +9,12 @@ 7C0020 + + 505788E2430D1573D925E713EA58CA23 + + + 7C54192073537CD1C448971547086423 + 36BD44795F06B59EECBDAAD6982BE426 diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..8331c0c --- /dev/null +++ b/tools/README.md @@ -0,0 +1,103 @@ +# tools + +Helpers for adding support for a new PS3 firmware release. + +Adding a firmware has always been a manual process: unpack two PUPs, decrypt +the CoreOS out of each, MD5 the ROS region, hand-edit `hashlist.xml`, and drop +the new payload in as `patch.bin`. These scripts cover the parts that can be +checked by a machine, so a typo in a hex digit fails here instead of on a +user's dump. + +Python 3. No third-party packages. + +## ros_hash.py + +Computes the ROS MD5 that `hashlist.xml` stores, and prints the XML entries +ready to paste. + +``` +python tools/ros_hash.py ofw_content cfw_content --version 4.93 +python tools/ros_hash.py content --name "4.93 CEX" +python tools/ros_hash.py content --name "4.93 CEX Patched (Evilnat based)" --patched +``` + +Input is a *decrypted* CoreOS payload -- the `content` file from an unpacked +`CORE_OS_PACKAGE.pkg`, not the `.pkg` itself and not the PUP. It is the same +7,340,000-byte blob that ships as `Patches/patch.bin`. + +The hash is the MD5 of the first `0x6FFFE0` bytes, matching +`HashCheck.CheckHash`. The byte-swapped MD5 is printed alongside it: NOR dumps +store the region swapped and NAND dumps do not, so seeing both makes a +mismatch obvious rather than silent. + +Sanity check -- this reproduces the 4.92 entry already in `hashlist.xml`: + +``` +$ python tools/ros_hash.py src/PS3DumpChecker/Patches/patch.bin \ + --name "4.92 CEX Patched (Evilnat based)" --patched + MD5 : 36BD44795F06B59EECBDAAD6982BE426 +``` + +## validate_data.py + +Checks `hashlist.xml` and `config.xml` before they ship. Run it after editing +either file. + +``` +python tools/validate_data.py +``` + +Catches duplicate MD5s, duplicate entry names, malformed hashes, non-hex size +and offset attributes, bad `patched` values, offsets missing attributes, and +hashes referencing a type that was never declared. Exits non-zero on error. + +## coreos_decrypt.py + +Decrypts `CORE_OS_PACKAGE.pkg` out of a PUP and writes the `content` blob -- +the 7,340,000-byte ROS payload `ros_hash.py` needs. + +``` +python tools/coreos_decrypt.py PS3UPDAT.PUP out_dir +python tools/coreos_decrypt.py --extract-selfs PS3UPDAT.PUP out_dir +``` + +Requires `pycryptodome` (`python -m pip install --user pycryptodome`). No other +external tools -- reimplements `pupunpack`, `unpkg`, and `cosunpkg` from +fail0verflow/ps3tools in Python, using the public retail PKG keys embedded in +the script. + +`--extract-selfs` writes each SELF from the decrypted content next to +`content`. Useful for sanity-checking the extraction (each file should start +with the `SCE\0` magic). + +## Getting the decrypted CoreOS + +1. Get both PUPs and verify them: + - official `PS3UPDAT.PUP` for the firmware, from Sony's update CDN + - the matching **CEX** Evilnat CFW -- not `noBD`, `noBT` or `noBD+noBT`, + which drop modules and therefore hash differently +2. `python tools/coreos_decrypt.py PS3UPDAT.PUP out_dir` on each. The + `out_dir/content` file is what `ros_hash.py` consumes. +3. `python tools/ros_hash.py ofw_out/content cfw_out/content --version X.XX`. + +`pup_info.py` prints a PUP's entry table if you want to confirm the layout +before decrypting. + +## Adding a firmware, end to end + +1. Verify both PUP downloads against their published checksums. The Evilnat + `.rar` ships an `md5.txt`; the official PUP has a SHA-256 published + alongside it. +2. `python tools/coreos_decrypt.py OFW.PUP ofw_out` and again for the CFW. +3. `python tools/ros_hash.py ofw_out/content cfw_out/content --version X.XX` +4. Paste both entries at the top of `` in + `src/PS3DumpChecker/hashlist.xml`, newest first. +5. Copy the CFW `content` to `src/PS3DumpChecker/Patches/patch.bin` and update + `Patches/patch_info.txt` to `noFSM X.XX (Evilnat based)`. +6. `python tools/validate_data.py` +7. Bump `AssemblyVersion` and `AssemblyFileVersion` in + `Properties/AssemblyInfo.cs`, and add a changelog entry. +8. Build Release_Embedded. The post-build step refreshes + `Latest Compiled Version/`, which is what the in-app updater serves -- + `default.cfg`, `default.hashlist` and their `.md5` files must be regenerated + or existing installs will keep fetching the old data. diff --git a/tools/coreos_decrypt.py b/tools/coreos_decrypt.py new file mode 100644 index 0000000..65f6f85 --- /dev/null +++ b/tools/coreos_decrypt.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +coreos_decrypt.py - Decrypt CORE_OS_PACKAGE.pkg from a PS3 PUP. + +Reimplements the SCE PKG decrypt pipeline from fail0verflow/ps3tools (unpkg, +cosunpkg) in pure Python. Ported from wargio/ps3tools tools.c, GPLv2. + +Input: PS3UPDAT.PUP (retail or Evilnat CFW). +Output: `content` file (decrypted CoreOS layout) + individual SELFs of the +CoreOS package alongside it. + +The `content` file is the 7,340,000-byte ROS payload the game hashes and +patches. That is what ros_hash.py needs and what ships as patch.bin. + +Usage: + python coreos_decrypt.py PS3UPDAT.PUP out_dir + python coreos_decrypt.py --extract-selfs PS3UPDAT.PUP out_dir + +Exit codes: 0 ok, 1 error. +""" + +import argparse +import os +import struct +import sys +import zlib + +try: + from Crypto.Cipher import AES +except ImportError: + print("error: pycryptodome missing. run: python -m pip install --user pycryptodome", + file=sys.stderr) + sys.exit(1) + + +# PKG keys from public scetool keys.conf. Two revisions cover every retail PUP +# that ships CORE_OS_PACKAGE; sce_decrypt_header brute-forces the list until +# the meta header decrypts to the expected zero pattern. +PKG_KEYS = [ + { + "revision": 0x00, + "erk": bytes.fromhex("A97818BD193A67A16FE83A855E1BE9FB5640938D4DBCB2CB52C5A2F8B02B1031"), + "riv": bytes.fromhex("4ACEF01224FBEEDF8245F8FF10211E6E"), + }, + { + "revision": 0x01, + "erk": bytes.fromhex("F8F99006F1C007D5D0B1909E9566E0E70B569399FC3394A811809FDB5CAE92CD"), + "riv": bytes.fromhex("59D28DB4ADDFB40B7D768BC9667C67B1"), + }, +] + + +def be(data, offset, length): + return int.from_bytes(data[offset:offset + length], "big") + + +def be16(d, o): return be(d, o, 2) +def be32(d, o): return be(d, o, 4) +def be64(d, o): return be(d, o, 8) + + +# -------- PUP layer (pupunpack) -------- + +PUP_MAGIC = b"SCEUF" +PUP_HEADER = 0x30 +PUP_ENTRY = 0x20 + +PUP_ENTRY_NAMES = { + 0x100: "version.txt", + 0x101: "license.xml", + 0x102: "promo_flags.txt", + 0x103: "update_flags.txt", + 0x104: "patch_build.txt", + 0x200: "ps3swu.self", + 0x201: "vsh.tar", + 0x202: "dots.txt", + 0x203: "patch_data.pkg", + 0x300: "update_files.tar", + 0x501: "spkg_hdr.tar", + 0x601: "ps3swu2.self", +} + + +def parse_pup(path): + """Return (data, entries[]) where each entry is dict(id, offset, length).""" + with open(path, "rb") as fh: + data = fh.read() + + if not data.startswith(PUP_MAGIC): + raise ValueError("not a PUP (magic mismatch)") + if len(data) < PUP_HEADER: + raise ValueError("truncated PUP header") + n = be64(data, 0x18) + table_end = PUP_HEADER + PUP_ENTRY * n + if n > 0x1000 or table_end > len(data): + raise ValueError( + "PUP claims %d entries, table would need 0x%X bytes of a %d-byte file" + % (n, table_end, len(data))) + entries = [] + for i in range(n): + base = PUP_HEADER + PUP_ENTRY * i + entries.append({ + "id": be64(data, base), + "offset": be64(data, base + 0x08), + "length": be64(data, base + 0x10), + }) + return data, entries + + +def find_entry(entries, entry_id): + for e in entries: + if e["id"] == entry_id: + return e + return None + + +# -------- SPKG layer (unpkg) -------- + +def aes256cbc_decrypt(key, iv, data): + return AES.new(key, AES.MODE_CBC, iv).decrypt(data) + + +def aes128ctr(key, iv, data): + """AES-128-CTR matching fail0verflow's aes128ctr (be64 nonce increment).""" + out = bytearray(len(data)) + counter = bytearray(iv) + ecb = AES.new(key, AES.MODE_ECB) + keystream = b"" + for i, b in enumerate(data): + if (i & 0xF) == 0: + keystream = ecb.encrypt(bytes(counter)) + lo = int.from_bytes(counter[8:16], "big") + 1 + if lo > 0xFFFFFFFFFFFFFFFF: + lo = 0 + hi = int.from_bytes(counter[0:8], "big") + 1 + counter[0:8] = hi.to_bytes(8, "big") + counter[8:16] = lo.to_bytes(8, "big") + out[i] = b ^ keystream[i & 0xF] + return bytes(out) + + +def sce_decrypt_header(pkg): + """Decrypt SCE meta header in place. Returns index of matching key or -1. + + Ported from tools.c sce_decrypt_header. For each candidate key, AES-256-CBC + the 0x40 bytes at meta_offset+0x20; a matching key leaves bytes + [0x10..0x20) and [0x30..0x40) all zero. + """ + meta_offset = be32(pkg, 0x0c) + header_len = be64(pkg, 0x10) + + matched = -1 + for idx, k in enumerate(PKG_KEYS): + tmp = aes256cbc_decrypt(k["erk"], k["riv"], + bytes(pkg[meta_offset + 0x20:meta_offset + 0x60])) + if all(b == 0 for b in tmp[0x10:0x20]) and all(b == 0 for b in tmp[0x30:0x40]): + pkg[meta_offset + 0x20:meta_offset + 0x60] = tmp + matched = idx + break + if matched < 0: + return -1 + + # tools.c does two aes128ctr calls sharing the same iv buffer, whose + # counter mutates in place -> effectively one continuous CTR stream over + # [+0x60, +header_len). Match that with a single call here. + key128 = bytes(pkg[meta_offset + 0x20:meta_offset + 0x30]) + iv = bytes(pkg[meta_offset + 0x40:meta_offset + 0x50]) + + meta_len = header_len - meta_offset + start = meta_offset + 0x60 + end = meta_offset + meta_len + pkg[start:end] = aes128ctr(key128, iv, bytes(pkg[start:end])) + return matched + + +def sce_decrypt_data(pkg): + """Decrypt every data segment in place using the section table.""" + meta_offset = be32(pkg, 0x0c) + n_hdr = be32(pkg, meta_offset + 0x60 + 0x0c) + keytable_base = meta_offset + 0x80 + 0x30 * n_hdr + + for i in range(n_hdr): + entry = meta_offset + 0x80 + 0x30 * i + offset = be64(pkg, entry) + size = be64(pkg, entry + 8) + keyid = be32(pkg, entry + 0x24) + ivid = be32(pkg, entry + 0x28) + if keyid == 0xFFFFFFFF or ivid == 0xFFFFFFFF: + continue + key = bytes(pkg[keytable_base + keyid * 0x10:keytable_base + keyid * 0x10 + 0x10]) + iv = bytes(pkg[keytable_base + ivid * 0x10:keytable_base + ivid * 0x10 + 0x10]) + pkg[offset:offset + size] = aes128ctr(key, iv, bytes(pkg[offset:offset + size])) + + +def unpkg_content(pkg): + """After decrypt, return the `content` blob (decompressed if needed).""" + meta_offset = be32(pkg, 0x0c) + dec_size = be64(pkg, 0x18) + + tmp = meta_offset + 0x80 + 0x30 * 2 + offset = be64(pkg, tmp) + size = be64(pkg, tmp + 8) + flag = be32(pkg, tmp + 0x2c) + size_real = dec_size - 0x80 + + raw = bytes(pkg[offset:offset + size]) + if flag == 0x2: + return zlib.decompress(raw) + return raw + + +# -------- CoreOS layer (cosunpkg) -------- + +def cosunpkg_files(content): + """Iterate (name, data) tuples from a decrypted CoreOS content blob.""" + n = be32(content, 4) + for i in range(n): + entry = 0x10 + 0x30 * i + offset = be64(content, entry) + size = be64(content, entry + 8) + name = content[entry + 0x10:entry + 0x30].rstrip(b"\x00").decode("ascii", "replace") + yield name, content[offset:offset + size] + + +# -------- driver -------- + +def extract_core_os_from_pup(pup_path): + """Return raw CORE_OS_PACKAGE.pkg bytes from a PUP. + + In retail PUPs the CoreOS package is inside update_files.tar, itself in + entry 0x300. `unpkg` runs after untarring update_files.tar to get + CORE_OS_PACKAGE.pkg. For simplicity we do the whole thing inline. + """ + data, entries = parse_pup(pup_path) + up = find_entry(entries, 0x300) + if up is None: + raise ValueError("PUP has no update_files.tar (entry 0x300)") + tar = data[up["offset"]:up["offset"] + up["length"]] + + # Minimal tar walker: 512-byte header blocks, name at 0, size octal at 124. + pos = 0 + while pos + 512 <= len(tar): + header = tar[pos:pos + 512] + if header == b"\x00" * 512: + break + name = header[0:100].split(b"\x00", 1)[0].decode("ascii", "replace") + size_field = header[124:136].split(b"\x00", 1)[0].strip() + size = int(size_field, 8) if size_field else 0 + pos += 512 + if name.endswith("CORE_OS_PACKAGE.pkg"): + return tar[pos:pos + size] + pos += (size + 511) & ~511 + raise ValueError("CORE_OS_PACKAGE.pkg not found in update_files.tar") + + +def decrypt_core_os_pkg(pkg_bytes): + """Given raw CORE_OS_PACKAGE.pkg, return content blob and matched key rev.""" + pkg = bytearray(pkg_bytes) + + if be16(pkg, 0x0a) != 3: + raise ValueError("not an SCE PKG (type != 3)") + + rev = sce_decrypt_header(pkg) + if rev < 0: + raise ValueError("header decrypt failed: no PKG key matches") + sce_decrypt_data(pkg) + return unpkg_content(pkg), PKG_KEYS[rev]["revision"] + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Decrypt CORE_OS_PACKAGE.pkg from a PS3 PUP.") + ap.add_argument("pup") + ap.add_argument("out") + ap.add_argument("--extract-selfs", action="store_true", + help="also write each SELF from the content blob") + args = ap.parse_args(argv) + + try: + print("[1/3] parsing PUP...") + pkg_bytes = extract_core_os_from_pup(args.pup) + print(" CORE_OS_PACKAGE.pkg extracted (%d bytes)" % len(pkg_bytes)) + + print("[2/3] decrypting SCE PKG...") + content, rev = decrypt_core_os_pkg(pkg_bytes) + print(" decrypted with pkg key revision %02x (%d bytes)" % (rev, len(content))) + except (OSError, ValueError, KeyError, struct.error) as exc: + print("error: %s" % exc, file=sys.stderr) + return 1 + + print("[3/3] writing outputs...") + os.makedirs(args.out, exist_ok=True) + content_path = os.path.join(args.out, "content") + with open(content_path, "wb") as fh: + fh.write(content) + print(" wrote %s (%d bytes)" % (content_path, len(content))) + + if args.extract_selfs: + out_root = os.path.realpath(args.out) + for name, blob in cosunpkg_files(content): + if not name: + continue + # Reject path traversal in archive-supplied names: no absolute + # paths, no drive letters, no separators. CoreOS entries are flat + # basenames like "lv0" or "lv2_kernel.self". + if (os.path.isabs(name) or "/" in name or "\\" in name + or name in (".", "..") or ":" in name): + print(" skip %r (unsafe path)" % name, file=sys.stderr) + continue + out = os.path.join(args.out, name) + if not os.path.realpath(out).startswith(out_root + os.sep) \ + and os.path.realpath(out) != out_root: + print(" skip %r (escapes out dir)" % name, file=sys.stderr) + continue + with open(out, "wb") as fh: + fh.write(blob) + print(" wrote %s (%d bytes)" % (out, len(blob))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pup_info.py b/tools/pup_info.py new file mode 100644 index 0000000..fed008a --- /dev/null +++ b/tools/pup_info.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +pup_info.py - Print the header and entry table of a PS3UPDAT.PUP. + +Useful for confirming a PUP is what you think it is before decrypting it, and +for spotting layout changes. Optionally extracts one entry raw. + +Usage: + python pup_info.py PS3UPDAT.PUP + python pup_info.py PS3UPDAT.PUP --extract 768 --out update_files.tar + +Note: extracting an entry yields the *encrypted* SCE package. For the ROS +`content` blob PS3DumpChecker consumes, use `coreos_decrypt.py` instead, which +walks the PUP and decrypts the CoreOS package end-to-end. + +The CoreOS package appears in two places in a modern retail PUP: + - top-level entry 0x200, and + - inside `update_files.tar` (entry 0x300). +The two blobs differ in wrapping but decrypt to the same 7,340,000-byte ROS +payload. `coreos_decrypt.py` walks the tar, matching the console's install +path. + +Exit codes: 0 ok, 1 error. +""" + +import argparse +import struct +import sys + +MAGIC = b"SCEUF" +HEADER_SIZE = 0x30 +ENTRY_SIZE = 0x20 + +# Entry ids seen in retail PUPs. +KNOWN_ENTRIES = { + 0x100: "version.txt", + 0x101: "license.xml", + 0x103: "promo_flags.txt", + 0x200: "CORE_OS_PACKAGE.pkg (top-level)", + 0x201: "UPDATE_FILES.pkg", + 0x202: "spkg_hdr.tar", + 0x300: "update_files.tar (contains CORE_OS_PACKAGE.pkg)", + 0x501: "CORE_OS_PACKAGE.pkg digests", + 0x601: "CORE_OS_PACKAGE.pkg signature", +} + + +def read_header(fh): + fh.seek(0) + raw = fh.read(HEADER_SIZE) + if len(raw) != HEADER_SIZE: + raise ValueError("file is too small to be a PUP") + if not raw.startswith(MAGIC): + raise ValueError("not a PUP (magic is %r, expected %r)" % (raw[:5], MAGIC)) + pkg_ver, img_ver, count, hdr_len, data_len = struct.unpack(">QQQQQ", raw[8:HEADER_SIZE]) + return { + "package_version": pkg_ver, + "image_version": img_ver, + "file_count": count, + "header_length": hdr_len, + "data_length": data_len, + } + + +def read_entries(fh, count): + fh.seek(HEADER_SIZE) + raw = fh.read(ENTRY_SIZE * count) + if len(raw) != ENTRY_SIZE * count: + raise ValueError("truncated entry table") + entries = [] + for i in range(count): + entry_id, offset, length, _pad = struct.unpack( + ">QQQQ", raw[i * ENTRY_SIZE:(i + 1) * ENTRY_SIZE]) + entries.append({"id": entry_id, "offset": offset, "length": length}) + return entries + + +def extract(fh, entry, out_path): + fh.seek(entry["offset"]) + remaining = entry["length"] + with open(out_path, "wb") as out: + while remaining: + chunk = fh.read(min(1 << 20, remaining)) + if not chunk: + raise ValueError("unexpected EOF while extracting") + out.write(chunk) + remaining -= len(chunk) + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Inspect a PS3 PUP.") + ap.add_argument("pup") + ap.add_argument("--extract", type=lambda s: int(s, 0), metavar="ID", + help="entry id to extract, e.g. 512 or 0x200") + ap.add_argument("--out", help="output path for --extract") + args = ap.parse_args(argv) + + if args.extract is not None and not args.out: + ap.error("--out is required with --extract") + + try: + with open(args.pup, "rb") as fh: + hdr = read_header(fh) + entries = read_entries(fh, hdr["file_count"]) + + print("file : %s" % args.pup) + print("package version : %d" % hdr["package_version"]) + print("image version : %d" % hdr["image_version"]) + print("file count : %d" % hdr["file_count"]) + print("header length : 0x%X" % hdr["header_length"]) + print("data length : 0x%X" % hdr["data_length"]) + print() + print("%-8s %-14s %-14s %s" % ("ID", "OFFSET", "LENGTH", "LIKELY CONTENT")) + for entry in entries: + print("%-8d %-14d %-14d %s" + % (entry["id"], entry["offset"], entry["length"], + KNOWN_ENTRIES.get(entry["id"], ""))) + + if args.extract is not None: + match = next((e for e in entries if e["id"] == args.extract), None) + if match is None: + print("\nerror: entry %d not found" % args.extract, file=sys.stderr) + return 1 + extract(fh, match, args.out) + print("\nextracted entry %d (%d bytes) to %s" + % (match["id"], match["length"], args.out)) + except (OSError, ValueError, struct.error) as exc: + print("error: %s" % exc, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ros_hash.py b/tools/ros_hash.py new file mode 100644 index 0000000..11a4a6e --- /dev/null +++ b/tools/ros_hash.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +ros_hash.py - Compute PS3DumpChecker ROS hashes from a decrypted CoreOS. + +Given a decrypted CoreOS payload (the "content" file produced by unpacking +CORE_OS_PACKAGE.pkg from a PS3UPDAT.PUP), this prints the MD5 that +PS3DumpChecker stores in hashlist.xml, plus the ready-to-paste XML entries. + +The hash is computed exactly the way HashCheck.CheckHash does it: +the first ROS_SIZE (0x6FFFE0) bytes of the region are MD5'd. For NOR the +region is byte-swapped first; NAND is not swapped. Both dump layouts share +the same CoreOS payload, so a single hash covers both -- but this tool +reports the swapped variant too, so a mismatch is visible rather than silent. + +Usage: + python ros_hash.py content --name "4.93 CEX" + python ros_hash.py content --name "4.93 CEX Patched (Evilnat based)" --patched + python ros_hash.py ofw_content cfw_content --version 4.93 + +Exit codes: 0 ok, 1 error. +""" + +import argparse +import hashlib +import os +import sys +from xml.sax.saxutils import quoteattr + +# Size of the ROS region hashed by PS3DumpChecker (HashCheck.cs / hashlist.xml). +ROS_SIZE = 0x6FFFE0 + +# Region offsets per dump type, from hashlist.xml . +OFFSETS = { + "NOR": {"size": 0x1000000, "ros0": 0xC0010, "ros1": 0x7C0010}, + "NAND": {"size": 0x10000000, "ros0": 0xC0030, "ros1": 0x7C0020}, +} + + +def swap_bytes(data): + """Byte-swap 16-bit words, mirroring Common.SwapBytes.""" + if len(data) % 2: + raise ValueError("data length must be even to byte-swap") + out = bytearray(data) + out[0::2], out[1::2] = data[1::2], data[0::2] + return bytes(out) + + +def read_ros(path): + """Read the first ROS_SIZE bytes of a decrypted CoreOS payload.""" + size = os.path.getsize(path) + if size < ROS_SIZE: + raise ValueError( + "%s is %d bytes, need at least %d (0x%X). " + "Is this the decrypted 'content' file?" % (path, size, ROS_SIZE, ROS_SIZE) + ) + with open(path, "rb") as fh: + data = fh.read(ROS_SIZE) + if len(data) != ROS_SIZE: + raise ValueError("short read on %s" % path) + return data, size + + +def md5_upper(data): + return hashlib.md5(data).hexdigest().upper() + + +def describe(path): + data, size = read_ros(path) + plain = md5_upper(data) + swapped = md5_upper(swap_bytes(data)) + return { + "path": path, + "size": size, + "trailing": size - ROS_SIZE, + "md5": plain, + "md5_swapped": swapped, + } + + +def xml_entry(name, md5, patched): + attrs = 'name=%s size="%X"' % (quoteattr(name), ROS_SIZE) + if patched: + attrs += ' patched="true"' + return ' \n %s\n ' % (attrs, md5) + + +def report(info, label): + print("== %s ==" % label) + print(" file : %s" % info["path"]) + print(" file size : %d bytes" % info["size"]) + print(" hashed : %d bytes (0x%X)" % (ROS_SIZE, ROS_SIZE)) + print(" trailing : %d bytes not hashed" % info["trailing"]) + print(" MD5 : %s" % info["md5"]) + print(" MD5 swapped : %s" % info["md5_swapped"]) + print() + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="Compute PS3DumpChecker ROS hashes from decrypted CoreOS payload(s).", + epilog="Obtain the 'content' file by unpacking CORE_OS_PACKAGE.pkg " + "(PUP entry 512) with PUAD GUI, pupunpack, or scetool.", + ) + ap.add_argument("content", nargs="+", + help="decrypted CoreOS payload(s); with --version pass OFW then CFW") + ap.add_argument("--name", help="hash entry name, e.g. '4.93 CEX'") + ap.add_argument("--patched", action="store_true", + help="mark the entry as a patched (CFW) ROS") + ap.add_argument("--version", + help="firmware version, e.g. 4.93; emits both OFW and patched " + "entries from two inputs") + args = ap.parse_args(argv) + + if len(args.content) > 2: + ap.error("pass at most two files (OFW and CFW)") + if len(args.content) == 2 and not args.version: + ap.error("--version is required when passing two files") + + print("PS3DumpChecker ROS hash tool") + print("ROS region size: 0x%X (%d bytes)" % (ROS_SIZE, ROS_SIZE)) + for kind, off in OFFSETS.items(): + print(" %-4s dump 0x%X: ROS0 @ 0x%X, ROS1 @ 0x%X" + % (kind, off["size"], off["ros0"], off["ros1"])) + print() + + try: + infos = [describe(p) for p in args.content] + except (OSError, ValueError) as exc: + print("error: %s" % exc, file=sys.stderr) + return 1 + + if len(infos) == 2: + report(infos[0], "OFW") + report(infos[1], "CFW (patched)") + if infos[0]["md5"] == infos[1]["md5"]: + print("error: OFW and CFW hash identically -- inputs are the same file", + file=sys.stderr) + return 1 + entries = [ + xml_entry("%s CEX Patched (Evilnat based)" % args.version, + infos[1]["md5"], True), + xml_entry("%s CEX" % args.version, infos[0]["md5"], False), + ] + else: + report(infos[0], "CoreOS") + if not args.name: + print("error: --name is required for a single input", file=sys.stderr) + return 1 + entries = [xml_entry(args.name, infos[0]["md5"], args.patched)] + + print("Paste into hashlist.xml, at the top of :") + print() + for entry in entries: + print(entry) + print() + print("The same payload is also the ROS patch: copy it to " + "src/PS3DumpChecker/Patches/patch.bin and update patch_info.txt.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/validate_data.py b/tools/validate_data.py new file mode 100644 index 0000000..c13a3f4 --- /dev/null +++ b/tools/validate_data.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +validate_data.py - Sanity-check PS3DumpChecker's data files. + +hashlist.xml and config.xml drive every check the app performs, and they are +edited by hand on each firmware release. A malformed entry is only noticed at +runtime, on a user's dump. This validates them up front. + +Checks performed on hashlist.xml: + - well-formed XML + - every has a name and a 32-hex-digit MD5 body + - no duplicate MD5s (two names claiming the same ROS) + - no duplicate names + - size attributes parse as hex and match the expected ROS size + - every has fsize/type/name/size and a hex offset body + - each hash's type has a matching block + +Checks performed on config.xml: + - well-formed XML + - offset/size attributes parse as hex where present + +Usage: + python validate_data.py + python validate_data.py --hashlist path --config path + +Exit codes: 0 all good, 1 problems found. +""" + +import argparse +import os +import re +import sys +import xml.etree.ElementTree as ET + +ROS_SIZE = 0x6FFFE0 +MD5_RE = re.compile(r"^[0-9A-F]{32}$") + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +DEFAULT_HASHLIST = os.path.join(REPO, "src", "PS3DumpChecker", "hashlist.xml") +DEFAULT_CONFIG = os.path.join(REPO, "src", "PS3DumpChecker", "config.xml") + + +class Report(object): + def __init__(self): + self.errors = [] + self.warnings = [] + + def error(self, msg): + self.errors.append(msg) + + def warn(self, msg): + self.warnings.append(msg) + + +def parse_hex(value): + try: + return int(value, 16) + except (TypeError, ValueError): + return None + + +def validate_hashlist(path, rep): + if not os.path.exists(path): + rep.error("hashlist not found: %s" % path) + return + try: + root = ET.parse(path).getroot() + except ET.ParseError as exc: + rep.error("hashlist.xml is not well-formed: %s" % exc) + return + + declared_types = set() + for type_el in root.iter("type"): + name = type_el.get("name") + if not name: + rep.error(" without a name attribute") + continue + if name in declared_types: + rep.error("duplicate " % name) + declared_types.add(name) + + seen_md5 = {} + seen_name = {} + count = 0 + + for type_el in root.iter("type"): + type_name = type_el.get("name") or "?" + for hash_el in type_el.iter("hash"): + count += 1 + name = hash_el.get("name") + body = (hash_el.text or "").strip().upper() + + if not name: + rep.error(" without a name (type %s, md5 %s)" + % (type_name, body or "empty")) + name = "" + + if not body: + rep.error("%s: empty MD5 body" % name) + elif not MD5_RE.match(body): + rep.error("%s: body is not a 32-hex-digit MD5: %r" % (name, body)) + else: + if body in seen_md5: + rep.error("duplicate MD5 %s shared by %r and %r" + % (body, seen_md5[body], name)) + else: + seen_md5[body] = name + + if name in seen_name: + rep.error("duplicate hash name %r" % name) + else: + seen_name[name] = body + + size_attr = hash_el.get("size") + if size_attr is None: + rep.error("%s: missing size attribute" % name) + else: + size = parse_hex(size_attr) + if size is None: + rep.error("%s: size=%r is not hex" % (name, size_attr)) + elif size != ROS_SIZE: + rep.error("%s: size 0x%X differs from the ROS size 0x%X" + % (name, size, ROS_SIZE)) + + patched = hash_el.get("patched") + if patched is not None and patched.lower() not in ("true", "false"): + rep.error("%s: patched=%r must be true or false" % (name, patched)) + + for offset_el in root.iter("offset"): + name = offset_el.get("name") or "" + for attr in ("fsize", "type", "name", "size"): + if offset_el.get(attr) is None: + rep.error("%s: missing %s attribute" % (name, attr)) + + fsize = offset_el.get("fsize") + if fsize is not None: + try: + int(fsize) + except ValueError: + rep.error("%s: fsize=%r is not a decimal integer" % (name, fsize)) + + if offset_el.get("size") is not None and parse_hex(offset_el.get("size")) is None: + rep.error("%s: size=%r is not hex" % (name, offset_el.get("size"))) + + body = (offset_el.text or "").strip() + if not body: + rep.error("%s: has no offset value" % name) + elif parse_hex(body) is None: + rep.error("%s: offset body %r is not hex" % (name, body)) + + otype = offset_el.get("type") + if otype and otype not in declared_types: + rep.error("%s: references type %r with no matching block" + % (name, otype)) + + print("hashlist.xml : %d hash entries, %d types, %d offsets" + % (count, len(declared_types), len(list(root.iter("offset"))))) + + +def validate_config(path, rep): + if not os.path.exists(path): + rep.error("config not found: %s" % path) + return + try: + root = ET.parse(path).getroot() + except ET.ParseError as exc: + rep.error("config.xml is not well-formed: %s" % exc) + return + + hex_attrs = ("offset", "size", "regionstart", "regionsize", "ldrsize") + elements = 0 + for el in root.iter(): + elements += 1 + for attr in hex_attrs: + raw = el.get(attr) + if raw is None: + continue + if parse_hex(raw) is None: + rep.error("<%s> %s=%r is not hex" % (el.tag, attr, raw)) + + print("config.xml : %d elements" % elements) + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Validate PS3DumpChecker data files.") + ap.add_argument("--hashlist", default=DEFAULT_HASHLIST) + ap.add_argument("--config", default=DEFAULT_CONFIG) + args = ap.parse_args(argv) + + rep = Report() + validate_hashlist(args.hashlist, rep) + validate_config(args.config, rep) + print() + + for msg in rep.warnings: + print("warning: %s" % msg) + for msg in rep.errors: + print("error: %s" % msg, file=sys.stderr) + + if rep.errors: + print("\nFAILED: %d error(s), %d warning(s)" + % (len(rep.errors), len(rep.warnings)), file=sys.stderr) + return 1 + print("OK: no errors, %d warning(s)" % len(rep.warnings)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())