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

splash() invokes splash_decoder->splash without the NULL guard its sibling callbacks get

Summary

splash() dereferences ->splash unchecked although init (:83) and term (:193) are both NULL-guarded: a decoder module with .splash==NULL (the designated-initializer pattern makes omission silent) transfers control to address 0 on the first screen-saver event. Live-reproduced on the stock guest (console=vidconsole + a NULL-decoder KLD + the exact scsplash_saver call): 'Fatal trap 12 fault VA 0x0, Stopped at 0:' - kernel dead, VGA panic screendump captured. All 12 in-tree registrants populate .splash (root-gated third-party/latent class). sibling-consistent NULL-guard fix A/B validated on a rebuilt kernel. Registry arithmetic beyond DF-2144 killed (decoders-stuck-at-1 is in-bounds); the kenv/namei surface does not exist in this file (image arrives only via preload MODINFO).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5777 Β· 17 files
FileTypeDescriptionSize
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 6.2 KB ↓ raw
nullsplash.c β€” 2.4 KB view raw
Makefile β€” 95 B ↓ download
build.sh β€” 121 B view raw
run.sh β€” 149 B view raw
build.log β€” 5.7 KB view raw
run.log β€” 1.7 KB view raw
panic.txt β€” 1.5 KB view raw
vga_panic.png β€” 8.8 KB ↓ download
vga_panic_2x.png β€” 15.3 KB ↓ download
vga_trace_2x.png β€” 15.8 KB ↓ download
fix.diff β€” 349 B view raw
fix_build.log β€” 1.2 KB view raw
fix_run.log β€” 563 B view raw
fixsrc.txt β€” 829 B view raw
verdict.json β€” 4.4 KB view raw

DF-5777 β€” splash() calls decoder->splash without the NULL guard init/term get

File: sys/dev/video/fb/splash.c:201-206 Severity: Info (hardening; trigger requires root-loaded decoder module β€” no in-tree registrant leaves .splash NULL) Status: reproduced live on the QEMU guest (kernel panic, Stopped at 0:).

What the bug is

int
splash(video_adapter_t *adp, int on)
{
    if (splash_decoder != NULL)
        return (*splash_decoder->splash)(adp, on);   /* <-- no NULL check */
    return ENODEV;
}

splash_register() accepts any splash_decoder_t (the SPLASH_DECODER / SAVER_MODULE macros let any kld module register one). The two sibling callbacks are both guarded β€” init at splash.c:83 (if (*decoder->init && ...)) and term at splash.c:193 (if (error == 0 && splash_decoder->term)) β€” but splash() invokes ->splash unchecked. A decoder registered with .splash == NULL becomes the active decoder (via splash_new()), and the first screen-saver event (or any caller of the exported splash(), e.g. syscons scsplash_saver at syscons.c:2560) transfers control to address 0.

All 12 in-tree registrants (bmp, pcx, 10 syscons savers) populate .splash, so this is defense-in-depth: a third-party or misdeclared root-loaded decoder module panics the kernel instead of getting ENODEV.

How to reproduce (on the audit QEMU rig)

The rig must have syscons as the kernel console so that splash_init() runs at boot (splash_adp != NULL β€” otherwise splash_register() takes the deferred path and the decoder is never activated):

  1. echo 'console="vidconsole"' > /boot/loader.conf.local && reboot (serial console wins otherwise: sio CN_REMOTE > sc CN_INTERNAL β€” see siocnprobe, sio.c:~3141; sccninit sets sc_console_unit, which gates the SC_KERNEL_CONSOLE path through scinit β†’ splash_init, syscons.c:3432).
  2. Build: sh build.sh (kld module nullsplash.ko, Makefile included).
  3. kldload /tmp/nullsplash/nullsplash.ko β€” dmesg shows nullsplash: decoder init, adp=0x... (.splash left NULL) proving the immediate-path registration succeeded.
  4. sysctl hw.nullsplash_fire=1 β€” the module calls splash(real_adp, TRUE), the exact call scsplash_saver() makes.

Expected result (baseline / unfixed kernel)

Kernel panics: Fatal trap 12: page fault while in kernel mode, fault virtual address = 0x0, Stopped at 0: β€” control transferred to NULL. The ssh session dies; with debug.debugger_on_panic=1 (default) the box sits in DDB on the VGA console. See panic.txt and the VGA screendumps vga_panic.png / vga_panic_2x.png (captured via the QEMU monitor).

Fixed kernel

Apply fix.diff (one-line guard), rebuild, repeat steps 2–4: the sysctl returns cleanly, dmesg shows nullsplash: returned -- guard present?, guest stays up. See fix_run.log, VERDICT.md.

