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)
PoC verification
Evidence pack
findings/poc/DF-5777 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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):
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).- Build:
sh build.sh(kld modulenullsplash.ko, Makefile included). kldload /tmp/nullsplash/nullsplash.koβ dmesg showsnullsplash: decoder init, adp=0x... (.splash left NULL)proving the immediate-path registration succeeded.sysctl hw.nullsplash_fire=1β the module callssplash(real_adp, TRUE), the exact callscsplash_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 driverbuild.logβ module build (clean under -Werror)run.logβ run transcript (serial console + ssh death)panic.txtβ transcribed panic from the VGA screendumpvga_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 notesfix.diffβ the one-line fix (validated on a rebuilt guest kernel)fix_build.log/fix_run.logβ patched-kernel build + PoC re-runVERDICT.md,manifest.json,verdict.json
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)
- Rig prep: the guest boots with
console="comconsole"(serial console wins consdev selection: sioCN_REMOTE(sio.c siocnprobe:cn_pri = (COM_FORCECONSOLE(flags) || (boothowto & RB_SERIAL)) ? CN_REMOTE : CN_NORMAL) > sysconsCN_INTERNAL(unconditional in sc_get_cons_priority, syscons_nexus.c:195). With serial as console,sccninitnever runs,sc_console_unitstays -1,scinitruns without SC_KERNEL_CONSOLE, sosplash_init()(gatedflags & SC_KERNEL_CONSOLEat syscons.c:3431) never runs andsplash_adp == NULLβ first kldload attempt proved this: no init kprintf, deferred path taken. 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.- Built the PoC kld (
nullsplash.ko): a decoder with.splash == NULLand.data_type == NULL(sosplash_find_data()returns 0 immediately, splash.c:59-60) and aninitthat returns 0 and stashes the realvideo_adapter_t. Clean build under-Werror. kldloadβ dmesg:nullsplash: decoder init, adp=0xffffffff81136880 (.splash left NULL)β the immediate path insplash_register()(splash.c:108-121) ransplash_test()andsplash_new()installed the decoder assplash_decoder.sysctl hw.nullsplash_fire=1β module callssplash(real_adp, TRUE), the exact call the in-tree callerscsplash_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; withdebug.debugger_on_panic=1the 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; hostsys/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(...)thennullsplash: 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 rootkldload);splash_initonly 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 β
decodersstuck at 1 after first registration means every later deferred registration writesdecoder_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 ofsplash_decoder): trigger requires root module operations and is the same class already filed as DF-2143 (stale decoder after unload) β not re-reported. splash_initdouble-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) andsctermis #if 0 dead code.splash_registerkmalloc/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
fixedApplied 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).
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.
No comments yet.