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

Quirk-apply fast path trusts stale pf / absent pf_last state: latent write-after-free and NULL-page write (unreachable with the shipped quirk table)

Summary

The 'if (pf_last == q->pf)' fast path conflates 'same template as last success' with 'pf is that template's live copy': the cfe-allocation error path kfrees pf without invalidating pf_last (later re-use -> STAILQ_INSERT_TAIL writes 8 bytes at +40/+48 into freed memory) and the wipe loop always exits pf==NULL so a NULL-pf table entry makes line 270 compute &NULL->cfe_head = 0x28 (deterministic NULL-page write). Machine-checked: the shipped 8-entry table can never take the fast path (matchmatrix over all 6 card identities; match sets <=2 with distinct templates) - dead code today; latent ceiling is one quirk-table edit repeating a template + one M_NOWAIT failure during card insertion. Verbatim-copy rig with failpoint allocator: FREED-WRITE at +41 (control CLEAN), nulltemplate SIGSEGV at 0x28/0x30, both fixed twins CLEAN. Guards fix validated in-rig.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5585 Β· 13 files
FileTypeDescriptionSize
README.md β€” 2.8 KB ↓ raw
VERDICT.md β€” 5.4 KB ↓ raw
poc_quirkfastpath.c β€” 24.3 KB view raw
build.sh β€” 108 B view raw
run.sh β€” 104 B view raw
build.log β€” 170 B view raw
run.log β€” 3.3 KB view raw
run.2.log β€” 3.3 KB view raw
fidelity.diff β€” 1.4 KB view raw
fix.diff β€” 786 B view raw
env.txt β€” 635 B view raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 5.9 KB view raw

DF-5585 β€” pccard_cis_quirks fast path trusts stale pf/absent pf_last state: write-after-free / NULL-page write landmines (latent; unreachable with the shipped table)

File: sys/bus/pccard/pccard_cis_quirks.c (pass 2, GLM 5.3). Full narrative: VERDICT.md.

Reproduce (deterministic userland rig of the verbatim kernel loop)

./build.sh                          # cc -O2 -Wall -o poc_quirkfastpath poc_quirkfastpath.c
./run.sh                            # ./poc_quirkfastpath all

