]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
pe-binary: bound section data against file size, cap UKI zero-padding hash, validate...
authorjeffhuang <jeff@docker.xydrsucermoubd24xgo33yhsgd.bx.internal.cloudapp.net>
Wed, 27 May 2026 18:08:38 +0000 (18:08 +0000)
committerYu Watanabe <watanabe.yu+github@gmail.com>
Thu, 28 May 2026 19:30:48 +0000 (04:30 +0900)
A hostile but structurally valid 382-byte PE32+ "EFI application" with a
single section whose VirtualSize is ~4 GiB and SizeOfRawData is 0 drives
uki_hash() into ~4.17 M iterations of SHA-256 over 1024 bytes of zeros
— wedging the parser for >10 s. Nine more slow-units share the same
shape. A separate MSAN finding from the new fuzzer (CIFuzz, memory
sanitizer) shows pe_load_headers() reading uninitialised heap memory
when SizeOfOptionalHeader is too small to actually contain
NumberOfRvaAndSizes.

Three tightenings in src/shared/pe-binary.c:

  1. In pe_load_sections, reject sections whose PointerToRawData +
     SizeOfRawData exceeds the actual file size. Raw section data must
     fit inside the file; this is the parser-wide invariant
     pe_hash / uki_hash / pe_read_section_data rely on.

  2. In uki_hash, cap the (VirtualSize - SizeOfRawData) zero-padding
     hash loop at 64 MiB. Real UKIs do not pad sections with tens of
     MiB of zero-equivalent data; anything above this cap is a
     malformed PE.

  3. In pe_load_headers, reject a PE whose SizeOfOptionalHeader is too
     small to cover up to NumberOfRvaAndSizes. Without this guard the
     subsequent size-mismatch check reads uninitialised optional-header
     bytes, caught by MSAN under CIFuzz.

Add the 382 B canonical reproducer (plus two structural siblings) and
the MSAN reproducer to test/fuzz/fuzz-pe-binary/. Also add a libFuzzer
harness in src/fuzz/fuzz-pe-binary.c and unit tests in
src/test/test-pe-binary.c that exercise each fix branch in isolation.
The 64 MiB hash boundary test is gated behind SYSTEMD_SLOW_TESTS so it
doesn't slow down emulated-arch CI.

This is a robustness fix, not a security fix: PE binaries consumed by
bootctl / systemd-stub / pcrlock / kernel-install / systemd-measure are
already trusted and signed at the consumer side, so the worst pre-fix
behaviour is wasted CPU on a UKI install / measure / inspect call.

Closes #42344.

Reported-by: AI-assisted libFuzzer campaign
Co-developed-by: Claude Opus 4.7 <noreply@anthropic.com>
src/fuzz/fuzz-pe-binary.c [new file with mode: 0644]
src/fuzz/meson.build
src/shared/pe-binary.c
src/test/meson.build
src/test/test-pe-binary.c [new file with mode: 0644]
test/fuzz/fuzz-pe-binary/empty [new file with mode: 0644]
test/fuzz/fuzz-pe-binary/minimal-pe [new file with mode: 0644]
test/fuzz/fuzz-pe-binary/truncated-header [new file with mode: 0644]
test/fuzz/fuzz-pe-binary/zero-padding [new file with mode: 0644]
test/fuzz/fuzz-pe-binary/zero-padding-dtb [new file with mode: 0644]
test/fuzz/fuzz-pe-binary/zero-padding-uname [new file with mode: 0644]

