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

sep==NULL misplaces NUL terminator and truncates output

Summary

Terminator *--outb=0 assumes every loop iteration wrote trailing separator at outb[-1]. When sep is NULL no separator written yet code still decrements outb so NUL overwrites low nibble of last hex digit. When caller sizes outlen for separator (HEX_NCPYLEN) but passes sep==NULL outlen drains by 3 per 2-byte iteration loop stops early converting only fraction of input. Net effect for sep==NULL callers: truncated wrong hex output. Concrete reachable instance: sys/netinet/if_ether.c:643 hexncpy(&ar->ar_hrd 2 hexstr 5 NULL) outlen 5 drains to 2 after one iteration (<MIN_OUTBLEN=3) loop runs once for 2-byte input line 62 clobbers low nibble of single converted byte. arp unknown hardware address format log prints incorrect 1-digit value for attacker-supplied ARP frames. Unauthenticated remote peer anyone who can send ARP frame with ar_hrd not in {ETHER IEEE802}. Impact limited to misleading diagnostics no memory corruption no info leak beyond what frame contained no DoS. [VERIFIED pass2: reproduced mechanically and live end-to-end - tap-injected ARP frame with ar_hrd 0x1234 logs arp unknown hardware address format 0x1 on stock kernel, 0x1234 on fixed; truncation matrix exact; diagnostic-only, in-bounds writes; combined fix validated, sep!=NULL control bit-identical]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2253 Β· 16 files
FileTypeDescriptionSize
README.md β€” 2.6 KB ↓ raw
VERDICT.md β€” 4.5 KB ↓ raw
harness_hexncpy.c β€” 4.3 KB view raw
arp_inject.c β€” 1.6 KB view raw
build.sh β€” 228 B view raw
run.sh β€” 206 B view raw
build.log β€” 2.1 KB view raw
run.log β€” 895 B view raw
arp_e2e.log β€” 629 B view raw
arp_e2e_fixed.log β€” 315 B view raw
fix.diff β€” 2.0 KB view raw
fix_build.log β€” 3.1 KB view raw
fix_run.log β€” 737 B view raw
env.txt β€” 470 B view raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 5.4 KB view raw

DF-2253 β€” sep==NULL misplaces NUL terminator and truncates output

sys/libkern/hexncpy.c lines 56-62 (Low, memcorrupt bucket / correctness). VERIFY run 2026-10-17.

What was verified and how

