From 87d3c0467298b54c30e0b2f72d50aa5e27784b2d Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Mon, 31 Aug 2026 21:25:37 -0400 Subject: [PATCH 1/3] compress: don't grow the output buffer forever on a truncated stream decompress_startswith() decodes just enough of a blob to compare its leading bytes against a prefix, and grows the output buffer whenever a round of decoding came back with less than prefix_len + 1 bytes decoded. That growth is only the right answer while the decoder actually wants more output room. If it instead ran out of input, i.e. the stream is truncated, then no amount of extra room will help, and the gzip and bzip2 paths double their allocation on every iteration until the process is out of memory. Neither decoder lets us tell the two cases apart by return code alone: inflate() reports the stalled stream as Z_BUF_ERROR, which we cannot simply refuse since that is also how it asks for a bigger output buffer, and BZ2_bzDecompress() does not distinguish them at all, it just keeps saying BZ_OK. The output space left over does tell us, though: a decoder that stopped without filling the buffer we gave it is waiting for input that is not coming, so report the stream as corrupt instead of growing. A 10 byte gzip header, or a 4 byte bzip2 header, with nothing behind it was enough to reach a single 4 GiB allocation this way. The xz and zstd paths already terminate, the former because liblzma turns a stalled stream into LZMA_BUF_ERROR. Note that no caller can reach this today: the only non-test caller is decompress_startswith_journal(), and compression_supported_journal() admits only NONE/XZ/LZ4/ZSTD. It is a trap for whoever wires up the other two, so let's close it now. --- src/basic/compress.c | 6 ++++++ src/test/test-compress.c | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/basic/compress.c b/src/basic/compress.c index f3e57dd284..8a3cf92a41 100644 --- a/src/basic/compress.c +++ b/src/basic/compress.c @@ -1440,6 +1440,9 @@ static int decompress_startswith_gzip( if (r == Z_STREAM_END) return 0; + if (s.avail_out > 0) + return -EBADMSG; /* truncated */ + size_t used = allocated - s.avail_out; if (!(greedy_realloc(buffer, allocated * 2, 1))) @@ -1506,6 +1509,9 @@ static int decompress_startswith_bzip2( if (r == BZ_STREAM_END) return 0; + if (s.avail_out > 0) + return -EBADMSG; /* truncated */ + size_t used = allocated - s.avail_out; if (!(greedy_realloc(buffer, allocated * 2, 1))) diff --git a/src/test/test-compress.c b/src/test/test-compress.c index 5c419310b8..e66fb5ce30 100644 --- a/src/test/test-compress.c +++ b/src/test/test-compress.c @@ -341,6 +341,19 @@ TEST(decompress_startswith_short) { ASSERT_OK_POSITIVE(decompress_startswith(c, buf, csize, &buf2, TEXT, i, TEXT[i])); ASSERT_OK_ZERO(decompress_startswith(c, buf, csize, &buf2, TEXT, i, 'y')); } + + /* A truncated stream leaves the decoder unable to make progress: it has consumed all the + * input there is, and the prefix we are after never materialises. Since + * decompress_startswith() grows its output buffer whenever it comes back with less than + * the prefix decoded, that has to be recognised, as more output room cannot substitute + * for missing input. Hence the result may be a match, a non-match, or -EBADMSG, but never + * -ENOMEM. */ + for (size_t i = 1; i < csize; i++) { + _cleanup_free_ void *buf2 = NULL; + + ASSERT_OK_OR(decompress_startswith(c, buf, i, &buf2, TEXT, strlen(TEXT), TEXT[strlen(TEXT)]), + -EBADMSG); + } } #undef TEXT } From 43cd946f9899f9ae67da319f73199be700e93524 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Mon, 31 Aug 2026 22:21:19 -0400 Subject: [PATCH 2/3] 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 From 65c112007a59cbb2e959a30e6ccd6fadadd75fc7 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Mon, 31 Aug 2026 22:21:19 -0400 Subject: [PATCH 3/3] fuzz: add a fuzzer for the qcow2 converter systemd-importd runs qcow2_detect()/qcow2_convert() over images it has just downloaded (see pull-raw.c), so the header, the L1/L2 tables and every offset and cluster descriptor in them are attacker controlled. There is a manual test for the happy path, but nothing that feeds it a malformed image. The corpus is two images, a plain data cluster and a zlib-compressed one (i.e. decompress_zlib_raw()), at the 512 byte minimum cluster size and trimmed to the last byte the converter reads, so 2K each. A zero cluster and a QCOW3 header are a flipped descriptor bit and a bumped version field away from these, so they are left to the fuzzer rather than carried in the tree. --- src/import/fuzz-qcow2.c | 32 ++++++++++++++++++++++++ src/import/meson.build | 4 +++ test/fuzz/fuzz-qcow2/compressed-cluster | Bin 0 -> 2048 bytes test/fuzz/fuzz-qcow2/plain-cluster | Bin 0 -> 2048 bytes 4 files changed, 36 insertions(+) create mode 100644 src/import/fuzz-qcow2.c create mode 100644 test/fuzz/fuzz-qcow2/compressed-cluster create mode 100644 test/fuzz/fuzz-qcow2/plain-cluster diff --git a/src/import/fuzz-qcow2.c b/src/import/fuzz-qcow2.c new file mode 100644 index 0000000000..36f11b42e8 --- /dev/null +++ b/src/import/fuzz-qcow2.c @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "fd-util.h" +#include "fuzz.h" +#include "memfd-util.h" +#include "qcow2-util.h" + +/* systemd-importd runs this over images it just downloaded (see pull-raw.c), so the header, the L1/L2 + * tables and every offset in them are attacker controlled. */ + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + _cleanup_close_ int qcow2_fd = -EBADF, raw_fd = -EBADF; + + if (size < sizeof(uint32_t)) + return 0; + + fuzz_setup_logging(); + + qcow2_fd = memfd_new_and_seal("fuzz-qcow2-in", data, size); + if (qcow2_fd < 0) + return 0; + + raw_fd = memfd_new("fuzz-qcow2-out"); + if (raw_fd < 0) + return 0; + + if (qcow2_detect(qcow2_fd) <= 0) + return 0; + + (void) qcow2_convert(qcow2_fd, raw_fd); + return 0; +} diff --git a/src/import/meson.build b/src/import/meson.build index a1ac50594f..a8fc08f855 100644 --- a/src/import/meson.build +++ b/src/import/meson.build @@ -93,6 +93,10 @@ executables += [ 'sources' : files('test-oci-util.c'), 'import' : ['systemd-importd'], }, + fuzz_template + { + 'sources' : files('fuzz-qcow2.c'), + 'import' : ['systemd-importd'], + }, ] install_data('org.freedesktop.import1.conf', diff --git a/test/fuzz/fuzz-qcow2/compressed-cluster b/test/fuzz/fuzz-qcow2/compressed-cluster new file mode 100644 index 0000000000000000000000000000000000000000..caeb4733d56abe38aad72b9579a515166543d0d1 GIT binary patch literal 2048 zcmWG^^Zd=gz`z8=$bb{V5rBy?LKx`EVA2HX41y{~6%0rSG=Obo84$ZiEgkp}Z~!}j bZQva;>Vg3Yf#d`UR%OWnv3k_fK@b7}MzRD* literal 0 HcmV?d00001 diff --git a/test/fuzz/fuzz-qcow2/plain-cluster b/test/fuzz/fuzz-qcow2/plain-cluster new file mode 100644 index 0000000000000000000000000000000000000000..72fd329042af1162cd3ac5db57a63326c27fa8ce GIT binary patch literal 2048 zcmWG^^Zd=gz`z8=$bb{V5rBy?LKx`EVA2HX41y{~6%0rSG=Obo84$ZiEgkp}XaGBb lZQva;>Vg3Y0S{LnAAf~FXa8^`1sBH<#{qG~sHFoI0sv*K2c`f3 literal 0 HcmV?d00001