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

TBRIDGE_GETRESULT without a prior TBRIDGE_LOADTEST leaves tbridge_testcase NULL and panics the kernel in tbridge_test_done's KKASSERT on any INVARIANTS build (stock X86_64_GENERIC ships INVARIANTS; all dfregress kernels build tbridge in)

Summary

Missing LOADTEST-state precondition on the GETRESULT ioctl: the mandatory tbridge_test_done() KKASSERTs tbridge_testcase != NULL - deterministic single-ioctl panic on INVARIANTS kernels (GENERIC ships them; dfregress builds tbridge in). Distinct from DF-2048 (curtest NULL one line earlier). Root-only 0600 debug driver. REPRODUCED on custom INVARIANTS kernel (panic.txt, guest wedged in DDB); kmod variant degrades to ENOTSUP; EINVAL-guard fix validated (patched kernel survives).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5019 Β· 15 files
FileTypeDescriptionSize
README.md β€” 2.6 KB ↓ raw
VERDICT.md β€” 3.5 KB ↓ raw
poc.c β€” 1.2 KB view raw
mytcase.c β€” 961 B view raw
build.sh β€” 260 B view raw
run.sh β€” 118 B view raw
build.log β€” 5.5 MB ↓ download
build.fix.log β€” 5.5 MB ↓ download
run.log β€” 1.5 KB view raw
run.kmod.log β€” 84 B view raw
panic.txt β€” 427 B view raw
env.txt β€” 491 B view raw
fix.diff β€” 372 B view raw
verdict.json β€” 3.5 KB view raw
manifest.json β€” 1.2 KB view raw

DF-5019 β€” tbridge GETRESULT without LOADTEST: NULL tbridge_testcase β†’ deterministic KKASSERT kernel panic (INVARIANTS builds)

Files

  • sys/dev/misc/tbridge/tbridge.c β€” the defect
  • mytcase.c β€” minimal out-of-tree testcase kmod (registers tbridge_curtest)
  • poc.c β€” userland trigger (one ioctl, run as root)
  • build.sh β€” exact guest build commands
  • run.sh β€” exact guest run commands

Build & run (on the DragonFly guest, as root)

See build.sh / run.sh. Two build modes matter:

  1. tbridge as a kldload'able kmod (/usr/src/sys/dev/misc/tbridge/Makefile): builds fine, /dev/tbridge appears (0600 root:wheel). On the stock GENERIC this is the only way to get the driver β€” GENERIC does not contain device tbridge (it is optional tbridge in sys/conf/files:2122, only LINT64 has it). Result: no panic β€” out-of-tree kmods compile without INVARIANTS (no opt_global.h), so the KKASSERT in tbridge_test_done is a no-op and the whole NULL walk degrades to copyout returning ENOTSUP (errno 45). See run.kmod.log.
  2. tbridge compiled into the kernel (device tbridge + options INVARIANTS, i.e. the dfregress regression-test configuration; stock X86_64_GENERIC also ships options INVARIANTS at sys/config/X86_64_GENERIC:56): deterministic kernel panic on the first TBRIDGE_GETRESULT ioctl. See run.log / panic.txt.

Expected output (kernel build)

GETRESULT returned -1, errno=45 (kernel survived: finding NOT reproduced)   <- kmod build
Fatal trap 9: ... assertion "tbridge_testcase != NULL" failed in
tbridge_test_done at /usr/src/sys/dev/misc/tbridge/tbridge.c:196           <- in-kernel build (panic)

Root cause

tbridge_dev_ioctl() (tbridge.c:240) handles TBRIDGE_GETRESULT (tbridge.c:265) without checking that a TBRIDGE_LOADTEST ever ran. With a testcase registered (tbridge_curtest != NULL, requires a testcase kmod) but no LOADTEST, tbridge_testcase (the proplib dict) is still NULL:

  • tbridge.c:266 kthread_create(tbridge_curtest->tb_run, ...) β€” runs the testcase, which calls tbridge_test_done() (any real testcase must),
  • tbridge.c:196 KKASSERT(tbridge_testcase != NULL) β€” panics on any INVARIANTS kernel,
  • without INVARIANTS the NULL dict then flows through testcase_get_timeout(NULL) (tbridge.c:274), testcase_set_result(NULL,..) (tbridge.c:201/289) and prop_dictionary_copyout_ioctl(NULL) β€” all NULL-tolerant in libprop β€” ending in ENOTSUP.

Distinct from known DF-2048 (tbridge_curtest NULL because no testcase module is loaded β€” one line earlier, same ioctl).

VERDICT.md
↓ download raw

DF-5019 β€” VERDICT

Status: reproduced (panic), fixed (validated). Impact: local DoS β€” deterministic kernel panic; root-gated. Confidence: certain.

What was claimed

TBRIDGE_GETRESULT (tbridge.c:265-297) performs no check that a TBRIDGE_LOADTEST preceded it. With a testcase module registered (tbridge_curtest != NULL) but no LOADTEST issued, tbridge_testcase (the proplib dict) is still NULL, and the testcase kthread's mandatory tbridge_test_done() hits KKASSERT(tbridge_testcase != NULL) (tbridge.c:196) β€” a deterministic kernel panic on any INVARIANTS kernel. Without INVARIANTS the NULL dict walks through proplib NULL-tolerantly and ends at prop_dictionary_copyout_ioctl(NULL) β†’ ENOTSUP.