diff --git a/src/fuzz/fuzz-pe-binary.c b/src/fuzz/fuzz-pe-binary.c
new file mode 100644 (file)
index 0000000..debec37
--- /dev/null
@@ -0,0 +1,91 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/* Fuzz target for userspace UKI / PE inspection.
+ *
+ * pe_load_headers / pe_load_sections / pe_read_section_data_by_name / uki_hash
+ * are exercised by bootctl, systemd-measure, pcrlock, kernel-install and
+ * reboot-util against UKIs that an unprivileged actor may have produced.
+ *
+ * Expected input: bytes that look like a PE/COFF file (DOS "MZ" header,
+ * `e_lfanew` pointing to a "PE\0\0" signature, followed by IMAGE_FILE_HEADER,
+ * IMAGE_OPTIONAL_HEADER and a section table). The harness wraps the bytes in
+ * a memfd, walks the headers, then attempts to read the known UKI sections
+ * and finally hashes them via uki_hash() when OpenSSL is available.
+ */
+
+#include <unistd.h>
+
+#include "alloc-util.h"
+#include "crypto-util.h"
+#include "fd-util.h"
+#include "fuzz.h"
+#include "memfd-util.h"
+#include "pe-binary.h"
+#include "tests.h"
+#include "uki.h"
+
+/* Cap section reads so a crafted VirtualSize cannot drive a multi-GiB malloc
+ * and OOM-kill the fuzzer. Well under the 16 MiB input limit; real code uses
+ * PE_SECTION_READ_MAX (16 KiB) or smaller. */
+#define FUZZ_SECTION_READ_MAX (1U*1024U*1024U)
+
+static void fuzz_read_section(
+                int fd,
+                const PeHeader *pe_header,
+                const IMAGE_SECTION_HEADER *sections,
+                const char *name) {
+
+        _cleanup_free_ void *buf = NULL;
+        size_t buf_size = 0;
+
+        (void) pe_read_section_data_by_name(fd, pe_header, sections, name, FUZZ_SECTION_READ_MAX, &buf, &buf_size);
+        DO_NOT_OPTIMIZE(buf);
+        DO_NOT_OPTIMIZE(buf_size);
+}
+
+int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
+        _cleanup_free_ IMAGE_DOS_HEADER *dos_header = NULL;
+        _cleanup_free_ PeHeader *pe_header = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        if (outside_size_range(size, 0, 16 * 1024 * 1024))
+                return 0;
+
+        fuzz_setup_logging();
+
+        _cleanup_close_ int fd = ASSERT_OK(memfd_new("fuzz-pe-binary"));
+        if (size > 0)
+                ASSERT_OK_EQ_ERRNO(write(fd, data, size), (ssize_t) size);
+        ASSERT_OK_ERRNO(lseek(fd, 0, SEEK_SET));
+
+        if (pe_load_headers(fd, &dos_header, &pe_header) < 0)
+                return 0;
+
+        if (pe_load_sections(fd, dos_header, pe_header, &sections) < 0)
+                return 0;
+
+        /* Exercise the section-read path for every UKI section. unified_sections[]
+         * (uki.h) is the canonical list; .sdmagic is written by sd-boot/sd-stub but
+         * is not part of that table, so read it as an extra step. */
+        FOREACH_ARRAY(s, unified_sections, _UNIFIED_SECTION_MAX)
+                fuzz_read_section(fd, pe_header, sections, *s);
+        fuzz_read_section(fd, pe_header, sections, ".sdmagic");
+
+        (void) pe_is_uki(pe_header, sections);
+        (void) pe_is_addon(pe_header, sections);
+        (void) pe_is_native(pe_header);
+
+#if HAVE_OPENSSL
+        if (dlopen_libcrypto(LOG_DEBUG) >= 0) {
+                void *hashes[_UNIFIED_SECTION_MAX] = {};
+                size_t hash_size = 0;
+
+                /* uki_hash() can return partway through with some hashes already
+                 * allocated; free unconditionally. */
+                (void) uki_hash(fd, sym_EVP_sha256(), hashes, &hash_size);
+                free_many(hashes, _UNIFIED_SECTION_MAX);
+        }
+#endif
+
+        return 0;
+}
index 43539422b0f47808495300ca6d9d5760c34e9759..65fc6896f1f77c2f7274a2041f34882817b70265 100644 (file)
@@ -8,6 +8,7 @@ simple_fuzzers += files(
         'fuzz-env-file.c',
         'fuzz-hostname-setup.c',
         'fuzz-json.c',
+        'fuzz-pe-binary.c',
         'fuzz-time-util.c',
         'fuzz-udev-database.c',
         'fuzz-user-record.c',
index 12abc1f3bf571e226274bef34d769a84a47d6ab3..fc6576a875770ecc877cd5102f1dce4724403598 100644 (file)
 #include "string-table.h"
 #include "string-util.h"
 
+/* Cap on the (VirtualSize - SizeOfRawData) zero-padding the UKI hasher
+ * will produce for a single section.  Any value beyond this is treated as
+ * a malformed PE — bounds the hash work an attacker can drive (#42344). */
+#define UKI_HASH_VIRTUAL_SIZE_PADDING_MAX (64U * 1024U * 1024U)
+
 /* Note: none of these function change the file position of the provided fd, as they use pread() */
 
 bool pe_header_is_64bit(const PeHeader *h) {
@@ -140,6 +145,12 @@ int pe_load_headers(
         if (!IN_SET(le16toh(pe_header->optional.Magic), UINT16_C(0x010B), UINT16_C(0x020B)))
                 return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG), "Optional header magic invalid.");
 
+        /* The optional header must be at least large enough to cover everything
+         * up to and including NumberOfRvaAndSizes — otherwise the equality
+         * check below would read uninitialised memory for that field. */
+        if (pe_header_size(pe_header) < PE_HEADER_OPTIONAL_FIELD_OFFSET(pe_header, DataDirectory))
+                return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG), "Optional header too small.");
+
         if (pe_header_size(pe_header) !=
             PE_HEADER_OPTIONAL_FIELD_OFFSET(pe_header, DataDirectory) +
             sizeof(IMAGE_DATA_DIRECTORY) * (uint64_t) le32toh(PE_HEADER_OPTIONAL_FIELD(pe_header, NumberOfRvaAndSizes)))