Expected decisive output (see run.log; exit 1 because findings>0): FREED-WRITE[repeat]: block ... (kmalloc #9) modified: first diff at +41 ... RESULT: DF-5585 repeat: quirk#3 fast path (pf_last==q->pf) STAILQ_INSERT_TAIL'd a cfe into the pf kfree'd at pccard_cis_quirks.c:282 -> write-after-free child died on SIGSEGV writing near address 0x28/0x30 ... RESULT: DF-5585 nulltemplate: NULL pf template + pf_last(NULL)==q->pf fast path -> &pf->cfe_head deref at offset 40 of NULL RESULT: DF-5585 matchmatrix: shipped table fast-path reachable by ANY card identity: NO Built and executed on the DragonFly guest (DragonFly 6.5-DEVELOPMENT x86_64, cc 8.3). The guest has no PCMCIA bridge (see env.txt), so the kernel path itself is not enterable; the rig embeds byte-identical copies of pccard_cis_quirk_match() (:201-216) and pccard_check_cis_quirks() (:218-292) β€” fidelity.diff shows the only differences are the 3 documented shim substitutions β€” plus a failpoint allocator that snapshots blocks at kfree time (kernel kfree does not scrub).

Scenarios: negative (clean install), shippedfail (shipped-table shape + cfe-alloc failure -> CLEAN: today's kernel is safe), repeat (write-after-free), fixed_repeat (fix.diff guard -> CLEAN), nulltemplate (NULL-page write, SIGSEGV child), fixed_nulltemplate (guard -> CLEAN), matchmatrix (machine-checked proof that NO card identity the shipped 8-entry table can match ever takes the fast path).

What it proves: the fast path if (pf_last == q->pf) at :263 dereferences pf under an invariant ("pf is the live copy built for template pf_last") that the cfe-allocation error path at :280-285 breaks by kfree'ing pf without invalidating it, and that a NULL q->pf entry breaks by matching pf_last == NULL == q->pf right after the wipe loop (which always exits with pf == NULL, :248-257). Unreachable with the shipped quirk table (every card's match set is <= 2 entries with distinct templates β€” proven by the matchmatrix); one table edit (a repeated/alternating template, exactly how :179-186 already shares templates across product ids) plus one M_NOWAIT failure during card insertion turns it into kernel heap corruption.

Recommended fix (fix.diff, validated in-rig by the fixed_* scenarios): NULL-guard table entries + reset pf_last on the :281-285 error path.

VERDICT.md
↓ download raw

DF-5585 β€” VERDICT

Finding: pccard_check_cis_quirks() fast path (if (pf_last == q->pf), sys/bus/pccard/pccard_cis_quirks.c:263-270) dereferences pf under an invariant the function's own error paths do not maintain. Two latent facets:

  1. Write-after-free β€” In the else-branch, when the cfe kmalloc(M_NOWAIT) fails (pccard_cis_quirks.c:280-285), the freshly allocated pf is kfreed (line 282) but the local pf keeps pointing at it and pf_last still names the previous successful template. If any LATER matching quirk re-uses that template (pf_last == q->pf true), the fast path runs STAILQ_INSERT_TAIL(&pf->cfe_head, ...) (line 270) into the freed pf β€” an 8-byte pointer write at offsetof(struct pccard_function, cfe_head) (=40 on amd64) plus a second write at +48, both attacker-influenced only in the sense that a quirk-table maintainer chooses the table; the trigger conditions are (a) one failed allocation during card insertion and (b) a table containing a repeated/alternating template among the entries one card can match.

  2. NULL-page write β€” The wipe loop (pccard_cis_quirks.c:248-257) always exits with pf == NULL (pf is the loop variable; its exit condition is pf != NULL failing). If a table entry has q->pf == NULL and it is the first match, pf_last == NULL == q->pf takes the fast path and line 270 computes &((struct pccard_function *)NULL)->cfe_head = address 0x28, writing through stqh_last at 0x30 β€” a deterministic kernel panic (NULL page is unmapped in the kernel), and a NULL-deref write landmine.

Reachability today (the honest gate): NOT reachable. The quirk match (pccard_cis_quirks.c:201-216) requires (manufacturer, product) equality (three 3COM pairs, each contributing a func0/func1 entry pair) or INVALID/INVALID plus cis1_info[0]/[1] string equality (SVEC vs NDC strings differ β€” pccarddevs:327,332). Therefore every card identity's match set is at most 2 entries whose templates always differ, consecutive matches never share a template, the fast path is DEAD CODE for the shipped table, and a cfe-alloc failure can only strike the last matching entry. This is machine-checked by the rig's matchmatrix scenario over all 6 card identities. Additionally the whole path requires a physical PCMCIA card insertion (pccard.c:198, after pccard_read_cis), and this QEMU guest has no PCMCIA bridge, so the kernel path was not enterable for testing. Reported Info / hardening (latent landmine): one future quirk-table edit β€” e.g. adding a second cfe or another product id re-using an existing template, the exact pattern lines 179-186 already use to share pccard_3cxem556_func0 across two product ids β€” converts facet 1 into kernel heap corruption reachable by card insertion under memory pressure.

Rig proof (what was actually run): poc_quirkfastpath.c embeds byte-identical copies of pccard_cis_quirk_match() and pccard_check_cis_quirks() (fidelity.diff: only the 3 documented shim substitutions — device_t dev→sc parameter, dropped PCCARD_SOFTC line, qlog in the dead bootverbose block). The rig's allocator snapshots every block at kfree time (kernel kfree does not scrub memory) and audits freed blocks for post-free modification.

  • shippedfail (shipped-table shape, cfe alloc of quirk#2 fails at the failpoint): CLEAN β€” negative control proving the shipped kernel is safe under this failure.
  • repeat (3-entry table re-using the func0 template; same failpoint): FREED-WRITE detected β€” block from kmalloc call #9 (the pf freed at :282) modified at +41 (the stored cfe pointer lands at cfe_head.stqh_first, offset 40..47), i.e. STAILQ_INSERT_TAIL wrote into freed memory. Exit 1.
  • nulltemplate (pf==NULL entry after a non-empty wipe): child process died on SIGSEGV writing near 0x28/0x30 β€” the userland image of the kernel NULL-page write.
  • fixed_repeat / fixed_nulltemplate (verbatim copy + fix.diff's two guards): CLEAN β€” both facets eliminated; the fixed run installs two function copies instead of corrupting freed memory.
  • Deterministic: run.log and run.2.log identical.

Impact ceiling: none in the shipped kernel (dead code path); latent heap write-after-free / NULL-write one table-entry away. Threat actor for the live variant is whoever can insert a card whose CIS matches a quirk β€” physical access, same threat model as the DF-5355..5364 family.

Root cause lines: sys/bus/pccard/pccard_cis_quirks.c:263 (fast-path predicate trusts pf_last==q->pf β‡’ pf live), :270 (insert through pf), :280-285 (kfree of pf without invalidating the tracking state), :248-257 (wipe leaves pf == NULL while pf_last == NULL too).

Fix: fix.diff β€” (1) skip malformed entries (q->pf == NULL || q->cfe == NULL), (2) reset pf_last = NULL on the :281-285 error path so the invariant "pf_last names the template of the live pf" always holds. Validated in-rig (fixed_* scenarios). Kernel-rebuild fix validation was not performed: the bug is unreachable in the shipped kernel, so there is no baseline kernel behavior to reproduce/eliminate (fix_status: not_testable).

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

not_testable
baseline no→ patch + rebuild →patched clean

Kernel-rebuild fix validation not applicable: the bug is unreachable in the shipped kernel (fast path is dead code for the shipped table -- matchmatrix), so no baseline kernel behavior exists to reproduce and eliminate. The fix was validated at rig level instead: fixed_repeat and fixed_nulltemplate (verbatim loop + fix.diff's two guards) run the same failing scenarios with zero freed-memory writes and no fault (run.log).

findings/poc/DF-5585/run.log (fixed_repeat / fixed_nulltemplate scenarios: 'freed-memory audit ... CLEAN'); findings/poc/DF-5585/fix.diff
↓ fix.diffper-fix-DF-5585

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-5585/run.log: 'FREED-WRITE[repeat]: block ... (kmalloc #9) modified: first diff at +41' (write into the pf kfree'd at pccard_cis_quirks.c:282); 'child died on SIGSEGV writing near address 0x28/0x30' (NULL-template facet); 'RESULT: DF-5585 matchmatrix: shipped table fast-path reachable by ANY card identity: NO'; fixed_* scenarios 'CLEAN'; fidelity.diff (rig function bodies identical to sys/ apart from 3 documented shims); VERDICT.md for the full narrative

PoC changes

Authored from scratch (no seed): verbatim copies of pccard_cis_quirk_match() and pccard_check_cis_quirks() with 3 documented shim substitutions (device_t->sc parameter, dropped PCCARD_SOFTC line, qlog in the dead bootverbose block); failpoint allocator with freed-block snapshot audit (kernel kfree does not scrub); fixed variant carries only the two fix.diff guard lines. One mid-run correction: initial nulltemplate framing claimed the wipe leaves pf dangling -- wrong, the wipe loop variable exits NULL, making the facet a NULL-page write (rig child SIGSEGV at 0x28/0x30), which the final version reports gracefully via fork.

Verified recommended fix

fix.diff: skip malformed quirk entries (q->pf/q->cfe NULL) and reset pf_last=NULL on the :281-285 cfe-allocation error path so the fast-path invariant always holds

Verdict

Latent memory-corruption landmines in the quirk-apply loop, machine-proven in a verbatim rig but NOT reachable in the shipped kernel (rig status honestly 'untested' at the kernel level: the guest has no PCMCIA bridge AND the path is dead code today). Facet 1 (write-after-free): the fast path 'if (pf_last == q->pf)' at pccard_cis_quirks.c:263-270 dereferences pf assuming it is the live copy of template pf_last, but the cfe-allocation error path at :280-285 kfrees the just-allocated pf (line 282) without invalidating that tracking state; any later matching quirk re-using the pf_last template then runs STAILQ_INSERT_TAIL(&pf->cfe_head,...) into freed memory -- 8-byte pointer writes at offsetof(pccard_function, cfe_head)=40 and +48. Rig 'repeat' scenario detects exactly this (FREED-WRITE, first diff +41, 5 bytes) while the shipped-table-shape control 'shippedfail' stays CLEAN. Facet 2 (NULL-page write): the wipe loop :248-257 always exits with pf==NULL (loop variable), so a table entry with q->pf==NULL matches pf_last==NULL==q->pf and line 270 computes &((struct pccard_function*)NULL)->cfe_head = 0x28, writing through stqh_last at 0x30 -- rig 'nulltemplate' child dies on SIGSEGV at exactly that address; kernel equivalent is a deterministic NULL-page write panic. UNREACHABILITY PROOF (why impact=none): the match semantics at :201-216 constrain every card identity to a match set of <=2 entries (three 3COM (manufacturer,product) pairs, each a func0/func1 pair with distinct templates; SVEC vs NDC cis1_info string sets are disjoint -- pccarddevs:327,332), so consecutive matches never share a template, the fast path is dead code for the shipped table, and a cfe-alloc failure can only strike the final matching entry -- machine-checked by the rig 'matchmatrix' over all 6 card identities. One quirk-table edit (a repeated/alternating template, the exact sharing pattern :179-186 already uses across two product ids) plus one M_NOWAIT failure during card insertion converts facet 1 into kernel heap corruption; physical-card threat model shared with the DF-5355..5364 family. Fix validated in-rig (fixed_repeat/fixed_nulltemplate CLEAN): NULL-guard table entries + reset pf_last on the :281-285 error path (fix.diff). Kernel-rebuild fix validation not performed: no baseline behavior exists to eliminate (bug unreachable in shipped kernel).