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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4607 Β· 5 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

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_testable
baseline no→ patch + rebuild →patched clean

fix.diff authored; baseline not stageable on this guest hardware, so no patched-kernel comparison is possible.

findings/poc/DF-4607/fix.diff
↓ fix.diffper-fix-DF-4607

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.