Distinct from known DF-2048 (tbridge_curtest NULL because no testcase module is loaded β€” dereferenced one line earlier at tbridge.c:266).

How it was proven (DragonFly 6.5-DEVELOPMENT guest, KVM)

  1. Stock kernel + kldload'able tbridge.ko: module built from in-tree sys/dev/misc/tbridge/Makefile, loaded, testcase kmod mytcase.ko (registers tb_run β†’ tbridge_test_done(RESULT_PASS)) loaded, PoC ran ioctl(TBRIDGE_GETRESULT) with no LOADTEST β†’ no panic (run.kmod.log): out-of-tree kmods compile without INVARIANTS (no -include opt_global.h), so the KKASSERT is compiled out; the ioctl returned errno 45 (ENOTSUP) from the NULL-dict copyout. This bounds the exposure of the kmod path to a benign error.
  2. tbridge compiled into the kernel (device tbridge appended to a copy of X86_64_GENERIC β†’ KERNCONF DF5019, make nativekernel, INVARIANTS active β€” stock X86_64_GENERIC ships options INVARIANTS, sys/config/X86_64_GENERIC:56): same PoC β†’ panic: assertion "tbridge_testcase != NULL" failed in tbridge_test_done at /usr/src/sys/dev/misc/tbridge/tbridge.c:196 (panic.txt, run.log), guest wedged in DDB, ssh dead. Deterministic, single-shot, no race.

Fix validation

fix.diff (guard tbridge_testcase == NULL β†’ EINVAL at the top of TBRIDGE_GETRESULT) applied inside the guest's /usr/src copy, kernel rebuilt (build.fix.log), installed, rebooted, identical PoC rerun: GETRESULT returned -1, errno=22 (EINVAL) β€” no panic, guest stayed up.

Exploit chain / uid=0?

None. The device node is 0600 root:wheel (verified: crw------- root:wheel) and the driver is optional tbridge (sys/conf/files:2122) β€” absent from X86_64_GENERIC (only LINT64 lists it), so reaching it at all requires the admin to build it in or kldload it, and then only root can open the node. Impact ceiling: root-triggerable kernel panic of a debug/test driver (realistically hit by dfregress runs: any GETRESULT after a failed/absent LOADTEST β€” e.g. LOADTEST failed because no testcase was registered yet β€” panics the regression kernel). The DF-2049 concurrency family similarly stays root-gated.

Notes

  • sys/sys/tbridge.h is not self-contained: a testcase module that includes only it fails to compile (SYSINIT lives in <sys/kernel.h>); worked around in mytcase.c. Build-trap only.
  • tbridge_printf underflow (DF-2047), hz * timeout overflow (DF-2050), concurrent-LOADTEST UAF (DF-2049) remain as known.

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, kernel rebuilt (nativekernel) and rebooted; identical PoC returns EINVAL (errno 22) with the kernel alive β€” panic gone. fix_patched_reproduced=false is the desired outcome (bug behavior does NOT reproduce on the patched kernel).

VERDICT.md (Fix validation section), build.fix.log, panic.txt (baseline) vs run.log narrative (patched run: 'GETRESULT returned -1, errno=22')
↓ fix.diffper-fix-DF-5019

Confirmed kernel references

Detail

Exploit chain

kldload/built-in tbridge + testcase kmod registering tb_run -> open /dev/tbridge (root only) -> ioctl(TBRIDGE_GETRESULT) with no LOADTEST -> kthread runs tb_run -> tbridge_test_done -> KKASSERT(tbridge_testcase != NULL) fails -> panic. No uid=0 path: 0600 device, opt-in driver.

Evidence (decisive lines)

panic.txt (exact assertion + DDB), run.log (baseline narrative), run.kmod.log (kmod path -> ENOTSUP), build.log/build.fix.log (kernel builds), env.txt (uname, /dev/tbridge perms, INVARIANTS), VERDICT.md, poc.c, mytcase.c, build.sh, run.sh, fix.diff

PoC changes

Seed had no runnable PoC. Authored: mytcase.c (out-of-tree testcase kmod; needed #include because sys/tbridge.h is not self-contained for SYSINIT), poc.c (raw ioctl trigger), build.sh/run.sh; plus the two-mode reproduction (kmod vs in-kernel) to bound exposure honestly.

Verified recommended fix

Reject TBRIDGE_GETRESULT with EINVAL when tbridge_testcase == NULL (no LOADTEST issued); see fix.diff.

Verdict

Deterministic kernel panic reproduced: ioctl(TBRIDGE_GETRESULT) with no prior TBRIDGE_LOADTEST drives the test kthread into tbridge_test_done() where KKASSERT(tbridge_testcase != NULL) (sys/dev/misc/tbridge/tbridge.c:196) fails on any INVARIANTS kernel β€” stock X86_64_GENERIC ships INVARIANTS and dfregress kernels build tbridge in; verified on a DragonFly 6.5-DEVELOPMENT guest with 'device tbridge' compiled in (panic + DDB wedge, ssh dead). On the stock kernel with tbridge kldload'ed as a module there is no panic: out-of-tree kmods compile without INVARIANTS, the KKASSERT is a no-op and the walk degrades to ENOTSUP (errno 45). Root-gated throughout (/dev/tbridge is 0600 root:wheel and the driver is opt-in), so impact is a root-triggerable panic of a debug driver, not privilege escalation. fix.diff (EINVAL guard at the top of TBRIDGE_GETRESULT) validated on the guest: baseline panics, patched kernel returns EINVAL and survives.