Skip to content

Missing integer overflow guard in compact_field buffer growth (clevis-decrypt-sss.c) #565

Description

@fderepas

Summary

The compact_field() function in src/pins/sss/clevis-decrypt-sss.c
reads a JWE compact-serialization field from stdin into a dynamically
grown buffer. The buffer size size is incremented by 4096 with no
overflow guard
. In theory, if size wraps around SIZE_MAX, the
subsequent realloc would shrink the buffer while the write index
used continues to grow, causing an out-of-bounds write.

Practical impact — none

This is a code-quality / hardening issue, not a practically
exploitable vulnerability:

  • 64-bit systems: the wrap requires ~18 EB of input — physically
    impossible.
  • 32-bit systems: although SIZE_MAX is ~4 GiB and the arithmetic
    wraps at that point, the 32-bit virtual address space is limited to
    ~3 GB. The realloc calls will fail and return NULL well before
    size can reach the wrap point. The existing check
    if (!tmp) goto error; catches the allocation failure, so the
    function exits cleanly before the overflow can occur.

The bug is a missing hardening guard — the code is technically
incorrect but is protected in practice by the address-space limit on
32-bit and the input-size impossibility on 64-bit. The fix is trivial
(one line) and worth applying for correctness.

Affected code

src/pins/sss/clevis-decrypt-sss.c, compact_field(), lines 84–107:

static json_t *
compact_field(FILE *file)
{
    json_t *str = NULL;
    char *buf = NULL;
    size_t used = 0;
    size_t size = 0;

    for (int c = fgetc(file); c != EOF && c != '.' && !isspace(c);
         c = fgetc(file)) {
        if (used >= size) {
            char *tmp = NULL;

            size += 4096;          /* BUG: no overflow check */
            tmp = realloc(buf, size);
            if (!tmp)
                goto error;

            buf = tmp;
        }

        buf[used++] = c;           /* OOB write when size has wrapped */
    }

    str = json_stringn(buf ? buf : "", buf ? used : 0);

error:
    free(buf);
    return str;
}

Root cause

The growth step size += 4096 (line 95) performs unsigned integer
addition with no overflow check. When size reaches the largest
4096-aligned value below SIZE_MAX (i.e. SIZE_MAX - 4095 on
32-bit = 0xFFFFF001), the next size += 4096 wraps to
0x00001000 (4096). realloc(buf, 4096) then shrinks the
buffer back to 4096 bytes, while used is approximately 4 billion.
The subsequent buf[used++] = c writes gigabytes past the
allocation.

The invariant used <= size (the buffer has room for the write) is
violated by the wrap. The code has no guard equivalent to
if (size > SIZE_MAX - 4096) goto error;.

Reproducer — standalone C demonstrating the 32-bit overflow

Since the overflow requires ~4 GB of input on real 32-bit hardware,
we demonstrate the bug by simulating compact_field with a
uint32_t size variable that wraps at 32 bits, proving the
arithmetic defect:

/*
 * repro_clevis_f2.c — demonstrates the integer overflow in compact_field().
 *
 * Simulates the 32-bit overflow by using uint32_t for the size variable.
 * On real 32-bit systems, size_t IS uint32_t and this is the exact behavior.
 *
 * Compile:  gcc -fsanitize=address -g -o repro_clevis_f2 repro_clevis_f2.c
 * Run:      ./repro_clevis_f2
 * Expected: Demonstrates that size wraps around and realloc shrinks the buffer.
 */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/*
 * Reproduces compact_field's growth logic with uint32_t size
 * (simulating a 32-bit size_t where SIZE_MAX == 0xFFFFFFFF).
 *
 * Instead of actually reading 4 GB of input, we simulate the state
 * just before the overflow by directly setting used and size to their
 * values at the wrap point.
 */
