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

Unsynchronized si_drv1/refcount lifecycle in fwmem open/close/ioctl/strategy β€” live-race kernel panic, post-race NULL write, and an 8-byte controlled UAF write over the slab freelist pointer (arbitrary-address allocation / double allocation)

Summary

open check-then-increments, close decrements/kfrees/NULLs, ioctl/strategy consume si_drv1 - all with no lock, devfs serializing none of it. Live: 4 threads racing open/close panicked the guest at fwmem_close+0x13 (NULL refcount deref); the post-race NULL state makes FW_SDEUI64 a supervisor write at 0x0; the ioctl-vs-final-close interleaving is an 8-byte fully attacker-controlled write at offset 0 of the freed 16-byte-zone chunk - the DFly SLAB freelist next pointer - after which kmalloc handed out the attacker-chosen address (zone assert) and duplicated a live chunk (already-allocated assert; silent double allocation on production builds). Operator-group local + FireWire controller present. Spinlock+NULL-guard fix validated: baseline panic vs 769,792 racing pairs with zero damage after; NULL ioctl face returns ENXIO.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5548 Β· 14 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 5.9 KB ↓ raw
df5547.c β€” 24.3 KB view raw
Makefile β€” 1.8 KB ↓ download
shim/ β€” 4.0 KB ↓ download
build.sh β€” 85 B view raw
run.sh β€” 183 B view raw
run_fatal.sh β€” 388 B view raw
build.log β€” 6.9 KB view raw
run.log β€” 4.6 KB view raw
fix_validation.log β€” 1.8 KB view raw
fix.diff β€” 3.3 KB view raw
env.txt β€” 619 B view raw
verdict.json β€” 5.2 KB view raw

DF-5548 β€” unsynchronized si_drv1/refcount lifecycle in fwmem open/close/ioctl/strategy

  • File: sys/bus/firewire/fwmem.c
  • fwmem_open (fwmem.c:266-278): check-then-increment of dev->si_drv1 / fms->refcount with no lock; fwmem_close (fwmem.c:291-298): decrement + kfree + NULL store with no lock; fwmem_ioctl (fwmem.c:407-410) and fwmem_strategy (fwmem.c:342-343) consume si_drv1 with no lock.
  • Consequences (all from unprivileged-but-device-permitted local users racing open/close on /dev/fwmem*, which devfs does not serialize): 1. open loads si_drv1 non-NULL, final close frees + NULLs it, open resumes β†’ NULL->refcount++ (write at NULL+8) β†’ kernel panic; or (compiler-CSE variant) refcount++ into the freed chunk (UAF write), and the new fd is left with si_drv1 == NULL so the next ioctl/read/write panics at fwmem.c:410 / fwmem.c:343 (test 3). 2. fwmem_ioctl(FW_SDEUI64) in flight across the final close β†’ 8 fully attacker-controlled bytes written at offset 0 of the freed 16-byte-zone chunk == the DragonFly slab freelist next pointer β†’ next-next kmalloc returns the attacker-chosen address (arbitrary allocation; test 2 proves the poisoning). 3. two closes racing the refcount-- β†’ double free / kfree(NULL) β†’ _kfree panics ("trying to free NULL pointer", sys/kern/kern_slaballoc.c).

Build (guest, root)

sh build.sh        # same harness as DF-5547 (production firewire.c + fwmem.c)

Run

sh run.sh          # test 2 (replay/poisoning: proof lines, then the
                   #        allocator assert panics on INVARIANTS kernels)
                   # test 4 (live race: Fatal trap 12 at fwmem_close+0x13)
sh run5.sh         # test 5: arbitrary-address poisoning variant (panics:
                   #        chunk_mark_allocated zone-membership assert)
sh run_fatal.sh    # test 3: deterministic NULL-write panic (write at 0x0)

Expected

  • test 2: FREED chunk ... (== ATTACKER VALUE, slab freelist poisoned), kmalloc#1 = ... (== freed chunk), then panic: memory chunk ... is already allocated!; kern.df5547_status β†’ DF-5548 REPRODUCED.
  • test 4: Fatal trap 12 in dfw_fwmem_open/dfw_fwmem_close (the renamed production fwmem_open/fwmem_close) β€” panic on serial console. If the window is not hit in 15 s, the replay results still prove the interleavings' consequences (on the recorded baseline runs it hit within moments).
  • test 3: Fatal trap 12: page fault while in kernel mode at 0x0 (supervisor write), faulting call in the renamed fwmem_ioctl (inlined into the sysctl frame in the recorded build).
  • test 5: panic: assertion "(((intptr_t)chunk ^ (intptr_t)z) & ZoneMask) == 0" failed in chunk_mark_allocated β€” kmalloc followed the poisoned pointer to the attacker-chosen module-global address.
