A fire alarm wired only to a room nobody uses
CVE-2026-85091 is a heap buffer overflow in zlib's gz_vacate() function, affecting versions 1.3.1.2 through 1.3.2 only. The flaw lives in the *non-blocking gzwrite* path — a feature added just months ago via the "N" mode flag in gzopen(). When gzwrite() stalls on a non-blocking descriptor, subsequent gzprintf() or gzvprintf() calls trigger gz_vacate() with a pending-input buffer larger than state->size. An inverted state->again check lets execution fall through into two sequential out-of-bounds heap writes: a null-terminator past the buffer end, then up to ~8 KB of attacker-influenced data via vsnprintf(). Under the right conditions, this is a real heap corruption primitive.
The vendor CVSS of 7.4 HIGH (AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H) and VulnCheck's CVSS v4 of 8.3 rate the *theoretical* ceiling of a network-reachable heap overflow in a ubiquitous library — and that ceiling is real. But the score ignores the brutal narrowing: the vulnerable code path requires an application that (a) links against the exact 1.3.1.2–1.3.2 window, (b) explicitly opts into the brand-new non-blocking gz* mode, and (c) calls gzprintf()/gzvprintf() after a write stall. nginx — the single largest zlib consumer — confirmed it **never calls the gz* file-handle API. Ubuntu's security team independently triaged this as Medium** priority. Multiple engineering teams (Prowler, govie-ds, Docker Hardened Images) are actively suppressing this CVE in container scans as non-exploitable in their stacks. The vendor score is technically defensible on paper but dramatically overstates the risk most defenders actually face.
5 steps from start to impact.
Target runs zlib 1.3.1.2–1.3.2
gz_vacate() compaction logic does not exist. Most production Linux distributions still ship zlib 1.2.x or 1.3.1. Alpine 3.22+ and some bleeding-edge container images ship 1.3.2.- Target host links against zlib >= 1.3.1.2 and <= 1.3.2
- The majority of enterprise fleets run older zlib (1.2.11–1.3.1). Debian bookworm ships 1.2.13, RHEL 9 ships 1.2.11.
- Container images using Alpine 3.21 or earlier are unaffected.
dpkg -l zlib1g, rpm -q zlib, apk info zlib, or SCA scanners (Trivy, Grype, Snyk). All major scanners flag this CVE.Application uses non-blocking gz* file-handle API
"N" (non-blocking) mode flag, or call gzdopen() on an already-O_NONBLOCK file descriptor. This is a brand-new feature introduced in zlib 1.3.1.2. The overwhelming majority of zlib consumers use the lower-level deflate()/inflate() streaming API or blocking gzopen() calls. Logging proxies and streaming compressors writing to pipes or sockets are the plausible (but still rare) consumers.- Application explicitly opts into non-blocking gz* mode
- Application calls gzwrite() on this handle
- nginx, curl, Python's zlib module, Go's compress/zlib, Java's java.util.zip — none use the gz* file-handle layer for non-blocking I/O.
- The 'N' mode is undocumented in most language bindings; adoption is near-zero outside bespoke C code.
strace/ltrace to confirm gzopen() calls with 'N' flag. No standard scanner checks for this API usage pattern.Write stall occurs on non-blocking descriptor
gzwrite() must encounter an EAGAIN / EWOULDBLOCK condition, causing it to return a partial count and leave more than state->size bytes of pending input with strm->next_in still pointing to caller-owned memory. This requires the output fd to have backpressure — a full pipe buffer, congested socket, or slow consumer.- Output fd returns EAGAIN during gzwrite()
- Pending input exceeds state->size after the stall
- Applications writing to local files (the common case) never see EAGAIN.
- Even pipe/socket writers may never stall under normal load.
Subsequent gzprintf()/gzvprintf() triggers overflow
gzprintf() or gzvprintf(). The inverted state->again check in gz_vacate() lets execution proceed instead of returning early. The function computes a next pointer past the allocated buffer, writes a null terminator out of bounds, then calls vsnprintf() writing up to ~8 KB of data past the heap buffer boundary. This corrupts adjacent heap metadata/objects.- Application calls gzprintf() or gzvprintf() after the stall — not gzwrite()
- Attacker has some influence over the format string arguments to shape the overflow
- Mixing gzwrite() with gzprintf() on the same handle is unusual — most applications use one or the other consistently.
- Controlling the overflow content enough for reliable RCE requires deep knowledge of the target's heap layout.
Heap corruption → crash or code execution
- Target binary lacks heap hardening (glibc tcache, jemalloc randomization, stack canaries)
- Attacker can retry or predict heap layout
- Modern allocators (glibc 2.34+ tcache hardening, musl's simple allocator) and ASLR make heap exploitation unreliable.
- Most affected Alpine containers use musl libc, which has a different heap layout than the PoC environment.
gzopen() with mode "N" or gzdopen() on a non-blocking fd. Run grep -r 'gzopen\|gzdopen' /path/to/source on your codebases, or use ltrace -e 'gzopen*' <binary> at runtime. If zero applications use this API, the CVE is a scanner false positive for your environment and you can document the exception. This is a no-SLA item per noisgate — go straight to the 365-day remediation window.RUN apk upgrade --no-cache zlib to Dockerfiles or rebuild base images. This eliminates the vulnerability at the library level. Target within the noisgate remediation SLA of 365 days for MEDIUM findings — though container rebuild cycles typically accomplish this much sooner.-fsanitize=address in staging/CI environments. ASan detects the heap overflow immediately and converts a silent corruption into a loud, deterministic crash with a stack trace. This won't help in production but catches the issue before deployment.- WAF / network-layer filtering — This is a library-level memory corruption bug, not a network protocol flaw. There is no HTTP request pattern or network signature to block.
- Disabling gzip compression in nginx/Apache — These servers use the deflate API, not the gz* file-handle API. They were never vulnerable regardless of compression settings.
- ASLR alone — ASLR raises the bar for reliable RCE but does not prevent the heap overflow or crash. The ~8 KB write primitive can still corrupt adjacent objects.
The supporting signals.
| In-the-wild exploitation | None observed. Not listed on CISA KEV. No campaigns, APT advisories, or incident reports reference this CVE as of 2026-10-16. |
|---|---|
| Proof-of-concept | A PoC gist by thesmartshadow (Ali Firas) exists at gist.github.com/thesmartshadow/e0b9481792afb7c31e86fee1ff084490. The upstream issue #1256 (filed by mat-mo) provides detailed reproduction steps and ASan traces. No weaponized exploit or Metasploit/Nuclei module exists. Not indexed on pocindex.io. |
| EPSS | 0.00356 (0.356% probability of exploitation in next 30 days). This places it roughly in the ~66th percentile — well below the threshold where EPSS signals real-world activity. |
| CISA KEV | Not listed. No binding operational directive applies. |
| CVSS vectors | v3.1: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:H (7.4 HIGH) — note AC:H (high complexity) and C:N (no confidentiality impact). v4.0: 8.3 HIGH per VulnCheck. |
| Affected versions | zlib 1.3.1.2 through 1.3.2. The non-blocking gz* code was introduced in commit 81cc0beb. Versions ≤ 1.3.1 and the entire 1.2.x series are not affected. |
| Fixed versions | Upstream fix: commit df84af25dc (2026-09-17). Alpine: 1.3.2-r1. Debian/Ubuntu: no fix shipped yet (deferred). No formal zlib point release (e.g. 1.3.3) has been tagged. |
| Scanning / exposure | No GreyNoise or Shodan signatures exist for this CVE — it is a *library* vulnerability, not a network-service flaw. SCA scanners (Trivy, Grype, Snyk, Mend) all flag zlib 1.3.2 containers. Docker Hardened Images catalog rates nginx-alpine images at zero vulns (nginx doesn't call gz* API). |
| Disclosure timeline | 2026-09-03: CVE published (CNA: VulnCheck). 2026-09-11: NVD last modified. 2026-09-17: upstream fix merged by Mark Adler. 2026-10-07: Alpine ships 1.3.2-r1. |
| Reporter | Ali Firas (GitHub: thesmartshadow) — coordinated via VulnCheck CNA. Separately, mat-mo filed issue #1256 with detailed technical analysis of the inverted state->again check. |
Sources.
Why this verdict
- Version-window friction: Only zlib 1.3.1.2–1.3.2 is affected. The 1.2.x series (still dominant on RHEL 8/9, Debian bookworm, Ubuntu 22.04/24.04) and 1.3.1 are completely immune. In a typical 10,000-host fleet, fewer than 15–20% of hosts are likely to ship the affected version — primarily bleeding-edge Alpine-based containers.
- API-path friction: The vulnerability is reachable *only* through the non-blocking gz* file-handle API using the
"N"mode flag introduced months ago. The vast majority of zlib consumers — nginx, curl, Python, Go, Java, OpenSSL — use the deflate/inflate streaming API or blocking gz* calls. Realistic estimate: <1% of applications linking zlib exercise the vulnerable code path. - Trigger-sequence friction: Even on applications using non-blocking gz*, the attacker must induce a write stall (EAGAIN) on the output fd *and* have the application subsequently call
gzprintf()/gzvprintf()— notgzwrite(). Mixing these calls on the same handle is architecturally unusual. - No exploitation evidence: EPSS 0.356%, no KEV listing, no in-the-wild campaigns, no weaponized exploits. The PoC is a local reproducer requiring a purpose-built test harness, not a network-deliverable exploit.
- Role multiplier: zlib is linked into nearly every Linux binary, giving it surface-area ubiquity. However, the *vulnerable code path* (non-blocking gz*) is NOT exercised by any canonical high-value-role software: domain controllers don't use it, hypervisors don't use it, CI/CD runners don't use it, backup agents don't use it, network edge appliances don't use it, database engines don't use it. The blast radius of this specific flaw is confined to the tiny population of bespoke C applications that opted into a brand-new, niche I/O mode. No high-value-role outcome (domain takeover, fleet compromise, supply-chain pivot) is plausible through this chain. The CRITICAL/HIGH floor does not apply.
- Independent triage confirmation: Ubuntu Security rated this Medium priority. Multiple engineering teams (Prowler, Docker Hardened Images, govie-ds) are explicitly suppressing it as non-exploitable in standard deployments.
Why not higher?
The non-blocking gz* API is exercised by essentially zero mainstream applications. Without a reachable code path, the heap overflow is theoretical in virtually all deployments. The AC:H rating in the vendor's own CVSS vector acknowledges the complexity. There are no network-service exposure vectors — this is a library bug that requires a cooperating application to trigger. No evidence of in-the-wild exploitation exists to override the friction analysis.
Why not lower?
It is still a genuine heap buffer overflow (CWE-787) with a demonstrated ~8 KB out-of-bounds write primitive. The PoC is public and reproducible under ASan. If a bespoke application *does* use non-blocking gz* mode (e.g., a custom logging proxy writing compressed streams to non-blocking pipes), the bug is real and exploitable for at least DoS, with theoretical RCE. The container ecosystem's rapid adoption of Alpine 3.22+ means the affected zlib version is spreading, even if the vulnerable API path is dormant. LOW would understate the risk to the small but nonzero population of affected applications.
Crowdsourced verification payload.
Run this on each target host (or in a container) as any user. It checks the installed zlib version against the affected range. Example: bash check_cve_2026_85091.sh. No special privileges required.
#!/usr/bin/env bash
# check_cve_2026_85091.sh — Detect zlib versions affected by CVE-2026-85091
# Affected: 1.3.1.2 through 1.3.2
# Run on target host or inside container. No root required.
set -euo pipefail
VULN_MIN="1.3.1.2"
VULN_MAX="1.3.2"
# Compare dotted version strings: returns 0 if $1 <= $2
version_lte() {
printf '%s\n%s' "$1" "$2" | sort -V | head -n1 | grep -qFx "$1"
}
# Try multiple methods to find zlib version
get_zlib_version() {
# Method 1: dpkg (Debian/Ubuntu)
if command -v dpkg >/dev/null 2>&1; then
ver=$(dpkg -l 'zlib1g' 2>/dev/null | awk '/^ii/{print $3}' | sed 's/.*://;s/[-+].*//')
if [ -n "$ver" ]; then echo "$ver"; return; fi
fi
# Method 2: rpm (RHEL/Fedora)
if command -v rpm >/dev/null 2>&1; then
ver=$(rpm -q --qf '%{VERSION}' zlib 2>/dev/null || true)
if [ -n "$ver" ] && [ "$ver" != "package zlib is not installed" ]; then echo "$ver"; return; fi
fi
# Method 3: apk (Alpine)
if command -v apk >/dev/null 2>&1; then
ver=$(apk info -v zlib 2>/dev/null | sed 's/^zlib-//;s/-r.*//')
if [ -n "$ver" ]; then echo "$ver"; return; fi
fi
# Method 4: parse libz.so directly
for lib in /usr/lib/libz.so /usr/lib/x86_64-linux-gnu/libz.so /lib/libz.so.1; do
if [ -f "$lib" ]; then
ver=$(strings "$lib" 2>/dev/null | grep -oP '^1\.[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)
if [ -n "$ver" ]; then echo "$ver"; return; fi
fi
done
echo ""
}
ZLIB_VER=$(get_zlib_version)
if [ -z "$ZLIB_VER" ]; then
echo "UNKNOWN — could not determine zlib version"
exit 2
fi
echo "Detected zlib version: $ZLIB_VER"
if version_lte "$VULN_MIN" "$ZLIB_VER" && version_lte "$ZLIB_VER" "$VULN_MAX"; then
echo "VULNERABLE — zlib $ZLIB_VER is in the affected range ($VULN_MIN – $VULN_MAX)"
exit 1
else
echo "PATCHED — zlib $ZLIB_VER is outside the affected range"
exit 0
fi