β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0842

Missing sliding-window allocation in inflate updatewindow() β€” NULL-pointer-write panic on malformed HAMMER2 zlib block

Summary

hammer2_zlib_inflate.c updatewindow() lines 373-392: MISSING allocation block. Upstream zlib 1.2.8 has if(state->window==Z_NULL){state->window=ZALLOC(strm,1U<<wbits,...)} β€” ABSENT from vendored copy. state->window=Z_NULL at init :200 never allocated anywhere (grep only kfree/NULL refs no kmalloc unlike deflate.c:254 which correctly kmalloc its window). Comments at :352 "If window does not exist yet, create it" :541 "goto inf_leave will create one" confirm allocation was lost during vendoring. Reachability: hammer2_strategy.c:257 inflate(&strm,Z_FINISH) on attacker-controlled on-disk bytes. Malformed/truncated/over-length zlib stream: inflate produces output but mode<CHECK (LEN/COPY/MATCH/LIT) inf_leave guard :1018-1019 TRUE updatewindow called copy>0 zmemcpy(state->window=NULL,...) writes through NULL = panic. Clean stream reaches DONE skips updatewindow = dormant under normal reads. Trigger: crafted HAMMER2 image ZLIB-compressed block truncated/over-length mount then cat file. Fix: restore kmalloc(1U<<wbits,M_INTWAIT) allocation block.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0842 Β· 17 files
FileTypeDescriptionSize
df0842_harness.c trigger-source kmod harness calling real in-kernel z_inflate* on a 20-byte truncated zlib stream -> NULL-window write 4.8 KB view raw
Makefile build-spec kmod makefile (bsd.kmod.mk) 148 B ↓ download
make_crafted_image.py exploit-trigger generates a crafted HAMMER2 image whose ZLIB block decompresses to 200KB (>avail_out) -> unprivileged cat panics 2.3 KB view raw
build.sh build-script exact kmod build command 358 B view raw
run.sh run-script exact kldload invocation 581 B view raw
build.log build-log full kmod build output on #0 baseline (BUILD_EXIT=0) 1.1 KB view raw
run.log run-log decisive baseline run: harness panic, fatal trap 12, VA=0x0, memcpy+0xfa 943 B view raw
boot_realtrigger.log run-log real unprivileged cat-by-maxx panic on #0 (identical RIP 0xffffffff80bcac8a) 693 B view raw
panic.txt panic-signature both panic signatures (harness + real trigger), fault VA=0x0, supervisor write 4.2 KB view raw
fix.diff suggested-fix git-apply-able: restore missing kmalloc(1<<wbits) window allocation in updatewindow() 1.0 KB view raw
fix_build.log build-log full single-fix kernel build (make -j6 nativekernel, NK_DONE rc=0) 5.6 MB ↓ download
fix_run.log run-log patched #1 kernel: harness 'NO PANIC z_inflate returned -5' + real trigger CAT_EXIT=0 graceful 1.3 KB view raw
env.txt environment uname/cc/sysctls for #0 baseline and #1 fix-validation kernels 528 B view raw
VERDICT.md verdict full narrative: root cause, trigger, reachability chain, impact, fix validation 9.3 KB ↓ raw
README.md readme human-facing summary + build/run instructions 4.1 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human-facing summary + build/run instructions
↓ download raw

DF-0842 β€” Missing sliding-window allocation in inflate updatewindow()

Severity: Medium (local DoS / NULL-pointer-write kernel panic) CWE: CWE-476 (NULL Pointer Dereference) File: sys/vfs/hammer2/zlib/hammer2_zlib_inflate.c (vendored zlib inflate) Kernel: DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC (HAMMER2 is the root filesystem)

The bug

HAMMER2 ships a vendored copy of zlib whose inflate never allocates the sliding window. state->window is set to Z_NULL in inflateInit2_() (:200) and is only ever referenced by kfree() (:174, :1047) and by updatewindow() (:382, :389, :392) β€” no kmalloc() exists for it anywhere in the file. Upstream zlib 1.2.8 has an explicit if (state->window == Z_NULL) state->window = ZALLOC(...) block inside updatewindow(); that block was dropped when zlib was vendored into HAMMER2. (The sibling hammer2_zlib_deflate.c:254 does correctly kmalloc() its window, confirming the asymmetry.)

