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

Off-by-one NUL write before caller buffer when inlen <= 0

Summary

hexncpy() asserts outb/inb non-NULL and outlen>=3 but never requires inlen>0. If caller passes inlen<=0 the conversion loop body never runs outb never advanced and terminator *--outb=0 at line 62 writes NUL byte one position BEFORE start of caller output buffer deterministic 1-byte heap/stack underflow write. KKASSERT at :52 omits any check on inlen. for loop at :56 gated by inlen>0 && outlen>=MIN_OUTBLEN when inlen<=0 loop body/post-expr never execute outb remains equal to addr (saved :55). *--outb=0 at :62 performs *(addr-1)=0. Decrement-and-write idiom only safe when at least one iteration advanced outb past addr. At least one in-tree caller m_print uipc_mbuf.c:2537 passes m2->m_len with no zero-guard can reach with m_len==0 mbuf. When outb is stack buffer corrupted byte is byte just below it adjacent local saved frame-pointer low byte or length/metadata field classic off-by-one stack-corruption primitive on heap corrupts preceding allocations tail. Debug/dump path not hot syscall. Real deterministic memory-corruption primitive in shared libkern routine exploitable moment any current/future caller passes non-positive length. [VERIFIED pass2: reproduced - canary 0xaa to 0x00 at addr-1 for inlen 0 and -1 through kernel-compiled real function; INVARIANTS nuance proven - every variable-inlen caller pairs HEX_NCPYLEN so zero length means outlen 0 which KKASSERTs first, silent underflow only on production kernels; sole in-tree m_print caller is musycc T1/E1 debug, no unpriv route so privesc candidate false; fix-validated A/B canary intact m_print survives]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2252 Β· 14 files
FileTypeDescriptionSize
README.md β€” 4.4 KB ↓ raw
VERDICT.md β€” 6.9 KB ↓ raw
harness_hexncpy.c β€” 4.3 KB view raw
build.sh β€” 223 B view raw
run.sh β€” 216 B view raw
build.log β€” 2.1 KB view raw
run.log β€” 731 B view raw
panic.txt β€” 693 B view raw
fix.diff β€” 2.0 KB view raw
fix_build.log β€” 3.1 KB view raw
fix_run.log β€” 1.1 KB view raw
env.txt β€” 470 B view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 6.3 KB view raw

DF-2252 β€” Off-by-one NUL write before caller buffer when inlen <= 0

sys/libkern/hexncpy.c lines 52-62 (Medium, memcorrupt). VERIFY run 2026-10-17. Evidence pack layout follows the house standard.

What was verified and how

Runtime kldload GPFs this guest, so the harness is compiled INTO the kernel: harness_hexncpy.c is appended to the guest's /usr/src/sys/libkern/hexncpy.c (same translation unit as the REAL production hexncpy()), built with make nativekernel KERNCONF=X86_64_GENERIC (stock X86_64_GENERIC has options INVARIANTS, config line 56), installed and booted. The harness is driven by sysctl kern.df_hexncpy=<mode>:

  • mode 1 β€” canary/output matrix around hexncpy() itself
  • mode 2 β€” m_print() with a legal m_len == 0 mbuf leading the chain

