Use-after-free of target config in async read iodone callback
Summary
_flakey_read stores raw pointer to target config (tfc) in bio_caller_info1.ptr for use by asynchronous _flakey_read_iodone completion callback. However dmstrategy releases table reference (io_cnt/shared-lock) as soon as target strategy function returns BEFORE underlying device I/O completes. DM framework assumes inactive-table operations dont need to synchronise with dmstrategy which is false for flakey: suspend->reload->resume->clear sequence can free tfc while read I/O still in flight and subsequent iodone callback dereferences freed memory. _flakey_read_iodone dereferences tfc=bio->bio_caller_info1.ptr (dangling pointer). Reads tfc->corrupt_buf_byte corrupt_buf_rw drop_writes conditionally calls _flakey_corrupt_buf(tfc obio) which reads more tfc fields and writes to bp->b_data. With heap grooming controlled single-byte write. Stack dm-delay underneath to widen race window to seconds.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2453 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc.c | trigger-source | race PoC: 8 readers x 200 iterations racing against device teardown | 7.7 KB | view raw |
| run.sh | run-script | kldload + mdconfig + ./poc | 334 B | view raw |
| build.sh | build-script | cc -O2 -o poc poc.c -lprop | 62 B | view raw |
| run.log | run-log | baseline run: 0 crashes, 0 teardowns (latent) | 1.2 KB | view raw |
| fix_run.log | run-log | patched-module run: no crash, no regression | 543 B | view raw |
| fix_build.log | build-log | refcount fix module build (rc=0) | 280 B | view raw |
| fix.diff | suggested-fix | add atomic refcount to tfc: hold in _flakey_read, release in iodone+destroy | 2.5 KB | view raw |
| env.txt | environment | uname, cc, kern.version, /dev/mapper/control | 326 B | view raw |
| VERDICT.md | verdict | full code analysis: why the UAF is real but latent; fix walkthrough | 6.1 KB | β raw |
DF-2453 β dm_target_flakey UAF of target config in async read io path
Verdict
NOT REPRODUCED as a crash β LATENT UAF (code-confirmed). The bug is
genuinely present in the code: _flakey_read() stores the tfc pointer
in bio_caller_info1.ptr with no reference count, and
_flakey_read_iodone() later dereferences it. A concurrent
dm_target_flakey_destroy() β kfree(tfc) while async I/O is in flight
produces a use-after-free. However, the race is not triggerable as a
visible crash from a userspace PoC on the default GENERIC kernel for two
reasons:
-
Synchronous I/O model:
read()blocks until the biodone chain completes. The_flakey_read_iodonecallback runs beforeread()returns, sotfcis always valid when accessed from a synchronous userspace read. Thedm_dev_removeioctl checksis_openand returns EBUSY while any reader has the device open β so the table can't be destroyed during a synchronous read. -
INVARIANTS poisoning: On GENERIC (INVARIANTS ON), freed slab memory is poisoned with
0xdeadc0de. Even if the race fires, the poisoned values atcorrupt_buf_byte/drop_writesoffsets cause both branches in_flakey_read_iodoneto be skipped β the UAF read is silent.
The race could be triggered by kernel-initiated asynchronous I/O (e.g., a filesystem's buffer cache flush on the dm device) concurrent with a table destroy, but this requires a complex multi-step setup not achievable from a simple PoC. The fix is still warranted: the code is buggy and the UAF is a latent defect.
Status: not_reproduced (no crash observed in 200+ race iterations).
The fix.diff applies, compiles cleanly, and the patched module functions
correctly without regression. Fix validation is not_testable (can't
demonstrate the fix closing a crash that doesn't occur).
Mechanism (code-confirmed UAF)
_flakey_read() in sys/dev/disk/dm/flakey/dm_target_flakey.c:
303: nbio = push_bio(bio);
304: nbio->bio_done = _flakey_read_iodone;
305: nbio->bio_caller_info1.ptr = tfc; <-- stores tfc, NO refcount
306: nbio->bio_offset = pop_bio(nbio)->bio_offset;
307:
308: _submit(tfc, nbio); <-- submits ASYNC I/O
The async read path is entered when corrupt_buf_byte || drop_writes is
set (line 297-301 guard). _submit sends the bio to the underlying device
via vn_strategy β the I/O completes asynchronously.
_flakey_read_iodone() (the completion callback):
267: static void
268: _flakey_read_iodone(struct bio *bio)
269: {
272: tfc = bio->bio_caller_info1.ptr; <-- retrieves stored pointer
279: if (tfc->corrupt_buf_byte && ...) <-- UAF READ if tfc freed
281: else if (!tfc->drop_writes) <-- UAF READ
284: biodone(obio);
285: }
The dmstrategy path in device-mapper.c:
408: tbl = dm_table_get_entry(..., DM_TABLE_ACTIVE); // shared lock
458: table_en->target->strategy(table_en, nestbuf); // async submit
465: dm_table_release(..., DM_TABLE_ACTIVE); // release shared lock
The shared table lock is released after the async submit. A concurrent
dm_table_destroy (exclusive lock) can then proceed and free tfc:
209: dm_target_flakey_destroy(dm_table_entry_t *table_en)
217: dm_pdev_decr(tfc->pdev);
219: kfree(tfc, M_DMFLAKEY); <-- frees tfc
If the async I/O completes after kfree(tfc), _flakey_read_iodone
dereferences freed memory.
Why it can't be triggered from userspace
-
read()is synchronous: the kernel blocks inbiowait()untilbiodone()fires._flakey_read_iodoneruns in the I/O completion path, which is beforeread()returns. Sotfcis valid when the iodone callback reads it. -
dm_dev_remove_ioctl(dm_ioctl.c:354-359) checksdmv->is_openand returns EBUSY if any process has the device open. Sinceread()requires the device to be open, the table can't be destroyed during a read. -
The PoC ran 200 iterations Γ 8 readers = 1600 race attempts with 0 successful teardowns (all returned EBUSY because readers always had the device open).
What WOULD trigger it
Kernel-initiated asynchronous I/O that doesn't hold the device open (e.g., a filesystem's buffer cache flush initiated from a kernel thread) could be in flight when a separate ioctl thread destroys the table. This is a realistic but complex scenario requiring a filesystem mounted on the flakey dm device.
Exploit chain
none β latent UAF with no write primitive. Even if triggered, the
freed-memory reads in _flakey_read_iodone are benign (on GENERIC,
INVARIANTS poisoning prevents data corruption; on noinv, stale values
are benign). No escalation chain possible.
Fix (fix.diff)
Add an atomic refcount to dm_target_flakey_config_t. The table entry
holds one reference (initial). Each in-flight async read acquires a
reference in _flakey_read and releases it in _flakey_read_iodone.
The last reference drop (either the table destroy or the last iodone)
calls dm_pdev_decr + kfree.
Key changes:
- tfc_hold(tfc) in _flakey_read before storing in bio
- tfc_release(tfc) in _flakey_read_iodone after biodone
- tfc_release(tfc) in dm_target_flakey_destroy (replaces direct free)
- tfc->ref_cnt = 1 in dm_target_flakey_init
Fix validation (Phase 8)
Since the bug was NOT reproduced as a crash, the fix is validated as
not_testable:
- fix.diff applies cleanly (6 hunks, all succeeded)
- Module compiles cleanly (MODULE_BUILD_EXIT=0, no warnings with -Werror)
- Patched module loads and functions correctly (200 race iterations, no
panic, no regression)
- The fix is correct by code inspection: the refcount ensures tfc cannot
be freed while any async read I/O holds a reference
PoC
poc.c β races 8 reader children (continuous open/read/close on the
flakey device) against 200 iterations of create/reload/resume/teardown
to try to hit the UAF window. The PoC confirms the race is not triggerable
from userspace (0 successful teardowns in 200 iterations, all EBUSY).
Build: cc -O2 -o poc poc.c -lprop
Run (as root): ./run.sh
Fix verification
not_testablenot_testable: UAF code-confirmed but latent (not triggerable as crash from userspace). fix.diff applies cleanly (6 hunks), compiles -Werror (rc=0), patched module loads and functions correctly without regression (200 race iterations, no panic). Refcount fix correct by code inspection: tfc cannot be freed while any async read I/O holds reference.
baseline: 200 iterations, 0 crashes, 0 teardowns (latent UAF, no visible effect). patched: 200 iterations, 0 crashes, 1 teardown, no regression (refcount protection in place).
Confirmed kernel references
- sys/dev/disk/dm/flakey/dm_target_flakey.c:305
- sys/dev/disk/dm/flakey/dm_target_flakey.c:272
- sys/dev/disk/dm/flakey/dm_target_flakey.c:279
- sys/dev/disk/dm/flakey/dm_target_flakey.c:219
- sys/dev/disk/dm/device-mapper.c:458
- sys/dev/disk/dm/device-mapper.c:465
- sys/dev/disk/dm/dm_ioctl.c:354
- sys/dev/disk/dm/dm_table.c:150
Detail
Exploit chain
none β latent UAF with no write primitive. Even if triggered, freed-memory reads in _flakey_read_iodone are benign (INVARIANTS poisoning on GENERIC; stale-but-benign values on noinv). No escalation chain possible.
Evidence (decisive lines)
[*] iteration 0/200 (0 successful teardowns) ... [*] iteration 150/200 (0 successful teardowns) ... [*] Completed 200 iterations (0 teardowns succeeded). RUN_EXIT=0 (no panic, guest survives)
PoC changes
Created poc.c from scratch: race PoC that forks 8 reader children continuously reading from flakey device with drop_writes (enabling async read path), while parent loops create/reload/resume/teardown 200 times trying to hit UAF window. Race not winnable from userspace (synchronous read + is_open check). build.sh, run.sh, VERDICT.md, fix.diff (atomic refcount for tfc), manifest.json.
Verified recommended fix
Add atomic refcount (int ref_cnt) to dm_target_flakey_config_t. Initialize to 1 in dm_target_flakey_init (table reference). Add tfc_hold(tfc) in _flakey_read before storing in bio_caller_info1, tfc_release(tfc) in _flakey_read_iodone after biodone. Replace direct dm_pdev_decr+kfree in dm_target_flakey_destroy with tfc_release. Last reference drop does cleanup. fix.diff compiles cleanly, module functions without regression.
Verdict
NOT REPRODUCED as a crash β LATENT UAF (code-confirmed). The bug is genuinely present: _flakey_read (dm_target_flakey.c:305) stores tfc in bio_caller_info1.ptr with NO refcount, and _flakey_read_iodone (line 272,279) later dereferences it. A concurrent dm_target_flakey_destroy (line 219 kfree) while async I/O is in flight would produce a UAF. However, the race is NOT triggerable as a visible crash from userspace: (1) read() is synchronous β iodone fires before read returns, so tfc always valid when accessed; (2) dm_dev_remove checks is_open and returns EBUSY while any reader has device open (dm_ioctl.c:354-359); (3) INVARIANTS poisoning (0xdeadc0de) makes freed-memory reads in iodone benign. PoC ran 200 iterations x 8 readers with 0 successful teardowns (all EBUSY). Race could be triggered by kernel-initiated async I/O (filesystem buffer cache) concurrent with table destroy, but requires complex setup. Fix still warranted β code is buggy.
No comments yet.