When inflate() is driven with a stream that produces output but exits before the CHECK trailer (e.g. truncated input, or output buffer exhaustion mid-decode), the inf_leave guard at :1018-1019 is TRUE and calls updatewindow(), which zmemcpy()s through state->window == NULL β†’ write to virtual address 0x0 β†’ fatal page fault (trap 12).

Reachability (unprivileged)

read(2) of a HAMMER2 file whose on-disk block is ZLIB-compressed reaches hammer2_strategy_read_completion() (hammer2_strategy.c:458-484) β†’ hammer2_decompress_ZLIB_callback() (:229, calls inflate(&strm, Z_FINISH) at :257). A malformed block (e.g. a ZLIB stream that decompresses past the 16 KB output buffer, or a truncated stream) triggers the NULL-window write. The root filesystem of the audit guest is itself HAMMER2, so this code path is live in the default GENERIC kernel. Realistic precondition (per the audit threat model): an admin mounts / makes mountable an attacker-controlled HAMMER2 image; the unprivileged user then reads the malformed file.

Reproduction

Two independent triggers, both confirmed against the unpatched #0 kernel:

1. Deterministic harness (df0842_harness.c + Makefile, build.sh/run.sh)

A kernel module that calls the real in-kernel z_inflate* symbols (the exact functions used by hammer2_strategy.c:257) on a 20-byte truncated zlib stream that decodes ~1.8 KB of output before the input is exhausted. kldload β†’ fatal trap 12, fault VA = 0x0, memcpy+0xfa.

2. Real unprivileged filesystem-image trigger (make_crafted_image.py)

A HAMMER2 image is built with setcomp zlib + setcheck none; a data block's ZLIB stream is replaced with one that decompresses to 200 KB (≫ the kernel's 16 KB output buffer). cat <file> as the unprivileged maxx user panics the kernel at the identical RIP (memcpy+0xfa, VA = 0x0). See make_crafted_image.py for the generator and VERDICT.md for the full reachability chain.

Build / run

# harness (deterministic):
ssh dfbsd     # root
cd /root/DF-0842 && make SYSDIR=/usr/src/sys KERNBUILDDIR=/usr/obj/usr/src/sys/X86_64_GENERIC
kldload ./df0842_harness.ko     # unpatched: panic; fixed: prints "NO PANIC"

# real unprivileged trigger:
# host: python3 make_crafted_image.py base.img crafted.img
# guest(root): vnconfig + mount crafted.img, chmod a+rX
# guest(maxx): cat /h2mnt/zd/big.bin        # unpatched: panic; fixed: clean read

Impact

NULL-pointer-write at a fixed address (page 0 is unmapped in the kernel) β†’ local denial of service (kernel panic). No escalation chain: the fault is a write to VA 0x0 which cannot be redirected to an attacker-controlled page. On a default GENERIC kernel (INVARIANTS ON) the panic is immediate.

Fix

fix.diff restores the missing allocation block in updatewindow(), matching upstream zlib 1.2.8 (using kmalloc(..., C_ZLIB_BUFFER_INFLATE, M_INTWAIT) to mirror the existing inflate-state and deflate-window allocations). Validated: on the patched #1 kernel both triggers behave gracefully (no panic); the malformed block yields a handled decompression error (HAMMER2 ZLIB: Fatal error during decompression.) instead of a crash.

VERDICT.md verdict full narrative: root cause, trigger, reachability chain, impact, fix validation
↓ download raw

DF-0842 β€” VERDICT

Verdict: REPRODUCED (local DoS / NULL-pointer-write panic), fix VALIDATED

Status: reproduced Β· Impact: panic (local DoS) Β· Confidence: certain Class: CWE-476 NULL Pointer Dereference (missing allocation, vendored zlib)


