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

index/rindex omit the ISO C char conversion of the search argument: never match for ch outside [-128,127], diverging from strchr/strrchr (backs every kernel strchr/strrchr via libkern.h)

Summary

Both primitives compare the sign-extended string byte against the UNTRUNCATED int ch, where ISO C converts c to char first: for ch outside [-128,127] the kernel never matches while libc does - and libkern.h inline-wraps strchr/strrchr to these, so this is the behavior of every in-kernel call site. Guest differential (ch in [-300,300] over a corpus embedding 0x80/0xE9/0xFF): 116 divergences (58+58), deterministic 3/3; guard-page-proven no memory unsafety (byte-at-a-time, halts at NUL). No in-tree caller passes a high-bit search byte (census) - latent parser-port hazard (field-separation/auth-confusion shape) + arch-dependent (char signedness). (char)ch cast fix validated (patched mismatches=0). Both twins marked done.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5712 Β· 12 files
FileTypeDescriptionSize
diffharness.c β€” 6.5 KB view raw
build.sh β€” 250 B view raw
run.sh β€” 118 B view raw
build.log β€” 903 B view raw
run.log β€” 1.4 KB view raw
run.2.log β€” 1.4 KB view raw
run.3.log β€” 1.4 KB view raw
env.txt β€” 218 B view raw
fix.diff β€” 463 B view raw
VERDICT.md β€” 5.6 KB ↓ raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 4.3 KB view raw
VERDICT.md
↓ download raw

DF-5712 VERDICT β€” index/rindex (char)ch truncation divergence

Status: REPRODUCED (deterministic, 3/3 runs) Β· Impact: none (no memory unsafety; semantic/conformance divergence, latent) Β· Confidence: certain

What was audited

Pass-2 twin audit of sys/libkern/rindex.c (53 LOC) and sys/libkern/index.c (52 LOC) — the kernel's strchr/strrchr backend (sys/sys/libkern.h:164-174 inline-wraps strchr→index, strrchr→rindex; registered standard in sys/conf/files:2208,2216).

The primitives family checklist

(a) c==0 tail handling / NUL-walk bounds β€” CLEAN, harness-proven. index tests *u.p == ch before the NUL test (sys/libkern/index.c:46-49), so index(p,0) returns the pointer to the terminating NUL β€” exact strchr semantics. rindex executes the match test on the NUL byte itself (sys/libkern/rindex.c:47-48 sets save = u.p when ch==0), then returns at rindex.c:49-50 β€” exact strrchr semantics. Harness Part 2: CH0_SEMANTICS: OK β€” match libc exactly (0 bad) across the whole corpus. The NUL-walk bound is: halt at the first zero byte β€” byte-at-a-time, no word-at-a-time shape exists in either file.

(b) Consumer census. Direct callers (all NUL-safe): - sys/vfs/smbfs/smbfs_vfsops.c:170,173 β€” index(strncpy(...),0): the entire f_mntfromname[MNAMELEN] is bzerod first (line 167), so the scan for the terminator is intrinsically bounded. (Legacy bucket.) - sys/vfs/smbfs/smbfs_vnops.c:975-1000 β€” NUL-terminated name parsing (legacy). - sys/netgraph/ksocket/ng_ksocket.c:222,579,586 β€” operates on a ksnprintf(name, sizeof(name), "%s", ...) copy (line 577) / the ng_parse NUL-terminated text buffer β€” bounded. - sys/ddb/db_input.c:249 β€” static, always-terminated ddb line buffer (privileged). - sys/libkern/fnmatch.c:99,105 β€” fnmatch contract requires NUL-terminated args. - sys/dev/raid/vinum/{vinumio.c:707,vinummemory.c:56,151} β€” NUL-terminated config strings (privileged ioctl path). - sys/dev/disk/nata/ata-disk.c:511-512 β€” index(param.model, ' ') where model[40] (ata-raid.h:781) is device-supplied identify data; a hostile drive could in theory present 40 non-space/non-NUL bytes making the scan continue into the surrounding ata_params (it stops at the first zero byte of the 512-byte identify image in the same softc β€” cosmetic ad_describe() string split only). Hardware-trust wart, not a primitive defect, not attacker-reachable. Indirect: every kernel strchr/strrchr call site compiles to these two functions. A grep for high-bit search arguments across sys/ found zero callers passing ch outside ASCII β€” the divergence below is latent.

(c) Guard-page differential β€” CLEAN, harness-proven. With the string's NUL placed on the last byte before a PROT_NONE page, all scans (including ch==0, returning exactly guard-1) complete without touching the guard page: GUARDPAGE_RESULT: byte-exact, halts at NUL (0 unexpected faults). The deliberate no-NUL demo faults at the guard β€” that is the documented NUL-termination contract, reachable in-kernel only via a consumer bug (none found).

(d) Word-at-a-time overread β€” NONE. Both loops are pure byte-at-a-time (for (;; ++u.p) index.c:45 / rindex.c:46). Unlike vectorized libc strchr, these never read past the terminator. Safest possible shape.