Baseline result (stock hexncpy.c, kernel #1 Oct 17 16:14:33 UTC 2026)

  • C2/C3 (the finding): destination handed to hexncpy() is &region[16] of a 32-byte kmalloc region filled with 0xAA. With inlen=0 and inlen=-1 (outlen=12, so the line-52 KKASSERT passes), the byte before the buffer (region[15]) goes 0xaa -> 0x00 while region[16] stays 0xaa: *--outb = 0 (hexncpy.c:62) wrote one byte BEFORE the caller buffer because the loop never advanced outb. underflow_canary_hits=2.
  • C1 control: inlen=4, outlen=12, sep="-" -> out=[de-ad-be-ef], canary intact β€” no underflow when inlen >= 1.
  • m_print path (mode 2): m_print() (sys/kern/uipc_mbuf.c:2527-2544) with m_len=0 leading mbuf passes outlen=HEX_NCPYLEN(0)=0 (sys/sys/libkern.h:67 #define HEX_NCPYLEN(s) (s * 3)), so on this INVARIANTS kernel the line-52 KKASSERT fires FIRST: panic: assertion "(outb != NULL && inb != NULL && outlen >= MIN_OUTBLEN)" failed in hexncpy at hexncpy.c:52 with backtrace m_print() -> hexncpy() (panic.txt). On production kernels KKASSERT compiles out (sys/sys/systm.h:116-119) and the same call instead falls through to the *--outb = 0 underflow demonstrated by C2/C3 β€” a 1-byte NUL write before the kmalloc(HEX_NCPYLEN(len), M_TEMP, ...) buffer (uipc_mbuf.c:2533).

Reachability (the honest scoping)

  • m_print() has exactly one in-tree caller: sys/dev/misc/musycc/musycc.c:754 (rx-error debug print on an ancient T1/E1 PCI card; no such hardware on the guest or in any common system). ddb has no m_print command in this tree, netstat -m reads sysctl counters only (no kernel m_print call), and no syscall/ioctl path reaches it. ddb call can invoke it manually, but ddb access is already root-equivalent.
  • Every other in-tree hexncpy() caller that passes a variable inlen (uipc_mbuf.c:2537, ciss.c:4181, mly.c:2652, amr_cam.c:623, bootp_subr.c:718, if_ether.c:759+ if_addrlen calls) pairs it with HEX_NCPYLEN(inlen), so inlen<=0 implies outlen==0 β€” KKASSERT panic on INVARIANTS kernels, underflow on production kernels. sppp guards with if (len) (if_spppsubr.c:5289-5293).
  • No unprivileged-reachable caller exists. The write primitive is real and deterministic, but the only reachable behaviors today are INVARIANTS-panic / production-underflow from diagnostics paths that an unprivileged user cannot invoke. Not a privesc candidate as the tree stands; it is a landmine for any future zero-length-tolerating caller.

Fix validation (A/B on the same guest)

fix.diff (also carries the DF-2253 accounting change β€” both live in the same loop; plus a companion m_print() skip-zero-m_len hunk): re-applied to the guest tree, kernel rebuilt (incremental nativekernel), rebooted into kernel #2, identical harness re-run:

  • C2/C3: canary stays 0xaa, buf0=0x00 (terminator lands INSIDE the buffer at addr[0]) β€” underflow gone.
  • C1: out=[de-ad-be-ef] β€” bit-identical to stock sep!="" behavior.
  • C4: deadbeef1234, C5: 1234, C6: deadbeef β€” sep==NULL outputs now full and correctly terminated (that part is DF-2253).
  • mode 2: m_print() skips the empty mbuf, prints the 8-byte mbuf, returns β€” no panic, guest stays up.

See run.log (baseline matrix), panic.txt (baseline m_print panic), fix_run.log (patched A/B), fix_build.log, build.log (baseline build).

Reproduce

# guest must be up with /usr/src (vm.sh reset with-src)
sh build.sh      # backups, harness append, nativekernel, installkernel, reboot
sh run.sh        # sysctl kern.df_hexncpy=1 ; sysctl kern.df_hexncpy=2 (panics)
VERDICT.md
↓ download raw

DF-2252 β€” VERDICT

Status: reproduced β€” impact: panic on this INVARIANTS guest through the real m_print() caller; the underlying deterministic 1-byte NUL underflow write is proven at function level on the kernel-compiled real hexncpy(). Fix validated A/B on a rebuilt kernel (fix.diff, BUILD2_RC=0). No unprivileged-reachable caller exists in the tree β€” see Reachability.

Root cause (path:line)

  • sys/libkern/hexncpy.c:56 β€” loop for (; inlen > 0 && outlen >= MIN_OUTBLEN; --inlen, outlen -= 3): inlen is never validated > 0 on entry (the :52 KKASSERT checks only outb/inb/outlen).
  • sys/libkern/hexncpy.c:62 β€” unconditional *--outb = 0: when the loop body never ran (inlen <= 0), outb == addr, so the terminator writes NUL at addr[-1] β€” one byte BEFORE the caller's buffer.

Reproduction (kernel-compiled harness around the REAL function)

Runtime kldload GPFs this guest, so harness_hexncpy.c was appended to the guest's /usr/src/sys/libkern/hexncpy.c (same TU as the production function), built with make nativekernel KERNCONF=X86_64_GENERIC (INVARIANTS config, sys/config/X86_64_GENERIC:56), installed, booted (kernel #1, Sat Oct 17 16:14:33 UTC 2026). Trigger: sysctl kern.df_hexncpy=1.

Destination = &region[16] of a 32-byte kmalloc'd region prefilled 0xAA; outlen=12 so the :52 KKASSERT passes (it ignores inlen):

case inlen stock kernel result verdict
C1 control, sep="-" 4 out=[de-ad-be-ef], canary 0xaa->0xaa no underflow when inlen>=1
C2 0 canary 0xaa -> 0x00, buf[0] stays 0xaa underflow reproduced
C3 -1 canary 0xaa -> 0x00 underflow reproduced

underflow_canary_hits=2 (C2+C3 only). run.log.

The cited caller, end to end (m_print)

sys/kern/uipc_mbuf.c:2527-2544 m_print() walks the chain calling hexncpy(m2->m_data, m2->m_len, hexstr, HEX_NCPYLEN(m2->m_len), "-") (:2537) with no zero guard β€” and m_len == 0 mbufs are legal. Crucially HEX_NCPYLEN(s) = (s * 3) (sys/sys/libkern.h:67), so m_len==0 passes outlen == 0:

  • INVARIANTS kernels (this guest): the :52 KKASSERT fires before the underflow β€” proven by harness mode 2 (m_gethdr chain with a legal zero-length leading mbuf, m_print() called for real): panic: assertion "(outb != NULL && inb != NULL && outlen >= MIN_OUTBLEN)" failed in hexncpy at /usr/src/sys/libkern/hexncpy.c:52, backtrace hexncpy() <- m_print() <- sysctl_df_hexncpy(), guest wedged at db> (panic.txt, serial console). ssh died, vm.sh status = down.
  • Production kernels: KKASSERT compiles out (sys/sys/systm.h:116-119), the same call falls through to *--outb = 0 β€” i.e. writes NUL immediately before the kmalloc(HEX_NCPYLEN(len), M_TEMP, M_ZERO|M_WAITOK) buffer (uipc_mbuf.c:2533). The C2/C3 canary result is exactly that write: a 1-byte heap NUL underflow primitive, value fixed (0x00), offset fixed (last byte of the preceding slab object / allocator metadata region).

Reachability β€” the honest scoping (TEAM DIRECTIVE)

Full caller sweep of hexncpy() in the tree:

  • m_print() itself has exactly ONE in-tree caller: sys/dev/misc/musycc/musycc.c:754 β€” RX-error debug print of an ancient T1/E1 PCI telephony card (and it clamps m_len to 16 but does not guard 0, musycc.c:750-754). No musycc hardware exists on the guest (or in any common modern system); the driver is not even probed.
  • ddb: no m_print command exists in sys/ddb/ in this tree; netstat -m reads sysctl counters only (no kernel m_print call); no syscall/ioctl/ netgraph path reaches it. ddb call can invoke it manually, but ddb console access is already root-equivalent.
  • All other variable-inlen callers (ciss.c:4181, mly.c:2652, amr_cam.c:623, bootp_subr.c:718, the if_ether.c if_addrlen log sites) pair outlen = HEX_NCPYLEN(inlen), so a zero length yields outlen == 0 β€” the same KKASSERT/underflow dichotomy, from device- controlled or boot-time lengths only. sppp_print_bytes shows the correct pattern: if (len) guard (sys/net/sppp/if_spppsubr.c:5289-5293).
  • Fixed-length callers (if_ether.c:643 inlen=2, mly.c:1344 inlen=4, mlx.c:1280, aac_debug.c:251) never pass inlen <= 0.

Conclusion: the write primitive is real and 100% deterministic, but no unprivileged-reachable caller passes inlen <= 0 today. privesc_candidate = false. The m_print path is the strongest reachable instance and it is a diagnostics routine behind root-only/debug/hardware walls. The bug remains a serious landmine: any future caller that tolerates zero-length input (e.g. a length straight off a packet header or a device field) instantly turns this into a silent 1-byte heap NUL corruption on production kernels (and a DoS panic on INVARIANTS ones).

Fix validation (A/B, same guest image)

fix.diff = (a) hexncpy.c: drain sep ? 3 : 2 per iteration, terminate via if (sep != NULL && outb != addr) *--outb = 0; else *outb = 0; β€” nothing-written (inlen <= 0) now terminates at addr[0] instead of addr[-1]; (b) companion uipc_mbuf.c hunk: m_print() skips empty mbufs (the cited caller's own zero guard). Applied to the guest tree (patch -p1, hunks at hexncpy.c:53 and uipc_mbuf.c:2534), incremental make nativekernel (BUILD2_RC=0, hexncpy.c + uipc_mbuf.c recompiled with -Werror), rebooted into kernel #2 (Sat Oct 17 16:28:39 UTC 2026).

Identical harness, identical inputs (fix_run.log):

case baseline (stock) patched
C1 sep="-" inlen=4 de-ad-be-ef, canary ok de-ad-be-ef, canary ok β€” bit-identical
C2 inlen=0 canary 0xaa->0x00 canary 0xaa->0xaa, buf0=0x00 β€” underflow GONE
C3 inlen=-1 canary 0xaa->0x00 canary 0xaa->0xaa, buf0=0x00 β€” underflow GONE
m_print m_len=0 (mode 2) KKASSERT panic, db>, guest down prints the 8-byte mbuf, returns, guest up
underflow_canary_hits 2 0

Bad behavior gone on the patched kernel; sep!="" output byte-for-byte unchanged; guest stayed up through all re-runs.

Artifacts

run.log (baseline matrix), panic.txt (baseline m_print panic + backtrace), fix_run.log (patched A/B), build.log / fix_build.log (nativekernel tails + RC lines; full 6 MB logs preserved on the guest at /root/build1.log, /root/build2.log), harness_hexncpy.c (sha256 27dccb374f1ca3665e01527d75dd6de76e28209674e811845f9ffdea24778a75), fix.diff (sha256 745fdae526ade23b4c34f118af06eeec2be0f312dd087f8859fce09270bfacd3, git-apply clean against sys/), env.txt, build.sh, run.sh.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src (patch -p1, hunks at hexncpy.c:53 and uipc_mbuf.c:2534), incremental make nativekernel (BUILD2_RC=0, -Werror), rebooted into kernel #2 Sat Oct 17 16:28:39 UTC 2026. Identical harness and inputs: baseline had canary 0xaa->0x00 on C2/C3 (underflow) and an m_print KKASSERT panic wedging the guest in ddb; patched kernel keeps the canary at 0xaa with buf0=0x00 (terminator in-bounds), C1 sep!=NULL output de-ad-be-ef byte-identical to stock, m_print with the zero-m_len chain prints the 8-byte mbuf and returns, guest stays up. fix_patched_reproduced=false means the BAD behavior is gone, which is the success criterion.

fix_run.log (patched matrix + mode-2 return); fix_build.log (BUILD2_RC=0, uipc_mbuf.c/hexncpy.c recompile lines); fix.diff sha256 745fdae526ade23b4c34f118af06eeec2be0f312dd087f8859fce09270bfacd3 (git apply --check clean against sys/); panic.txt + run.log (baseline A side)
↓ fix.diffper-fix-DF-2252

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (baseline matrix: C2/C3 canary 0xaa->0x00 = the underflow, C1 control clean, underflow_canary_hits=2); panic.txt (baseline m_print zero-m_len: panic assertion outlen >= MIN_OUTBLEN failed in hexncpy at hexncpy.c:52, backtrace hexncpy <- m_print <- sysctl_df_hexncpy, db> prompt); fix_run.log (patched kernel #2 16:28:39: canary intact, buf0=0x00, de-ad-be-ef identical, m_print returns, guest up); fix_build.log (nativekernel BUILD2_RC=0 with hexncpy.c+uipc_mbuf.c recompiled -Werror); harness_hexncpy.c sha256 27dccb374f1ca3665e01527d75dd6de76e28209674e811845f9ffdea24778a75

PoC changes

Authored fresh (no seeded pack on disk). Per guest quirk (runtime kldload GPFs) the rig is compiled into the kernel: harness_hexncpy.c appended to the guest copy of sys/libkern/hexncpy.c so it links the production function in the same TU, driven by sysctl kern.df_hexncpy; pseudo-device tap added to X86_64_GENERIC for the DF-2253 end-to-end. Guest source verified byte-identical to the audit tree (sha256) before patching.

Verified recommended fix

Guard the terminator against the nothing-converted case and fix sep==NULL accounting: drain (sep ? 3 : 2) per iteration and write *outb = 0 when sep == NULL or no char was written (see fix.diff, plus m_print skip-empty-mbuf companion hunk).

Verdict

hexncpy() (sys/libkern/hexncpy.c:41-64) never validates inlen > 0 on entry; the :52 KKASSERT checks only outb/inb/outlen. With inlen <= 0 and outlen >= 3 the loop body at :56 never runs, outb stays at addr, and the unconditional terminator --outb = 0 at :62 writes NUL one byte BEFORE the caller buffer. Proven on the kernel-compiled REAL function (runtime kldload GPFs this guest): destination &region[16] of a 0xAA-prefilled 32-byte kmalloc region, outlen=12; inlen=0 and inlen=-1 both flip the canary byte region[15] from 0xaa to 0x00 while region[16] is untouched (run.log, underflow_canary_hits=2); the inlen=4 control leaves it intact. The cited caller m_print() (sys/kern/uipc_mbuf.c:2527-2544) calls hexncpy(m2->m_data, m2->m_len, hexstr, HEX_NCPYLEN(m2->m_len), -) at :2537 with no zero guard; HEX_NCPYLEN(s)=(s3) (sys/sys/libkern.h:67), so a legal m_len==0 mbuf passes outlen=0 - on this INVARIANTS guest the harness (real m_gethdr chain, real m_print) panics with assertion outlen >= MIN_OUTBLEN failed in hexncpy at hexncpy.c:52, backtrace hexncpy <- m_print, guest wedged in ddb (panic.txt); on production kernels KKASSERT compiles out (sys/sys/systm.h:116-119) and the same call executes the --outb = 0 underflow into the byte before the kmalloc(3len, M_TEMP) buffer - exactly the C2/C3 write. REACHABILITY (team directive): full caller sweep shows m_print has exactly one in-tree caller, the musycc T1/E1 PCI card RX-debug path (sys/dev/misc/musycc/musycc.c:754, clamps m_len to 16 but does not guard 0); ddb has no m_print command, netstat -m uses sysctl counters only, no syscall/ioctl path exists, and every other variable-inlen caller pairs outlen=HEX_NCPYLEN(inlen) so zero length means outlen=0 (same panic/underflow split, device- or boot-controlled lengths only; sppp guards with if(len), if_spppsubr.c:5289). privesc_candidate=false: the 1-byte NUL underflow primitive is real and deterministic but NO unprivileged-reachable caller passes inlen<=0 in the current tree - it is a landmine for any future zero-length-tolerant caller. fix.diff (drain sep?3:2, terminate at addr[0] when nothing converted, plus m_print skip-empty-mbuf hunk) validated A/B on a rebuilt kernel: C2/C3 canary stays 0xaa with buf0=0x00, C1 sep!=NULL output byte-identical (de-ad-be-ef), m_print mode-2 returns without panic, guest up.