Root cause (confirmed by source trace + two live reproductions)

HAMMER2's vendored zlib inflate (sys/vfs/hammer2/zlib/hammer2_zlib_inflate.c) never allocates the sliding window state->window:

  • inflateInit2_() sets state->window = Z_NULL (hammer2_zlib_inflate.c:200).
  • inflateReset2() may also reset it to Z_NULL (:175).
  • The only other references to state->window are kfree() (:174, :1047) and the writes inside updatewindow() (:382, :389, :392).
  • There is no kmalloc() / ZALLOC() for state->window anywhere in the file (grep -n 'window' hammer2_zlib_inflate.c shows only NULL sets, frees, and the derefs). Upstream zlib 1.2.8 has an explicit if (state->window == Z_NULL) { state->window = ZALLOC(...); } block inside updatewindow() β€” this block was lost when zlib was vendored into HAMMER2. The header claims ZLIB_VERSION "1.2.8" (hammer2_zlib.h:42) but the code is not: the allocation was dropped. The sibling hammer2_zlib_deflate.c:254 does correctly kmalloc() its own window, confirming the asymmetry is a vendoring defect, not an intentional omission.

The comment at :352 ("If window does not exist yet, create it") and :541 ("goto inf_leave will create one and copy") document the intent β€” but the code never creates it.

Trigger mechanism

updatewindow() is called from the inf_leave path of inflate() when:

/* hammer2_zlib_inflate.c:1018-1019 */
if (state->wsize || (out != strm->avail_out && state->mode < BAD &&
        (state->mode < CHECK || flush != Z_FINISH)))
    if (updatewindow(strm, strm->next_out, out - strm->avail_out)) { ... }

On the first inflate() call state->wsize == 0, so the first clause is FALSE. With flush == Z_FINISH (the HAMMER2 caller, hammer2_strategy.c:257), the guard reduces to "output was produced AND mode < CHECK". CHECK/DONE/BAD/MEM are all >= CHECK (hammer2_zlib_inflate.h:39-44); the decode modes HEAD … LIT are < CHECK. So any stream that produces output but exits before the trailer β€” truncated input (avail_in exhausted mid-decode) or output-buffer exhaustion (a stream that decompresses past avail_out) β€” makes the guard TRUE.

updatewindow() then sets wsize/wnext/whave (:374-378) and immediately zmemcpy(state->window + ..., ...) (:382/:389/:392) through state->window == NULL β†’ write to virtual address 0x0 β†’ fatal page fault.

Reachability (unprivileged, default GENERIC)

read(2) as unprivileged user
  -> hammer2_vop_strategy / hammer2_strategy_read     (hammer2_strategy.c)
  -> hammer2_strategy_read_completion                  (hammer2_strategy.c:458)
       switch (HAMMER2_DEC_COMP(focus->bref.methods))
         case HAMMER2_COMP_ZLIB:                       (:480)
  -> hammer2_decompress_ZLIB_callback                  (:229)
       inflateInit(&strm_decompress)                   (:244)  window = NULL
       strm.avail_in  = focus->bytes                   (:253)  on-disk block size
       strm.avail_out = bp->b_bufsize                  (:255)  16 KB
       inflate(&strm_decompress, Z_FINISH)             (:257)
  -> z_inflate()  -> inf_leave (mode<CHECK, output)    (hammer2_zlib_inflate.c:556,1018)
  -> updatewindow()                                    (:366)
  -> zmemcpy(state->window == NULL, ...)               (:382/389/392)
  -> write to VA 0x0 -> fatal trap 12.

HAMMER2 is the root filesystem of the audit guest (vbd0s1d on / (hammer2)) and ships in X86_64_GENERIC (options HAMMER2). The realistic precondition (per the audit threat model) is an admin mounting / making mountable an attacker-controlled HAMMER2 image; the unprivileged user then reads the malformed ZLIB-compressed file. (The sibling finding DF-0265 covered the other zlib copy sys/net/zlib.c via netgraph7_deflate, which is LATENT β€” not in GENERIC. DF-0842 is in HAMMER2, which IS in GENERIC, so it is live.)

