From: Joseph Marrero Corchado Date: Tue, 28 Jul 2026 18:15:56 +0000 (-0400) Subject: static-delta: Add resource limits for LZMA decompression X-Git-Tag: archive/raspbian/2026.4-1+rpi1^2~9^2~1^2~14^2~2 X-Git-Url: https://dgit.raspbian.org/?a=commitdiff_plain;h=7d1c30b2d73caf80ae09e571b4e844afe3610fbc;p=ostree.git static-delta: Add resource limits for LZMA decompression A malicious repository can serve crafted static delta content that exhausts client memory and disk during ostree pull before checksum validation completes. Cap the LZMA decoder memory to 100 MiB (matching RPM's default) and bound decompressed delta part output to 512 MiB. The size limit is enforced on both compressed and uncompressed code paths. Closes: RHEL-189208 --- diff --git a/Makefile-tests.am b/Makefile-tests.am index 638a145e..e7c5c8cd 100644 --- a/Makefile-tests.am +++ b/Makefile-tests.am @@ -419,7 +419,7 @@ tests_test_ot_tool_util_CFLAGS = $(TESTS_CFLAGS) tests_test_ot_tool_util_LDADD = $(TESTS_LDADD) tests_test_lzma_SOURCES = src/libostree/ostree-lzma-common.c src/libostree/ostree-lzma-compressor.c \ - src/libostree/ostree-lzma-decompressor.c tests/test-lzma.c + src/libostree/ostree-lzma-decompressor.c src/libotutil/ot-fs-utils.c tests/test-lzma.c tests_test_lzma_CFLAGS = $(TESTS_CFLAGS) $(OT_DEP_LZMA_CFLAGS) tests_test_lzma_LDADD = $(TESTS_LDADD) $(OT_DEP_LZMA_LIBS) diff --git a/src/libostree/ostree-lzma-decompressor.c b/src/libostree/ostree-lzma-decompressor.c index 2ac48a32..bf62fc9c 100644 --- a/src/libostree/ostree-lzma-decompressor.c +++ b/src/libostree/ostree-lzma-decompressor.c @@ -26,6 +26,16 @@ #include #include +/* Cap the LZMA decoder memory limit to 100 MiB to prevent decompression bombs + * from exhausting system memory via crafted static delta content (CVE / RHEL-189208). + * + * ostree's compressor uses preset 8 which implies a 32 MiB dictionary, so + * legitimate streams need ~33-35 MiB. 100 MiB gives ~3x headroom and matches + * the default used by RPM (rpmio/rpmio.cc, `100<<20`). Even preset 9 (64 MiB + * dictionary) fits comfortably within this limit. + */ +#define OSTREE_LZMA_DECODER_MEMLIMIT (100ULL * 1024ULL * 1024ULL) + enum { PROP_0, @@ -115,7 +125,7 @@ _ostree_lzma_decompressor_convert (GConverter *converter, const void *inbuf, gsi if (!self->initialized) { - res = lzma_stream_decoder (&self->lstream, G_MAXUINT64, 0); + res = lzma_stream_decoder (&self->lstream, OSTREE_LZMA_DECODER_MEMLIMIT, 0); if (res != LZMA_OK) goto out; self->initialized = TRUE; diff --git a/src/libostree/ostree-repo-static-delta-core.c b/src/libostree/ostree-repo-static-delta-core.c index f666e8a5..4f51a4f5 100644 --- a/src/libostree/ostree-repo-static-delta-core.c +++ b/src/libostree/ostree-repo-static-delta-core.c @@ -677,7 +677,7 @@ _ostree_static_delta_part_open (GInputStream *part_in, GBytes *inline_part_bytes { int part_fd = g_file_descriptor_based_get_fd ((GFileDescriptorBased *)part_in); - /* No compression, no checksums - a fast path */ + /* No compression - a fast path */ if (!ot_variant_read_fd (part_fd, 1, G_VARIANT_TYPE (OSTREE_STATIC_DELTA_PART_PAYLOAD_FORMAT_V0), trusted, &ret_part, error)) @@ -692,6 +692,16 @@ _ostree_static_delta_part_open (GInputStream *part_in, GBytes *inline_part_bytes g_variant_ref_sink (ret_part); } + /* Enforce the same size limit as for compressed parts so that an + * attacker cannot bypass the decompression-bomb defence simply by + * setting compression type to 0 (CVE / RHEL-189208). + */ + if (g_variant_get_size (ret_part) > OSTREE_STATIC_DELTA_PART_MAX_USIZE_BYTES) + return glnx_throw ( + error, + "Uncompressed delta part size %" G_GSIZE_FORMAT " exceeds maximum %" G_GUINT64_FORMAT, + g_variant_get_size (ret_part), (guint64)OSTREE_STATIC_DELTA_PART_MAX_USIZE_BYTES); + if (!skip_checksum) g_checksum_update (checksum, g_variant_get_data (ret_part), g_variant_get_size (ret_part)); @@ -700,7 +710,8 @@ _ostree_static_delta_part_open (GInputStream *part_in, GBytes *inline_part_bytes { g_autoptr (GConverter) decomp = (GConverter *)_ostree_lzma_decompressor_new (); g_autoptr (GInputStream) convin = g_converter_input_stream_new (source_in, decomp); - g_autoptr (GBytes) buf = ot_map_anonymous_tmpfile_from_content (convin, cancellable, error); + g_autoptr (GBytes) buf = ot_map_anonymous_tmpfile_from_content_with_limit ( + convin, OSTREE_STATIC_DELTA_PART_MAX_USIZE_BYTES, cancellable, error); if (!buf) return FALSE; diff --git a/src/libostree/ostree-repo-static-delta-private.h b/src/libostree/ostree-repo-static-delta-private.h index 72208c2e..d711de62 100644 --- a/src/libostree/ostree-repo-static-delta-private.h +++ b/src/libostree/ostree-repo-static-delta-private.h @@ -23,8 +23,17 @@ G_BEGIN_DECLS -/* Arbitrarily chosen */ -#define OSTREE_STATIC_DELTA_PART_MAX_SIZE_BYTES (16 * 1024 * 1024) +/* Maximum uncompressed size for a single static delta part (512 MiB). + * Enforced on both generation and consumption sides to prevent + * decompression bombs from exhausting memory/disk (CVE / RHEL-189208). + * + * The delta compiler splits parts at max-chunk-size (default 32 MB), so + * legitimate parts are typically well under 32 MB uncompressed. 512 MiB + * provides ~16x headroom over the default, which is generous enough to + * accommodate large custom --max-chunk-size values while still rejecting + * decompression bombs that would expand to gigabytes. + */ +#define OSTREE_STATIC_DELTA_PART_MAX_USIZE_BYTES (512ULL * 1024ULL * 1024ULL) /* 1 byte for object type, 32 bytes for checksum */ #define OSTREE_STATIC_DELTA_OBJTYPE_CSUM_LEN 33 diff --git a/src/libotutil/ot-fs-utils.c b/src/libotutil/ot-fs-utils.c index 1e961a98..41aa944f 100644 --- a/src/libotutil/ot-fs-utils.c +++ b/src/libotutil/ot-fs-utils.c @@ -178,12 +178,16 @@ ot_fd_readall_or_mmap (int fd, goffset start, GError **error) return glnx_fd_readall_bytes (fd, NULL, error); } -/* Given an input stream, splice it to an anonymous file (O_TMPFILE). - * Useful for potentially large but transient files. +/* Given an input stream, splice it to an anonymous file (O_TMPFILE) + * with an optional upper bound on the number of bytes written. + * If @max_bytes is non-zero and the stream produces more than that + * many bytes, an error is returned. This prevents decompression-bomb + * style attacks where a small compressed payload expands to exhaust + * memory or disk (CVE / RHEL-189208). */ GBytes * -ot_map_anonymous_tmpfile_from_content (GInputStream *instream, GCancellable *cancellable, - GError **error) +ot_map_anonymous_tmpfile_from_content_with_limit (GInputStream *instream, guint64 max_bytes, + GCancellable *cancellable, GError **error) { g_auto (GLnxTmpfile) tmpf = { 0, @@ -192,10 +196,34 @@ ot_map_anonymous_tmpfile_from_content (GInputStream *instream, GCancellable *can return NULL; g_autoptr (GOutputStream) out = g_unix_output_stream_new (tmpf.fd, FALSE); - gssize n_bytes_written = g_output_stream_splice ( - out, instream, G_OUTPUT_STREAM_SPLICE_CLOSE_SOURCE | G_OUTPUT_STREAM_SPLICE_CLOSE_TARGET, - cancellable, error); - if (n_bytes_written < 0) + + guint64 total = 0; + while (TRUE) + { + guchar buf[65536]; + gssize n_read + = g_input_stream_read (instream, buf, sizeof (buf), cancellable, error); + if (n_read < 0) + return NULL; + if (n_read == 0) + break; + + total += (guint64)n_read; + if (max_bytes > 0 && total > max_bytes) + { + g_set_error (error, G_IO_ERROR, G_IO_ERROR_NO_SPACE, + "Decompressed delta part exceeds configured limit of %" G_GUINT64_FORMAT + " bytes", + max_bytes); + return NULL; + } + + gsize n_written; + if (!g_output_stream_write_all (out, buf, (gsize)n_read, &n_written, cancellable, error)) + return NULL; + } + + if (!g_output_stream_close (out, cancellable, error)) return NULL; g_autoptr (GMappedFile) mfile = g_mapped_file_new_from_fd (tmpf.fd, FALSE, error); @@ -204,6 +232,16 @@ ot_map_anonymous_tmpfile_from_content (GInputStream *instream, GCancellable *can return g_mapped_file_get_bytes (mfile); } +/* Given an input stream, splice it to an anonymous file (O_TMPFILE). + * Useful for potentially large but transient files. + */ +GBytes * +ot_map_anonymous_tmpfile_from_content (GInputStream *instream, GCancellable *cancellable, + GError **error) +{ + return ot_map_anonymous_tmpfile_from_content_with_limit (instream, 0, cancellable, error); +} + gboolean ot_parse_file_by_line (const char *path, gboolean (*cb) (const char *, void *, GError **), void *cbdata, GCancellable *cancellable, GError **error) diff --git a/src/libotutil/ot-fs-utils.h b/src/libotutil/ot-fs-utils.h index 7df79ba2..e112ccb7 100644 --- a/src/libotutil/ot-fs-utils.h +++ b/src/libotutil/ot-fs-utils.h @@ -70,6 +70,10 @@ gboolean ot_dfd_iter_init_allow_noent (int dfd, const char *path, GLnxDirFdItera GBytes *ot_map_anonymous_tmpfile_from_content (GInputStream *instream, GCancellable *cancellable, GError **error); +GBytes *ot_map_anonymous_tmpfile_from_content_with_limit (GInputStream *instream, guint64 max_bytes, + GCancellable *cancellable, + GError **error); + GBytes *ot_fd_readall_or_mmap (int fd, goffset offset, GError **error); gboolean ot_parse_file_by_line (const char *path, gboolean (*cb) (const char *, void *, GError **), diff --git a/tests/test-lzma.c b/tests/test-lzma.c index 5c1ce9dc..7ca3898a 100644 --- a/tests/test-lzma.c +++ b/tests/test-lzma.c @@ -21,10 +21,12 @@ #include "libglnx.h" #include "ostree-lzma-compressor.h" #include "ostree-lzma-decompressor.h" +#include "ot-fs-utils.h" #include #include #include #include +#include #include #include @@ -104,12 +106,113 @@ test_lzma_big_buffer (void) helper_test_compress_decompress (buffer, buffer_size); } +/* Test that the LZMA decompressor rejects streams whose dictionary size + * exceeds the configured memory limit (OSTREE_LZMA_DECODER_MEMLIMIT = 100 MiB). + * We craft a minimal valid LZMA stream header that requests a 128 MiB + * dictionary, which should trigger LZMA_MEMLIMIT_ERROR -> "Exceeded memory + * limit" when the decompressor tries to process it. + */ +static void +test_lzma_memlimit (void) +{ + g_autoptr (GError) error = NULL; + + /* Build a minimal XZ stream that requests a 128 MiB dictionary. + * lzma_options_lzma with dict_size = 128 MiB, compressed via + * lzma_stream_encoder() with LZMA_CHECK_CRC64. + */ + lzma_options_lzma opts; + lzma_lzma_preset (&opts, LZMA_PRESET_DEFAULT); + opts.dict_size = 128U * 1024U * 1024U; /* 128 MiB - exceeds 100 MiB limit */ + + lzma_filter filters[] = { { .id = LZMA_FILTER_LZMA2, .options = &opts }, + { .id = LZMA_VLI_UNKNOWN, .options = NULL } }; + + lzma_stream strm = LZMA_STREAM_INIT; + lzma_ret ret = lzma_stream_encoder (&strm, filters, LZMA_CHECK_CRC64); + g_assert_cmpint (ret, ==, LZMA_OK); + + /* Compress a tiny payload just to produce a valid stream header */ + const guint8 input[] = "test"; + guint8 outbuf[4096]; + strm.next_in = input; + strm.avail_in = sizeof (input); + strm.next_out = outbuf; + strm.avail_out = sizeof (outbuf); + ret = lzma_code (&strm, LZMA_FINISH); + g_assert (ret == LZMA_STREAM_END || ret == LZMA_OK); + gsize compressed_size = sizeof (outbuf) - strm.avail_out; + lzma_end (&strm); + + /* Now try to decompress through our decompressor — it should fail + * with "Exceeded memory limit" because the stream header requests + * a 128 MiB dictionary but our limit is 100 MiB. + */ + g_autoptr (GConverter) decomp = (GConverter *)_ostree_lzma_decompressor_new (); + g_autoptr (GInputStream) raw_in + = g_memory_input_stream_new_from_data (outbuf, compressed_size, NULL); + g_autoptr (GInputStream) convin = g_converter_input_stream_new (raw_in, decomp); + g_autoptr (GOutputStream) out = g_memory_output_stream_new_resizable (); + + gssize n = g_output_stream_splice ( + out, convin, G_OUTPUT_STREAM_SPLICE_CLOSE_SOURCE | G_OUTPUT_STREAM_SPLICE_CLOSE_TARGET, NULL, + &error); + g_assert_cmpint (n, ==, -1); + g_assert_error (error, G_IO_ERROR, G_IO_ERROR_FAILED); + /* The error message from ostree-lzma-common.c for LZMA_MEMLIMIT_ERROR */ + g_assert (strstr (error->message, "memory limit") != NULL); +} + +/* Test that ot_map_anonymous_tmpfile_from_content_with_limit() correctly + * rejects input that exceeds the configured byte limit. + */ +static void +test_lzma_decompressed_size_limit (void) +{ + g_autoptr (GError) error = NULL; + const gsize data_size = 8192; + g_autofree guint8 *data = g_new0 (guint8, data_size); + memset (data, 'X', data_size); + + g_autoptr (GInputStream) in = g_memory_input_stream_new_from_data (data, data_size, NULL); + + /* Set a limit smaller than the data — should fail */ + GBytes *result = ot_map_anonymous_tmpfile_from_content_with_limit (in, 4096, NULL, &error); + g_assert_null (result); + g_assert_error (error, G_IO_ERROR, G_IO_ERROR_NO_SPACE); + g_assert (strstr (error->message, "exceeds configured limit") != NULL); +} + +/* Test that ot_map_anonymous_tmpfile_from_content_with_limit() succeeds + * when the input fits within the limit. + */ +static void +test_lzma_decompressed_size_limit_ok (void) +{ + g_autoptr (GError) error = NULL; + const gsize data_size = 4096; + g_autofree guint8 *data = g_new0 (guint8, data_size); + memset (data, 'Y', data_size); + + g_autoptr (GInputStream) in = g_memory_input_stream_new_from_data (data, data_size, NULL); + + /* Limit is exactly the data size — should succeed */ + g_autoptr (GBytes) result + = ot_map_anonymous_tmpfile_from_content_with_limit (in, data_size, NULL, &error); + g_assert_no_error (error); + g_assert_nonnull (result); + g_assert_cmpint (g_bytes_get_size (result), ==, data_size); +} + int main (int argc, char **argv) { g_test_init (&argc, &argv, NULL); g_test_add_func ("/lzma/random-buffer", test_lzma_random); g_test_add_func ("/lzma/big-buffer", test_lzma_big_buffer); + g_test_add_func ("/lzma/memlimit", test_lzma_memlimit); + g_test_add_func ("/lzma/decompressed-size-limit", test_lzma_decompressed_size_limit); + g_test_add_func ("/lzma/decompressed-size-limit-ok", test_lzma_decompressed_size_limit_ok); return g_test_run (); }