@@ -160,6 +171,7 @@ int pe_load_sections(
                 IMAGE_SECTION_HEADER **ret_sections) {
 
         _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+        struct stat st;
         size_t nos;
         ssize_t n;
 
@@ -182,6 +194,26 @@ int pe_load_sections(
         if ((size_t) n != sizeof(IMAGE_SECTION_HEADER) * nos)
                 return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG), "Short read while reading section table.");
 
+        /* The section's raw bytes must fit inside the file.  This is the
+         * fundamental invariant the parser relies on later (pe_hash, uki_hash,
+         * pe_read_section_data, ...); reject obvious malformations early so
+         * downstream loops don't get driven by attacker-controlled sizes. */
+        if (fstat(fd, &st) < 0)
+                return log_debug_errno(errno, "Failed to stat PE file: %m");
+
+        FOREACH_ARRAY(section, sections, nos) {
+                uint64_t prd = le32toh(section->PointerToRawData), srd = le32toh(section->SizeOfRawData), end;
+
+                /* SizeOfRawData == 0 is legitimate (BSS-like, uninitialised) —
+                 * PointerToRawData is then meaningless and not used as an offset. */
+                if (srd == 0)
+                        continue;
+
+                if (!ADD_SAFE(&end, prd, srd) || end > (uint64_t) st.st_size)
+                        return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG),
+                                               "PE section raw data exceeds file size.");
+        }
+
         if (ret_sections)
                 *ret_sections = TAKE_PTR(sections);
 
