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

npxsave() missing FPU security wipe: previous FP owner's XMM0-15 are handed to the next exec'd image β€” cross-process/cross-exec FP-register info leak inside the vkernel

Summary

The vkernel's npxsave fxsave()s and fninit()s but never clears the SSE register file (pc64's twin wipes XMM via fpurstor(&gd_zerofpu); the vkernel mdglobaldata has no gd_zerofpu at all): go_user's eager FP handoff + npxinit's live-register seeding mean the previous FP owner's XMM0-15 become the next exec'd image's initial FP state - deterministic across fork+exec and exit->next-first-entry, quiet because the vkernel kernel is -mno-sse. Guest vkernel repro 3/3: 15/16 registers carried the victim's exact 128-bit markers into the exec'd attacker image (xmm0 clobbered by libc SSE use); ported-wipe fix validated 0/16 x3. Classic FP-resident secret exposure: AES-NI round keys, bignum limbs. Amplification note: on AVX hosts the vkernel manages no YMM at all (fxsave only, CPUID-masked but executable), extending the class to YMM0-15.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5556 Β· 17 files
FileTypeDescriptionSize
README.md β€” 4.5 KB ↓ raw
VERDICT.md β€” 5.5 KB ↓ raw
victim.c β€” 2.2 KB view raw
attacker.c β€” 3.5 KB view raw
prober.c β€” 956 B view raw
build.sh β€” 195 B view raw
build_image.sh β€” 1.2 KB view raw
run.sh β€” 188 B view raw
build.log β€” 178 B view raw
run.log β€” 17.9 KB view raw
run.fixed.log β€” 17.9 KB view raw
leak_sample.txt β€” 1.6 KB view raw
env.txt β€” 262 B view raw
fix.diff β€” 654 B view raw
manifest.hash β€” 227 B ↓ download
manifest.json β€” 1.3 KB view raw
verdict.json β€” 6.5 KB view raw

DF-5556 β€” vkernel npxsave() missing FPU security wipe: previous FP owner's XMM registers are handed to the next exec'd image (cross-process/cross-exec FP state leak)

What

sys/platform/vkernel64/x86_64/npx.c:248-258 β€” the vkernel's npxsave() saves the outgoing thread's FP state and calls fninit(), but never wipes the SSE register file:

void
npxsave(union savefpu *addr)
{
    crit_enter();
    fpusave(addr, 0);
    mdcpu->gd_npxthread = NULL;
    fninit();                       /* x87 only β€” XMM0-15 keep old data */
    crit_exit();
}

pc64's twin (sys/platform/pc64/x86_64/npx.c:303-317) got the security wipe years ago:

    fninit();
    fpurstor(&md->gd_zerofpu, npx_xcr0_mask);   /* security wipe */

with union savefpu gd_zerofpu (BSS-zeroed) in pc64's mdglobaldata (sys/platform/pc64/include/globaldata.h:72). The vkernel copy never got it β€” sys/platform/vkernel64/include/globaldata.h has no gd_zerofpu at all.

Why it leaks (mechanism, all on this file / its platform)

The vkernel multiplexes its user processes inside ONE host thread, and go_user() (sys/platform/vkernel64/x86_64/trap.c:1371-1377) does an eager FP switch on every entry to emulated user mode:

    if (mdcpu->gd_npxthread != curthread) {
        if (mdcpu->gd_npxthread)
            npxsave(mdcpu->gd_npxthread->td_savefpu);
        npxdna(tf);
    }

