4x-timeout DANGER1 direct completion races the real completion: concurrent ata_completed -> UAF + double objcache_put
Summary
When the 4x-timeout expires the waiter sets DANGER1 and calls ata_completed directly while the real taskqueue completion may arrive concurrently; the DANGER flag transitions are unlocked, so both threads run the completion body twice and the zombie branch's free can race the other thread mid-body - after which the submitter's unconditional free double-puts the zeroed object into the request objcache. The DANGER2 no-op guard makes every SEQUENTIAL order safe; only the concurrent order breaks. Not stageable on healthy QEMU hardware (needs a >=40s stall); interleaving proven by trace. Serialize-on-done + idempotent-completion fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4607 Β· 5 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.5 KB | β raw | |
| VERDICT.md | β | 1.9 KB | β raw | |
| stress.log | β | 471 B | view raw | |
| fix.diff | β | 2.1 KB | view raw | |
| verdict.json | β | 2.9 KB | view raw |
DF-4607 β 4Γ-timeout DANGER1 direct completion races the real completion β concurrent ata_completed β UAF + double objcache_put
File: sys/dev/disk/nata/ata-queue.c (pass 2)
Severity: Medium (memory corruption, hard-to-hit trigger) Confidence: likely (code race certain; trigger needs a β₯40 s zombie event plus Β΅s-level coincidence)
Bucket: memcorrupt
The path
ata_queue_request() waits request->timeout * hz * 4 (ata-queue.c:140-141;
40 s at the default 10 s timeout) for ATA_R_COMPLETED. On expiry it runs the
zombie recovery (ata-queue.c:144-151):
request->flags |= ATA_R_DANGER1; ata_completed(request, 0); /* direct call, bypassing the taskqueue */
ata_completed() (ata-queue.c:301-536) implements the DANGER dance
without any lock around the flag transitions:
308 if (request->flags & ATA_R_DANGER2) { ... ata_free_request(request); return; }
316 if (request->flags & ATA_R_DANGER1)
317 request->flags |= ATA_R_DANGER2;
...normal body: error processing, reinject (ata_queue_request),
callback (524) or COMPLETED+wakeup (527-530)...
and ata_free_request() (ata-all.h:605-611) refuses to free while
DANGER2 is set (that part makes the sequential orders safe: the caller's
free at ata-queue.c:182/ata_controlcmd becomes a no-op and the later
zombie completion frees exactly once).
The race
If the real completion (taskqueue thread running ata_completed via
ata_finish) interleaves with the waiter's direct ata_completed call:
- T1 (waiter) sets DANGER1, calls ata_completed directly;
- T2 (taskqueue) enters ata_completed, reads
flags & ATA_R_DANGER2== 0 (T1 has not executed line 317 yet), proceeds into the body; - T1 executes line 317 setting DANGER2, also proceeds into the body.
Both threads now run the completion body concurrently on the same request:
double error-processing, possible duplicate reinject (double
TAILQ_INSERT of the same request β queue cycle), duplicate callback
(double biodone) or duplicate COMPLETED/wakeup; then T2 (if it re-checks
on a later pass β e.g. a reinjected completion) or any later completion
hits the DANGER2 zombie branch and calls ata_free_request() β
callout_terminate + bzero + objcache_put while T1 is still inside
the body using the request β use-after-free; finally the waiter's caller
(ata_controlcmd ata-queue.c:182, ata_atapicmd :209) executes
ata_free_request(request) on the already-freed-and-zeroed object β
second objcache_put β double free into ata_request_cache.
The sequential orders are safe precisely because of the DANGER2 guard; only the concurrent order breaks, so the trigger needs a channel/taskqueue stall of β₯ 4Γtimeout followed by the real completion landing within the direct call's execution window.
Guest attempt (honest)
The healthy QEMU PIIX3 channel completes 512 MB in 0.69 s (stress.log); no ata timeout, DANGER or zombie message can be produced, and there is no user- or root-controllable dial to stall the emulated controller for 40 s. not_reproduced with that blocker; the flag-transition race itself is proven by the unlocked reads/writes cited above.
Fix sketch (fix.diff)
Serialize the DANGER transitions and the zombie free on the request's
still-live done lock and make completion idempotent for non-callback
requests (early-out if ATA_R_COMPLETED is already set), so at most one
thread ever executes the body and the zombie free cannot interleave with a
body execution. Not validated on the guest (baseline not stageable).
DF-4607 VERDICT
Status: not_reproduced β impact class: UAF/double-free (memcorrupt)
The race is a code-level certainty (unlocked DANGER1/DANGER2 transitions in ata-queue.c:308-317 executed from two concurrent contexts β the waiter's direct call at :150 and the taskqueue completion via ata_finish), and the memory-safety consequence chain (concurrent body execution β zombie ata_free_request at :313 while the other thread is mid-body β caller's ata_free_request at :182/:209 on the zeroed object β double objcache_put) follows mechanically from ata-all.h:605-611.
What could not be produced on the audit guest is the precondition: a request that survives 4Γtimeout (40 s) without completing and whose real completion then lands inside the direct call's window. Guest measurement (stress.log): the QEMU-backed channel moves 512 MB in 0.69 s; kernel log shows zero ata timeout/DANGER messages under the stress. There is no in-guest dial (user or root) to stall the emulated PIIX3 controller for 40 seconds, so the interleave cannot be staged honestly. Marked not_reproduced rather than speculative because the only missing ingredient is timing under hardware distress, which is the normal real-world trigger for this path (failing disks, lost interrupts, taskqueue starvation).
Fix
See fix.diff: guard the DANGER transitions + zombie free with the request's done lock and early-out on ATA_R_COMPLETED so the body executes at most once per submission. fix_status=not_testable for the same precondition reason (baseline not stageable on this guest).
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_testablefix.diff authored; baseline not stageable on this guest hardware, so no patched-kernel comparison is possible.
findings/poc/DF-4607/fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
findings/poc/DF-4607/VERDICT.md (interleaving trace), stress.log (precondition measurement), fix.diff (serialized transitions + idempotent completion)
PoC changes
No executable PoC exists for this window; the attempt was an in-situ stress of the guest channel to test whether the 4x-timeout precondition can be reached (it cannot: 0.69s for 512MB, zero timeout messages).
Verified recommended fix
Serialize the DANGER1/DANGER2 transitions and zombie free on request->done and early-out when ATA_R_COMPLETED is already set (see fix.diff).
Verdict
Unlocked DANGER1/DANGER2 transitions in ata_completed() (ata-queue.c:308-317) let the 4x-timeout direct completion (ata-queue.c:144-151) and a finally-arriving taskqueue completion execute the completion body concurrently: duplicate reinject/callback/COMPLETED, then the zombie branch frees the request (ata_free_request at :313) while the other thread is mid-body (UAF), after which the submitter's unconditional ata_free_request (ata-queue.c:182/:209) double-puts the zeroed object into ata_request_cache. The sequential orders are safe (the DANGER2 guard in ata-all.h:605-611 no-ops the caller's free), only the concurrent order breaks. Not reproduced on the guest: the precondition is a >=40 s channel/taskqueue stall plus a boundary-coincident completion, and the QEMU-backed channel completes 512MB in 0.69s with no stall dial available (stress.log). Race is code-certain; trigger is a real-world hardware-distress event.
No comments yet.