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)
PoC verification
Evidence pack
findings/poc/DF-5556 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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, thenfork()+execl()s the attacker (a different program image). - attacker is a crt-free static binary whose
_startreads 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 runnpxdna()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).
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): loadshi=0xDF555600DEADBEEF, lo=reg_indexinto XMM0..15, thenfork()+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)
sys/platform/vkernel64/x86_64/npx.c:248-258βnpxsave()doesfxsave+fninit()and no XMM wipe (pc64 twin wipes viafpurstor(&md->gd_zerofpu)atsys/platform/pc64/x86_64/npx.c:314; vkernel64'smdglobaldatahas nogd_zerofpuat all,sys/platform/vkernel64/include/globaldata.h:66-84).sys/platform/vkernel64/x86_64/trap.c:1371-1377βgo_user()does an eager FP handoff on every entry to emulated user mode.sys/platform/vkernel64/x86_64/npx.c:187-191,145β a fresh image's first entry (TDF_USINGFP clear) runsnpxinit()whosefpusave()is anfxsaveof the live registers β the previous owner's XMM data becomes the new image's initial FP state (registers and itstd_savefpu).sys/platform/vkernel64/x86_64/npx.c:154-159βnpxexit()likewise only saves; a dying process's XMM state stays live for the next first-use thread.
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 0x400182cmp %rbp,%r13looks correct; the siblingIDX=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:0errno path without crt (switched to raw syscalls); vkernel initial%rspsits 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 whereTDF_USINGFPgets 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
fixedBaseline 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
Confirmed kernel references
- sys/platform/vkernel64/x86_64/npx.c:248
- sys/platform/vkernel64/x86_64/npx.c:145
- sys/platform/vkernel64/x86_64/npx.c:187
- sys/platform/vkernel64/x86_64/npx.c:222
- sys/platform/vkernel64/x86_64/npx.c:154
- sys/platform/vkernel64/x86_64/trap.c:1371
- sys/platform/vkernel64/include/globaldata.h:66
- sys/platform/pc64/x86_64/npx.c:314
- sys/platform/pc64/include/globaldata.h:72
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.
No comments yet.