Contents

  • nullsplash.c / Makefile β€” PoC kld module (deliberately NULL .splash)
  • build.sh / run.sh β€” build + host-side driver
  • build.log β€” module build (clean under -Werror)
  • run.log β€” run transcript (serial console + ssh death)
  • panic.txt β€” transcribed panic from the VGA screendump
  • vga_panic.png, vga_panic_2x.png, vga_trace_2x.png β€” QEMU monitor screendumps of the DDB screen (the vidconsole kernel prints there)
  • env.txt β€” guest uname, console rig notes
  • fix.diff β€” the one-line fix (validated on a rebuilt guest kernel)
  • fix_build.log / fix_run.log β€” patched-kernel build + PoC re-run
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-5777 β€” VERDICT

Finding: splash() invokes splash_decoder->splash without a NULL guard (sys/dev/video/fb/splash.c:204-205), although the sibling callbacks are guarded (init: splash.c:83; term: splash.c:193).

Verdict: REPRODUCED (panic), then fix validated (fixed). Severity stays Info / hardening: the only registrants of splash_decoder_t are kernel modules (root kldload / loader preloaded), and all 12 in-tree registrants populate .splash; there is no unprivileged or remote reach into this registry. The bug turns a misdeclared third-party decoder module into an instant kernel panic instead of an ENODEV.

