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)
PoC verification
Evidence pack
findings/poc/DF-2252 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 legalm_len == 0mbuf 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®ion[16]of a 32-bytekmallocregion filled with 0xAA. Withinlen=0andinlen=-1(outlen=12, so the line-52 KKASSERT passes), the byte before the buffer (region[15]) goes 0xaa -> 0x00 whileregion[16]stays 0xaa:*--outb = 0(hexncpy.c:62) wrote one byte BEFORE the caller buffer because the loop never advancedoutb.underflow_canary_hits=2. - C1 control:
inlen=4, outlen=12, sep="-"->out=[de-ad-be-ef], canary intact β no underflow wheninlen >= 1. - m_print path (mode 2):
m_print()(sys/kern/uipc_mbuf.c:2527-2544) withm_len=0leading mbuf passesoutlen=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:52with backtracem_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 = 0underflow demonstrated by C2/C3 β a 1-byte NUL write before thekmalloc(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 nom_printcommand in this tree,netstat -mreads sysctl counters only (no kernel m_print call), and no syscall/ioctl path reaches it. ddbcallcan invoke it manually, but ddb access is already root-equivalent.- Every other in-tree
hexncpy()caller that passes a variableinlen(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 withHEX_NCPYLEN(inlen), soinlen<=0impliesoutlen==0β KKASSERT panic on INVARIANTS kernels, underflow on production kernels. sppp guards withif (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 ataddr[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)
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β loopfor (; inlen > 0 && outlen >= MIN_OUTBLEN; --inlen, outlen -= 3):inlenis never validated> 0on entry (the :52 KKASSERT checks onlyoutb/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 ataddr[-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 = ®ion[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_gethdrchain 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, backtracehexncpy() <- m_print() <- sysctl_df_hexncpy(), guest wedged atdb>(panic.txt, serial console). ssh died,vm.sh status= down. - Production kernels:
KKASSERTcompiles out (sys/sys/systm.h:116-119), the same call falls through to*--outb = 0β i.e. writes NUL immediately before thekmalloc(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 clampsm_lento 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_printcommand exists insys/ddb/in this tree;netstat -mreads sysctl counters only (no kernelm_printcall); no syscall/ioctl/ netgraph path reaches it. ddbcallcan invoke it manually, but ddb console access is already root-equivalent. - All other variable-
inlencallers (ciss.c:4181,mly.c:2652,amr_cam.c:623,bootp_subr.c:718, theif_ether.cif_addrlenlog sites) pairoutlen = HEX_NCPYLEN(inlen), so a zero length yieldsoutlen == 0β the same KKASSERT/underflow dichotomy, from device- controlled or boot-time lengths only.sppp_print_bytesshows the correct pattern:if (len)guard (sys/net/sppp/if_spppsubr.c:5289-5293). - Fixed-length callers (
if_ether.c:643inlen=2,mly.c:1344inlen=4,mlx.c:1280,aac_debug.c:251) never passinlen <= 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
fixedfix.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)
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 ®ion[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.
No comments yet.