@@ -578,6 +610,16 @@ int uki_hash(int fd,
                         uint8_t zeroes[1024] = {};
                         size_t remaining = le32toh(section->VirtualSize) - le32toh(section->SizeOfRawData);
 
+                        /* Bound the zero-padding hash work.  An attacker can otherwise
+                         * set VirtualSize close to UINT32_MAX with SizeOfRawData = 0,
+                         * driving ~4 GiB of SHA-256 work per section on a tiny file
+                         * (issue #42344 — wedges the parser for >10 s on 382 B). */
+                        if (remaining > UKI_HASH_VIRTUAL_SIZE_PADDING_MAX)
+                                return log_debug_errno(SYNTHETIC_ERRNO(EBADMSG),
+                                                       "Section VirtualSize exceeds SizeOfRawData by %zu bytes (cap %zu); refusing to hash.",
+                                                       remaining,
+                                                       (size_t) UKI_HASH_VIRTUAL_SIZE_PADDING_MAX);
+
                         while (remaining > 0) {
                                 size_t sz = MIN(sizeof(zeroes), remaining);
 
index 6465b6c1160f7ef59710d150ac5f170bd438d6b8..03635c2efc7149c35a1aa4051145cdbf098cbdb0 100644 (file)
@@ -168,6 +168,7 @@ simple_tests += files(
         'test-parse-util.c',
         'test-path-lookup.c',
         'test-path-util.c',
+        'test-pe-binary.c',
         'test-percent-util.c',
         'test-pidref.c',
         'test-pressure.c',
diff --git a/src/test/test-pe-binary.c b/src/test/test-pe-binary.c
new file mode 100644 (file)
index 0000000..af80600
--- /dev/null
@@ -0,0 +1,523 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+/* Regression tests for the algorithmic-complexity DoS fix in src/shared/pe-binary.c
+ * (issue #42344 — PE file with attacker-controlled VirtualSize wedges uki_hash
+ * in an unbounded SHA-256 zero-padding loop).
+ *
+ * Coverage:
+ *   - pe_load_sections() rejects sections whose PointerToRawData + SizeOfRawData
+ *     exceeds the actual file size (PLAN §3.1).
+ *   - uki_hash() refuses to hash a section whose VirtualSize exceeds
+ *     SizeOfRawData by more than UKI_HASH_VIRTUAL_SIZE_PADDING_MAX (=64 MiB)
+ *     and instead returns -EBADMSG (PLAN §3.2).
+ *
+ * Tests build small PE32+ images in a memfd. The header layout mirrors the
+ * canonical 382-byte reproducer (DOS at 0x00, e_lfanew=0x40, PE32+ optional
+ * header of 0xf0 bytes, section table starting at file offset 0x148) so
+ * pe_load_headers() accepts every test fixture; only the section table and
+ * file size are varied per test. */
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "alloc-util.h"
+#include "fd-util.h"
+#include "memfd-util.h"
+#include "pe-binary.h"
+#include "tests.h"
+#include "uki.h"
+#include "unaligned.h"
+
+#if HAVE_OPENSSL
+#  include "crypto-util.h"
+#endif
+
+/* Match the cap defined in src/shared/pe-binary.c (PLAN §3.2). */
+#define UKI_HASH_PADDING_CAP_BYTES (64U * 1024U * 1024U)
+
+/* File offsets of the PE32+ header skeleton this test builds. */
+#define SECTION_TABLE_OFFSET 0x148
+#define IMAGE_FILE_HEADER_NOS_OFFSET 0x46    /* NumberOfSections (le16) */
+#define IMAGE_SECTION_HEADER_BYTES 40
+
+/* Header skeleton, byte-for-byte compatible with the canonical reproducer
+ * so that pe_load_headers() succeeds without further mutation. We only
+ * patch NumberOfSections at runtime (offset 0x46) and append section
+ * headers + raw data after the optional header. */
+static const uint8_t HEADER_SKELETON[SECTION_TABLE_OFFSET] = {
+        /* DOS header: "MZ" at 0x00, e_lfanew = 0x40 at 0x3c */
+        [0x00] = 'M', 'Z',
+        [0x3c] = 0x40, 0x00, 0x00, 0x00,
+
+        /* PE signature at 0x40 */
+        [0x40] = 'P', 'E', 0x00, 0x00,
+
+        /* IMAGE_FILE_HEADER at 0x44 */
+        [0x44] = 0x64, 0x86,                  /* Machine = 0x8664 (x86_64) */
+        /* [0x46]: NumberOfSections — patched per test */
+        [0x54] = 0xf0, 0x00,                  /* SizeOfOptionalHeader = 0xf0 */
+        [0x56] = 0x22, 0x00,                  /* Characteristics = executable image */
+
+        /* IMAGE_OPTIONAL_HEADER (PE32+) at 0x58 */
+        [0x58] = 0x0b, 0x02,                  /* Magic = 0x020b (PE32+) */
+        [0x9c] = 0x0a, 0x00,                  /* Subsystem = EFI Application */
+        [0xc4] = 0x10, 0x00, 0x00, 0x00,      /* NumberOfRvaAndSizes = 16 (matches SizeOfOptionalHeader=0xf0) */
+};
+
+typedef struct SectionSpec {
+        const char *name;       /* PE section name; truncated/padded to 8 bytes on write */
+        uint32_t virtual_size;
+        uint32_t virtual_address;
+        uint32_t size_of_raw_data;
+        uint32_t pointer_to_raw_data;
+} SectionSpec;
+
+/* Build a PE32+ image in a fresh memfd containing the given sections.
+ *
+ * If override_file_size > 0, the memfd is written with exactly that many
+ * bytes (zero-padded if larger than what the sections need). This lets a
+ * test claim section data past the actual EOF, or reserve headroom past
+ * the last section. If 0, the file is sized to exactly contain the
+ * headers + section table + every section's raw data. */
+static int build_pe_file(
+                const SectionSpec *specs,
+                size_t n_sections,
+                size_t override_file_size) {
+
+        /* Minimum size is "headers + section table"; sections with SizeOfRawData>0
+         * must additionally fit inside the file. */
+        size_t base = SECTION_TABLE_OFFSET + n_sections * IMAGE_SECTION_HEADER_BYTES;
+        size_t needed = base;
+        FOREACH_ARRAY(spec, specs, n_sections)
+                if (spec->size_of_raw_data > 0) {
+                        uint64_t end = (uint64_t) spec->pointer_to_raw_data + spec->size_of_raw_data;
+                        if (end > needed)
+                                needed = end;
+                }
+
+        size_t file_size = override_file_size > 0 ? override_file_size : needed;
+        ASSERT_GE(file_size, base);
+        _cleanup_free_ uint8_t *buf = ASSERT_NOT_NULL(new0(uint8_t, file_size));
+
+        memcpy(buf, HEADER_SKELETON, sizeof(HEADER_SKELETON));
+        unaligned_write_le16(buf + IMAGE_FILE_HEADER_NOS_OFFSET, (uint16_t) n_sections);
+
+        /* Section table at 0x148. */
+        FOREACH_ARRAY(spec, specs, n_sections) {
+                uint8_t *sh = buf + SECTION_TABLE_OFFSET + (spec - specs) * IMAGE_SECTION_HEADER_BYTES;
+                size_t nlen = strnlen(spec->name, 8);
+                memcpy(sh, spec->name, nlen);
+                unaligned_write_le32(sh + 8,  spec->virtual_size);
+                unaligned_write_le32(sh + 12, spec->virtual_address);
+                unaligned_write_le32(sh + 16, spec->size_of_raw_data);
+                unaligned_write_le32(sh + 20, spec->pointer_to_raw_data);
+                /* Characteristics, relocs, etc., left zero. */
+        }
+
+        int fd = ASSERT_OK(memfd_new("test-pe-binary"));
+
+        /* Write only file_size bytes — that lets the caller construct a
+         * PE whose section table claims data past EOF. */
+        ASSERT_OK_EQ_ERRNO(write(fd, buf, file_size), (ssize_t) file_size);
+        ASSERT_OK_ERRNO(lseek(fd, 0, SEEK_SET));
+        return fd;
+}
+
+static int load_headers_and_sections(
+                int fd,
+                IMAGE_DOS_HEADER **ret_dos,
+                PeHeader **ret_pe,
+                IMAGE_SECTION_HEADER **ret_sections) {
+
+        int r = pe_load_headers(fd, ret_dos, ret_pe);
+        if (r < 0)
+                return r;
+        return pe_load_sections(fd, *ret_dos, *ret_pe, ret_sections);
+}
+
+/* ======================================================================
+ * pe_load_headers — SizeOfOptionalHeader / NumberOfRvaAndSizes bound
+ * ====================================================================== */
+
+/* If SizeOfOptionalHeader is so small that pread() does not actually
+ * populate NumberOfRvaAndSizes, the optional-header size check at the end
+ * of pe_load_headers would read uninitialised heap memory (caught by MSAN
+ * under CIFuzz). pe_load_headers must reject such files with -EBADMSG
+ * before touching that field. */
+TEST(pe_load_headers_optional_header_too_small) {
+        /* Minimum allowed by the existing magic check is 2 bytes (only
+         * IMAGE_OPTIONAL_HEADER.Magic). That is well below the
+         * NumberOfRvaAndSizes offset for both PE32 and PE32+. */
+        uint8_t buf[64 + 4 + 20 + 2] = {};   /* DOS + PE sig + file hdr + 2-byte optional */
+        buf[0] = 'M'; buf[1] = 'Z';
+        unaligned_write_le32(buf + 0x3c, 64);
+        memcpy(buf + 64, "PE\0\0", 4);
+        /* IMAGE_FILE_HEADER: leave most fields zero, set
+         * SizeOfOptionalHeader = 2 (offset 0x10 within IMAGE_FILE_HEADER). */
+        unaligned_write_le16(buf + 64 + 4 + 16, 2);
+        /* Optional header Magic = 0x020B (PE32+). */
+        unaligned_write_le16(buf + 64 + 4 + 20, 0x020B);
+
+        _cleanup_close_ int fd = ASSERT_OK(memfd_new("test-pe-binary"));
+        ASSERT_OK_EQ_ERRNO(write(fd, buf, sizeof(buf)), (ssize_t) sizeof(buf));
+        ASSERT_OK_ERRNO(lseek(fd, 0, SEEK_SET));
+
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        ASSERT_ERROR(pe_load_headers(fd, &dos, &pe), EBADMSG);
+}
+
+/* ======================================================================
+ * pe_load_sections — bounds check from PLAN §3.1
+ * ====================================================================== */
+
+/* Happy path: a section whose raw data fits comfortably inside the file
+ * (i.e. file has plenty of trailing headroom beyond the section). */
+TEST(pe_load_sections_valid_in_bounds) {
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 16,
+                .size_of_raw_data = 16,
+                .pointer_to_raw_data = SECTION_TABLE_OFFSET + IMAGE_SECTION_HEADER_BYTES,
+        };
+        _cleanup_close_ int fd = build_pe_file(&s, /* n_sections= */ 1, /* override_file_size= */ 4096);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+}
+
+/* Boundary: PointerToRawData + SizeOfRawData == file_size is OK. */
+TEST(pe_load_sections_section_exactly_fills_file) {
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 16,
+                .size_of_raw_data = 16,
+                .pointer_to_raw_data = SECTION_TABLE_OFFSET + IMAGE_SECTION_HEADER_BYTES,
+        };
+        size_t exact_size = (size_t) s.pointer_to_raw_data + s.size_of_raw_data;
+        _cleanup_close_ int fd = build_pe_file(&s, /* n_sections= */ 1, exact_size);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+}
+
+/* Section's raw data claims to extend one byte past EOF — must be rejected. */
+TEST(pe_load_sections_section_past_eof_by_one) {
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 16,
+                .size_of_raw_data = 16,
+                .pointer_to_raw_data = SECTION_TABLE_OFFSET + IMAGE_SECTION_HEADER_BYTES,
+        };
+        size_t short_size = (size_t) s.pointer_to_raw_data + s.size_of_raw_data - 1;
+        _cleanup_close_ int fd = build_pe_file(&s, /* n_sections= */ 1, short_size);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_ERROR(load_headers_and_sections(fd, &dos, &pe, &sections), EBADMSG);
+}
+
+/* PointerToRawData itself already past EOF, with non-zero SizeOfRawData. */
+TEST(pe_load_sections_pointer_way_past_eof) {
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 16,
+                .size_of_raw_data = 16,
+                .pointer_to_raw_data = UINT32_C(0x10000000),
+        };
+        _cleanup_close_ int fd = build_pe_file(&s, /* n_sections= */ 1, /* override_file_size= */ 4096);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_ERROR(load_headers_and_sections(fd, &dos, &pe, &sections), EBADMSG);
+}
+
+/* SizeOfRawData == 0 means "BSS-equivalent / uninitialised"; PointerToRawData
+ * is a don't-care and must NOT be bounds-checked, even when nonsense. */
+TEST(pe_load_sections_size_zero_with_huge_pointer) {
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 0x100,
+                .size_of_raw_data = 0,
+                .pointer_to_raw_data = UINT32_MAX,
+        };
+        _cleanup_close_ int fd = build_pe_file(&s, /* n_sections= */ 1, /* override_file_size= */ 0);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+}
+
+/* SizeOfRawData == 0 with PointerToRawData == 0 — most natural BSS case. */
+TEST(pe_load_sections_size_zero_pointer_zero) {
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 0x40,
+                .size_of_raw_data = 0,
+                .pointer_to_raw_data = 0,
+        };
+        _cleanup_close_ int fd = build_pe_file(&s, /* n_sections= */ 1, /* override_file_size= */ 0);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+}
+
+/* PointerToRawData + SizeOfRawData overflows uint32 (both near UINT32_MAX).
+ * The fix uses uint64 arithmetic + __builtin_add_overflow, so this must be
+ * rejected, not silently wrap. */
+TEST(pe_load_sections_uint32_overflow_sum) {
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = UINT32_C(0xffffffff),
+                .size_of_raw_data = UINT32_C(0x80000000),
+                .pointer_to_raw_data = UINT32_C(0x80000000),
+        };
+        /* uint32 sum wraps to 0, but uint64 sum = 0x100000000 — must still
+         * be rejected against a tiny file. */
+        _cleanup_close_ int fd = build_pe_file(&s, /* n_sections= */ 1, /* override_file_size= */ 4096);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_ERROR(load_headers_and_sections(fd, &dos, &pe, &sections), EBADMSG);
+}
+
+/* Zero sections: bounds check loop runs zero times, fstat still succeeds. */
+TEST(pe_load_sections_zero_sections) {
+        _cleanup_close_ int fd = build_pe_file(NULL, /* n_sections= */ 0, /* override_file_size= */ 0);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+}
+
+/* Multiple sections, all valid — happy path with N>1. */
+TEST(pe_load_sections_multi_all_valid) {
+        size_t base = SECTION_TABLE_OFFSET + 3 * IMAGE_SECTION_HEADER_BYTES;
+        SectionSpec specs[3] = {
+                { .name = ".linux",   .virtual_size = 16,  .size_of_raw_data = 16,
+                  .pointer_to_raw_data = (uint32_t) base },
+                { .name = ".initrd",  .virtual_size = 8,   .size_of_raw_data = 8,
+                  .pointer_to_raw_data = (uint32_t) (base + 16) },
+                { .name = ".cmdline", .virtual_size = 0,   .size_of_raw_data = 0,
+                  .pointer_to_raw_data = 0 },
+        };
+        _cleanup_close_ int fd = build_pe_file(specs, /* n_sections= */ 3, /* override_file_size= */ 0);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+}
+
+/* Multiple sections; one of them claims data past EOF — the whole file
+ * must be rejected, not just that section silently skipped. */
+TEST(pe_load_sections_multi_one_past_eof) {
+        size_t base = SECTION_TABLE_OFFSET + 3 * IMAGE_SECTION_HEADER_BYTES;
+        SectionSpec specs[3] = {
+                { .name = ".linux",   .virtual_size = 16, .size_of_raw_data = 16,
+                  .pointer_to_raw_data = (uint32_t) base },
+                { .name = ".initrd",  .virtual_size = 8,  .size_of_raw_data = 8,
+                  .pointer_to_raw_data = (uint32_t) (base + 16) },
+                { .name = ".cmdline", .virtual_size = 32, .size_of_raw_data = 32,
+                  .pointer_to_raw_data = UINT32_C(0x40000000) },   /* nonsense */
+        };
+        _cleanup_close_ int fd = build_pe_file(specs, /* n_sections= */ 3, /* override_file_size= */ 4096);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_ERROR(load_headers_and_sections(fd, &dos, &pe, &sections), EBADMSG);
+}
+
+/* ======================================================================
+ * uki_hash — zero-padding cap from PLAN §3.2
+ * ====================================================================== */
+
+#if HAVE_OPENSSL
+
+/* Helper: call uki_hash on a freshly built single-section PE. Returns the
+ * uki_hash() return value; any allocated hashes are freed. */
+static int call_uki_hash_with(SectionSpec spec) {
+        _cleanup_close_ int fd = build_pe_file(&spec, /* n_sections= */ 1, /* override_file_size= */ 0);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        int r = load_headers_and_sections(fd, &dos, &pe, &sections);
+        if (r < 0)
+                return r;
+
+        void *hashes[_UNIFIED_SECTION_MAX] = {};
+        size_t hash_size = 0;
+        r = uki_hash(fd, sym_EVP_sha256(), hashes, &hash_size);
+        free_many(hashes, _UNIFIED_SECTION_MAX);
+        return r;
+}
+
+/* No padding needed (VirtualSize == SizeOfRawData) — the cap branch is
+ * skipped entirely; hash succeeds. */
+TEST_RET(uki_hash_no_padding) {
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 16,
+                .size_of_raw_data = 16,
+                .pointer_to_raw_data = SECTION_TABLE_OFFSET + IMAGE_SECTION_HEADER_BYTES,
+        };
+        ASSERT_OK(call_uki_hash_with(s));
+        return EXIT_SUCCESS;
+}
+
+/* One-byte padding — well below the cap. Must succeed. */
+TEST_RET(uki_hash_one_byte_padding) {
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = 17,
+                .size_of_raw_data = 16,
+                .pointer_to_raw_data = SECTION_TABLE_OFFSET + IMAGE_SECTION_HEADER_BYTES,
+        };
+        ASSERT_OK(call_uki_hash_with(s));
+        return EXIT_SUCCESS;
+}
+
+/* Boundary: VirtualSize - SizeOfRawData == cap (64 MiB) — must succeed
+ * (PLAN §4 says the comparison is strict ">"). SHA-256 of 64 MiB of zeros
+ * is ~100–200 ms on a modern CPU but can be much slower on emulated arches,
+ * so gate behind the slow-tests opt-in. */
+TEST_RET(uki_hash_at_cap_boundary) {
+        if (!slow_tests_enabled())
+                return log_tests_skipped("slow tests disabled");
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = UKI_HASH_PADDING_CAP_BYTES,
+                .size_of_raw_data = 0,
+                .pointer_to_raw_data = 0,
+        };
+        ASSERT_OK(call_uki_hash_with(s));
+        return EXIT_SUCCESS;
+}
+
+/* One byte over the cap — must be rejected with -EBADMSG. */
+TEST_RET(uki_hash_one_over_cap_rejected) {
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = UKI_HASH_PADDING_CAP_BYTES + 1,
+                .size_of_raw_data = 0,
+                .pointer_to_raw_data = 0,
+        };
+        ASSERT_ERROR(call_uki_hash_with(s), EBADMSG);
+        return EXIT_SUCCESS;
+}
+
+/* VirtualSize == UINT32_MAX, SizeOfRawData == 0. This is the worst-case
+ * shape an attacker can produce: ~4 GiB of zero-padding. Without the cap
+ * this is the >10 s wedge from #42344; with the cap it must return
+ * -EBADMSG essentially instantly. */
+TEST_RET(uki_hash_max_virtual_size_rejected) {
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = UINT32_MAX,
+                .size_of_raw_data = 0,
+                .pointer_to_raw_data = 0,
+        };
+        ASSERT_ERROR(call_uki_hash_with(s), EBADMSG);
+        return EXIT_SUCCESS;
+}
+
+/* Reproduces the exact section shape of the canonical 382-byte slow-unit
+ * (.initrd VS=0xff000000 RSD=0). Pre-fix this drove ~8.5 s of SHA-256; the
+ * cap must reject it instantly. This is the in-code analogue of the
+ * regression input pinned in test/fuzz/fuzz-pe-binary/. */
+TEST_RET(uki_hash_canonical_42344_reproducer_pattern) {
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        SectionSpec s = {
+                .name = ".initrd",
+                .virtual_size = UINT32_C(0xff000000),
+                .size_of_raw_data = 0,
+                .pointer_to_raw_data = 0,
+        };
+        ASSERT_ERROR(call_uki_hash_with(s), EBADMSG);
+        return EXIT_SUCCESS;
+}
+
+/* Multiple sections, one of which trips the cap — uki_hash() must abort
+ * the entire call (return -EBADMSG) rather than silently skipping. */
+TEST_RET(uki_hash_one_bad_among_many_rejected) {
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        size_t base = SECTION_TABLE_OFFSET + 2 * IMAGE_SECTION_HEADER_BYTES;
+        SectionSpec specs[2] = {
+                { .name = ".linux",  .virtual_size = 16, .size_of_raw_data = 16,
+                  .pointer_to_raw_data = (uint32_t) base },
+                { .name = ".initrd", .virtual_size = UKI_HASH_PADDING_CAP_BYTES + 1,
+                  .size_of_raw_data = 0, .pointer_to_raw_data = 0 },
+        };
+        _cleanup_close_ int fd = build_pe_file(specs, /* n_sections= */ 2, /* override_file_size= */ 0);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+
+        void *hashes[_UNIFIED_SECTION_MAX] = {};
+        size_t hash_size = 0;
+        ASSERT_ERROR(uki_hash(fd, sym_EVP_sha256(), hashes, &hash_size), EBADMSG);
+        free_many(hashes, _UNIFIED_SECTION_MAX);
+        return EXIT_SUCCESS;
+}
+
+/* Sanity check: uki_hash on a "no UKI sections present" PE doesn't hit
+ * the cap path at all (no section with VS>SRD that matches the unified
+ * sections table). Just confirms the cap doesn't reject the empty case. */
+TEST_RET(uki_hash_empty_pe_does_not_reject) {
+        if (dlopen_libcrypto(LOG_DEBUG) < 0)
+                return log_tests_skipped("openssl not available");
+
+        _cleanup_close_ int fd = build_pe_file(NULL, /* n_sections= */ 0, /* override_file_size= */ 0);
+        _cleanup_free_ IMAGE_DOS_HEADER *dos = NULL;
+        _cleanup_free_ PeHeader *pe = NULL;
+        _cleanup_free_ IMAGE_SECTION_HEADER *sections = NULL;
+
+        ASSERT_OK(load_headers_and_sections(fd, &dos, &pe, &sections));
+
+        void *hashes[_UNIFIED_SECTION_MAX] = {};
+        size_t hash_size = 0;
+        /* No UKI sections to hash — succeeds without hitting the cap path. */
+        ASSERT_OK(uki_hash(fd, sym_EVP_sha256(), hashes, &hash_size));
+        free_many(hashes, _UNIFIED_SECTION_MAX);
+        return EXIT_SUCCESS;
+}
+
+#endif  /* HAVE_OPENSSL */
+
+DEFINE_TEST_MAIN(LOG_DEBUG);
diff --git a/test/fuzz/fuzz-pe-binary/empty b/test/fuzz/fuzz-pe-binary/empty
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/test/fuzz/fuzz-pe-binary/minimal-pe b/test/fuzz/fuzz-pe-binary/minimal-pe
new file mode 100644 (file)
index 0000000..66222cf
Binary files /dev/null and b/test/fuzz/fuzz-pe-binary/minimal-pe differ
diff --git a/test/fuzz/fuzz-pe-binary/truncated-header b/test/fuzz/fuzz-pe-binary/truncated-header
new file mode 100644 (file)
index 0000000..f0c277d
Binary files /dev/null and b/test/fuzz/fuzz-pe-binary/truncated-header differ
diff --git a/test/fuzz/fuzz-pe-binary/zero-padding b/test/fuzz/fuzz-pe-binary/zero-padding
new file mode 100644 (file)
index 0000000..7265ce7
Binary files /dev/null and b/test/fuzz/fuzz-pe-binary/zero-padding differ
diff --git a/test/fuzz/fuzz-pe-binary/zero-padding-dtb b/test/fuzz/fuzz-pe-binary/zero-padding-dtb
new file mode 100644 (file)
index 0000000..1ff64ed
Binary files /dev/null and b/test/fuzz/fuzz-pe-binary/zero-padding-dtb differ
diff --git a/test/fuzz/fuzz-pe-binary/zero-padding-uname b/test/fuzz/fuzz-pe-binary/zero-padding-uname
new file mode 100644 (file)
index 0000000..6c6b2ab
Binary files /dev/null and b/test/fuzz/fuzz-pe-binary/zero-padding-uname differ