For a thread whose TDF_USINGFP is clear (any freshly exec'd image), npxdna() (npx.c:187-191) calls npxinit(), whose fpusave(curthread->td_savefpu, 0) (npx.c:145) is an fxsave of the live registers β€” the previous FP owner's leftovers β€” followed by fpurstor() of the same seeded image (npxdna, npx.c:222). Nothing in the chain ever clears XMM0-15, and the vkernel kernel itself is compiled -mno-sse so kernel code never clobbers them either.

Net effect: whatever a process leaves in XMM0-15 survives process boundaries β€” through exit() (npxexit only saves, npx.c:154-159) and, deterministically, through the fork+exec of the next image (the child's first user-mode entry seeds from the live registers; cpu_fork() at sys/platform/vkernel64/x86_64/vm_machdep.c:110-112 saves the parent's FP via the same non-wiping npxsave, and nothing between that and the child's first entry touches XMM).

FP-resident secrets are the classic target: AES-NI round-key schedules (openssl keeps expanded keys in XMM), bignum limbs (MPIR/GMP), hashed key material β€” exactly the data class that OSes wipe FP registers for (this is why pc64 has the wipe; FreeBSD fixed the same class in SA-05:14/netisr advisory history).

Verified (this pack)

Inside a vkernel (kernel.debug -m 512m -r /vkroot.img -n 1, built from the audited tree, on the DragonFly QEMU guest):

  • victim loads a distinct 128-bit marker (hi=0xDF555600DEADBEEF, lo=register index) into XMM0-15, then fork()+execl()s the attacker (a different program image).
  • attacker is a crt-free static binary whose _start reads XMM0-15 as its very first instructions and writes raw hex to stdout.
  • Result (run.log), 3/3 runs: 15 of 16 registers contain the victim's exact marker bytes (xmm0 is clobbered by the exec path; xmm1-15 leak): X01 df555600deadbeef 0000000000000001 X02 df555600deadbeef 0000000000000002 ... X0f df555600deadbeef 000000000000000f
  • Control run (attacker alone, no victim): all registers zero.
  • Cross-process probe variant (prober: unrelated process fork+exec'ing readouts while an independent victim spins): no markers β€” the prober's own user-mode re-entries run npxdna() which fxrstor's the prober's own saved state over the live registers before each child's first entry. The leak channel therefore requires the victim (or a victim-descended exec chain) to be the last FP owner before the probe's first user entry β€” which is exactly the shell/mail-client/crypto-app-that-execs shape, and any attacker controlling a program the victim execs, or racing the victim's exit with an exec probe (no FP-using intermediary scheduled in between).
  • Fix validation: applying fix.diff (the pc64 wipe ported to the vkernel), rebuilding VKERNEL64 and re-running the exact PoC: 0/16 markers, registers read back zero (run.fixed.log).

Fix

Port pc64's wipe (fix.diff): add union savefpu gd_zerofpu; to vkernel64's mdglobaldata and fpurstor(&mdcpu->gd_zerofpu, 0); after fninit() in npxsave().

Build / Run

See build.sh / build_image.sh / run.sh (rig adapted from the proven DF-5337/DF-5538 vkernel packs; needs the DF-5474 SYSTEM_LD workaround to link VKERNEL64).

VERDICT.md
↓ download raw

DF-5556 β€” VERDICT

status: reproduced (leak) Β· fix validated Β· impact: cross-process/cross-exec FP-register info leak inside a vkernel

What was run

Rig (from the proven DF-5337/DF-5538 packs): DragonFly QEMU guest β†’ /usr/obj/usr/src/sys/VKERNEL64/kernel.debug -m 512m -r /vkroot.img -n 1 -e init_path=/bin/vkinit (single vCPU so all emulated processes share one hardware FPU; VKERNEL64 linked with the DF-5474 --defsym __build_id_* workaround).

  • victim (normal static binary): loads hi=0xDF555600DEADBEEF, lo=reg_index into XMM0..15, then fork()+execl("/root/attacker").
  • attacker (static, -nostartfiles, raw syscalls): _start's first instructions read XMM0..15 and write raw hex to stdout.
  • control: attacker alone; prober variant: unrelated process fork+exec'ing readouts next to a spinning victim.

Baseline (unfixed vkernel)

run.log (raw vkernel console; leak_sample.txt extracted):

# /root/victim fork; echo RUNA1_RC=$?; sleep 2
X00 0000000000000000 0000000000000000      <- clobbered by execl()'s libc SSE
X01 df555600deadbeef 0000000000000001      <- victim's marker
X02 df555600deadbeef 0000000000000002
...
X0f df555600deadbeef 000000000000000f

3/3 runs: 15/16 registers carry the victim's exact 128-bit markers (xmm1-15; xmm0 is overwritten by the libc execl path of the victim's own child — the child runs victim-image libc between fork and exec). Control run: 16/16 zeros. Cross-process prober variant: 0 markers — the prober's own user-mode re-entries (go_user→npxdna restores the prober's saved state) overwrite the live registers before each child's first entry; the channel therefore needs the victim to be the last FP owner before the fresh image's first user entry (the fork+exec chain of RUN A proves it deterministically; the exit-then-exec and victim-execs-attacker-controlled-program shapes are the real-world versions).

Root cause (code)

The vkernel kernel itself is compiled -mno-sse -msoft-float, so no kernel code clobbers XMM between owner and victim-image β€” the channel is quiet and deterministic.

Fix validation

fix.diff (pc64's wipe ported: union savefpu gd_zerofpu in vkernel64 mdglobaldata + fpurstor(&mdcpu->gd_zerofpu, 0) after fninit() in npxsave()), applied to the guest /usr/src, make nativekernel KERNCONF=VKERNEL64 (RC=0, npx.o rebuilt, fix-build.log), exact same PoC re-run:

RUN A Γ—3 : markers = 0/16, 0/16, 0/16   (run.fixed.log)
control   : 0/16 (unchanged)

Bad behavior gone; the fork-path cpu_fork() β†’ npxsave() (and every switch-out) now zeroes the live register file after saving.

PoC quirks (honesty notes)

  • The attacker's in-binary LEAK_REGS= counter printed 0 despite the raw hex lines showing markers β€” an unexplained miscompilation/artifact of the crt-free binary (the disassembled compare at 0x400182 cmp %rbp,%r13 looks correct; the sibling IDX= counter for the low halves worked). The canonical count is taken externally from the raw dump lines (leak_sample.txt / analysis in the run logs): 15/16 Γ— 3.
  • Early PoC iterations crashed for environment reasons, each fixed and documented: libc %fs:0 errno path without crt (switched to raw syscalls); vkernel initial %rsp sits under the stack top page (switched to scalar-per-register output with a tiny frame); victim dumps went to the pipe instead of the console (moved to stderr); the readout must happen at _start (first user entry), not after a blocking syscall β€” the first entry is exactly where TDF_USINGFP gets set and the seed happens.

Attempts

7 distinct guest iterations (build system fix, 2 PoC crashes, instrumented victim proving load+syscall survival, readout-point fix, final baseline, final fixed run).

Impact ceiling

In-vkernel cross-process disclosure of FP-register-resident secrets (AES-NI round keys, bignum limbs) β€” up to 256 bytes per observed handoff (XMM1-15 in the repro). Not kernel-memory disclosure; requires the victim to be the last FP owner before the attacker image's first user entry (deterministic via the victim's own fork+exec; otherwise a race). Severity: Medium (kernleak bucket), same class as DF-0938/DF-1056 (uninit/leftover FP state exposure) and the pc64 wipe exists precisely to prevent it.

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

Baseline vkernel: RUN A x3 = 15/16 XMM registers hold the victim's markers. After applying fix.diff to guest /usr/src and rebuilding VKERNEL64 (RC=0, npx.o rebuilt), the exact same PoC: 0/16 markers in all 3 runs, control unchanged (zeros). The fork-path cpu_fork()->npxsave() and every switch-out now zero the live register file after saving. Bad behavior gone.

fix.diff; run.fixed.log (0 markers x3); run.log (baseline 15/16 x3); leak_sample.txt
↓ fix.diffper-fix-DF-5556

Confirmed kernel references

Detail

Exploit chain

1) victim process in the vkernel performs FP-resident crypto (AES-NI expanded keys in XMM). 2) victim execs another program (or exits) with keys still in XMM. 3) npxsave/npxexit save the state but leave the live registers untouched (no wipe). 4) the exec'd (or next first-entry) image's first user-mode entry runs npxdna->npxinit which seeds its FP state from the live registers. 5) the new image's _start reads XMM0-15 -> previous image's key material. Demonstrated with distinct per-register markers: 15/16 registers leaked, 3/3 runs, deterministic via fork+exec.

