From 9379d9b667c6ed3324d7c17cf544ef98301726f5 Mon Sep 17 00:00:00 2001 From: iwanicki92 Date: Sun, 27 Sep 2026 12:20:20 +0200 Subject: [PATCH 1/5] blob: fall back to a 64 MB ROM when a file lies past 32 MB Some client images carry a 64 MB flash, such as the Lenovo ThinkPad Z13 Gen 2 (BIOS N41ET63W), whose BIOS L2 directory points at 0x3f40100. With the ROM capped at 32 MB that entry fails with "File offset overflows ROM bounds!" and the whole image fails to parse. The size taken from the input file stays the default. Only when parsing the first page hits that overflow, and 64 MB fits after the ROM offset, is the ROM parsed again as 64 MB. Picking 64 MB from the file size alone would skip a 32 MB ROM that starts past offset 0 in a 64 MB file, and would let compressed entries of a 32 MB ROM in a bigger file run into the bytes after it. Signed-off-by: iwanicki92 --- psptool/blob.py | 27 +++++++- tests/unit/test_rom_layouts.py | 114 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_rom_layouts.py diff --git a/psptool/blob.py b/psptool/blob.py index 64dd5a6..42345d6 100644 --- a/psptool/blob.py +++ b/psptool/blob.py @@ -30,6 +30,9 @@ class Blob(NestedBuffer): _FIRMWARE_ENTRY_MAGIC = b'\xAA\x55\xAA\x55' # All structures per Rom must be in 16MB windows _MAX_PAGE_SIZE = 16 * 1024 * 1024 + # Assumed only when a file entry points past the ROM size taken from the + # input size, so a 32 MB ROM inside a bigger file keeps its size + _BIG_ROM_SIZE = 64 * 1024 * 1024 # ROM-relative offsets at which a FET may legitimately appear, in the # try-order used by the parser (the loop breaks on first successful @@ -60,7 +63,7 @@ def __init__(self, buffer: bytearray, size: int, psptool): rom_size = _rom_size * 1024 * 1024 self.psptool.ph.print_warning(f"Input file is {self.buffer_size:#x}, will assume ROM size of {_rom_size}M") - # For each FET, we try to create a 16MB ROM starting at `FET - offset` + # For each FET, we try to create a ROM starting at `FET - offset` for fet_location in self._find_fets(): fet_parsed = False for fet_offset in self.POSSIBLE_FET_OFFSETS: @@ -75,7 +78,7 @@ def __init__(self, buffer: bytearray, size: int, psptool): rom_offset = fet_location - fet_offset # e.g. 0x20800 - 0x20000 = 0x0800 # W/A for images with single ROMs that have entries crossing 16MB boundary if rom_page == 0: - potential_rom = Rom(self, rom_size, rom_offset, fet_offset, psptool) + potential_rom = self._create_first_page_rom(rom_size, rom_offset, fet_offset) else: potential_rom = Rom(self, min(rom_size, self._MAX_PAGE_SIZE), rom_offset, fet_offset, psptool) self.roms.append(potential_rom) @@ -95,6 +98,26 @@ def __init__(self, buffer: bytearray, size: int, psptool): def __repr__(self): return f'Blob({self.roms=})' + def _create_first_page_rom(self, rom_size, rom_offset, fet_offset): + psptool = self.psptool + directories = dict(psptool.directories_by_offset) + files = dict(psptool.files_by_offset) + references = {file: len(file.references) for file in files.values()} + try: + return Rom(self, rom_size, rom_offset, fet_offset, psptool) + except AssertionError as e: + if 'overflows ROM bounds' not in str(e) or rom_offset + self._BIG_ROM_SIZE > self.buffer_size: + raise + + # Drop what the failed attempt registered before parsing again + psptool.directories_by_offset = directories + psptool.files_by_offset = files + for file, count in references.items(): + del file.references[count:] + psptool.ph.print_warning(f"File offset past {rom_size >> 20}M, will assume ROM size of " + f"{self._BIG_ROM_SIZE >> 20}M") + return Rom(self, self._BIG_ROM_SIZE, rom_offset, fet_offset, psptool) + def _construct_range_dict(self): all_files = self.unique_files() diff --git a/tests/unit/test_rom_layouts.py b/tests/unit/test_rom_layouts.py new file mode 100644 index 0000000..27daab4 --- /dev/null +++ b/tests/unit/test_rom_layouts.py @@ -0,0 +1,114 @@ +# PSPTool - Display, extract and manipulate PSP firmware inside UEFI images +# Copyright (C) 2026 contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +"""Synthetic ROMs for layouts seen in Lenovo ThinkPad client firmware. + +- A 64 MB flash whose BIOS L2 directory points past 32 MB (Z13 Gen 2), + and 32 MB ROMs in files of 64 MB that must keep their size. +- 0x48/0x4a entries pointing straight at a $PL2 directory rather than at + a header holding its offset (L13 Gen 4). +- A BIOS directory entry holding a plain PE image, whose bytes at the + header's size field are text (L13 Gen 4). +""" + +import contextlib +import io +import os +import struct +import tempfile +import unittest + +from psptool import PSPTool +from psptool.blob import Blob +from psptool.utils import fletcher32 + +MB = 1024 * 1024 +FET_OFFSET = Blob.POSSIBLE_FET_OFFSETS[0] +PSP_DIR_OFFSET = FET_OFFSET + 0x1000 +HEADER_FILE_SIZE = 0x100 + +# Directory additional_info: version bit set, address mode 1 (flash offset) +FLASH_OFFSET_MODE = (1 << 31) | (1 << 24) + + +def directory(magic: bytes, entries) -> bytes: + body = struct.pack(' bytes: + header = bytearray(HEADER_FILE_SIZE) + header[0x62] = 0x0C # boot loader major in BOOTLOADER_VERSION_TO_ZEN, else PSPTool warns + header[0x6c:0x70] = struct.pack(' bytes: + blob = bytearray(size) + blob[FET_OFFSET - 4:FET_OFFSET] = b'\xff' * 4 + blob[FET_OFFSET:FET_OFFSET + 4] = Blob._FIRMWARE_ENTRY_MAGIC + blob[FET_OFFSET + 4:FET_OFFSET + 8] = struct.pack(' Date: Sun, 27 Sep 2026 12:20:20 +0200 Subject: [PATCH 2/5] directory: follow L2 pointers that point straight at a directory In some images the 0x48/0x4a entries of the $PSP directory point at the $PL2 directory itself rather than at a header holding its offset, for example the Lenovo ThinkPad L13 Gen 4 (BIOS R26ET54W). Reading an offset from inside the directory then gives garbage, the L2 directory is skipped as empty, and every file in it is missing from the output. A directory reached this way inherits the parent's generation, which can be None. update_zen_generation now assigns in that case instead of failing with a TypeError when the same directory is reached again with a generation. The generation read from an indirection header gets its own name, so it does not leak into a direct pointer later in the same directory. Signed-off-by: iwanicki92 --- psptool/directory.py | 35 ++++++++++++++++++++++++---------- tests/unit/test_rom_layouts.py | 27 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/psptool/directory.py b/psptool/directory.py index 7e85c59..c8d914c 100644 --- a/psptool/directory.py +++ b/psptool/directory.py @@ -101,17 +101,26 @@ def create_directories_if_not_exist(cls, offset, fet, zen_generation=None) -> Li # 3. Recursively add tertiary directories (double references introduced in Zen 4), if applicable for tertiary_directory_offset in directory.tertiary_directory_offsets: directory_body = fet.rom.get_bytes(tertiary_directory_offset, 32) + + # Some ROMs point straight at the L2 directory instead of at a + # header holding its offset, so there is nothing to resolve + if directory_body[:4] in cls.DIRECTORY_MAGICS + BiosDirectory.DIRECTORY_MAGICS: + created_directories += cls.create_directories_if_not_exist( + tertiary_directory_offset, fet, zen_generation) + continue + actual_tertiary_offset = int.from_bytes(directory_body[16:20], 'little') zen_generation_id = directory_body[21:24] - zen_generation = cls.get_possible_zen_generation(zen_generation_id) - if zen_generation == 'unknown': + tertiary_zen_generation = cls.get_possible_zen_generation(zen_generation_id) + if tertiary_zen_generation == 'unknown': fet.psptool.ph.print_warning(f"Unknown {zen_generation_id=}") zen_generation_id = hex(int.from_bytes(directory_body[20:24], 'little')) - zen_generation += f' (PSP ID {zen_generation_id})' + tertiary_zen_generation += f' (PSP ID {zen_generation_id})' # Resolve one more indirection - tertiary_directories = cls.create_directories_if_not_exist(actual_tertiary_offset, fet, zen_generation) + tertiary_directories = cls.create_directories_if_not_exist( + actual_tertiary_offset, fet, tertiary_zen_generation) created_directories += tertiary_directories return created_directories @@ -257,12 +266,18 @@ def update_entry_fields(self, file: File, type_, size, offset): self.update_checksum() def update_zen_generation(self, fet, zen_generation): - if zen_generation is not None: - if zen_generation not in self.zen_generation: - self.zen_generation += '\n' + zen_generation - for offset in self.secondary_directory_offsets: - dir = fet.psptool.directories_by_offset[offset] - dir.update_zen_generation(fet, zen_generation) + if zen_generation is None: + return + # A directory first reached without a generation takes the new one + if self.zen_generation is None: + self.zen_generation = zen_generation + elif zen_generation not in self.zen_generation: + self.zen_generation += '\n' + zen_generation + else: + return + for offset in self.secondary_directory_offsets: + dir = fet.psptool.directories_by_offset[offset] + dir.update_zen_generation(fet, zen_generation) class BiosDirectory(Directory): diff --git a/tests/unit/test_rom_layouts.py b/tests/unit/test_rom_layouts.py index 27daab4..360ca26 100644 --- a/tests/unit/test_rom_layouts.py +++ b/tests/unit/test_rom_layouts.py @@ -109,6 +109,33 @@ def test_32mb_rom_at_offset_in_64mb_file(self): self.assertEqual(pt.blob.roms[0].addr_mask, 32 * MB - 1) self.assertEqual({type_ for type_, _ in files(pt)}, {0x01}) + def test_l2_pointer_straight_at_directory(self): + l2_offset = FET_OFFSET + 0x4000 + file_offset = FET_OFFSET + 0x5000 + data = rom(8 * MB, [(0x48, 0x400, l2_offset)], [ + (l2_offset, directory(b'$PL2', [(0x01, HEADER_FILE_SIZE, file_offset)])), + (file_offset, header_file()), + ]) + pt, _ = parse(data) + magics = [d.magic for d in pt.blob.roms[0].directories] + self.assertEqual(magics, [b'$PSP', b'$PL2']) + self.assertIn((0x01, file_offset), files(pt)) + + def test_l2_reached_directly_and_through_ish(self): + l2_offset = FET_OFFSET + 0x4000 + ish_offset = FET_OFFSET + 0x3000 + file_offset = FET_OFFSET + 0x5000 + ish = bytes(16) + struct.pack(' Date: Sun, 27 Sep 2026 12:20:21 +0200 Subject: [PATCH 3/5] header_file: reject header sizes past the parent buffer A BIOS directory entry of type 0x5c in the Lenovo ThinkPad L13 Gen 4 (BIOS R26ET54W) holds a plain PE image, so the header's size field at 0x6c is text (0x6b20796e). The file then grew past the ROM and parsing failed later with an assertion in NestedBuffer. Raise File.ParseError instead, which skips the entry with a warning. Signed-off-by: iwanicki92 --- psptool/header_file.py | 4 ++++ tests/unit/test_rom_layouts.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/psptool/header_file.py b/psptool/header_file.py index 7b443a6..5febf23 100644 --- a/psptool/header_file.py +++ b/psptool/header_file.py @@ -63,6 +63,10 @@ def _parse(self): if self.has_sha256_checksum and self.has_sha384_checksum: raise File.ParseError('File should not have both sha256 and sha384 checksum bits set!') + if self.buffer_offset + self.rom_size > self.parent_buffer.buffer_size: + raise File.ParseError(f'Header size 0x{self.rom_size:x} overflows the parent buffer, ' + f'likely not a PSP header') + if self.rom_size == 0: self.rom_size = self.buffer_size elif self.rom_size > self.buffer_size: diff --git a/tests/unit/test_rom_layouts.py b/tests/unit/test_rom_layouts.py index 360ca26..b42ede4 100644 --- a/tests/unit/test_rom_layouts.py +++ b/tests/unit/test_rom_layouts.py @@ -136,6 +136,23 @@ def test_l2_reached_directly_and_through_ish(self): self.assertEqual([d.magic for d in directories], [b'$PSP', b'$PL2']) self.assertEqual(directories[1].zen_generation, 'Zen 3 (PSP ID 0xbc0c0100)') + def test_header_size_past_rom_skips_file(self): + bad_offset = FET_OFFSET + 0x4000 + good_offset = FET_OFFSET + 0x5000 + data = rom(8 * MB, [ + (0x01, HEADER_FILE_SIZE, bad_offset), + (0x73, HEADER_FILE_SIZE, good_offset), + ], [ + (bad_offset, header_file(rom_size=0x6b20796e)), + (good_offset, header_file(rom_size=2 * HEADER_FILE_SIZE)), + ]) + pt, warnings = parse(data) + self.assertIn('overflows the parent buffer', warnings) + self.assertEqual(files(pt), {(0x73, good_offset)}) + # A header size past the entry size but inside the ROM still counts + (good,) = pt.blob.unique_files() + self.assertEqual(good.buffer_size, 2 * HEADER_FILE_SIZE) + if __name__ == '__main__': unittest.main() From 66ef5aa94f8d44321d305de59da4275f4cd79d05 Mon Sep 17 00:00:00 2001 From: iwanicki92 Date: Mon, 28 Sep 2026 22:22:25 +0200 Subject: [PATCH 4/5] directory: skip slot headers that point at a non-directory In the Lenovo ThinkPad T14 Gen 7 AMD (BIOS R38SG07W) every 0x4a entry of the $PSP directory leads to a slot header whose location is an APCB rather than a $PL2 directory. from_offset raised Directory.ParseError for the unknown magic, which aborted the whole parse, so none of the directories or files were listed. Skip such a target with a warning, the same way empty entries already are. The $PL2 behind the 0x48 slot headers still parses, including its DRTM TA. Signed-off-by: iwanicki92 --- psptool/directory.py | 6 +++--- tests/unit/test_rom_layouts.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/psptool/directory.py b/psptool/directory.py index c8d914c..eb29816 100644 --- a/psptool/directory.py +++ b/psptool/directory.py @@ -89,9 +89,9 @@ def create_directories_if_not_exist(cls, offset, fet, zen_generation=None) -> Li if "Empty entry" in str(e): fet.psptool.ph.print_warning(f"Skipping empty directory entry at offset 0x{offset:x}") return [] - else: - # Re-raise other parse errors - raise + # Some slot headers point at other data, such as an APCB + fet.psptool.ph.print_warning(f"Skipping non-directory at offset 0x{offset:x}") + return [] # 2. Recursively add secondary directories referenced by the just created directory, if applicable for secondary_directory_offset in directory.secondary_directory_offsets: diff --git a/tests/unit/test_rom_layouts.py b/tests/unit/test_rom_layouts.py index b42ede4..06479ee 100644 --- a/tests/unit/test_rom_layouts.py +++ b/tests/unit/test_rom_layouts.py @@ -14,6 +14,8 @@ a header holding its offset (L13 Gen 4). - A BIOS directory entry holding a plain PE image, whose bytes at the header's size field are text (L13 Gen 4). +- A 0x4a slot header pointing at an APCB rather than at a $PL2 directory + (T14 Gen 7 AMD). """ import contextlib @@ -153,6 +155,26 @@ def test_header_size_past_rom_skips_file(self): (good,) = pt.blob.unique_files() self.assertEqual(good.buffer_size, 2 * HEADER_FILE_SIZE) + def test_slot_header_pointing_at_non_directory_is_skipped(self): + ish_a = FET_OFFSET + 0x2000 + ish_b = FET_OFFSET + 0x3000 + l2_offset = FET_OFFSET + 0x4000 + apcb_offset = FET_OFFSET + 0x6000 + file_offset = FET_OFFSET + 0x5000 + zen_id = b'\x00\x01\x0C\xBC' + data = rom(8 * MB, [(0x48, 0x20, ish_a), (0x4a, 0x20, ish_b)], [ + (ish_a, bytes(16) + struct.pack(' Date: Tue, 29 Sep 2026 01:04:45 +0200 Subject: [PATCH 5/5] entry: read x86 physical addresses in flash offset directories coreboot's amdfwtool writes the APOB NV entries of SoCs up to Cezanne and Mendocino as an x86 physical address (SPI_ROM_BASE plus the flash offset) with entry address mode 0, inside a BIOS directory with address mode 1. The entry address mode was only honoured in mode 2 and 3 directories, so the offset 0xff020000 was read as a flash offset and the ROM failed with "File offset overflows ROM bounds!", e.g. the StarBook Mk VI AMD firmware on LVFS. Treat an entry with address mode 0 and an offset of 0xff000000 or more as a physical address in a mode 1 directory. No flash offset reaches that range. Signed-off-by: iwanicki92 --- psptool/entry.py | 5 +++++ tests/unit/test_rom_layouts.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/psptool/entry.py b/psptool/entry.py index a5f9005..010343c 100644 --- a/psptool/entry.py +++ b/psptool/entry.py @@ -46,6 +46,11 @@ def file_offset(self): # entry address mode must be taken into account, otherwise ignored. if addr_mode == 2 or addr_mode == 3: addr_mode = self.address_mode + # coreboot's amdfwtool writes APOB NV entries on older SoCs as an x86 + # physical address (entry address mode 0) in a mode 1 directory. No + # flash offset reaches 0xff000000. + elif addr_mode == 1 and self.address_mode == 0 and self.offset >= 0xFF000000: + addr_mode = 0 if addr_mode == 0: # x86 physical address, should be in range 0xff000000 - 0xffffffff diff --git a/tests/unit/test_rom_layouts.py b/tests/unit/test_rom_layouts.py index 06479ee..9def5a8 100644 --- a/tests/unit/test_rom_layouts.py +++ b/tests/unit/test_rom_layouts.py @@ -16,6 +16,8 @@ header's size field are text (L13 Gen 4). - A 0x4a slot header pointing at an APCB rather than at a $PL2 directory (T14 Gen 7 AMD). +- An x86 physical address with entry address mode 0 in a directory with + address mode 1, as coreboot writes APOB NV entries (StarBook Mk VI AMD). """ import contextlib @@ -175,6 +177,13 @@ def test_slot_header_pointing_at_non_directory_is_skipped(self): self.assertEqual(magics, [b'$PSP', b'$PL2']) self.assertIn((0x01, file_offset), files(pt)) + def test_physical_address_in_flash_offset_directory(self): + file_offset = FET_OFFSET + 0x4000 + data = rom(16 * MB, [(0x01, HEADER_FILE_SIZE, 0xFF000000 + file_offset)], + [(file_offset, header_file())]) + pt, _ = parse(data) + self.assertEqual(files(pt), {(0x01, file_offset)}) + if __name__ == '__main__': unittest.main()