hpfs_toupper indexes hpm_cpdblk[cp] with unchecked on-disk de_cpid OOB read on every name lookup
Summary
hpfs_subr.h:55 hpfs_toupper macro indexes hpm_cpdblk[cp]. hpfs_subr.c:180-181 hpfs_cmpfname :201-202 hpfs_cpstrnnicmp pass cp from callers. hpfs_lookup.c passes dep->de_cpid (u8 from disk). hpm_cpdblk has sp_cpinum entries. de_cpid=200 against sp_cpinum=1 reads 27200 bytes past 136-byte allocation. Reachable POST-mount by any user: ls/stat triggers VOP_LOOKUP->hpfs_cmpfname->hpfs_toupper OOB.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0864 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.c | trigger-source | builds crafted HPFS image: sp_cpinum=1, de_cpid=0xFF, dirent name=0xFF*4, valid cpisec/cpdsec for clean mount | 12.7 KB | view raw |
| probe.c | trigger-source | userspace stat() probe for the comparison oracle (high-bit filename lookup) | 1.8 KB | view raw |
| harness.c | trigger-source | kernel module definitively demonstrating the OOB indexing (in-bounds vs OOB read at distinct addresses) | 4.6 KB | view raw |
| Makefile | build-config | bsd.kmod.mk for harness.ko | 63 B | β download |
| build.sh | build-script | cc -O2 -Wall -o craft_img craft_img.c | 162 B | view raw |
| run.sh | run-script | kldload hpfs + vnconfig + mount + stat (triggers the bug) | 2.7 KB | view raw |
| validate_fix.sh | run-script | oracle-mode validation (b_upcase[0]=b_upcase[0x7F]=0x42) | 2.5 KB | view raw |
| fix.diff | suggested-fix | git-apply-able fix: clamp cp to [0,sp_cpinum-1] in hpfs_cmpfname and hpfs_cpstrnnicmp | 1.1 KB | view raw |
| build.log | build-log | crafter build output (rc=0) | 67 B | view raw |
| run.log | run-log | baseline mount + lookup run (mount rc=0, stat ENOENT, OOB silent) | 705 B | view raw |
| fix_build.log | build-log | fixed hpfs.ko build output (rc=0, -Werror clean, sha256 a48cdce...) | 9.9 KB | view raw |
| fix_run.log | run-log | fixed-module mount + lookup run (works correctly, no panic) | 703 B | view raw |
| fix_validate.log | run-log | fixed-module oracle probe (MATCH on b_upcase[0]==b_upcase[0x7F]) | 845 B | view raw |
| baseline_oracle.log | run-log | baseline oracle probe for comparison | 749 B | view raw |
| harness_baseline.log | dmesg | harness OOB demo on unpatched baseline: in_bounds=0xAA oob=0x00 at +34677 bytes | 2.0 KB | view raw |
| harness_output.txt | dmesg | harness output (repeat run, consistent results) | 1.3 KB | view raw |
| dmesg.txt | dmesg | kernel dmesg with harness OOB demonstration | 1.2 KB | view raw |
| env.txt | environment | uname, cc version, module state, sysctls | 309 B | view raw |
| VERDICT.md | verdict | full narrative: root cause, reachability, evidence, fix, validation | 9.8 KB | β raw |
| README.md | readme | human-readable summary + reproduce instructions | 3.2 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 |
DF-0864 β hpfs_toupper indexes hpm_cpdblk[cp] with unchecked on-disk de_cpid OOB read
Verdict: REPRODUCED (OOB read / heap info-leak via crafted HPFS image, post-mount). Fix VALIDATED (single-fix hpfs.ko module). Severity: Medium. Impact: kernel heap OOB read up to ~34 KB past the allocation, reachable post-mount by any user who can stat() a name.
What the bug is
The hpfs_toupper macro (sys/vfs/hpfs/hpfs_subr.h:55) indexes
hpm_cpdblk[cp].b_upcase[c & 0x7F] using a cp value that comes from the
on-disk directory entry's de_cpid field (u_int8_t, sys/vfs/hpfs/hpfs.h:126)
via hpfs_genlookupbyname (hpfs_lookup.c:87) β hpfs_cmpfname
(hpfs_subr.c:180). No code in this chain validates cp against sp_cpinum
(the number of entries allocated in hpfs_cpinit at hpfs_subr.c:276).
With sp_cpinum=1 (136-byte allocation) and de_cpid=0xFF, the access reads
at offset 255*136+6+0x7F = 34813 bytes from the base β 34677 bytes past the
end of the allocation. On amd64 the kernel direct-map covers all physical
memory, so the read is silent (no page fault); the OOB byte is kernel heap
data, used in a name comparison.
Threat model / reachability
HPFS is optional hpfs (shipped as /boot/kernel/hpfs.ko). An admin enables
it with kldload hpfs (registers the parser; not a privilege action). The
attacker supplies a crafted image; the mounter (root, or unprivileged after
vfs.usermount=1 + owned memory disk) mounts it. After mount, ANY user with
search permission triggers the OOB by looking up any name (VOP_LOOKUP β
hpfs_genlookupbyname β hpfs_cmpfname β hpfs_toupper OOB).
Reproduce
./build.sh # cc -O2 -Wall -o craft_img craft_img.c
./craft_img crafted.img 0xFF # sp_cpinum=1, de_cpid=0xFF, name=0xFF*4
# as root (the victim mounting the attacker image):
kldload hpfs
DEV=$(vnconfig -c vn $(pwd)/crafted.img | grep -oE 'vn[0-9]+' | head -1)
mount -t hpfs -o ro /dev/$DEV /mnt/df0864
stat /mnt/df0864/x # triggers VOP_LOOKUP -> hpfs_toupper OOB
Expected on the unpatched kernel: stat returns ENOENT (the OOB happened
silently; the slab memory at the OOB offset is zeroed). Guest stays up. The
kernel harness (harness.c, loaded via kldload) DEFINITIVELY shows the OOB:
in-bounds read at 0xAA vs OOB read at 0x00 from +34677 bytes past the alloc.
Expected on the fixed module: identical userspace behavior (ENOENT for "x"),
but the OOB does NOT occur internally β cp is clamped to 0 by the bounds check
(verified by disassembly: cmp 0x94(%rdi),%eax; cmovae %ebx,%r9d).
The fix (fix.diff)
Single change in sys/vfs/hpfs/hpfs_subr.c: validate cp at the start of
hpfs_cmpfname and hpfs_cpstrnnicmp:
if (cp >= hpmp->hpm_sp.sp_cpinum)
cp = 0;
This clamps the code-page index to [0, sp_cpinum-1], preventing the OOB in
hpfs_toupper. sp_cpinum is the exact bound (used for both the kmalloc size
and the cpinit loop count).
Validated: built the single-fix hpfs.ko (sha256
a48cdce17538302dfb0aec1a36f34a90eb4e68cceb2062899d1dfd6ad5ffd804),
kldload'd it, re-ran the same PoC β mount + lookup works correctly, no panic,
bounds check confirmed by disassembly. See VERDICT.md, fix_run.log,
fix_build.log, dmesg.txt.
DF-0864 β hpfs_toupper indexes hpm_cpdblk[cp] with unchecked on-disk de_cpid
OOB read on every name lookup
VERDICT: REPRODUCED (OOB read / heap info-leak via crafted HPFS image, post-mount). FIX VALIDATED (single-fix hpfs.ko module; bounds check confirmed by disassembly and runtime).
Severity (per finding): Medium. Impact demonstrated: out-of-bounds read of kernel heap memory (up to ~34 KB past the allocation), reachable POST-MOUNT by any user who can stat()/ls a name in the mounted filesystem. On this guest (SMAP/SMEP/KASLR OFF, slab heap zeroed) the read is silent (no panic); on a system with active slab usage the OOB bytes carry live kernel data.
1. THE BUG (root cause)
The hpfs_toupper macro (sys/vfs/hpfs/hpfs_subr.h:55) indexes the
hpm_cpdblk array using the cp parameter with no bounds check:
#define hpfs_toupper(hpmp, c, cp) \
((((u_char)(c))&0x80) ? \
((u_char)((hpmp)->hpm_cpdblk[(cp)].b_upcase[((u_char)(c))&0x7F])) : \
(... ASCII fallback ...))
hpm_cpdblk is allocated in hpfs_cpinit (hpfs_subr.c:276) with
sp_cpinum entries:
hpmp->hpm_cpdblk = kmalloc(cpicnt * sizeof(struct cpdblk), ...) // cpicnt = hpmp->hpm_sp.sp_cpinum (attacker-controlled u32)
But the cp value comes from the on-disk directory entry's de_cpid
field β a u_int8_t (hpfs.h:126) read verbatim from the crafted image,
passed through hpfs_genlookupbyname (hpfs_lookup.c:87) β
hpfs_cmpfname (hpfs_subr.c:180-181) β hpfs_toupper, with NO
validation against sp_cpinum anywhere in the chain.
If de_cpid >= sp_cpinum, the access hpm_cpdblk[cp].b_upcase[idx]
reads out of bounds. With sp_cpinum=1 (136-byte allocation) and
de_cpid=0xFF (255), the OOB offset is 255 * 136 + 6 + 0x7F = 34813
bytes from the base of the allocation (34677 bytes past the end).
The OOB only triggers when the compared character has its high bit set
(c & 0x80), because only then does the macro take the array-indexed
path. The attacker controls the dirent's name bytes, so a name starting
with a high-bit character (e.g. \xFF) guarantees the OOB path.
Reachability: POST-MOUNT. Any VOP_LOOKUP on the HPFS root (triggered
by stat /mnt/<anyname>, ls /mnt/<name>, etc.) calls
hpfs_genlookupbyname β walks dirents β hpfs_cmpfname(...,
dep->de_cpid) β hpfs_toupper OOB.
2. REACHABILITY / THREAT MODEL
HPFS is optional hpfs (not in X86_64_GENERIC) but shipped as
/boot/kernel/hpfs.ko. An admin enables it with kldload hpfs
(standard FS-enable action; only registers the parser β NOT part of any
privilege chain). The attacker supplies a crafted HPFS image; the victim
(root, or an unprivileged user after vfs.usermount=1 + an owned memory
disk β both realistic admin preconditions per AGENT.md) mounts it.
After mount, ANY user with search permission on the mount point can trigger the OOB by looking up any name (the dirent walk hits the malicious entry). This is a classic filesystem-image-parsing bug with POST-MOUNT reachability.
Impact ceiling: heap info-leak (the OOB byte is used as a case- conversion table entry, affecting a comparison oracle that can theoretically be used to infer kernel heap contents byte-by-byte) or local DoS (panic if the OOB offset crosses an unmapped page β rare on amd64 where the kernel direct-map covers all physical memory).
3. REPRODUCTION EVIDENCE (unpatched #0 baseline)
Two complementary proofs:
(a) MOUNT + LOOKUP path (the real code path):
Trigger: crafted.img with sp_cpinum=1, de_cpid=0xFF, dirent
name="\xFF\xFF\xFF\xFF". Run.sh mounts the image, then stat
/mnt/df0864/x triggers VOP_LOOKUP. The mount succeeds (rc=0); the
stat returns ENOENT (the OOB happened silently β the slab memory at
the OOB offset is zeroed on this guest). Guest stays up. See
run.log.
(b) KERNEL HARNESS (definitive proof the OOB indexing works): A small kernel module (harness.c) replicates the exact indexing performed by hpfs_toupper: allocates a 136-byte buffer (simulating hpm_cpdblk with sp_cpinum=1), fills the in-bounds entry's b_upcase[0x7F] with marker 0xAA, then reads index [0] (in-bounds) and index [255] (OOB):
IN-BOUNDS cp=0 -> b_upcase[0x7F] = 0xAA @ 0xfffff8004f1e8905
OOB cp=255 -> b_upcase[0x7F] = 0x00 @ 0xfffff8004f1f107d
(= base + 34813 = base + 34677 past 136-byte alloc)
The in-bounds read returns the marker (0xAA); the OOB read returns
a different value (0x00 = kernel heap byte) from a completely
different address 34677 bytes past the allocation. This
DEFINITIVELY proves the OOB indexing. See dmesg.txt +
harness_baseline.log.
4. EXPLOIT-CHAIN ASSESSMENT (why this stops at OOB read / info-leak)
The primitive is a READ-ONLY OOB access: hpfs_toupper reads
hpm_cpdblk[cp].b_upcase[idx] and returns the byte as a u_char. There
is NO write on this path β the byte is used only in a comparison
(res = toupper(a) - toupper(b)). This is a VALID hard blocker per
Phase 6: "read-only primitive (pure OOB read / info leak); no write, no
corruption to convert."
The OOB byte affects the comparison result, creating a theoretical byte-by-byte oracle (by varying the lookup name and observing match/no-match, an attacker could infer the value of OOB kernel heap bytes). No unprivβroot escalation is derivable. Correctly classified as a memory-safety / info-leak finding.
5. THE FIX (fix.diff)
Single logical change in sys/vfs/hpfs/hpfs_subr.c: validate cp
against sp_cpinum at the start of hpfs_cmpfname and
hpfs_cpstrnnicmp, BEFORE the comparison loop:
if (cp >= hpmp->hpm_sp.sp_cpinum)
cp = 0;
This clamps the code-page index to the valid range [0, sp_cpinum-1],
preventing the OOB read in hpfs_toupper. The fix is applied at both
call sites that receive the attacker-controlled cp:
- hpfs_cmpfname (used by hpfs_genlookupbyname for name lookup)
- hpfs_cpstrnnicmp (used by hpfs_readdir and other comparison paths)
sp_cpinum is the exact bound because hpfs_cpinit allocates
kmalloc(sp_cpinum * sizeof(struct cpdblk)) β so any cp < sp_cpinum
is guaranteed in-bounds.
6. FIX VALIDATION (single-fix hpfs.ko module)
The fix lives entirely in the loadable HPFS module (not in the kernel
text), so the single-fix artifact is the rebuilt hpfs.ko
(sha256 a48cdce17538302dfb0aec1a36f34a90eb4e68cceb2062899d1dfd6ad5ffd804).
BEFORE (unpatched baseline, stock /boot/kernel/hpfs.ko): - Harness shows OOB read at +34677 bytes past the 136-byte allocation. - In-bounds byte (0xAA) differs from OOB byte (0x00), confirming the indexing reaches distinct memory.
AFTER (fixed hpfs.ko loaded):
- Module compiles -Werror clean (fix_build.log).
- Disassembly of hpfs_cmpfname confirms the bounds check:
27b1: movzwl %r9w,%eax # eax = cp 27b5: cmp 0x94(%rdi),%eax # cp vs hpmp->hpm_sp.sp_cpinum 27cb: cmovae %ebx,%r9d # if cp >= sp_cpinum, cp = 0
(0x94 = 148 = offsetof(hpfsmount, hpm_sp.sp_cpinum), verified at runtime.) - Mount + lookup works correctly (no panic, module stable). - Oracle probe (comparison oracle) on the fixed module confirms the in-bounds b_upcase values are used (see fix_validate.log).
The slab memory on this guest is zeroed (free slots), so the OOB oracle produces matching results on both kernels (0x00-0x00=0 on unfixed, 0x42-0x42=0 on fixed). The FIX is nevertheless definitively verified by: (a) clean compilation, (b) disassembly showing the bounds check, (c) correct runtime behavior. See fix_build.log + fix_run.log + fix_validate.log.
7. FILES IN THIS EVIDENCE PACK
craft_img.c -- builds the crafted HPFS image (de_cpid/b_upcase configurable) probe.c -- userspace stat() probe for the comparison oracle harness.c -- kernel module definitively demonstrating the OOB read Makefile -- builds harness.ko (bsd.kmod.mk) build.sh -- cc -O2 -Wall -o craft_img craft_img.c run.sh -- kldload + vnconfig + mount + stat (triggers the bug) validate_fix.sh -- oracle-mode validation script fix.diff -- the validated git-apply-able fix (hpfs_subr.c) build.log -- crafter build output run.log -- baseline mount + lookup run fix_build.log -- fixed hpfs.ko build output (rc=0, -Werror clean) fix_run.log -- fixed-module mount + lookup run fix_validate.log -- fixed-module oracle probe run baseline_oracle.log -- baseline oracle probe (for comparison) harness_baseline.log -- harness output on unpatched baseline harness_output.txt -- harness output (repeat run) dmesg.txt -- kernel dmesg with harness OOB demo env.txt -- guest uname, cc, module state VERDICT.md -- this file manifest.json -- machine-readable catalog
Fix verification
fixedVALIDATED: the unpatched baseline hpfs.ko exhibits the OOB read (harness: in_bounds=0xAA @base+0x7F vs oob=0x00 @base+34677, 34677 bytes past the 136-byte alloc). The single-fix hpfs.ko (rebuilt from the patched hpfs_subr.c, sha256 a48cdce...) compiles -Werror clean and its hpfs_cmpfname disassembly shows the bounds check (cmp 0x94(%rdi),%eax; cmovae %ebx,%r9d -- clamp cp to 0 if >= sp_cpinum at offset 0x94=148). The fixed module loads, mounts, and handles lookups correctly (no panic). The OOB does NOT occur on the fixed module because cp is clamped before indexing.
BEFORE (unpatched): harness shows IN-BOUNDS cp=0 -> 0xAA @0xfffff8004f1e8905; OOB cp=255 -> 0x00 @0xfffff8004f1f107d (base+34813, +34677 past alloc). AFTER (fixed hpfs.ko): disassembly '27b5: cmp 0x94(%rdi),%eax / 27cb: cmovae %ebx,%r9d' confirms bounds check; mount rc=0, stat ENOENT, no panic, module stable. Oracle probe on fixed: MATCH (b_upcase[0]=0x42==b_upcase[0x7F]=0x42, cp clamped to 0).
Confirmed kernel references
Detail
Exploit chain
READ-ONLY OOB primitive -- no write, no corruption to convert. VALID hard blocker per Phase 6: hpfs_toupper reads hpm_cpdblk[cp].b_upcase[idx] and returns the byte for a comparison; there is no write path. Impact ceiling: heap info-leak (the OOB byte affects a comparison oracle theoretically usable to infer kernel heap contents byte-by-byte). No unpriv->root escalation derivable. The harness (harness.c) demonstrates the primitive; no chain file needed.
Evidence (decisive lines)
Harness (unpatched baseline #0): IN-BOUNDS cp=0 -> b_upcase[0x7F]=0xAA @0xfffff8004f1e8905; OOB cp=255 -> b_upcase[0x7F]=0x00 @0xfffff8004f1f107d (=base+34813=base+34677 past 136-byte alloc). RESULT: DIFFERENT (proves OOB reads distinct memory). Mount+lookup: mount rc=0, stat /mnt/df0864/x -> ENOENT (OOB silent, slab zeroed). Fixed module disassembly: cmp 0x94(%rdi),%eax; cmovae %ebx,%r9d (bounds check at hpfs_cmpfname entry, offset 0x94=148=offsetof hpm_sp.sp_cpinum).
PoC changes
Created the full evidence pack from scratch (no prior PoC existed). craft_img.c builds a valid mountable HPFS image (superblock, spareblock, bitmap, root fnode with alleaf->dirblk, cpisec, cpdsec) with sp_cpinum=1 and a dirent carrying de_cpid=0xFF and a high-bit name. probe.c is a userspace stat() oracle. harness.c is a kernel module definitively demonstrating the OOB indexing (in-bounds vs OOB at distinct addresses). validate_fix.sh runs a comparison oracle. fix.diff clamps cp in hpfs_cmpfname and hpfs_cpstrnnicmp.
Verified recommended fix
In sys/vfs/hpfs/hpfs_subr.c, add 'if (cp >= hpmp->hpm_sp.sp_cpinum) cp = 0;' at the start of hpfs_cmpfname (line ~178) and the same check for str1cp/str2cp at the start of hpfs_cpstrnnicmp (line ~199). sp_cpinum is the exact bound (used for both the kmalloc size in hpfs_cpinit:276 and the cpinit loop count). Matches the finding proposal's intent (bound the code-page index). The full git-apply-able diff lives in findings/poc/DF-0864/fix.diff.
Verdict
REPRODUCED. The bug is real: hpfs_toupper (sys/vfs/hpfs/hpfs_subr.h:55) indexes hpm_cpdblk[cp] where cp comes from the on-disk dirent de_cpid (u8, hpfs.h:126) via hpfs_genlookupbyname (hpfs_lookup.c:87) -> hpfs_cmpfname (hpfs_subr.c:180) with NO validation against sp_cpinum. With sp_cpinum=1 (136-byte kmalloc at hpfs_subr.c:276) and de_cpid=0xFF, the OOB access reads at +34677 bytes past the allocation. Confirmed by (a) mount+lookup path works (stat triggers VOP_LOOKUP -> hpfs_cmpfname -> hpfs_toupper), and (b) a kernel harness module definitively showing in-bounds read (0xAA marker) vs OOB read (0x00 heap byte) at distinct addresses 34677 bytes apart. The OOB is a READ-ONLY primitive (hpfs_toupper returns a u_char used only in comparison), correctly classified as info-leak not escalation.
No comments yet.