static-delta: Add resource limits for LZMA decompression
authorJoseph Marrero Corchado <jmarrero@redhat.com>
Tue, 28 Jul 2026 18:15:56 +0000 (14:15 -0400)
committerJoseph Marrero Corchado <jmarrero@redhat.com>
Tue, 28 Jul 2026 18:15:56 +0000 (14:15 -0400)
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
Makefile-tests.am
src/libostree/ostree-lzma-decompressor.c
src/libostree/ostree-repo-static-delta-core.c
src/libostree/ostree-repo-static-delta-private.h
src/libotutil/ot-fs-utils.c
src/libotutil/ot-fs-utils.h
tests/test-lzma.c

index 638a145e960ff8fd6196f573b2c3cf9030792fb4..e7c5c8cdf2864373a7fc2620146ba94376988038 100644 (file)
@@ -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)
 
index 2ac48a32002cff984fcbf9cc493babb063718fe0..bf62fc9c9e5d73e3c47b31393470eaf3e9c64dad 100644 (file)
 #include <lzma.h>
 #include <string.h>
 
+/* 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;
index f666e8a577c7bdb2ebc3915048cb02be70fa39e0..4f51a4f5a6396c3f2fb3fd4091932c30fb9c724e 100644 (file)
@@ -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;
 
index 72208c2edd8d55fe55c9a4e0771c6b6cc86a2ca4..d711de62b91b655230f50c253eea711313a8beeb 100644 (file)
 
 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
 
index 1e961a986cf83978d9fbe15170dd2fe5f9907d7b..41aa944f565ec3b55cdd95b115d80803a53e9028 100644 (file)
@@ -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)
index 7df79ba2a64e2acb13298fbb082f3c7ed923341f..e112ccb75d0960010711a4727581d696fc3711a3 100644 (file)
@@ -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 **),
index 5c1ce9dc16950559b45d3c2f77c804aa0021c6c8..7ca3898a0692ef0ebc043bf200d6a5cb251727ff 100644 (file)
 #include "libglnx.h"
 #include "ostree-lzma-compressor.h"
 #include "ostree-lzma-decompressor.h"
+#include "ot-fs-utils.h"
 #include <gio/gio.h>
 #include <gio/gmemoryoutputstream.h>
 #include <gio/gunixoutputstream.h>
 #include <glib.h>
+#include <lzma.h>
 #include <stdlib.h>
 #include <string.h>
 
@@ -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 ();
 }