Same kernel-compiled harness as DF-2252 (harness_hexncpy.c appended to the guest's /usr/src/sys/libkern/hexncpy.c, make nativekernel, sysctl-driven): cases C4/C5/C6 exercise sep == NULL through the REAL production function.

  • C5 β€” the cited reachable instance, exact call shape of sys/netinet/if_ether.c:643 (hexncpy((unsigned char *)&ar->ar_hrd, 2, hexstr, 5, NULL) from arpintr()): input bytes 12 34 (ar_hrd 0x1234) -> stock kernel returns the string "1" β€” the loop wrote '1','2' then stopped (outlen drains 3 per iteration: 5 -> 2 < 3), and *--outb = 0 (hexncpy.c:62) backed over the '2'. One hex digit instead of four; the low nibble of the first byte is silently dropped.
  • C4 β€” generic truncation: inlen=6, outlen=HEX_NCPYLEN(6)=18, sep=NULL -> "deadbeef123" (11 digits, the 12th clobbered): the drain-3 accounting converts only ceil-ish 2/3 of the buffer when no separator is written.
  • C6 β€” mly.c:1344 shape: inlen=4, outlen=12, sep=NULL -> "deadbee" instead of "deadbeef".

End-to-end on the live stock kernel (kernel #1, INVARIANTS)

arp_inject.c writes a crafted ethernet frame (ar_hrd = 0x1234) to /dev/tap0; tapwrite() injects it into ifp->if_input = ether_input() (sys/net/tap/if_tap.c:981) -> NETISR_ARP -> arpintr() (sys/netinet/if_ether.c:630-648) -> the line-643 hexncpy -> console:

arp_inject: wrote 42 bytes to /dev/tap0 (ar_hrd=0x1234)
arp: unknown hardware address format (0x1)      <- stock: ONE digit, should be (0x1234)

(arp_e2e.log.) The injector needs root only because /dev/tap0 is root-only on the guest β€” on a real network, any LAN peer can send such a frame unauthenticated; the impact is a wrong one-digit diagnostic in the log (cosmetic/correctness, no memory unsafety: hexstr is char[6] and the write stays at hexstr[1]).

Fix validation (A/B, kernel #2 with fix.diff)

  • C5 -> "1234", C4 -> "deadbeef1234", C6 -> "deadbeef": sep==NULL now drains 2 per iteration and terminates right after the last digit.
  • sep!="" control C1 -> de-ad-be-ef, bit-identical to stock.
  • End-to-end re-run: arp: unknown hardware address format (0x1234) β€” full four digits (arp_e2e_fixed.log).

Reproduce

# guest up with /usr/src (vm.sh reset with-src)
sh build.sh      # harness (+ pseudo-device tap) into kernel, nativekernel, reboot
sh run.sh        # matrix sysctl + tap create + arp_inject + dmesg check
VERDICT.md
↓ download raw

DF-2253 β€” VERDICT

Status: reproduced β€” impact: none beyond a wrong diagnostic (this is a correctness bug in a print helper, not memory unsafety; every write stays inside the caller's buffer). All three claim elements verified through the REAL kernel function, plus the unauthenticated-packet end-to-end on the live stock kernel. Fix validated A/B.

Root cause (path:line)

  • sys/libkern/hexncpy.c:56 β€” outlen -= 3 unconditionally, but the separator write at :59-60 is conditional on sep != NULL.
  • sys/libkern/hexncpy.c:62 β€” *--outb = 0 backs the pointer over the last written char regardless of what that char was.

Combined effect when sep == NULL:

  1. Truncation: budget drains 3/iteration while only 2 chars are written β€” a HEX_NCPYLEN(n)-sized buffer converts at most ~2n/3 bytes... (exactly: floor((outlen)/3) iterations = n for a paired buffer, but for buffers sized as 3n the loop stops one iteration early relative to what 2-char groups would allow) and the output is silently shorter than the input hex.
  2. Misplaced terminator: the NUL overwrites the low-nibble digit of the last converted byte.

Verified case matrix (kernel-compiled harness, kernel #1, stock code)

case call shape input stock output correct output
C4 inlen=6 outlen=18 sep=NULL (generic) de ad be ef 12 34 deadbeef123 (12th digit clobbered) deadbeef1234
C5 if_ether.c:643 exact: inlen=2 outlen=5 sep=NULL 12 34 (ar_hrd 0x1234) 1 1234
C6 mly.c:1344 shape: inlen=4 outlen=12 sep=NULL de ad be ef deadbee deadbeef
C1 control sep="-" inlen=4 outlen=12 de ad be ef de-ad-be-ef (unchanged)

Reachable instance, end to end on the live stock kernel

sys/netinet/if_ether.c:630-648 arpintr(): any ARP frame whose ar_hrd is neither ARPHRD_ETHER nor ARPHRD_IEEE802 logs "arp: unknown hardware address format (0x%s)" with the value produced by hexncpy((unsigned char *)&ar->ar_hrd, 2, hexstr, 5, NULL) (:643).

Demonstration (arp_inject.c + tap(4)): writing a crafted 42-byte ethernet frame with ar_hrd = 0x1234 to /dev/tap0 lands directly in ifp->if_input (sys/net/tap/if_tap.c:981) β†’ ether_input() β†’ NETISR_ARP β†’ arpintr() β†’ the :643 call. Stock kernel console:

arp_inject: wrote 42 bytes to /dev/tap0 (ar_hrd=0x1234)
arp: unknown hardware address format (0x1)

One hex digit instead of four (arp_e2e.log; reproduced twice, two identical lines). The injector needs root only because /dev/tap0 is root-only on the guest; on a real network an unauthenticated LAN peer triggers the same path with a single frame β€” hence the finding's threat model. Memory safety: hexstr is char hexstr[6] on arpintr's stack (if_ether.c:632); the function writes at most hexstr[0..1] here β€” no overflow, purely a wrong diagnostic value (operators see 0x1 for any 0x10?? hrd, and 0x0 for any 0x00?? β€” the top nibble only).

Fix validation (A/B, kernel #2 with fix.diff)

Same harness + same tap injection on the patched kernel (fix_run.log, arp_e2e_fixed.log):

case baseline patched
C4 deadbeef123 deadbeef1234
C5 1 1234
C6 deadbee deadbeef
C1 sep!="" de-ad-be-ef de-ad-be-ef β€” bit-identical
ARP e2e (0x1) (0x1234) β€” full value logged

fix.diff changes the drain to sep ? 3 : 2 and terminates right after the last digit for sep == NULL (and after the trailing separator, as historically, for sep != NULL); the loop still honors outlen >= 3 before each group, so the NUL always lands at index <= outlen-1 β€” the fix cannot overflow any HEX_NCPYLEN-paired buffer (checked for if_ether's hexstr[6] and mly's outlen=12 call).

Artifacts

run.log (baseline matrix), arp_e2e.log / arp_e2e_fixed.log (packet end-to-end A/B), fix_run.log (patched matrix), build.log / fix_build.log, arp_inject.c (sha256 18d55cc12e5cb93fc7439c11acc73eaeb5ed4852eb07c230935e1555e2d1d98d), harness_hexncpy.c, fix.diff (shared with DF-2252, git-apply clean), env.txt, 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, incremental make nativekernel (BUILD2_RC=0), rebooted into kernel #2 Sat Oct 17 16:28:39 UTC 2026. Identical matrix and identical tap injection: baseline C5 [1] / C4 deadbeef123 / C6 deadbee and live ARP log (0x1) become C5 1234 / C4 deadbeef1234 / C6 deadbeef and live ARP log (0x1234); sep!=NULL control de-ad-be-ef byte-identical to stock; the loop still requires outlen >= 3 before each group so the NUL always lands at index <= outlen-1 (no overflow introduced; checked against if_ether hexstr[6] and mly outlen=12 shapes). fix_patched_reproduced=false means the BAD behavior is gone, which is the success criterion.

arp_e2e.log vs arp_e2e_fixed.log (packet-level A/B: (0x1) -> (0x1234)); fix_run.log (patched matrix); fix_build.log (BUILD2_RC=0); fix.diff sha256 745fdae526ade23b4c34f118af06eeec2be0f312dd087f8859fce09270bfacd3 (git apply --check clean against sys/)
↓ fix.diffper-fix-DF-2253

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (baseline matrix: C5 out=[1], C4 deadbeef123, C6 deadbee, C1 control clean); arp_e2e.log (stock kernel, tap injection: arp: unknown hardware address format (0x1) for a frame carrying ar_hrd=0x1234, twice); arp_e2e_fixed.log (patched kernel: (0x1234)); fix_run.log (patched matrix C4/C5/C6 correct, sep!=NULL identical); fix_build.log (nativekernel BUILD2_RC=0); arp_inject.c sha256 18d55cc12e5cb93fc7439c11acc73eaeb5ed4852eb07c230935e1555e2d1d98d

PoC changes

Authored fresh (no seeded pack on disk). Rig compiled into the kernel (kldload GPFs this guest): shared harness with DF-2252 for the function-level matrix, plus arp_inject.c writing a crafted ARP frame to /dev/tap0 (tap pseudo-device added to the guest X86_64_GENERIC) to reach the real arpintr path on the live kernel. Guest source verified byte-identical to the audit tree before patching.

Verified recommended fix

Only write separator-then-replace-terminator when sep != NULL: drain (sep ? 3 : 2) per iteration and terminate right after the last digit for sep == NULL (see fix.diff, shared with DF-2252).

Verdict

With sep == NULL the loop at sys/libkern/hexncpy.c:56 still drains outlen by 3 per iteration but only writes 2 chars (:57-58), and the terminator *--outb = 0 at :62 backs over the last written digit instead of following it. Verified through the kernel-compiled REAL function (runtime kldload GPFs this guest; harness compiled into sys/libkern/hexncpy.c via make nativekernel): C5 with the exact if_ether.c:643 call shape (inlen=2, outlen=5, sep=NULL, ar_hrd bytes 12 34) returns the string 1 - one digit, the 2 clobbered by the misplaced NUL; C4 (inlen=6, outlen=HEX_NCPYLEN(6)=18) returns deadbeef123 instead of deadbeef1234; C6 (mly.c:1344 shape, inlen=4 outlen=12) returns deadbee instead of deadbeef; the sep!=NULL control is unaffected (de-ad-be-ef). End-to-end on the live stock kernel: a crafted ethernet frame with ar_hrd=0x1234 written to /dev/tap0 is injected by tapwrite into ifp->if_input (sys/net/tap/if_tap.c:981) -> ether_input -> NETISR_ARP -> arpintr (sys/netinet/if_ether.c:630-648) -> the :643 hexncpy call, and the console logs arp: unknown hardware address format (0x1) - one digit where 0x1234 belongs (arp_e2e.log, reproduced twice with identical output). Any unauthenticated LAN peer can trigger this path with a single frame; the guest injector needs root only because /dev/tap0 is root-only. Memory safety is NOT violated: hexstr is char[6] (if_ether.c:632) and the function writes at most hexstr[0..1] here - the impact ceiling is a wrong operator-facing diagnostic (top nibble of the first hrd byte only), so impact=none beyond correctness. fix.diff (drain sep?3:2, terminate after the last digit for sep==NULL, keep trailing-separator replacement for sep!=NULL) validated A/B on the rebuilt kernel #2: C5 -> 1234, C4 -> deadbeef1234, C6 -> deadbeef, live ARP log now (0x1234), sep!=NULL output byte-identical, guest up.