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

ext2_readdir never corrects the caller's cookie count β€” missing '*' deref leaves stale uninitialized kernel heap in the over-reported tail (VOP contract violation, latent NFS-facing heap disclosure)

Field Value
ID DF-3031
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N
CWE CWE-628 (latent CWE-908)
File sys/vfs/ext2fs/ext2_vnops.c
Lines sink ext2_lookup.c:262 (dispatch :2142)
Area vfs/ext2fs
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket kernleak
Reported pending
Known CVE none
CVE match novel

Summary

ext2_readdir allocates a worst-case cookie array with malloc (no M_ZERO), reports the ALLOCATED count to the caller via ap->a_ncookies = ncookies, fills only allocated-minus-remaining slots, and then subtracts the remainder from the POINTER instead of through it: 'ap->a_ncookies -= ncookies;' (:262) β€” leaving the caller's ncookies at the full allocated value. Every sibling FS dereferences (msdosfs, ufs, hammer, hammer2, tmpfs). The in-tree consumers (nfsrv_readdir/nfsrv3_readdir) are coincidentally dirent-bounded, so no live OOB today β€” but any consumer trusting the count reads uninitialized kernel heap straight into NFS READDIR replies, and the 'ncookies == 0' stale-cookie retry guard is unreachable for ext2. Filed Low/certain: defect objectively wrong, impact latent. Fix: one character ('ap->a_ncookies -= ncookies;') + optionally M_ZERO.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of ext2_vnops.c (GLM 5.3). DF-0783 re-confirmed still present (fresh runtime GPF on stock kernel appended to its pack); DF-0784 unchanged.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3031 Β· 5 files
FileTypeDescriptionSize
README.md β€” 4.0 KB ↓ raw
VERDICT.md β€” 2.9 KB ↓ raw
fix.diff β€” 388 B view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 1.6 KB view raw

DF-3031 β€” ext2_readdir never updates the caller's *a_ncookies (missing * deref)

Severity: Low (latent uninitialized-kernel-heap disclosure / VOP contract violation) Class: CWE-628 (incorrect API contract) + latent CWE-908 (uninitialized memory exposure) File: sys/vfs/ext2fs/ext2_lookup.c:262 (function dispatched from sys/vfs/ext2fs/ext2_vnops.c:2142 β€” .vop_readdir = ext2_readdir) Confidence: certain (the defect is objectively wrong vs. its own intent and every sibling FS; impact ceiling is honestly latent β€” see below)

Summary

ext2_readdir allocates a cookie array sized for the worst case, reports the allocated count to the caller, fills only part of it, and then tries to subtract the unfilled remainder β€” but forgets the *, so the caller's count is never corrected:

