Missing distance bounds validation in inflate: heap OOB read when windowBits<15
| Field | Value |
|---|---|
| ID | DF-0265 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:A/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:C |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/net/zlib.c |
| Lines | 4824-5085 |
| Area | net |
| Confidence | certain |
| Discovered | 2026-06-30 |
| Reported | pending |
Summary
The in-kernel zlib is version 1.0.4 from December 1997 (FILEVERSION
971210) β 28 years old, predating ALL modern zlib security hardening.
The inflate code computes back-reference source pointers from decoded
distance codes without checking that the distance does not exceed the
allocated window size. DEFLATE allows distances up to 32768 bytes, but
inflateInit2_ permits windowBits as low as 8 (256-byte window). A
malicious compressed stream using large distance codes against a small
window causes a heap buffer over-read of up to ~32KB.
Root cause
Distance code range: cpdist[30] (line 4149) allows distances up
to 32768 bytes.
Window size: inflateInit2_ (line 3180) allows windowBits 8..15.
Window allocation is 1 << wbits bytes (line 3350).
Missing check: The decoded distance is used directly in pointer
arithmetic at line 4825 (inflate_codes COPY case):
s->end - (c->sub.copy.dist - (q - s->window))
and line 5076 (inflate_fast):
r = s->end - e
No check exists that dist <= (1 << s->wbits). Modern zlib validates
dist <= state->dmax β this check is entirely absent in 1.0.4.
Threat model & preconditions
- Attacker position: Authenticated PPP peer (via netgraph7_deflate
compression). The PPP peer negotiates
windowBits < 15during CCP, then sends a compressed frame containing a large distance code. - Impact: Kernel heap info leak (up to ~32KB of adjacent heap contents per malicious packet) and/or kernel panic (OOB read crossing into unmapped memory).
- Required config: PPP with deflate compression enabled.
netgraph7_deflateuses raw DEFLATE mode (-windowBits,nowrap=1), bypassing zlib header validation entirely.
Recommended fix
Short-term: Add distance validation before the COPY case:
if (c->sub.copy.dist > (1 << s->wbits)) {
z->msg = "invalid distance too far back";
c->mode = BADCODE;
r = Z_DATA_ERROR;
LEAVE;
}
Long-term: Replace this 28-year-old zlib with the modern
sys/contrib/zlib/ already present in the tree, which has 28 years
of security fixes including this one.
References
- CVE-2018-25032 (modern zlib heap buffer overflow)
- This zlib version: 1.0.4, FILEVERSION 971210
- Modern zlib fix:
if (dist > state->dmax)check ininflate.c
Timeline
- 2026-06-30 Discovered during automated audit.
PoC verification
Evidence pack
findings/poc/DF-0265 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | guard-page allocator + inflate driver; links the verbatim audited zlib.c and catches the window underflow as SIGSEGV | 11.1 KB | view raw |
| zlib.c | audit-source-copy | byte-identical copy of sys/net/zlib.c (the vulnerable code under test) | 172.9 KB | view raw |
| zlib.h | audit-source-copy | byte-identical copy of sys/net/zlib.h | 42.4 KB | view raw |
| kshim.h | build-shim | userspace stubs for kernel-only MODULE_VERSION/MAX tokens; -include'd, does NOT touch inflate code | 842 B | view raw |
| gen_stream.py | stream-generator | host-side script that produced the crafted raw-DEFLATE stream embedded in harness.c | 1.6 KB | view raw |
| build.sh | build-script | exact cc command | 480 B | view raw |
| run.sh | run-script | exact run command (arg = windowBits) | 362 B | view raw |
| build.log | build-log | final successful build, full output | 13 B | view raw |
| run.log | run-log | decisive vulnerable run (wbits=-8): SIGSEGV -255 bytes before window | 709 B | view raw |
| run.control.log | run-log | control run (wbits=-15): clean Z_STREAM_END, total_out=916 | 573 B | view raw |
| run_stress.log | run-log | 3 repeat runs of the vulnerable config (deterministic) | 1.9 KB | view raw |
| env.txt | environment | uname, cc version, kldstat, module availability (ng_deflate NOT built) | 748 B | view raw |
| fix.diff | suggested-fix | git-apply-able unified diff: dist>window check in inflate_codes AND inflate_fast | 1.6 KB | view raw |
| VERDICT.md | verdict | full narrative analysis: root cause, reproduction, reachability, fix verification | 7.7 KB | β raw |
| README.md | readme | human reproduce instructions | 3.0 KB | β raw |
DF-0265 β PoC: heap OOB read in zlib 1.0.4 inflate when windowBits < 15
Verbatim copy of the audited sys/net/zlib.c (and sys/net/zlib.h), plus a
userspace harness that links them unmodified and drives inflate with a crafted
raw-DEFLATE stream. A guard page placed before every zlib allocation surfaces
the sliding-window underflow as a SIGSEGV β conclusive proof of the missing
distance-bounds check.
Build
./build.sh # cc -O2 -Wall -include kshim.h -o harness harness.c zlib.c
kshim.h only stubs out the kernel-only MODULE_VERSION / MAX tokens that
zlib.c references at file scope; it does not touch the inflate code
under test (the file is byte-identical to sys/net/zlib.c).
Run
./run.sh # vulnerable config: windowBits=-8 (256-byte window) ./run.sh -15 # control: windowBits=-15 (32KiB window), no OOB
Expected (bug present), windowBits=-8
[harness] *** SIGSEGV/SIGBUS during inflate at 0x800480f01 *** [harness] nearest alloc #2 [user=0x800481000 size=256 (0x100)]: fault is -255 bytes (BEFORE-start (underflow into guard page)) [harness] RESULT: OOB READ CONFIRMED -- inflate dereferenced memory outside an allocation ...
The fault address is 255 bytes before the 256-byte sliding window β i.e.
inflate read s->end - (dist - (q - s->window)) with dist far larger than
the window and underflowed past s->window into the guard page.
Expected, windowBits=-15 (control)
[harness] inflate returned 1, msg=(null), total_out=916
The 32 KiB window swallows the stream's distance (658) cleanly β same code,
same stream, no OOB. This isolates the cause to the missing dist <= window
check at small windowBits.
With the fix applied (see fix.diff)
windowBits=-8 : inflate returned -3, msg=invalid distance too far back # rejected, no OOB windowBits=-15: inflate returned 1, total_out=916 # still decodes cleanly
Files
| file | purpose |
|---|---|
harness.c |
guard-page allocator + inflate driver; identifies the faulting allocation |
zlib.c,zlib.h |
verbatim copies of the audited sys/net/zlib.c / sys/net/zlib.h |
kshim.h |
userspace stubs for the kernel-only MODULE_VERSION / MAX tokens (-include) |
build.sh,run.sh |
exact reproduce commands |
gen_stream.py |
how the crafted raw-DEFLATE stream embedded in harness.c was produced |
build.log |
final successful build output |
run.log |
decisive vulnerable run (wbits=-8), full output |
run.control.log |
control run (wbits=-15), full output |
run_stress.log |
3 repeat runs (deterministic) |
env.txt |
guest uname / cc / kldstat / module availability |
fix.diff |
git-apply-able unified diff adding the distance-bounds check |
VERDICT.md |
full narrative analysis |
manifest.json |
machine-readable artifact catalog |
See VERDICT.md for the full root-cause analysis, reachability on the audited
kernel, and the threat model.
DF-0265 β Verdict: REPRODUCED (latent on the audited kernel config)
Verdict: REPRODUCED β the missing distance-bounds check in
sys/net/zlib.c (zlib 1.0.4, FILEVERSION 971210) is real and exploitable,
confirmed deterministically by linking the exact, unmodified audited source
into a userspace harness and catching a sliding-window underflow on a guard
page. The vulnerable kernel consumer (netgraph7_deflate) is not compiled
into the audited X86_64_GENERIC kernel and has no loadable ng_deflate.ko
on the guest, so the defect is latent on the shipped master DEV kernel;
it becomes live the moment an operator builds/loads NETGRAPH7_DEFLATE.
1. The bug, line by line
sys/net/zlib.c is zlib 1.0.4 (header at line 18: ==FILEVERSION 971210==).
It is compiled into the kernel via sys/conf/files:
net/zlib.c optional mxge pciβ used bydev/netif/mxgenet/zlib.c optional netgraph7_deflateβ used bynetgraph7/deflate
inflateInit2_ (sys/net/zlib.c:3143) clamps windowBits to 8..15
(line 3175) and allocates a window of 1 << wbits bytes in
inflate_blocks_new (sys/net/zlib.c:3714-3726: s->window = ZALLOC(z,1,w),
s->end = s->window + w). With windowBits=8 the window is 256 bytes.
DEFLATE distance codes, however, can resolve to 1..32768 bytes
(cpdist[30] at sys/net/zlib.c:4149, max entry 24577 + extra bits β 32768).
Nothing in the decoder rejects a distance larger than the window. Two code
paths consume the decoded distance directly in pointer arithmetic with no
bounds check:
-
inflate_codesCOPY case (sys/net/zlib.c:4822-4831):c f = (uInt)(q - s->window) < c->sub.copy.dist ? s->end - (c->sub.copy.dist - (q - s->window)) : /* <-- underflow */ q - c->sub.copy.dist;Whendist - (q - s->window) > (s->end - s->window)(i.e.distexceeds the window size plus the current write offset), the subtractions->end - (dist - (q - s->window))underflows pasts->window. The subsequent copy loop (OUTBYTE(*f++), lines 4834-4838) then reads memory before the window allocation. -
inflate_fast(sys/net/zlib.c:5073-5088): same pattern,e = d - (uInt)(q - s->window); r = s->end - e;at lines 5075-5076, followed by*q++ = *r++copies that read from the underflowed pointer.
Modern zlib validates this. The newer copy in the same tree
(sys/vfs/hammer2/zlib/) has the check in both paths:
- hammer2_zlib_inflate.c:932: if (copy > state->whave) { "invalid distance too far back"; BAD; }
- hammer2_zlib_inffast.c:177: if (dist > dmax) { "invalid distance too far back"; ... }
The 1.0.4 code in sys/net/zlib.c has neither β confirmed by reading the
full inflate_codes and inflate_fast functions. The finding is accurate.
2. Reproduction
The harness (harness.c) links the verbatim sys/net/zlib.c in its
userspace compile mode (which the file explicitly supports: it #includes
"zlib.h" when _KERNEL is undefined and contains the identical
distance-handling code that ships in the kernel). It supplies a custom
zcalloc/zcfree that mmap's every allocation with a PROT_NONE guard page
immediately before the user pointer, then drives inflate with a crafted
raw-DEFLATE stream (see gen_stream.py) that contains a distance of ~658 β
well past a 256-byte window.
Decisive run, windowBits=-8 (256-byte window), run.log:
[harness] 3 allocations made by inflateInit2:
#2 user=0x800481000 size=256 (0x100) <-- the sliding window
[harness] calling inflate on 551-byte stream, window=256 bytes...
[harness] *** SIGSEGV/SIGBUS during inflate at 0x800480f01 ***
[harness] nearest alloc #2 [user=0x800481000 size=256 (0x100)]:
fault is -255 bytes (BEFORE-start (underflow into guard page))
[harness] RESULT: OOB READ CONFIRMED
inflate read 255 bytes before the window start, into the guard page. The
same stream with windowBits=-15 (32 KiB window) decompresses cleanly
(run.control.log: inflate returned 1, total_out=916) β same code, same
input, no OOB. This isolates the cause to the missing distance check at small
windowBits.
Reproduced 3/3 times (run_stress.log); fully deterministic.
3. Reachability on the audited master DEV kernel
| consumer | windowBits | reachable on X86_64_GENERIC? |
|---|---|---|
dev/netif/mxge/if_mxge.c:675 (firmware inflate) |
inflateInit β default 15 (32 KiB) |
safe by construction (distance β€ 32768 β€ window); plus the firmware blob is root-loaded from disk, not attacker-controlled. |
netgraph7/deflate/ng_deflate.c:270 (PPP deflate) |
inflateInit2(-windowBits), 8..15 accepted (line 236) |
NOT compiled in. No ng_deflate.ko on the guest, NETGRAPH7/NETGRAPH7_DEFLATE absent from sys/config/X86_64_GENERIC. kldstat shows only kernel/ehci.ko/xhci.ko. |
So on the shipped master DEV kernel the vulnerable path is dead code:
sys/net/zlib.c is compiled only for mxge (which uses the safe default
window). The defect becomes reachable only if an operator explicitly enables
netgraph7_deflate (custom kernel build or a hand-built module, both
root-gated).
If netgraph7_deflate is enabled, the threat model in the finding holds: a
malicious authenticated PPP peer negotiates windowBits=8 during CCP, the
local PPP daemon configures the node via NGM_DEFLATE_CONFIG, and the peer
then ships a compressed frame whose DEFLATE stream carries a large distance
code. inflate underflows the 256-byte window and either leaks adjacent kernel
heap into the decompressed output (returned to the peer) or panics if the
underflow crosses into an unmapped page. That is a genuine remote
authenticated heap OOB read / DoS β the High severity the finding claims β
but only on a kernel built with that option.
4. Impact classification
- Code defect: real, confirmed, deterministic OOB read of up to ~32 KiB of kernel heap before the inflate window (and panic when the underflow leaves the slab/page).
- On the audited kernel (
X86_64_GENERIC, master DEV): latent β not reachable without enablingNETGRAPH7_DEFLATE. Effective severity on this config: Low (latent hardening gap). Themxgepath is safe. - On a kernel with
NETGRAPH7_DEFLATEenabled: High β remote authenticated heap info-leak / DoS via crafted PPP deflate frames, exactly as the finding states.
5. The fix (fix.diff, verified)
Add a dist > window check in both consume sites, mirroring modern zlib's
"invalid distance too far back" rejection:
inflate_codesDISTEXTβCOPY (sys/net/zlib.c:4820): after the distance is fully decoded,if (c->sub.copy.dist > (uInt)(s->end - s->window))β setBADCODE,Z_DATA_ERROR,LEAVE.inflate_fast(sys/net/zlib.c:5063): afterdis computed,if (d > (uInt)(s->end - s->window))βUNGRAB; UPDATE; return Z_DATA_ERROR.
The check uses s->end - s->window (the actual allocated window size) so it
needs no new state. Verified against the harness: with the patch applied,
windowBits=-8 returns Z_DATA_ERROR "invalid distance too far back" (no
OOB), while windowBits=-15 still decompresses the legitimate stream
(total_out=916). The long-term recommendation (replace the 28-year-old
sys/net/zlib.c with the modern sys/vfs/hammer2/zlib/ copy already in the
tree, or upstream sys/contrib/zlib/) stands; this minimal fix closes the
specific OOB read.
This fix matches the finding markdown's ## Recommended fix (the
short-term dist > (1 << s->wbits) guard) in intent; the implementation here
uses the equivalent (s->end - s->window) and covers both inflate_codes
and inflate_fast (the markdown snippet only showed inflate_codes).
Confirmed kernel references
- sys/net/zlib.c:18
- sys/net/zlib.c:3143
- sys/net/zlib.c:3175
- sys/net/zlib.c:3714
- sys/net/zlib.c:4149
- sys/net/zlib.c:4822
- sys/net/zlib.c:5073
- sys/vfs/hammer2/zlib/hammer2_zlib_inflate.c:932
- sys/vfs/hammer2/zlib/hammer2_zlib_inffast.c:177
- sys/conf/files:1189
- sys/conf/files:1751
- sys/netgraph7/deflate/ng_deflate.c:236
- sys/netgraph7/deflate/ng_deflate.c:270
- sys/dev/netif/mxge/if_mxge.c:675
- sys/config/X86_64_GENERIC
Detail
Exploit chain
Pure out-of-bounds READ (CWE-125), not a write primitive, so no uid0 chain. Trigger: crafted raw-DEFLATE stream with a distance code > (1<<windowBits) fed to inflate opened with windowBits<15. Primitive: inflate underflows s->window and copies up to ~32KB of adjacent kernel heap into the decompressed output; in the netgraph7_deflate path that output is returned to the remote PPP peer (heap info-leak), or the underflow crosses an unmapped page and panics the kernel (DoS). Ceiling = info-leak + panic; no code-exec primitive derivable from a read-only underflow. Not reachable on the audited kernel without enabling netgraph7_deflate.
Evidence (decisive lines)
findings/poc/DF-0265/run.log (decisive): '[harness] *** SIGSEGV/SIGBUS during inflate at 0x800480f01 ***' / 'nearest alloc #2 [user=0x800481000 size=256 (0x100)]: fault is -255 bytes (BEFORE-start (underflow into guard page))' / 'RESULT: OOB READ CONFIRMED'. run.control.log: windowBits=-15 -> 'inflate returned 1, total_out=916' (no OOB, same code+stream). run_stress.log: 3/3 deterministic. fix.diff verified: with patch, windowBits=-8 -> 'inflate returned -3, msg=invalid distance too far back' (no OOB), windowBits=-15 still total_out=916. zlib.c/zlib.h are byte-identical to sys/net/ (cmp confirmed).
PoC changes
The finding shipped with NO PoC folder; I authored the entire evidence pack from scratch. Wrote harness.c (guard-page zcalloc/zcfree via mmap+mprotect, SIGSEGV siglongjmp catcher, allocation tracking, optional windowBits argv), copied sys/net/zlib.c + sys/net/zlib.h byte-identical, added kshim.h (-include'd) to stub the kernel-only MODULE_VERSION/MAX tokens so the verbatim zlib.c builds in userspace without touching the inflate code under test, and gen_stream.py to produce the crafted raw-DEFLATE stream (pat(258)+gap(400)+pat(258) compressed at windowBits=-15 forces the encoder to emit distance ~658). Iterations: fixed MODULE_VERSION build error, fixed NULL z->zalloc hang (NO_ZCFUNCS means the caller must wire zalloc like ng_deflate.c:255), enlarged the stream gap from 1->400 bytes so the distance exceeds window+max_write_offset (the 1-byte gap gave distance 259 which wraps to exactly s->window, no OOB), added the windowBits=-15 control, and verified fix.diff rejects the stream while preserving legitimate decompression.
Verified recommended fix
fix.diff adds 'if (dist > (uInt)(s->end - s->window)) { z->msg="invalid distance too far back"; ...Z_DATA_ERROR; }' in BOTH inflate_codes (sys/net/zlib.c:4820, after DISTEXT) and inflate_fast (sys/net/zlib.c:5063, after d is computed), mirroring modern zlib's hammer2_zlib_inflate.c:932 / hammer2_zlib_inffast.c:177. Verified: patch rejects the malicious stream (Z_DATA_ERROR) and leaves legitimate windowBits=15 decompression intact. Supersedes the finding markdown proposal (which only sketched inflate_codes; this also covers inflate_fast and uses the actual window size s->end-s->window). Long-term: replace the 28-year-old sys/net/zlib.c with the modern sys/vfs/hammer2/zlib/ copy already in the tree.
Verdict
REPRODUCED at the code level. sys/net/zlib.c is zlib 1.0.4 (FILEVERSION 971210); its inflate_codes COPY case (sys/net/zlib.c:4822-4831) and inflate_fast (sys/net/zlib.c:5073-5088) compute the back-reference source as s->end-(dist-(q-s->window)) with NO check that dist <= window size, unlike the modern copy in sys/vfs/hammer2/zlib/ which guards at hammer2_zlib_inflate.c:932 and hammer2_zlib_inffast.c:177. I linked the EXACT, byte-identical audited sys/net/zlib.c into a userspace harness with a guard-page allocator and fed a crafted raw-DEFLATE stream carrying a distance of ~658 against a 256-byte window (windowBits=-8, the mode ng_deflate.c:270 uses): inflate faulted reading 255 bytes BEFORE the window allocation (run.log). The same stream with windowBits=-15 decompresses cleanly (run.control.log, total_out=916), isolating the cause to the missing check. Reproduced 3/3 (run_stress.log). REACHABILITY CAVEAT: on the shipped master DEV kernel the only vulnerable consumer, netgraph7_deflate, is NOT built (no ng_deflate.ko, no NETGRAPH7 in sys/config/X86_64_GENERIC, kldstat shows only kernel/ehci/xhci); the other consumer, if_mxge.c:675, uses inflateInit (default windowBits=15, 32KiB window) which is safe by construction and root-loaded anyway. So the defect is real and exploitable but LATENT on this kernel config; it becomes a remote authenticated heap OOB-read/DoS (the High severity claimed) only if an operator builds/loads NETGRAPH7_DEFLATE.