From 43cd946f9899f9ae67da319f73199be700e93524 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Mon, 31 Aug 2026 22:21:19 -0400 Subject: [PATCH] fuzz: add a fuzzer for the decompression entrypoints fuzz-compress only ever feeds the decompressors their own output: it compresses the fuzzer input and then decompresses the result, so the stream the decoders see is well formed by construction. The blobs they get in reality come out of journal files, systemd-journal-remote streams, coredumps and downloaded images, i.e. from wherever the data was written, which is not necessarily somewhere we trust. So hand them the fuzzer input directly instead, across all four entrypoints: decompress_blob(), decompress_startswith(), decompress_stream(), and the decompressor_detect()/decompressor_push() pair that systemd-importd feeds from the network. The latter is pushed in chunks, so that the decoders see the same fragmentation a real download produces. The decompressed size is capped, as an unbounded decode of a hostile blob measures the allocator rather than the decoder. The corpus carries the two truncated stream headers that used to send decompress_startswith() allocating until OOM, fixed in the previous commit. --- src/fuzz/fuzz-decompress.c | 165 ++++++++++++++++++ src/fuzz/meson.build | 1 + test/fuzz/fuzz-decompress/push-xz | Bin 0 -> 96 bytes .../fuzz-decompress/truncated-bzip2-header | Bin 0 -> 20 bytes .../fuzz-decompress/truncated-gzip-header | Bin 0 -> 26 bytes test/fuzz/fuzz-decompress/xz-blob | Bin 0 -> 108 bytes test/fuzz/fuzz-decompress/zstd-startswith | Bin 0 -> 56 bytes 7 files changed, 166 insertions(+) create mode 100644 src/fuzz/fuzz-decompress.c create mode 100644 test/fuzz/fuzz-decompress/push-xz create mode 100644 test/fuzz/fuzz-decompress/truncated-bzip2-header create mode 100644 test/fuzz/fuzz-decompress/truncated-gzip-header create mode 100644 test/fuzz/fuzz-decompress/xz-blob create mode 100644 test/fuzz/fuzz-decompress/zstd-startswith diff --git a/src/fuzz/fuzz-decompress.c b/src/fuzz/fuzz-decompress.c new file mode 100644 index 0000000000..545c0a8961 --- /dev/null +++ b/src/fuzz/fuzz-decompress.c @@ -0,0 +1,165 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include + +#include "alloc-util.h" +#include "compress.h" +#include "fd-util.h" +#include "fuzz.h" +#include "memfd-util.h" +#include "memory-util.h" + +/* Unlike fuzz-compress, which round-trips data through a compressor and thus only ever hands the + * decoders well-formed input, this feeds arbitrary bytes straight to the decompression entrypoints. + * That is the situation the decoders are actually in: the blobs they parse come from journal files, + * systemd-journal-remote streams and coredumps, i.e. from wherever the data was written, which is not + * necessarily somewhere we trust. */ + +/* Cap the output so that a decompression bomb doesn't OOM the fuzzer: we are looking for memory errors + * in the decoders, not for their willingness to allocate. */ +#define DST_MAX_LIMIT (2U * 1024U * 1024U) + +typedef enum EntryPoint { + ENTRYPOINT_BLOB, + ENTRYPOINT_STARTSWITH, + ENTRYPOINT_STREAM, + ENTRYPOINT_PUSH, + _ENTRYPOINT_MAX, +} EntryPoint; + +typedef struct PushState { + uint64_t written; + uint64_t limit; +} PushState; + +static int push_callback(const void *data, size_t size, void *userdata) { + PushState *s = ASSERT_PTR(userdata); + + /* Same reasoning as for DST_MAX_LIMIT: stop the decode once enough came out, rather than let a + * bomb run to completion. */ + s->written += size; + if (s->written > s->limit) + return -EFBIG; + + return 0; +} + +typedef struct header { + uint8_t entrypoint; + uint8_t extra; + uint16_t prefix_len; + uint8_t reserved[8]; /* Extra space to keep fuzz cases stable in case we need to add stuff in the + * future. */ + uint32_t dst_max; + uint8_t data[]; +} header; + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + int r; + + if (size < offsetof(header, data) + 1) + return 0; + + const header *h = (const header*) data; + const uint8_t *blob = h->data; + const size_t blob_size = size - offsetof(header, data); + + /* Take the algorithm from the blob rather than from the header: this is what the callers that + * do not know it in advance use, and it keeps the corpus independent of the (internal) order of + * the Compression enum. */ + uint8_t magic[COMPRESSION_MAGIC_BYTES_MAX] = {}; + memcpy_safe(magic, blob, MIN(blob_size, sizeof(magic))); + + Compression alg = compression_detect_from_magic(magic); + if (alg < 0 || !compression_supported(alg)) + return 0; + + fuzz_setup_logging(); + + /* Leave 0 ("no limit") out of reach: an unbounded decode of a hostile blob is a memory + * exhaustion test, not a memory safety test. */ + size_t dst_max = (h->dst_max % DST_MAX_LIMIT) + 1; + + log_info("Using compression %s, blob size=%zu, dst_max=%zu", + compression_to_string(alg), blob_size, dst_max); + + switch ((EntryPoint) h->entrypoint) { + + case ENTRYPOINT_BLOB: { + _cleanup_free_ void *buf = NULL; + size_t dst_size; + + r = decompress_blob(alg, blob, blob_size, &buf, &dst_size, dst_max); + if (r < 0) + log_debug_errno(r, "decompress_blob() failed: %m"); + else + log_debug("Decompressed %zu bytes to → %zu bytes", blob_size, dst_size); + break; + } + + case ENTRYPOINT_STARTSWITH: { + _cleanup_free_ void *buf = NULL; + + /* Compare against a prefix taken from the blob itself, so that a match is at least + * reachable rather than astronomically unlikely. */ + size_t prefix_len = MIN((size_t) h->prefix_len, blob_size); + + r = decompress_startswith(alg, blob, blob_size, &buf, blob, prefix_len, h->extra); + if (r < 0) + log_debug_errno(r, "decompress_startswith() failed: %m"); + else + log_debug("Prefix of %zu bytes %s", prefix_len, r > 0 ? "matches" : "does not match"); + break; + } + + case ENTRYPOINT_STREAM: { + _cleanup_close_ int fd = -EBADF, null_fd = -EBADF; + ssize_t n; + + fd = memfd_new_and_seal("fuzz-decompress", blob, blob_size); + if (fd < 0) { + log_debug_errno(fd, "Failed to allocate memfd, skipping: %m"); + return 0; + } + + n = lseek(fd, 0, SEEK_SET); + if (n < 0) + return 0; + + null_fd = open("/dev/null", O_WRONLY|O_CLOEXEC|O_NOCTTY); + if (null_fd < 0) + return 0; + + (void) decompress_stream(alg, fd, null_fd, dst_max); + break; + } + + case ENTRYPOINT_PUSH: { + /* This is the path systemd-importd takes for downloaded images: sniff the compression + * off the first bytes, then push the stream in as it arrives off the network, and + * finally push a zero-length chunk to finalize. Feed it in chunks so that the decoders + * see the same fragmentation a real download produces. */ + _cleanup_(compressor_freep) Decompressor *d = NULL; + PushState state = { .limit = dst_max }; + + r = decompressor_detect(&d, blob, blob_size); + if (r <= 0) + break; + + size_t chunk = (h->prefix_len % 4096) + 1; + for (size_t o = 0; o < blob_size; o += chunk) { + r = decompressor_push(d, blob + o, MIN(chunk, blob_size - o), push_callback, &state); + if (r < 0) + break; + } + if (r >= 0) + (void) decompressor_push(d, /* data= */ NULL, /* size= */ 0, push_callback, &state); + break; + } + + default: + log_debug("Unknown entry point %u, skipping.", h->entrypoint); + } + + return 0; +} diff --git a/src/fuzz/meson.build b/src/fuzz/meson.build index 10848a77e6..b1190b3331 100644 --- a/src/fuzz/meson.build +++ b/src/fuzz/meson.build @@ -25,4 +25,5 @@ executables += [ # The following fuzzers do not work on oss-fuzz. See #11018. if not want_ossfuzz simple_fuzzers += files('fuzz-compress.c') + simple_fuzzers += files('fuzz-decompress.c') endif diff --git a/test/fuzz/fuzz-decompress/push-xz b/test/fuzz/fuzz-decompress/push-xz new file mode 100644 index 0000000000000000000000000000000000000000..33c19c068613d0fcd265f6a529a00e0f4bef5c55 GIT binary patch literal 96 zcmZQ(a9}_HMuxxURS{7P3@p#CZE<5_P-GMXN|xw9UHpK-oIxa(L2aVjfs0eZpG-da pSoh&tX@&dZKm)DbHbGG_Y{9K6G7v+vi$FkfAJ*Q2>=q94P<* literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-decompress/truncated-bzip2-header b/test/fuzz/fuzz-decompress/truncated-bzip2-header new file mode 100644 index 0000000000000000000000000000000000000000..84f37947acdfc71e98103532eb2a91a79927d10a GIT binary patch literal 20 ScmZQ%U|~Q2Mh2&-3`+n5l>tNm literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-decompress/truncated-gzip-header b/test/fuzz/fuzz-decompress/truncated-gzip-header new file mode 100644 index 0000000000000000000000000000000000000000..3fb9a24e50028d237394168235f78f3c375b0b61 GIT binary patch literal 26 VcmZQ%U|~Q2Mh5wA4ltMLKL7{(0lEMH literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-decompress/xz-blob b/test/fuzz/fuzz-decompress/xz-blob new file mode 100644 index 0000000000000000000000000000000000000000..b4c38729e67d8c48759e0929f23b36ca01d773d9 GIT binary patch literal 108 zcmZQzU|~Q2MuxxURS{7P3@p#CZE<5_P-GMXN|xw9UHpJyK7({DgW5#50~e=+Kbd^; zvF^hSJEQ%RzjIyI5?gl$WQN>df8kHRcr_Uq%@;5N`PR}Kg*F~Z|IGxFWQmLd05K{c AO8@`> literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-decompress/zstd-startswith b/test/fuzz/fuzz-decompress/zstd-startswith new file mode 100644 index 0000000000000000000000000000000000000000..66db325598389bfc58680c86d0ecc9989a2ea16b GIT binary patch literal 56 zcmZQ%;9x)iMh1?lLee@O2Fic64{O%}CA3$yX@PFUm>bVq_3<_f_kjcCHiv D9;pq+ literal 0 HcmV?d00001