/* ext2_lookup.c:171-175 β€” allocation is NOT zeroed */
ncookies = ncookies / (offsetof(struct ext2fs_direct_2, e2d_namlen) + 4) + 1;
cookies = malloc(ncookies * sizeof(*cookies), M_TEMP, M_WAITOK);
*ap->a_ncookies = ncookies;          /* caller sees the ALLOCATED count */
*ap->a_cookies = cookies;
...
/* ext2_lookup.c:241-246 β€” one cookie filled per dirent actually written */
...
/* ext2_lookup.c:260-262 */
if (ap->a_ncookies != NULL) {
        if (error == 0) {
                ap->a_ncookies -= ncookies;   /* BUG: pointer arithmetic on the
                                                  arg pointer; should be
                                                  *ap->a_ncookies -= ncookies */

Every other DragonFly filesystem dereferences here: sys/vfs/msdosfs/msdosfs_vnops.c:1738 (*ap->a_ncookies -= ncookies), sys/vfs/ufs/ufs_vnops.c:1634 (*ap->a_ncookies = cookie_index), hammer/hammer2/tmpfs all set the used count. The typo corrupts only the local copy of the int * (harmless in itself) while leaving the caller's ncookies at the full allocated value (e.g. uio_resid/10 + 1), of which only allocated - remaining slots were initialized.

Impact

  • The cookies array is malloc'd without M_ZERO (ext2_lookup.c:173), so the unfilled tail is stale kernel heap. Any consumer that iterates cookies[0 .. ncookies) (as the contract entitles it to) reads uninitialized kernel memory β€” for the only in-tree cookie consumer (the NFS server, sys/vfs/nfs/nfs_serv.c:3045 and :3337) that would turn into a kernel-heap disclosure over the network in READDIR replies.
  • Today's in-tree consumers are coincidentally safe: both nfsrv loops pair each cookie with a dirent and are bounded by cpos < cend where cend tracks the bytes actually written (nfs_serv.c:3066-3067, 3101-3102), so they never touch the uninitialized tail. The over-report additionally makes the stale-cookie recovery check ncookies == 0 β†’ retry (nfs_serv.c:3120, 3407) unreachable for ext2, degrading NFS READDIR restart correctness on ext2.
  • Net: contract violation + latent heap-info-leak/OOB read primitive for any current or future consumer that trusts the count. Filed Low because no live disclosure exists in-tree today.

Fix

One character: dereference the pointer.

--- a/sys/vfs/ext2fs/ext2_lookup.c
+++ b/sys/vfs/ext2fs/ext2_lookup.c
@@ -259,7 +259,7 @@
    if (error == EJUSTRETURN)
        error = 0;
    if (ap->a_ncookies != NULL) {
        if (error == 0) {
-           ap->a_ncookies -= ncookies;
+           *ap->a_ncookies -= ncookies;
        } else {
            free(*ap->a_cookies, M_TEMP);

(Applying M_ZERO to the malloc at :173 is a reasonable belt-and-suspenders addition, but the deref fix restores the contract.)

Verification status

Verification skipped by policy (Low severity / latent impact β€” no Critical/High, memcorrupt, privesc, or trivially-runnable local trigger). A runtime demonstration would require exporting an ext2 filesystem over loopback NFS in the single-tenant guest and instrumenting the server; even then the in-tree consumer's dirent-bounded loop would show no visible misbehavior β€” the defect is a code-level contract violation proven by inspection above. See VERDICT.md for the full static proof.

VERDICT.md
↓ download raw

DF-3031 β€” VERDICT

Status: untested (verification skipped by policy β€” Low severity, latent impact) Defect: certain. Impact: latent (no live in-tree OOB/leak β€” proven below).

The defect is real and certain

  1. sys/vfs/ext2fs/ext2_lookup.c:173-174: cookies = malloc(ncookies * sizeof(*cookies), M_TEMP, M_WAITOK); (no M_ZERO) and *ap->a_ncookies = ncookies; β€” the caller is handed the allocated count.
  2. sys/vfs/ext2fs/ext2_lookup.c:241-247: exactly one cookie slot is filled per vop_write_dirent() success; skipped entries (e2d_ino == 0 or offset < startoffset, line 227-228) fill neither dirent nor cookie. The used count is therefore allocated - ncookies_remaining, strictly smaller than the allocation whenever any entry is skipped or the uio fills before the worst case.
  3. sys/vfs/ext2fs/ext2_lookup.c:262: ap->a_ncookies -= ncookies; performs pointer arithmetic on the local copy of int *a_ncookies (advancing it ncookies * sizeof(int) bytes into nfsrv's frame β€” unused afterwards). The caller's ncookies keeps the allocated value. Every sibling implementation writes through the pointer: sys/vfs/msdosfs/msdosfs_vnops.c:1738, sys/vfs/ufs/ufs_vnops.c:1634, sys/vfs/hammer/hammer_vnops.c:1769, sys/vfs/hammer2/hammer2_vnops.c:767, sys/vfs/tmpfs/tmpfs_vnops.c (cookie_index). The vop contract (documented at sys/vfs/nfs/nfs_serv.c:2890-2902) is "VOP_READDIR() returns the number of valid cookies".

Why the impact is only latent today

The only in-tree consumers are nfsrv_readdir (nfs_serv.c:3045) and nfsrv3_readdir (nfs_serv.c:3337). Both walk cookies strictly in lockstep with dirents inside while (cpos < cend && ncookies > 0) loops (nfs_serv.c:3112-3119/3141-3204 and 3399-3405/3445-3549) where cend = rbuf + siz and siz was reduced by io.uio_resid (nfs_serv.c:3066-3067) β€” i.e. bounded by the bytes ext2_readdir actually wrote. Cookie reads therefore never run past the filled region with the current server code. Consequences that do exist today: the stale-cookie retry guard if (cpos >= cend || ncookies == 0) (nfs_serv.c:3120, 3407) can never take its ncookies == 0 branch for ext2, and any future/refactored consumer that trusts the reported count reads uninitialized kernel heap (malloc, no M_ZERO) straight into NFS reply buffers β€” 8 stale bytes per over-counted cookie.

Why we did not build a guest PoC

  • No Critical/High, memcorrupt, privesc bucket; not a trivially-runnable local trigger (requires an NFS server fronting an ext2 mount).
  • Even with loopback NFS in the guest, the in-tree consumer is dirent-bounded, so no externally observable misbehavior exists to observe; the demonstration would have to be a code-reading proof identical to the one above.

*ap->a_ncookies -= ncookies; (one character). Optionally add M_ZERO at the malloc. See fix.diff.

Fix verification

not_testable
↓ fix.diffper-fix-DF-3031

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3031/VERDICT.md β€” static proof with sibling-FS comparison (msdosfs_vnops.c:1738 dereferences)', 'findings/poc/DF-3031/fix.diff β€” one-character fix']

PoC changes

n/a β€” no PoC seed; static-proof pack only

Verified recommended fix

*ap->a_ncookies -= ncookies; (dereference the pointer; optionally M_ZERO the cookie malloc)

Verdict

Defect certain, impact latent: ext2_readdir reports the allocated cookie count and never corrects it because line 262 does pointer arithmetic (ap->a_ncookies -= ncookies) instead of writing through the pointer. The cookie array is malloc'd without M_ZERO, so the unfilled tail is uninitialized kernel heap; today's only consumers (nfs_serv.c:3045/3337) are coincidentally dirent-bounded, so no live disclosure exists in-tree β€” filed Low as a VOP-contract violation with latent heap-leak/OOB-read for any consumer that trusts the count. Verification skipped by policy (Low/latent; no runnable local trigger).