VERDICT.md
↓ download raw

DF-5548 VERDICT β€” unsynchronized si_drv1/refcount lifecycle in fwmem open/close/ioctl/strategy

Reproduced: YES β€” live race kernel panic + controlled UAF write with slab-freelist poisoning (production code)

fwmem_open() (sys/bus/firewire/fwmem.c:266-278) does a check-then-act on dev->si_drv1 / fms->refcount with no lock; fwmem_close() (fwmem.c:291-298) decrements, kfrees and NULLs si_drv1 with no lock; fwmem_ioctl() (fwmem.c:407-410) and fwmem_strategy() (fwmem.c:342-343) consume si_drv1 with no lock. devfs serializes neither: two processes open()/close()ing /dev/fwmem0.0 (0660 root:operator, created by fwdev_clone(), fwdev.c:827-849) race these windows directly. crit_enter() in strategy does not help across CPUs.

Evidence (all production entry points, df5547.ko harness)

  1. Live race β†’ kernel panic (run.log test 4). 4 kernel threads racing production fwmem_open/fwmem_close on one shared cdev β€” the in-kernel equivalent of two processes open()+close()ing the device. Result within moments:

Fatal trap 12: page fault while in kernel mode fault virtual address = 0x8 (supervisor READ) Stopped at dfw_fwmem_close+0x13: movl 0x8(%rbx),%eax

0x8 is offsetof(struct fwmem_softc, refcount) β€” production fwmem.c:292 dereferenced a NULL si_drv1, constructible only through the race (every close in the racer was preceded by a successful open).

  1. Post-race state β†’ NULL write panic (run.log test 3). The state "fd alive, si_drv1 == NULL" (open's fwmem.c:266 check passed, the final close's fwmem.c:295-297 freed + NULLed in between): production fwmem_ioctl(FW_SDEUI64) bcopys 8 bytes to &fms->eui with fms == NULL:

