From d5ed93ddf0fed02b45891a27968a7a1c1dd877b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20P=C3=ADsa=C5=99?= Date: Thu, 6 Aug 2026 17:05:21 +0200 Subject: [PATCH] Reject compressed YAML files which decompress to much Small compressed files can decompress to very large files. Then processing the very large decompressed data consumes unexpectedly large amount of memory. This amplification of use of resources can be interpreted as a denial of service. While libmodulemd uses a streaming YAML parser, effectivelly skipping large sequences of white spaces, constructing YAML scalars from very long sequences of the same character succesfully propages to higher level data structures. For example this YAML document: document: modulemd version: 2 data: name: test stream: a version: 1 context: c arch: x86_64 summary: Text description: Text license: module: - MITA...A where "MITA...A" is a sequence of 2^26 "A" characters occupies roughly 64 MB uncompressed, but only 64 KB after compressing with gzip. When processing the document with this library 265 MB of memory is allocated: $ ls -l /tmp/input.yaml.gz -rw-r--r--. 1 test test 65297 Aug 6 13:10 /tmp/input.yaml.gz $ /usr/bin/time -f %M modulemd-validator /tmp/input.yaml.gz /tmp/input.yaml.gz validated successfully 271688 To avoid these decompression bombs, this patch adds a limit for decompressed data. The limit is 64 MiB for small files, 64 times the compressed size for large files. 64 MiB is 10 times more than the largest modulemd YAML files observed in the wild and the factor of 64 is a doubled factor of XZ compression ratio on the largest observed file. Reaching the limit is explained with a Glib warning and reported with FALSE return value from the public function (i.e. modulemd_module_index_update_from_file()). The warning is used because libyaml and libmodulemd do not support reporting a detailed cause of a libyaml reader callback's error. Implementing the proper error reporting is out of scope of this decompression bomb mitigation. Implementation note: This patch adds a test which cannot check for modulemd_module_index_update_from_file() return value because the reader callback emits a warning which Glib unhelpully turns into a SIGABORT if G_DEBUG=fatal-warning enviroment variable is set. Instead, the test checks for the abortion. This is not a different from reporting standard I/O error in libmodulemd. This flaw was added with aad63a59ca02be2cc7afb6e6c8a0c3bd5a036dd6 commit ("Add helpers for reading compressed files") and first released in 2.8.0 version. Acknowledgement: Aisle Research Resolves: CVE-2026-92823 --- .../modulemd-2.0/modulemd-module-index.h | 6 +++ .../private/modulemd-compression-private.h | 20 ++++++++ modulemd/modulemd-compression.c | 28 ++++++++++-- modulemd/modulemd-module-index.c | 26 +++++++++-- modulemd/tests/test-modulemd-compression.c | 43 ++++++++++++++++++ .../test_data/compression/small_bomb.yaml.gz | Bin 0 -> 65297 bytes 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 modulemd/tests/test_data/compression/small_bomb.yaml.gz diff --git a/modulemd/include/modulemd-2.0/modulemd-module-index.h b/modulemd/include/modulemd-2.0/modulemd-module-index.h index cb876e89e..a2fb679d6 100644 --- a/modulemd/include/modulemd-2.0/modulemd-module-index.h +++ b/modulemd/include/modulemd-2.0/modulemd-module-index.h @@ -161,6 +161,12 @@ modulemd_module_index_new (void); * %NULL (if you don't care) or a pointer to %NULL (if you want to know the * error). On output, it will become allocated only if an error occured. * + * If libmodulemd is built with RPM I/O support, the YAML file might be + * compressed with a compression algorithm supported by RPM I/O. Then + * a content of the YAML file will be transparently decompressed. To prevent + * compression bomb attacks, a limit is imposed on the decompressed size: + * 64 MiB for small files and 64 times the compressed size for large files. + * * Returns: %TRUE if the update was successful. Returns %FALSE and sets * @failures appropriately if any of the YAML subdocuments were invalid or * sets @error if there was a fatal parse error. diff --git a/modulemd/include/private/modulemd-compression-private.h b/modulemd/include/private/modulemd-compression-private.h index ef6bdf651..1d8c7618a 100644 --- a/modulemd/include/private/modulemd-compression-private.h +++ b/modulemd/include/private/modulemd-compression-private.h @@ -32,6 +32,26 @@ #ifdef HAVE_RPMIO +/** + * modulemd_decompressor_context: + * @rpmio_fd: RPM I/O file descriptor. + * @decompressed: How many decompressed bytes have been read so far. + * @limit: Maximum number of bytes allowed to be decompressed. + * # + * #modulemd_decompressor_context wraps file descriptor and axuliary metadata + * about a file to be decompressed. + * + * Since 2.15.4 + */ +typedef struct _modulemd_decompressor_context +{ + FD_t rpmio_fd; + size_t decompressed; + size_t limit; + +} modulemd_decompressor_context; + + /** * mmd_Fclose: * @fd: (in): A FD_t from rpmio.h to be closed diff --git a/modulemd/modulemd-compression.c b/modulemd/modulemd-compression.c index f5bf48b90..3c4ea789d 100644 --- a/modulemd/modulemd-compression.c +++ b/modulemd/modulemd-compression.c @@ -20,6 +20,7 @@ #ifdef HAVE_RPMIO #include +#include #endif #include "modulemd-compression.h" @@ -251,17 +252,36 @@ compressed_stream_read_fn (void *data, size_t size, size_t *size_read) { - FD_t rpmio_fd = (FD_t)data; - ssize_t read = Fread (buffer, sizeof (*buffer), size, rpmio_fd); + struct _modulemd_decompressor_context *context = + (struct _modulemd_decompressor_context *)data; + ssize_t read = Fread (buffer, sizeof (*buffer), size, context->rpmio_fd); if (read < 0) { - g_warning ("Got error [%d] reading the file", Ferror (rpmio_fd)); + g_warning ("Got error [%d] reading the file", + Ferror (context->rpmio_fd)); return 0; } + context->decompressed += read; + if (context->decompressed < (size_t)read) + { + /* Handle integer overflow */ + context->decompressed = SIZE_MAX; + } - *size_read = read; + if (context->decompressed > context->limit) + { + g_warning ( + "A safety limit (%zu B) exceeded while decompressing the file", + context->limit); + // TODO: Store this reason into the context and pick it up from there + // whenever libmodulemd sets "Parser error" GError message after + // returning from yaml_parser_parse(). libyaml sets + // parser.error=YAML_READER_ERROR on any error from the reader callback. + return 0; + } + *size_read = read; return 1; } #else diff --git a/modulemd/modulemd-module-index.c b/modulemd/modulemd-module-index.c index ee5597ef5..2532713b6 100644 --- a/modulemd/modulemd-module-index.c +++ b/modulemd/modulemd-module-index.c @@ -19,6 +19,8 @@ #ifdef HAVE_RPMIO #include +#include +#include #endif #include "modulemd.h" @@ -641,8 +643,24 @@ modulemd_module_index_update_from_file_ext (ModulemdModuleIndex *self, * suite of tools to deal with it. We need to construct a special "mode" * argument to pass to Fdopen(). */ - FD_t rpmio_fd = NULL; + modulemd_decompressor_context context = { + .rpmio_fd = NULL, .decompressed = 0, .limit = 1 << 26, /* 64 MB */ + }; g_auto (FD_t) fd_dup = NULL; + struct stat statbuf; + + /* Scale the decompression limit according to a compressed size. + * A compression ratio of XZ on typical modulemd data is 1:30. + * Giving a leeway of factor 2 and rounding it to a power of 2 yields + * a factor limit of 64 (2^6). */ + if (0 == fstat (fd, &statbuf) && statbuf.st_size > 0) + { + size_t scaled_limit = (statbuf.st_size <= SIZE_MAX >> 6) ? + (size_t)statbuf.st_size << 6 : + SIZE_MAX; + if (scaled_limit > context.limit) + context.limit = scaled_limit; + } fmode = modulemd_get_rpmio_fmode ("r", comtype); if (!fmode) @@ -674,9 +692,9 @@ modulemd_module_index_update_from_file_ext (ModulemdModuleIndex *self, } g_debug ("Calling rpmio::Fdopen (%p, %s)", fd_dup, fmode); - rpmio_fd = Fdopen (fd_dup, fmode); + context.rpmio_fd = Fdopen (fd_dup, fmode); - if (!rpmio_fd) + if (!context.rpmio_fd) { g_set_error_literal ( error, @@ -689,7 +707,7 @@ modulemd_module_index_update_from_file_ext (ModulemdModuleIndex *self, g_debug ("rpmio::Fdopen (%p, %s) succeeded", fd_dup, fmode); return modulemd_module_index_update_from_custom ( - self, compressed_stream_read_fn, rpmio_fd, strict, failures, error); + self, compressed_stream_read_fn, &context, strict, failures, error); #else /* HAVE_RPMIO */ g_set_error_literal ( diff --git a/modulemd/tests/test-modulemd-compression.c b/modulemd/tests/test-modulemd-compression.c index 06b3c618e..ce834058c 100644 --- a/modulemd/tests/test-modulemd-compression.c +++ b/modulemd/tests/test-modulemd-compression.c @@ -14,7 +14,11 @@ #include #include +#include "config.h" #include "modulemd-compression.h" +#ifdef HAVE_RPMIO +#include "modulemd-module-index.h" +#endif #include "private/modulemd-compression-private.h" #include "private/modulemd-yaml.h" #include "private/test-utils.h" @@ -224,6 +228,40 @@ test_modulemd_get_rpmio_fmode (void) } +#ifdef HAVE_RPMIO +/* A compressed file smaller than 64 MiB / 64 which decompresses to + * more than 64 MiB should be rejected. */ +static void +test_modulemd_minimal_limit (void) +{ + if (g_test_subprocess ()) + { + g_autofree const gchar *filename = NULL; + g_autoptr (ModulemdModuleIndex) index = NULL; + g_autoptr (GPtrArray) failures = NULL; + g_autoptr (GError) error = NULL; + + filename = g_strdup_printf ("%s/compression/small_bomb.yaml.gz", + g_getenv ("TEST_DATA_PATH")); + g_assert_nonnull (filename); + index = modulemd_module_index_new (); + g_assert_nonnull (index); + modulemd_module_index_update_from_file ( + index, filename, TRUE, &failures, &error); + /* TODO: Check for false return value and set error, once libmodulemd + * supports reporting YAML reader callback errors. Until then we need + * to wrap this modulemd_module_index_update_from_file() call into + * a subprocess because the "safety limit" warning in the callback + * aborts the tests when G_DEBUG=fatal-warning environment variable is + * set. */ + return; + } + g_test_trap_subprocess (NULL, 0, G_TEST_SUBPROCESS_DEFAULT); + g_test_trap_assert_failed (); +} +#endif + + int main (int argc, char *argv[]) { @@ -244,5 +282,10 @@ main (int argc, char *argv[]) g_test_add_func ("/modulemd/compression/rmpio/fmode", test_modulemd_get_rpmio_fmode); +#ifdef HAVE_RPMIO + g_test_add_func ("/modulemd/compression/limit/minimum", + test_modulemd_minimal_limit); +#endif + return g_test_run (); } diff --git a/modulemd/tests/test_data/compression/small_bomb.yaml.gz b/modulemd/tests/test_data/compression/small_bomb.yaml.gz new file mode 100644 index 0000000000000000000000000000000000000000..220ad6d34829fb3fcc51da573a2836ca0c06018d GIT binary patch literal 65297 zcmeI*y-O5u7zgl^JH#MTNh2~-0-=V^Xp-{8UT_F3DGh8W92_q7NKqmi6eJ>o90J23 zoSNc@2q~c#!Lp;msk{pWj~2;7Lg8FIuDrjZqt7$^=I8sFpWpM;+$$-0&@*@~5RAsI z4-JNIcJ=iJel0vZH+QtGIQ{y3fBkIWROR}7Vs^2*^KRY2uRG4}|MDghZf|_Q*}6Hk zws7EK*VFFU;nibnD;GY@-ROJ~oxIpsJ-;+?{LJT>iHdk{{7yVxm#mmc=AMs7c79s= z_;_mHaN_6h$?xj}TMbRi6Pe2!!>#?RyCdV>Rf|8KWW&q1R-%VCF2zdcvnNJ|-kpB+ z_R*E1fFeME009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0Rja6b%D{EOu9MWWFj|R z-rk`I5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7csfkGEZjRo@`{%do|mbO9< zqkjSf2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkLHqpNgRXnQfh=+@P$lRO9z zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5;&f)}_QX(-Dd30NQL3l)4C0SFKv zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB=EYOu9Kg3y{c7m$&mEK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PWdtH5M$|9tNn*C0p7GK8*kb2oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+K(zFJ^zSIZ-s0>={mZ63`5gfs0t5&UAV7cs o0RjXF5FkK+009C72oNAZfB*pk1PBoLKLpl}%x1o&;(