Evidence (decisive lines)

run.log (raw vkernel console, baseline: X01..X0f = df555600deadbeef + reg index, 3 RUNA sections, control zeros), leak_sample.txt (extracted marker lines), run.fixed.log (post-fix console: 0 markers), fix.diff (gd_zerofpu + npxsave wipe), fix-build.log excerpt in build log (RC=0, npx.o rebuilt), VERDICT.md (full narrative incl. PoC quirks), env.txt (guest uname, image md5)

PoC changes

Wrote everything from scratch (no seed). Iterations: (1) libc-based crt-free attacker crashed in libc .cerror (%fs:0 errno TLS) -> raw syscall stubs; (2) 460-byte stack frame crossed the vkernel's initial stack-top page -> scalar per-register readout with tiny frame; (3) victim dumps initially went into the pipe -> stderr; (4) decisive fix: the readout must be the FIRST instructions of _start β€” TDF_USINGFP is set and the seed happens at the image's first user-mode entry (go_user eager npxdna), so reading after a blocking syscall returns only the attacker's own restored state; (5) fork+exec protocol for deterministic victim->new-image handoff. The in-binary LEAK_REGS counter prints 0 despite the raw hex showing markers (unexplained crt-free-binary artifact; sibling IDX counter works) β€” canonical count taken externally from the raw dump lines.

