Skip to content

Fix gz_write() crash and data duplication after a stall on a non-blocking destination - #1307

Open
XananasX7 wants to merge 1 commit into
madler:developfrom
XananasX7:fix-gz-write-nonblocking-stall
Open

Fix gz_write() crash and data duplication after a stall on a non-blocking destination#1307
XananasX7 wants to merge 1 commit into
madler:developfrom
XananasX7:fix-gz-write-nonblocking-stall

Conversation

@XananasX7

@XananasX7 XananasX7 commented Sep 4, 2026

Copy link
Copy Markdown

Summary

gz_write() crashes (and can corrupt memory) when a write to a non-blocking destination stalls and is resumed. Triggered by the documented usage pattern for the 'N' mode added in 1.3.2: gzwrite() returns a partial count on EAGAIN, and the caller continues with subsequent gz* calls.

Root cause

When a large write takes the direct-compression path in gz_write() (gzwrite.c, else branch), strm->next_in is pointed at the caller's buffer. If gz_comp() stalls with EAGAIN, it returns -1 with strm->avail_in > 0 and strm->next_in still inside the caller's buffer. Those bytes were not counted as consumed (the partial return excludes them), so the caller will resubmit them.

The small (len < state->size) buffered path assumes any pending input lives in the internal input buffer (state->in):

have = (unsigned)((state->strm.next_in + state->strm.avail_in) - state->in);
copy = state->size - have;          /* underflows to ~2^32 */
...
memcpy(state->in + have, buf, copy);  /* wild write (gzwrite.c:217) */

Since next_in points into the caller's buffer (a completely unrelated address), have is a huge bogus offset and the memcpy writes to a wild address — deterministic SEGV under ASan and on real builds.

The natural retry loop also silently duplicates data: once the destination drains, the next large write first pre-compresses the still-pending bytes (gzwrite.c, if (state->strm.avail_in && gz_comp(...) == -1)) and then compresses the caller's resubmitted buffer, which starts at exactly those same bytes — encoding them twice.

Fix

The pending bytes were never given to deflate() and were not counted as consumed, so the caller always resubmits them. gz_write() now drops that stale pending reference when it is not in the internal input buffer and lets the new buffer be compressed in its place. Input pending in the internal buffer is untouched: it was already counted as consumed and is not resubmitted.

Verification (ASan + UBSan, clang 14, unmodified 1.3.2-era develop HEAD)

Test Before After
big gzwrite stalls on full socket, then gzputs SEGV in gz_write gzwrite.c:217 clean, gzputs succeeds
natural retry loop (off += gzwrite(...)) draining peer SEGV after ~65 KB completes
retry loop, mixed buffered/direct/single-byte writes, 200 KB SEGV byte-exact round trip
gzflush(Z_FINISH) retried until Z_OK, then gzclose n/a decompressed output identical to input
blocking mode "w" and "wN" on regular files works works, unchanged

make test passes on the patched tree.

Minimal reproducer

int sv[2]; socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
int snd = 4096; setsockopt(sv[0], SOL_SOCKET, SO_SNDBUF, &snd, sizeof snd);
fcntl(sv[0], F_SETFL, O_NONBLOCK);
unsigned char fill[1<<20]; memset(fill, 0, sizeof fill);
while (write(sv[0], fill, 1<<16) > 0) ;        /* fill the socket */
gzFile gz = gzdopen(sv[0], "wN");
unsigned char data[100000];                     /* e.g. pseudo-random */
int r = gzwrite(gz, data, 100000);              /* returns partial, EAGAIN */
gzputs(gz, "short string");                     /* SEGV in gz_write() */

When a large gzwrite() to a non-blocking destination stalls (EAGAIN),
gz_write() returns the number of bytes consumed with the remaining input
still pending in the caller's buffer (strm.next_in points into that
buffer, not into state->in).  A subsequent small write (gzwrite,
gzputs, gzputc, gzfwrite, gzprintf) then computed how much room was
left in the input buffer as the difference between the foreign next_in
pointer and state->in, producing a huge 'have' value, an underflowed
'copy' size, and a memcpy to a wild address in gzwrite.c.  In the
natural retry pattern the same pending bytes were also compressed a
second time once the destination drained, silently duplicating data in
the output stream.

The pending input was never provided to deflate() and was not counted
as consumed, so the caller resubmits it; drop the stale pending
reference in gz_write() and let the new buffer be compressed in its
place.  Input pending in the internal input buffer is unaffected: it
was already counted as consumed and is not resubmitted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant