Skip to content

Heap write past caller's buffer in gz_write()/gzputc() after non-blocking write stall #1292

Description

@f9j2n6nd8k-eng

Heap write past caller's buffer in gz_write()/gzputc() after non-blocking write stall

File: gzwrite.c, gz_write() small-write path, memcpy(state->in + have, buf, copy) at line ~217 (also gzputc() fast path, lines ~325-345; transparent "T" mode affected via the same path).
Introduced: with the non-blocking device support (same feature as #1256; first released in 1.3.1.2, also present in 1.3.2 and master).
This bug is different from issue #1256

Summary

gz_write() has two paths: a small-write path that copies into the internal input buffer state->in (2 * state->size bytes), and a direct path for len >= state->size that points strm->next_in at the caller's buffer and compresses from there. When a direct-path write stalls on EAGAIN (non-blocking fd, consumer not draining), gz_comp() returns -1 and leaves strm->next_in still inside the caller's buffer with strm->avail_in > 0. A subsequent small write then computes have via pointer arithmetic across the two unrelated objects and memcpy()s to a destination derived from it — past the caller's buffer, marching forward by len per call.

The buggy code

/* gz_write(), small-write path (len < state->size) */
    for (;;) {
        unsigned have, copy;

        if (state->strm.avail_in == 0)
            state->strm.next_in = state->in;         /* reset only when empty */
        have = (unsigned)((state->strm.next_in + state->strm.avail_in) -
                          state->in);                 /* cross-object subtraction */
        copy = state->size - have;
        if (copy > len)
            copy = (unsigned)len;
        memcpy(state->in + have, buf, copy);          /* OOB destination */
        ...
/* direct path (len >= state->size): on stall, next_in/avail_in are left
   pointing into the caller's buffer */
    else {
        if (state->strm.avail_in && gz_comp(state, Z_NO_FLUSH) == -1)
            return 0;
        state->strm.next_in = (z_const Bytef *)buf;
        do {
            ...
            ret = gz_comp(state, Z_NO_FLUSH);
            n -= state->strm.avail_in;
            len -= n;
            if (ret == -1)
                return state->again ? put - len : 0;  /* stall: avail_in > 0 remains */
        } while (len);
    }

gz_comp() on EAGAIN returns -1 before touching the input: deflate() is not called, so strm->next_in and strm->avail_in are preserved exactly as the direct path left them — next_in inside the caller's buffer, avail_in > 0.

Why it overflows

The small path only resets next_in = state->in when avail_in == 0. In the stale state (avail_in > 0, next_in in the caller's buffer) it computes:

have = (unsigned)((next_in + avail_in) - state->in)

This is pointer subtraction across two unrelated allocations (undefined behavior; truncated to 32 bits). copy is then clamped to len, so the corruption is in the destination, not the length:

destination = state->in + have  ≈  next_in + avail_in

i.e. just past the consumed region of the caller's buffer. Each subsequent small write advances the destination by len bytes (avail_in is inflated by the copied amount), marching beyond the caller's buffer and into adjacent heap, until an unmapped page is hit.

gzputc()'s fast path computes the identical have itself before falling back to gz_write(); with a garbage have < state->size it writes state->in[have] = c directly (out-of-bounds single byte); otherwise it reaches the same memcpy. Transparent mode ("T") is affected through the same code, since gz_comp()'s direct branch preserves the stale next_in on EAGAIN the same way.

Secondary primitive (read side)

With the stale state, a later successful flush (gzflush()/gzclose() once the consumer drains) makes deflate() consume from next_in — reading past the caller's buffer or from freed memory and emitting those bytes into the gzip output stream (out-of-bounds/freed-memory read leaked to the stream).

Trigger requirements

  1. gzFile on a non-blocking descriptor (O_NONBLOCK; gzopen mode "N" or gzdopen of an already non-blocking fd).
  2. A gzwrite() with len >= state->size (default 8192) of incompressible data (random/encrypted payloads) that stalls on EAGAIN — the stale state is only transiently reachable: if the pipe has any room, the direct-path entry drain resets it, so the pipe must be completely full when the stall occurs (depends on pipe capacity; macOS 16 KiB, Linux 64 KiB).
  3. A subsequent small write (gzputc() or gzwrite() with len < state->size), which returns full success while corrupting memory.

Impact

  • Heap write past the caller's buffer with attacker-influenced content, marching forward per call: heap corruption with RCE potential in processes that legitimately use non-blocking gz output (logging proxies, streaming services); deterministic DoS in all cases.
  • The write destination resolves precisely to next_in + avail_in only when the caller's buffer and state->in share the same high 32 address bits; otherwise the 32-bit truncation yields a random wild address (still a crash).
  • The API returns success (positive counts) in the corrupt states, so callers cannot detect the condition.

Reproduction (ASan)

#include <zlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

int main(void) {
    int p[2];
    pipe(p);
    fcntl(p[1], F_SETFL, O_NONBLOCK);
    gzFile gz = gzdopen(p[1], "w");
    char big[65536];   /* < glibc mmap threshold: heap-local placement */
    char small[64] = "AAAA";
    int i;
    for (i = 0; i < 65536; i++) big[i] = (char)rand(); /* incompressible */

    {   /* pre-fill the pipe so the first big write stalls deterministically */
        char filler[16384];
        for (i = 0; i < 16384; i++) filler[i] = (char)rand();
        while (write(p[1], filler, sizeof filler) > 0);
    }

    gzwrite(gz, big, 65536);   /* stalls: next_in stays inside big, avail_in > 0 */
    for (;;)
        gzwrite(gz, small, 64); /* memcpy at next_in + avail_in, past big */
    return 0;
}

ASan reports a heap-buffer-overflow (memcpy in gz_write, gzwrite.c:217) as the destination marches past big; with unlucky buffer placement the first report is a SEGV/BUS on a wild address.

Suggested fix

In the small path, restore the invariant before computing have: if strm->avail_in != 0 and strm->next_in is outside [state->in, state->in + 2*state->size), drain first via gz_comp(Z_NO_FLUSH) and return the partial/error count on failure — never fall through to memcpy. Apply the same guard in gzputc() before its own have computation. Do not simply reset next_in = state->in while avail_in > 0 (that would discard unconsumed data and corrupt the stream).

Related: #1256 (the gzvprintf() variant of the same non-blocking write-stall handling; this is a distinct bug in gz_write()/gzputc()).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions