From 0c5cba64209db554077dfe12c5f1f3b1c547f666 Mon Sep 17 00:00:00 2001 From: jeffhuang Date: Wed, 27 May 2026 18:08:38 +0000 Subject: [PATCH] pe-binary: bound section data against file size, cap UKI zero-padding hash, validate optional header size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/fuzz/fuzz-pe-binary.c | 91 ++++ src/fuzz/meson.build | 1 + src/shared/pe-binary.c | 42 ++ src/test/meson.build | 1 + src/test/test-pe-binary.c | 523 ++++++++++++++++++++ test/fuzz/fuzz-pe-binary/empty | 0 test/fuzz/fuzz-pe-binary/minimal-pe | Bin 0 -> 256 bytes test/fuzz/fuzz-pe-binary/truncated-header | Bin 0 -> 90 bytes test/fuzz/fuzz-pe-binary/zero-padding | Bin 0 -> 382 bytes test/fuzz/fuzz-pe-binary/zero-padding-dtb | Bin 0 -> 902 bytes test/fuzz/fuzz-pe-binary/zero-padding-uname | Bin 0 -> 442 bytes 11 files changed, 658 insertions(+) create mode 100644 src/fuzz/fuzz-pe-binary.c create mode 100644 src/test/test-pe-binary.c create mode 100644 test/fuzz/fuzz-pe-binary/empty create mode 100644 test/fuzz/fuzz-pe-binary/minimal-pe create mode 100644 test/fuzz/fuzz-pe-binary/truncated-header create mode 100644 test/fuzz/fuzz-pe-binary/zero-padding create mode 100644 test/fuzz/fuzz-pe-binary/zero-padding-dtb create mode 100644 test/fuzz/fuzz-pe-binary/zero-padding-uname diff --git a/src/fuzz/fuzz-pe-binary.c b/src/fuzz/fuzz-pe-binary.c new file mode 100644 index 0000000000..debec376a0 --- /dev/null +++ b/src/fuzz/fuzz-pe-binary.c @@ -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 + +#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, §ions) < 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; +} diff --git a/src/fuzz/meson.build b/src/fuzz/meson.build index 43539422b0..65fc6896f1 100644 --- a/src/fuzz/meson.build +++ b/src/fuzz/meson.build @@ -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', diff --git a/src/shared/pe-binary.c b/src/shared/pe-binary.c index 12abc1f3bf..fc6576a875 100644 --- a/src/shared/pe-binary.c +++ b/src/shared/pe-binary.c @@ -13,6 +13,11 @@ #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); diff --git a/src/test/meson.build b/src/test/meson.build index 6465b6c116..03635c2efc 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -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 index 0000000000..af806009b7 --- /dev/null +++ b/src/test/test-pe-binary.c @@ -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 +#include +#include +#include + +#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, §ions)); +} + +/* 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, §ions)); +} + +/* 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, §ions), 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, §ions), 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, §ions)); +} + +/* 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, §ions)); +} + +/* 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, §ions), 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, §ions)); +} + +/* 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, §ions)); +} + +/* 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, §ions), 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, §ions); + 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, §ions)); + + 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, §ions)); + + 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 index 0000000000..e69de29bb2 diff --git a/test/fuzz/fuzz-pe-binary/minimal-pe b/test/fuzz/fuzz-pe-binary/minimal-pe new file mode 100644 index 0000000000000000000000000000000000000000..66222cf4dad36d502233f0403669b8aaf134a7c4 GIT binary patch literal 256 acmeZ`Vjvqh0CfhqGBBjHk!{3)YXkuEf&ym% literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-pe-binary/truncated-header b/test/fuzz/fuzz-pe-binary/truncated-header new file mode 100644 index 0000000000000000000000000000000000000000..f0c277d1b3f3d902155b04e0848785dc73ba4583 GIT binary patch literal 90 dcmeZ`Vjvqh0CfhqGBBjHp&G%&pv1t<1OR1K0+;{* literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-pe-binary/zero-padding b/test/fuzz/fuzz-pe-binary/zero-padding new file mode 100644 index 0000000000000000000000000000000000000000..7265ce755ca2702de6b6bf263dd4125b8156fa7a GIT binary patch literal 382 zcmeZ`Vjvqh0CfhqGBBjHF`}69fkBCZn@Jl*42k0aRxJP&VParlgG#_?29P)lM34(i pfdqg6O2HX$K3oK%j!Gn|j|dy8mzkGYQj`Kx`2YWZIl>yiDgbN23R3_8 literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-pe-binary/zero-padding-dtb b/test/fuzz/fuzz-pe-binary/zero-padding-dtb new file mode 100644 index 0000000000000000000000000000000000000000..1ff64ed12df7d36e28c9abd375ef82912307750b GIT binary patch literal 902 zcmeZ`Vjvqh0CfhqGBBjHaiEy-fkBCZn~4ua42k2w2;v9;F%ts=8&m>DgT+}Of?Qw< zq!0+86r2I)!$lzK28h(lFD^>W09p?86U=W6P=COjl6aWJq6tqyMe+L=KN_G?^pnw%1!;Bcni+_;bVG97* C^BK|r literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-pe-binary/zero-padding-uname b/test/fuzz/fuzz-pe-binary/zero-padding-uname new file mode 100644 index 0000000000000000000000000000000000000000..6c6b2ab9c7590c976f5ada9d160912859a110f56 GIT binary patch literal 442 zcmeZ`Vjvqh0CfhqGBBjHF`}69fkBCZn~4ua42k0aRxJP&VParlgG#_?29P)lM34(i nfdqg6O2HX$K3oK%4ni8x10Y+dSDKfYn+g>94+O{(G-Cq*_IVwW literal 0 HcmV?d00001