Verified recommended fix

Port pc64's security wipe: add union savefpu gd_zerofpu to vkernel64 mdglobaldata and fpurstor(&mdcpu->gd_zerofpu, 0) after fninit() in npxsave()

Verdict

REPRODUCED + FIX VALIDATED. sys/platform/vkernel64/x86_64/npx.c:248-258 npxsave() saves the outgoing thread's FP state and fninit()s the x87 unit but never wipes the SSE register file β€” pc64's twin wipes via fpurstor(&md->gd_zerofpu) (pc64 npx.c:314, gd_zerofpu in pc64 globaldata.h:72); vkernel64's mdglobaldata has no gd_zerofpu at all. Because go_user() (trap.c:1371-1377) eagerly hands the FPU over on every entry to emulated user mode and a fresh image's first entry runs npxdna->npxinit whose fpusave (npx.c:145) fxsave()s the LIVE registers into the new thread's td_savefpu and fxrstor()s them back (npxdna npx.c:222), whatever the previous FP owner left in XMM0-15 becomes the new program image's initial FP state; the vkernel kernel is compiled -mno-sse so nothing in between clobbers it. PoC inside a vkernel on the guest: victim loads hi=0xDF555600DEADBEEF/lo=index into XMM0-15 then fork()+exec()s the attacker (crt-free static reader whose _start reads XMM first thing): 3/3 runs, 15/16 registers contain the victim's exact marker bytes (xmm0 clobbered by the victim-child's libc execl SSE use; see leak_sample.txt + run.log). Control run: all zeros. Unrelated-prober variant: 0 markers (the prober's own user re-entries restore its own state first) β€” the channel needs the victim to be the last FP owner before the fresh image's first user entry, which the fork+exec chain provides deterministically and which the victim-execs-attacker-program / exit-then-exec shapes provide in practice. Applying fix.diff (port of pc64's wipe: gd_zerofpu in vkernel64 mdglobaldata + fpurststor of it in npxsave), rebuilding VKERNEL64 and re-running the exact PoC: 0/16 markers in all 3 runs (run.fixed.log). Impact ceiling: in-vkernel cross-process disclosure of FP-resident secrets (AES-NI key schedules, bignum limbs), 256 bytes per observed handoff β€” Medium, kernleak bucket.