Evidence

Reproduction 1 β€” deterministic harness (df0842_harness.c)

A kernel module calling the real in-kernel z_inflate* symbols (renamed inflate→z_inflate via hammer2_zlib_zconf.h:50) on a 20-byte truncated zlib stream that decodes ~1.8 KB before the input is exhausted. On the unpatched #0 kernel, kldload produces (from boot.log):

DF-0842: inflateInit_ ret=0 ...; now calling z_inflate(Z_FINISH) on a 20-byte truncated stream
Fatal user address access from kernel mode from kldload at ffffffff80bcac8a
Fatal trap 12: page fault while in kernel mode
fault virtual address      = 0x0
fault code                 = supervisor write data, page not present
instruction pointer        = 0x8:0xffffffff80bcac8a
Stopped at      memcpy+0xfa:    repe movsq      (%rsi),%es:(%rdi)

fault code = supervisor write data confirms it is a WRITE through NULL (the zmemcpy into the unallocated window), and memcpy+0xfa is the zmemcpy (repe movsq writing through %rdi == NULL).

Reproduction 2 β€” real unprivileged filesystem trigger (make_crafted_image.py)

A 64 MB HAMMER2 image is created (newfs_hammer2), mounted, and a directory is marked hammer2 setcomp zlib + hammer2 setcheck none (so the data block's check is none and its bytes can be freely corrupted without tripping a metadata CRC). A compressible file is written, the image is unmounted, and the ZLIB data block is replaced (host-side, make_crafted_image.py) with a stream that decompresses to 200 KB β€” far larger than the kernel's 16 KB per-block output buffer. The image is re-mounted, and the unprivileged maxx user runs cat /h2mnt/zd/big.bin:

 Fatal trap 12: page fault while in kernel mode
 cpuid = 0; lapic id = 0
 fault virtual address  = 0x0
 fault code     = supervisor write data, page not present
 instruction pointer    = 0x8:0xffffffff80bcac8a
 current process        = Idle
 Stopped at      memcpy+0xfa:    repe movsq      (%rsi),%es:(%rdi)

The RIP (0xffffffff80bcac8a) and the faulting instruction are byte-identical to the harness panic β€” confirming both reach the same sink: updatewindow() β†’ zmemcpy(state->window == NULL).

(current process = Idle indicates the fault is in the HAMMER2 xop strategy thread performing asynchronous read completion β€” exactly hammer2_strategy_read_completion β†’ hammer2_decompress_ZLIB_callback.)

Impact / escalation

A NULL-pointer-WRITE at fixed address 0x0 is a pure local DoS: page 0 is not mapped in the DragonFlyBSD kernel, so the write faults immediately and cannot be redirected to an attacker-controlled page. There is no privilege-escalation chain β€” the only effect is a kernel panic. (No SMAP/SMEP bypass or slab grooming is relevant here: the primitive is a write to a fixed unmapped address, not a controllable corruption of a live kernel object.)

PoC changes from the (absent) scaffold

There was no finding markdown or PoC scaffold on disk for DF-0842 (only the DB row). The runner authored the entire evidence pack from the DB claim: df0842_harness.c + Makefile + build.sh/run.sh (deterministic harness calling real z_inflate), make_crafted_image.py (real unprivileged filesystem-image trigger), fix.diff, this VERDICT.md, and the logs.

Fix (fix.diff) β€” VALIDATED

Restores the missing window allocation in updatewindow(), matching upstream zlib 1.2.8, using the kernel allocator style already used for the inflate state (:196) and the deflate window (hammer2_zlib_deflate.c:254):

+    /* if window not allocated yet, allocate it. ... */
+    if (state->window == Z_NULL) {
+        state->window = (unsigned char FAR *)
+                        kmalloc(1U << state->wbits,
+                                C_ZLIB_BUFFER_INFLATE, M_INTWAIT);
+        if (state->window == Z_NULL) return 1;
+    }

Fix validation (Phase 8, built + booted single-fix kernel)

  • Baseline (#0, unpatched): both triggers panic (fatal trap 12, VA=0x0, memcpy+0xfa) β€” harness via kldload; real trigger via unprivileged cat.
  • Patched (#1, Sat Jul 11 10:26:05 UTC 2026, built from same source + fix.diff only):
  • harness kldload β†’ DF-0842: z_inflate returned -5 (NO PANIC -- window was allocated; bug is absent/fixed). avail_out=6384 total_out=1808, guest stays UP, module loads/unloads cleanly.
  • real trigger cat as maxx β†’ CAT_EXIT=0, guest UP, dmesg shows the graceful HAMMER2 ZLIB: Fatal error during decompression. path (hammer2_strategy.c:258-261 handles ret != Z_STREAM_END by bzeroing the buffer) β€” no NULL deref, no panic.

fix_status = fixed (clean before/after on both triggers).

Note on the allocation flags

The fix uses plain kmalloc(..., M_INTWAIT) (no M_ZERO), matching upstream zlib 1.2.8 and the existing deflate-window allocation. The inflate window is always written via whave/wnext tracking before being read, so uninitialized contents are not leaked in normal operation. (The unrelated info-leak class of DF-0266 concerns the other zlib copy sys/net/zlib.c; if desired, adding M_ZERO here would be defense-in-depth against a future distance-bounds regression, but it is out of scope for this NULL-deref fix.)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. On the unpatched #0 baseline both triggers panic: the z_inflate kmod harness (kldload) -> 'Fatal trap 12, fault virtual address = 0x0, supervisor write data, Stopped at memcpy+0xfa', and the unprivileged read (maxx cat crafted HAMMER2 image) -> identical 'Fatal trap 12, VA=0x0, RIP 0xffffffff80bcac8a, memcpy+0xfa'. On the single-fix #1 kernel (built from same source + fix.diff, booted bare /boot/kernel/kernel) BOTH triggers behave gracefully: harness -> 'z_inflate returned -5 (NO PANIC -- window was allocated), total_out=1808', module loads/unloads cleanly, guest stays UP; real trigger -> CAT_EXIT=0, guest UP, dmesg shows the handled 'HAMMER2 ZLIB: Fatal error during decompression.' path (hammer2_strategy.c:258 handles ret!=Z_STREAM_END by bzero+continue). Clean before/after => fix closes the bug.

baseline #0 harness: 'DF-0842: inflateInit_ ret=0 ... z_inflate(Z_FINISH) on a 20-byte truncated stream' -> 'Fatal trap 12 ... fault virtual address = 0x0 ... Stopped at memcpy+0xfa'. baseline #0 real (maxx): identical 'Fatal trap 12 ... VA=0x0 ... memcpy+0xfa'. patched #1 harness: 'DF-0842: z_inflate returned -5 (NO PANIC -- window was allocated; bug is absent/fixed). avail_out=6384 total_out=1808', guest UP, no panic. patched #1 real (maxx): CAT_EXIT=0, guest UP, dmesg 'HAMMER2 ZLIB: Fatal error during decompression.' (graceful).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Jul 11 10:26:05 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (single-fix kernel: same source + fix.diff only, sha256 kernel = eb00fc104255b27a4a19cf161e2e523d02b50d6894d2b5fbcca5f8b514730561)

Confirmed kernel references

Detail

Exploit chain

none (non-corruption class: NULL-pointer-WRITE at fixed address 0x0). Two independent reproductions on the unpatched #0 kernel: (1) deterministic kmod harness df0842_harness.c calling the REAL in-kernel z_inflate* symbols on a 20-byte truncated zlib stream -> kldload panics at fault VA=0x0, memcpy+0xfa (supervisor write); (2) real UNPRIVILEGED filesystem-image trigger make_crafted_image.py -- a HAMMER2 image with setcomp zlib + setcheck none whose data block's ZLIB stream is replaced with one decompressing to 200KB (>> the 16KB avail_out) -> cat <file> by maxx (uid 1001) panics at the IDENTICAL RIP 0xffffffff80bcac8a (memcpy+0xfa, VA=0x0), confirming both reach the updatewindow()->zmemcpy(NULL) sink. Impact ceiling: pure local DoS (kernel panic); page 0 is unmapped so the write cannot be redirected to an attacker page -- no uid0 chain is derivable, exactly as the task predicted for a fixed-offset NULL deref.

Evidence (decisive lines)

BASELINE #0 harness: "DF-0842: inflateInit_ ret=0 ... now calling z_inflate(Z_FINISH) on a 20-byte truncated stream" / "Fatal trap 12: page fault while in kernel mode" / "fault virtual address = 0x0" / "fault code = supervisor write data, page not present" / "Stopped at memcpy+0xfa: repe movsq (%rsi),%es:(%rdi)". REAL UNPRIVILEGED TRIGGER (maxx cat crafted HAMMER2 image): identical "Fatal trap 12 ... fault virtual address = 0x0 ... instruction pointer = 0x8:0xffffffff80bcac8a ... Stopped at memcpy+0xfa". PATCHED #1 kernel: harness -> "DF-0842: z_inflate returned -5 (NO PANIC -- window was allocated; bug is absent/fixed). avail_out=6384 total_out=1808"; real trigger -> CAT_EXIT=0, guest UP, dmesg "HAMMER2 ZLIB: Fatal error during decompression." (graceful).

PoC changes

No finding markdown or PoC scaffold existed on disk for DF-0842 (only the DB row); the runner authored the entire evidence pack. Created df0842_harness.c (kmod calling real z_inflate* on a truncated stream), Makefile/build.sh/run.sh, make_crafted_image.py (real unprivileged HAMMER2-image trigger: replaces a ZLIB data block with a 200KB-decompressing stream; uses setcheck none so no metadata CRC is tripped), fix.diff, VERDICT.md, README.md, manifest.json, and full logs.

Verified recommended fix

In sys/vfs/hammer2/zlib/hammer2_zlib_inflate.c updatewindow(), immediately after the if (state->wsize == 0){...} init block (~line 378), restore the missing window allocation: if (state->window == Z_NULL) { state->window = (unsigned char FAR *)kmalloc(1U << state->wbits, C_ZLIB_BUFFER_INFLATE, M_INTWAIT); if (state->window == Z_NULL) return 1; }. This matches upstream zlib 1.2.8 and uses the same allocator/bucket as the inflate-state (inflateInit2_ :196) and deflate-window (hammer2_zlib_deflate.c:254) allocations. The finding markdown's proposal was not on disk; this fix is the canonical upstream restoration. Full git-apply-able diff in findings/poc/DF-0842/fix.diff.

Verdict

REPRODUCED (local DoS / NULL-pointer-write panic), fix VALIDATED. The HAMMER2-vendored zlib inflate (sys/vfs/hammer2/zlib/hammer2_zlib_inflate.c) never kmalloc()s state->window: it is set to Z_NULL at inflateInit2_() (:200) and only ever freed (:174/:1047) or written through in updatewindow() (:382/:389/:392) -- no allocation exists anywhere in the file (grep confirms). Upstream zlib 1.2.8 has an explicit if(state->window==Z_NULL) state->window=ZALLOC(...) block inside updatewindow() that was lost during vendoring; the sibling hammer2_zlib_deflate.c:254 does correctly kmalloc its window, confirming the asymmetry. When inflate() is driven with a stream that produces output but exits before the CHECK trailer (truncated input or output-buffer exhaustion), the inf_leave guard at :1018-1019 is TRUE, updatewindow() runs, and zmemcpy(state->window==NULL,...) writes to virtual address 0x0 -> fatal trap 12. Reachability confirmed end-to-end: read(2) of a ZLIB-compressed HAMMER2 file reaches hammer2_strategy.c:480/229 -> inflate(Z_FINISH) at :257; HAMMER2 is the root filesystem in GENERIC so the path is live.