Fatal trap 12 ... fault virtual address = 0x0, supervisor WRITE

  1. Controlled UAF write β†’ slab freelist poisoning (run.log test 2 / test 5). Stepwise replay of the ioctl-vs-final-close interleaving (ioctl had loaded si_drv1 at fwmem.c:407 before the close's kfree at fwmem.c:296):

df5548: after production close: si_drv1=0, freed chunk head (c_Next) = 0xfffff8008d5e15f0 df5548: after production ioctl(FW_SDEUI64): FREED chunk 0xfffff8008d5f05d0 now holds 0xfffff8008d5ef5e0 (== ATTACKER VALUE, slab freelist poisoned) df5548: kmalloc#1 = 0xfffff8008d5f05d0 (== freed chunk) -- zone head now walks to the poisoned pointer panic: memory chunk 0xfffff8008d5ef5e0 is already allocated!

FW_SDEUI64 writes the user-supplied 8-byte EUI64 at offset 0 of the freed 16-byte zone chunk β€” exactly where DragonFly's SLAB allocator keeps the freelist next pointer (sys/kern/kern_slaballoc.c). kmalloc#2 followed the attacker value onto a LIVE chunk ("already allocated" INVARIANTS panic on this kernel; on a production non-INVARIANTS kernel this is a silent double allocation β€” two owners of one kernel object). With an arbitrary kernel address as the value, kmalloc handed out the attacker-chosen address (test 5: chunk_mark_allocated zone-membership assert β€” again, the only thing that stopped a silent arbitrary-address allocation here). The racing variant is the mid-interleaving of fwmem_ioctl vs fwmem_close; the equivalent open-side window (fwmem.c:266β†’269-270) yields the same freed-chunk increment and the NULL+8 write observed live in (1).

  1. Double-close / desync consequences (code-read, consistent with (1)): two closes racing fms->refcount-- can both take 1β†’0 β†’ kfree twice, and the second kfree(dev->si_drv1, ...) with si_drv1 == NULL panics ("trying to free NULL pointer", sys/kern/kern_slaballoc.c:1410).

Exploit chain (demonstrated skeleton)

race open-vs-close (or ioctl-vs-close) β†’ 8-byte fully-controlled write at offset 0 of a freed 16-byte-zone chunk (the SLAB freelist next pointer) β†’ next-next kmalloc(16) returns the attacker-chosen address / duplicates a live chunk β†’ overlap with a live kernel object of the 16-byte zone β†’ object-field corruption. On this INVARIANTS guest the allocator's asserts convert the last step into a reliable kernel panic (deterministic DoS); the arbitrary-address hand-out itself was observed.

Fix validation

fix.diff: a spinlock serializing the whole si_drv1/refcount lifecycle (open's check-increment and alloc-install, close's decrement-free-NULL, ioctl's locked read + ENXIO NULL guard, strategy's locked EUI copy), plus the M_FW→M_FWMEM type fix (DF-5549). Applied to the guest's /usr/src, module rebuilt from the patched production file (fix_validation.log):

case baseline fixed
test 4 live race (4 threads) Fatal trap 12 in fwmem_close+0x13 moments after start 769,792 open+close pairs, guest up, zero damage
test 3 si_drv1==NULL ioctl Fatal trap 12, supervisor WRITE at 0x0 returns ENXIO, guest up

fix_status: fixed. (The stepwise replay test 2 deliberately hand-constructs the mid-race state, bypassing the added serialization; fix validation therefore uses the production-path live race and the NULL-guard path.)

Notes

  • Related-but-distinct from DF-5321 (firewire.c xfer/tlabel completion race): same missing-serialization family, different objects (si_drv1/fwmem_softc/bp vs tlabel/xfer) and different entry points.
  • The kfree(dev->si_drv1, M_FW) wrong-malloc-type free (fwmem.c:296 vs the M_FWMEM alloc at fwmem.c:272) is DF-5549 (accounting-only on DFly's allocator, which derives the zone from the chunk header) and is fixed in this pack's diff.

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 and module rebuilt from patched production sources: live race survived 769,792 open+close pairs across 4 threads with zero damage (baseline: Fatal trap 12 in fwmem_close within moments); the NULL-si_drv1 ioctl face returns ENXIO instead of a supervisor write at 0x0; the stepwise replay is intentionally not fix-sensitive (it hand-constructs the mid-race state), so validation used the production-path race and NULL-guard faces

fix_validation.log (verbatim baseline/fixed outputs for tests 3 and 4); fix.diff (spinlock + NULL guards + M_FWMEM)
↓ fix.diffper-fix-DF-5548

Confirmed kernel references

Detail

Exploit chain

operator-group user, FW controller present: race open() vs close() (or ioctl vs final close) on /dev/fwmem0.0 -> (a) NULL+8 write / NULL bcopy -> kernel panic DoS, or (b) FW_SDEUI64's 8 user-controlled bytes land on the freed fwmem_softc chunk's slab freelist next pointer -> next-next kmalloc(16) returns the attacker-chosen kernel address or duplicates a live 16-byte-zone object -> overlap/corruption of a live kernel object (arbitrary-address allocation observed; on INVARIANTS kernels the allocator asserts panic first)

Evidence (decisive lines)

run.log: 'Stopped at dfw_fwmem_close+0x13: movl 0x8(%rbx),%eax' (live race); 'fault virtual address = 0x0, supervisor write' (test 3); 'FREED chunk ... now holds ... (== ATTACKER VALUE, slab freelist poisoned)' + 'kmalloc#1 = ... (== freed chunk)' + 'panic: memory chunk ... is already allocated!' (test 2) and the chunk_mark_allocated zone assert (test 5); fix_validation.log: 'race finished, 769792 open+close pairs survived' and 'UNREACHED: ioctl returned' (ENXIO) on the fixed build

PoC changes

no seed PoC; same DF-5413-family harness as DF-5547 (production firewire.c+fwmem.c, -D renames incl. fwmem block-RPC exports and M_FWMEM). Fixes vs drafts: racer threads must sleep 1 tick every 256 pairs (spinning LWKT kernel threads starve userland and wedge the guest without proving anything); the same-slab groom needs a 4096-chunk bank on the HEAP (a 4096-entry stack array blew the kernel stack guard) to carve fresh contiguous slab pages; the groom partner index bug (pairc vs pair+1) corrected after the first successful run

Verified recommended fix

serialize the si_drv1/refcount lifecycle with a spinlock across fwmem_open/fwmem_close, take the lock (and return ENXIO on NULL) in fwmem_ioctl/fwmem_strategy; free with M_FWMEM (see fix.diff)

Verdict

Reproduced three ways on production code: (1) 4 threads racing production fwmem_open/fwmem_close on one shared cdev panicked the guest in fwmem_close+0x13 (movl 0x8(%rbx),%eax, fms==NULL); (2) the post-race fd-with-NULL-si_drv1 state turns production fwmem_ioctl(FW_SDEUI64) into a supervisor write at 0x0; (3) the ioctl-vs-final-close interleaving gives an 8-byte controlled UAF write over the freed chunk's slab freelist pointer, followed by kmalloc handing out the poisoned address ('already allocated' assert for a live-chunk target; zone-membership assert for an arbitrary module-global target). Fix (spinlock around the whole lifecycle + NULL guards + right malloc type) validated: 769,792 racing open/close pairs with zero damage vs baseline panic, and the NULL ioctl face returns ENXIO.