How it was reproduced (baseline, unpatched kernel)

  1. Rig prep: the guest boots with console="comconsole" (serial console wins consdev selection: sio CN_REMOTE (sio.c siocnprobe: cn_pri = (COM_FORCECONSOLE(flags) || (boothowto & RB_SERIAL)) ? CN_REMOTE : CN_NORMAL) > syscons CN_INTERNAL (unconditional in sc_get_cons_priority, syscons_nexus.c:195). With serial as console, sccninit never runs, sc_console_unit stays -1, scinit runs without SC_KERNEL_CONSOLE, so splash_init() (gated flags & SC_KERNEL_CONSOLE at syscons.c:3431) never runs and splash_adp == NULL β€” first kldload attempt proved this: no init kprintf, deferred path taken.
  2. echo 'console="vidconsole"' > /boot/loader.conf.local && reboot β€” syscons becomes the kernel console (sc0 attaches with flags 0x300 = SC_EFI_FB|SC_KERNEL_CONSOLE), splash_init() runs at boot.
  3. Built the PoC kld (nullsplash.ko): a decoder with .splash == NULL and .data_type == NULL (so splash_find_data() returns 0 immediately, splash.c:59-60) and an init that returns 0 and stashes the real video_adapter_t. Clean build under -Werror.
  4. kldload β€” dmesg: nullsplash: decoder init, adp=0xffffffff81136880 (.splash left NULL) β€” the immediate path in splash_register() (splash.c:108-121) ran splash_test() and splash_new() installed the decoder as splash_decoder.
  5. sysctl hw.nullsplash_fire=1 β€” module calls splash(real_adp, TRUE), the exact call the in-tree caller scsplash_saver() makes (syscons.c:2560). Result: kernel panic β€” Fatal trap 12: page fault while in kernel mode, fault virtual address = 0x0, Stopped at 0: (control transferred to address 0), process 988 = the sysctl caller. ssh died; with debug.debugger_on_panic=1 the box sat in DDB on the VGA console until hypervisor reset. Evidence: panic.txt + vga_panic.png / vga_panic_2x.png (QEMU-monitor VGA screendumps; the kernel console was vidconsole for this run).

Why the module-driven splash() call rather than waiting for the screen saver: the saver invocation also depends on syscons idle-blanking state (scrn_timer / SC_SCRN_IDLE / current_saver, syscons.c:2193-2229) which is orthogonal to splash.c; the finding under audit is the unguarded indirect call at splash.c:205, reached through the same public splash() entry the in-tree caller uses.

Fix validation (patched kernel)

  • Applied fix.diff (one line: if (splash_decoder != NULL && splash_decoder->splash != NULL)) to the guest's /usr/src copy only; host sys/ untouched. make nativekernel KERNCONF=X86_64_GENERIC (buildkernel refused: no built world) β€” BUILD_EXIT=0. Installed; uname shows #1: Sun Oct 11 20:47:22 UTC 2026.
  • Re-ran the IDENTICAL PoC on the patched kernel: nullsplash: calling splash(...) then nullsplash: returned -- guard present?, sysctl exit 0, guest alive (uptime confirmed). See fix_run.log; applied-diff and kernel hashes in fix_build.log / fixsrc.txt.

Baseline (panic) vs patched (clean ENODEV-style return) β€” A/B complete.

Why Info and not higher

  • Registry writes (splash_register) happen only from module load events (boot-time preload or root kldload); splash_init only from the console driver's boot-time attach. No copyin/ioctl/sysctl from userland touches this file. An unprivileged user cannot influence which decoder is active.
  • All in-tree decoders/savers set .splash (bmp/pcx + 10 syscons savers verified), so the NULL path needs a buggy third-party root-loaded module.
  • Root can crash the kernel by design; the fix is defense-in-depth consistency with the two sibling NULL guards.

Negative results recorded during pass 2 (nothing else found)

  • Registry arithmetic beyond DF-2144: re-derived the insertion logic β€” decoders stuck at 1 after first registration means every later deferred registration writes decoder_set[1] (in-bounds, capacity 4); no integer overflow (sizeof(*p)*(decoders+4) with bounded module count); realloc path only reachable at decoders==0. No OOB is reachable; the functional miscount is exactly DF-2144.
  • "kenv splash.image / namei" surface: does not exist in this file β€” no kenv/getenv/namei/copyin anywhere under sys/dev/video/fb/ (rg-verified). The image arrives exclusively as loader-preloaded module metadata read via preload_search_by_type/MODINFO_ADDR/MODINFO_SIZE (splash.c:61-71), i.e. boot-config trust level.
  • offx/offy window-adapt math: not in this file (decoder-side; covered by the pass-1 findings DF-1857/1858/1859/2110/2111 in splash_bmp/splash_pcx).
  • Unlocked registry races (kldload/kldunload MOD events vs saver-thread splash() reads of splash_decoder): trigger requires root module operations and is the same class already filed as DF-2143 (stale decoder after unload) β€” not re-reported.
  • splash_init double-call / dropping an active decoder without term (splash.c:167): unreachable β€” guarded by SC_SPLASH_SCRN / SC_INIT_DONE in syscons (syscons.c:3430-3434) and scterm is #if 0 dead code.
  • splash_register kmalloc/bcopy/kfree error path (splash.c:129-137): correct β€” ENOMEM leaves state untouched, old array freed after copy.
  • kprintf of the image pointer at splash.c:73-74: bootverbose-only, console already root-trusted; not a finding.

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

Applied the one-line guard to the guest's /usr/src copy (host sys/ untouched), rebuilt with make nativekernel KERNCONF=X86_64_GENERIC (BUILD_EXIT=0), installed and rebooted (uname #1 Sun Oct 11 20:47:22 UTC 2026). Identical PoC on the patched kernel: no panic, module prints 'nullsplash: returned -- guard present?', sysctl exit 0, guest stays up. Baseline on the stock kernel (#0 Jul 2 2026) panicked with Stopped at 0:.

fix.diff; fix_run.log (patched A/B); fix_build.log + fixsrc.txt (applied diff in guest tree, kernel md5 1a5ed551b16ceea081ccc60a2120a8f5 patched vs 4625046e8061b34e1758d291bc6cad18 stock); panic.txt + vga_panic_2x.png (baseline).
↓ fix.diffper-fix-DF-5777

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.txt + vga_panic_2x.png (DDB screendump: 'Stopped at 0:', fault virtual address = 0x0, current process 988 = sysctl caller); run.log (baseline transcript: init kprintf proving immediate-path registration, then ssh death); fix_run.log (patched kernel A/B); fix.diff (applied to guest /usr/src only; host sys/ untouched); fix_build.log/fixsrc.txt (nativekernel BUILD_EXIT=0, uname #1 Sun Oct 11 20:47:22 UTC 2026, kernel md5s).

PoC changes

Seed sketch did not exist (new pass-2 finding). PoC authored from scratch: kld module with .splash=NULL/.data_type=NULL decoder + SYSCTL_PROC trigger that calls splash(real_adp, TRUE) with the adapter captured during decoder init. Rig change required: /boot/loader.conf.local console=vidconsole + reboot (serial-only console leaves splash_adp NULL via CN_REMOTE > CN_INTERNAL consdev selection). Direct saver-timeout triggering was also attempted (vidcontrol -t / CONS_SAVERSTART ioctl) but syscons idle-blanking state is orthogonal; the module-driven call exercises the identical public splash() entry point.

Verified recommended fix

Guard the indirect call like its siblings: if (splash_decoder != NULL && splash_decoder->splash != NULL) return (*splash_decoder->splash)(adp, on);

Verdict

REPRODUCED then FIX VALIDATED. splash() (sys/dev/video/fb/splash.c:204-205) invokes (*splash_decoder->splash)(adp, on) without the NULL guard its sibling callbacks get (init at :83, term at :193). With the guest rig switched to vidconsole so splash_init() runs (syscons.c:3432), a kld decoder registered with .splash == NULL becomes the active splash_decoder; calling the exported splash() -- the same call scsplash_saver() makes at syscons.c:2560 -- transfers control to address 0: Fatal trap 12, fault va 0x0, 'Stopped at 0:', kernel dead in DDB, ssh gone. One-line guard rebuilt into a nativekernel: identical PoC returns cleanly ('nullsplash: returned -- guard present?', sysctl exit 0, guest up). Severity Info/hardening: registry writes require root module load; all 12 in-tree registrants fill .splash, so only a misdeclared third-party root-loaded module hits it.