The finding (reproduced)

if (*u.p == ch) (index.c:46, rindex.c:47) compares int-promoted char against the untruncated int ch. ISO C requires the char conversion. On x86_64 (signed char): for ch ∈ [128,255] (and any other out-of-range value, e.g. ±256), the kernel never matches; libc matches (char)ch. Concrete samples from run.log: str="" ch=-256 kernel=-1 libc=0 (libc finds (char)(-256)==0, the terminator); str="a" ch=256 kernel=-1 libc=1. Totals: 58 index + 58 rindex = 116 mismatches, 3/3 runs identical.

Security framing: fail-to-match, never OOB. A ported parser searching a high-bit delimiter (β‰₯0x80) would treat delimited fields as one lump β€” a field-separation/auth-confusion shape. No current in-tree trigger β†’ Low, latent.

Exploit chain

None β€” there is no memory-corruption primitive and no in-tree trigger; this is a correctness/conformance defect in a pure function. Pushing to uid=0 is not applicable to this class; the differential harness is the demonstration.

Fix validation (function-level, in-harness)

fix.diff casts the comparison: if (*u.p == (char)ch) in both files. The exact patched bodies (sed-applied to the verbatim kernel sources, one char changed β€” diff shown in build.log) were compiled into the same harness: PATCHED_TOTAL_MISMATCHES=0 (3/3 runs). (char)ch is the libc-exact fix on both signednesses of char: on signed-char machines (char)128 == (char)0x80 == -128 matches the sign-extended byte; on unsigned-char machines the same cast matches the zero-extended byte, mirroring libc byte comparison. A kernel rebuild is not meaningful for this pure function beyond the object-level equivalence already demonstrated; the rig did not need to be reset (no kernel state touched, guest stayed clean, no panic).

Guest

DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 x86_64, cc 8.3, page size 4096 (env.txt). Guest /usr/src/sys/libkern/{index,rindex}.c sha256-identical to the audited repo tree (build.log).

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

The one-character-per-file fix was validated inside the same harness: build.sh sed-applies exactly if (u.p == ch) -> if (u.p == (char)ch) to the verbatim kernel sources (diff shown in build.log) and compiles the patched bodies alongside; the differential vs libc drops from 116 mismatches to 0 (PATCHED_TOTAL_MISMATCHES=0, 3/3 runs). The cast reproduces libc semantics on both signednesses of char. A full kernel rebuild adds nothing for this stateless pure function beyond the demonstrated object-level equivalence, so none was performed; fix.diff is authored against the read-only sys/ tree and was never applied there.

findings/poc/DF-5712/run.log: 'DIFF patched: index 0 mismatches, rindex 0 mismatches' / 'PATCHED_TOTAL_MISMATCHES=0'; build.log: the 44c44/45c45-style diff hunks showing only the cast changed; fix.diff: git-apply-able unified diff for both files.
↓ fix.diffper-fix-DF-5712

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-5712/run.log: 'DIFF baseline: index 58 mismatches, rindex 58 mismatches' + 'BASELINE_TOTAL_MISMATCHES=116' + samples 'str="" ch=-256 kernel=-1 libc=0'; 'CH0_SEMANTICS: OK'; 'GUARDPAGE_RESULT: byte-exact, halts at NUL (0 unexpected faults)'; 'PATCHED_TOTAL_MISMATCHES=0'. Identical in run.2.log/run.3.log. build.log proves the guest /usr/src sources are sha256-identical to the audited tree and the compiled bodies are byte-identical (BODY-IDENTICAL lines).

PoC changes

Harness authored fresh (no seed). (1) kernel #include substituted with for the userland build (bodies untouched, cmp-proven); (2) corpus literal "a\x80b\x80c" split into adjacent literals after a gcc hex-escape warning; (3) guest transfer via b64decode (guest lacks base64(1)).

Verified recommended fix

Cast the search argument to char before comparing in both primitives: if (*u.p == (char)ch) in sys/libkern/index.c and sys/libkern/rindex.c (see fix.diff).

Verdict

The kernel's index()/rindex() (the backend of every in-kernel strchr/strrchr via sys/sys/libkern.h:164-174) compare the sign-extended string byte against the untruncated int search argument at sys/libkern/index.c:46 and sys/libkern/rindex.c:47, where ISO C requires converting the argument to char first. For any ch outside [-128,127] the kernel scans never match while libc truncates and matches the corresponding byte: the userland differential harness (kernel sources compiled verbatim in the DragonFly guest, ch in [-300,300] over a high-bit corpus) shows 116 divergences from libc (58 index + 58 rindex), identical across 3 runs, dropping to 0 with the one-character ((char)ch) fix. No memory unsafety: both scans are byte-at-a-time and halt exactly at the NUL β€” guard-page-proven, ch==0 tail semantics match libc exactly. No in-tree caller passes a high-bit search byte (census in VERDICT.md), so the defect is latent: a parser ported from userland that searches for a delimiter >= 0x80 would silently treat every occurrence as absent (field-separation confusion shape).