int main(void)
{
    const uint32_t SIZE_MAX_32 = UINT32_MAX;  /* 0xFFFFFFFF */

    /*
     * After reading (SIZE_MAX_32 - 4095) bytes, the state is:
     *   used = 0xFFFFF001 (just triggered the last successful realloc)
     *   size = 0xFFFFF000 (largest 4096-aligned value < SIZE_MAX_32)
     *
     * Wait — let's trace the exact sequence:
     *   size starts at 0, grows by 4096 each time used >= size.
     *   After N growths: size = N * 4096.
     *   The last growth that fits: N = 0xFFFFF000 / 4096 = 0x000FFFFF
     *   So size = 0xFFFFF000, and the buffer holds up to 0xFFFFF000 bytes.
     *
     * When used == 0xFFFFF000 (buffer full), the next byte triggers:
     *   used >= size  →  true
     *   size += 4096  →  0xFFFFF000 + 0x1000 = 0x100000000
     *                    which wraps to 0x00000000 on 32-bit!
     *
     * realloc(buf, 0) → implementation-defined (free or tiny alloc)
     * buf[used++] = c → writes at offset 0xFFFFF000 into a freed/tiny buffer
     *
     * Actually: 0xFFFFF000 + 4096 = 0x100000000 → wraps to 0x0 (32-bit).
     * But let's check: does it wrap to 0 or 4096?
     *   0xFFFFF000 = 4294963200
     *   + 4096     = 4294967296 = 2^32 → wraps to 0
     *
     * realloc(buf, 0) is equivalent to free(buf) on many implementations,
     * then buf[used++] = use-after-free + OOB write.
     */

    printf("Simulating compact_field on 32-bit (uint32_t size):\n\n");

    uint32_t size = 0;
    uint32_t used = 0;

    /* Simulate growth up to the wrap point */
    printf("1. Buffer grows normally: size += 4096 each time used >= size\n");
    size = 0xFFFFF000u;  /* last successful growth */
    used = 0xFFFFF000u;  /* buffer is now full */
    printf("   After ~4 GB of input: size = 0x%08X, used = 0x%08X\n",
           size, used);

    /* Now: used >= size triggers growth */
    printf("\n2. Next byte: used(0x%08X) >= size(0x%08X) → grow buffer\n",
           used, size);
    size += 4096;
    printf("   size += 4096 → size = 0x%08X  *** WRAPPED TO %u ***\n",
           size, size);

    printf("\n3. realloc(buf, %u) → ", size);
    if (size == 0) {
        printf("realloc(buf, 0) = free(buf) or tiny alloc!\n");
    } else {
        printf("realloc(buf, %u) = buffer SHRUNK from ~4 GB to %u bytes!\n",
               size, size);
    }

    printf("\n4. buf[used++] = c → writes at offset 0x%08X (%u)\n",
           used, used);
    printf("   into a buffer of size %u → OOB WRITE of ~%u GB past allocation\n",
           size, (used - size) / (1024*1024*1024));

    printf("\n*** INTEGER OVERFLOW CONFIRMED: "
           "size wraps from 0xFFFFF000 to 0x%08X ***\n", size);

    /*
     * Concrete proof with actual memory (small scale):
     * Allocate 8192 bytes, simulate the state where size wrapped to 4096.
     */
    printf("\n--- Concrete small-scale demo with ASan ---\n");
    char *buf = malloc(4096);  /* simulates post-realloc(buf, 4096) after wrap */
    if (!buf) return 1;
    memset(buf, 0, 4096);

    /* After wrap: size = 4096 (or 0), used = 0xFFFFF000.
     * The code does buf[used++] = c; but used is huge.
     * We simulate with a smaller offset that still triggers ASan: */
    printf("Writing buf[4096] (one past allocation) to trigger ASan...\n");
    buf[4096] = 'X';  /* ASan: heap-buffer-overflow WRITE */

    free(buf);
    return 0;
}

Expected output

Without ASan (arithmetic proof):

Simulating compact_field on 32-bit (uint32_t size):

1. Buffer grows normally: size += 4096 each time used >= size
   After ~4 GB of input: size = 0xFFFFF000, used = 0xFFFFF000

2. Next byte: used(0xFFFFF000) >= size(0xFFFFF000) → grow buffer
   size += 4096 → size = 0x00000000  *** WRAPPED TO 0 ***

3. realloc(buf, 0) → realloc(buf, 0) = free(buf) or tiny alloc!

4. buf[used++] = c → writes at offset 0xFFFFF000 (4294963200)
   into a buffer of size 0 → OOB WRITE of ~3 GB past allocation

*** INTEGER OVERFLOW CONFIRMED: size wraps from 0xFFFFF000 to 0x00000000 ***

With ASan (heap-buffer-overflow on the small-scale demo):

==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 1 at 0x... thread T0
    #0 0x... in main repro_clevis_f2.c:104
    ...
0x... is located 0 bytes after 4096-byte region [0x...,0x...)

SUMMARY: AddressSanitizer: heap-buffer-overflow repro_clevis_f2.c:104 in main

Scope

compact_field() is called from compact_jwe() (line 118), which
parses the JWE compact serialization from stdin. This happens in
main() at line 153 — before any cryptographic verification:

jwe = compact_jwe(stdin);    /* ← parses input */
if (!jwe)
    goto egress;

The three compact_field calls parse the protected, encrypted_key,
and iv fields.

Fix

Add an overflow guard before the size increment:

--- a/src/pins/sss/clevis-decrypt-sss.c
+++ b/src/pins/sss/clevis-decrypt-sss.c
@@ -93,6 +93,8 @@
         if (used >= size) {
             char *tmp = NULL;
 
+            if (size > SIZE_MAX - 4096)
+                goto error;
             size += 4096;
             tmp = realloc(buf, size);
             if (!tmp)

How this was found

Found by applying the Squeeze Loop strategy ("The Squeeze Loop
Strategy: Catching Coherent-and-Wrong Artifacts with an
Author-Independent Executable Oracle," Zenodo
10.5281/zenodo.20787816,
2026) on its C terrain. The WP analysis surfaced this not via a
default RTE alarm (unsigned integer wrap is defined behavior in C),
but through the non-inductive loop invariant used <= size
(range_preserved) — the functional bound that ensures the buffer
has room for each write. This invariant fails to discharge because
size += 4096 can wrap, breaking the used <= size relationship.

A refutation probe inserting assert size <= SIZE_MAX - 4096 before
size += 4096 causes the invariant to discharge and the assert
becomes the sole residual — localizing the failure exactly to the
missing overflow guard, not prover weakness. The guard that makes the
proof green is the fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions