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

vga_save_font()/vga_load_font() accept unvalidated glyph index and count β€” latent OOB-write API one caller away from a real overflow (user-influenced mouse_char already flows in)

Summary

Both font functions validate only page; ch, count and their product with fontsize feed ch*32 straight into bcopy_toio/bcopy_fromio. All current callers are kernel-internal and clamped (syscons ch=0/count=256; the user-settable mouse_char pre-clamped to <=251), so no trigger exists today; one future caller forwarding an unchecked value gets an int-overflowed ch*32 sign-extended into a wild KVA write. Guest hostile-input run proves every currently user-reachable vga.c sink is bounds-checked (modes/palette rejected, unpriv EPERM, /dev/vga absent). Glyph-range validation fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4158 Β· 10 files
FileTypeDescriptionSize
README.md β€” 3.0 KB ↓ raw
VERDICT.md β€” 3.0 KB ↓ raw
vgafuzz.c β€” 5.0 KB view raw
build.sh β€” 99 B view raw
run.sh β€” 144 B view raw
build.log β€” 69 B view raw
run.log β€” 2.6 KB view raw
env.txt β€” 604 B view raw
fix.diff β€” 968 B view raw
verdict.json β€” 3.0 KB view raw

DF-4158 β€” vga_save_font/vga_load_font have no internal bounds on (ch, count, fontsize) + guest proof the user-reachable vga.c surface is bounds-checked

File: sys/dev/video/fb/vga.c (pass 2, GLM 5.3)

What (the hardening gap)

vga_save_font() / vga_load_font() (vga.c:1189-1233, 1241-1286) validate page (0..7) but perform zero validation of the glyph index ch, glyph count, or their combination with fontsize:

if (fontsize == 32) {
    bcopy_toio(data, (uintptr_t)segment + ch*32, fontsize*count);
} else {
    for (c = ch; count > 0; ++c, --count) {
        bcopy_toio(data, (uintptr_t)segment + c*32, fontsize);

Every caller today is kernel-internal and clamped (syscons: ch=0,count=256; scmouse.c:888 passes the user-influenced sc->mouse_char as ch, which MOUSE_MOUSECHAR pre-clamps to ≀ (unsigned char)-1-4, scmouse.c:881), so this is currently a latent API hazard: one future caller that forwards an unchecked ch/count gets an OOB write through ch*32 (int overflow β†’ negative β†’ sign-extended uintptr_t) into arbitrary KVA, or a OOB read of the data source. Filed as Info/defense-in-depth.

Guest verification (negative-space proof for the rest of the file)

vgafuzz.c exercises every user-reachable vga.c sink reachable through syscons (/dev/ttyvN β†’ sc_vid_ioctl β†’ vga_dev_ioctl/fb_commonioctl) with hostile scalars. Results on the live guest (run.log):

  • FBIO_SETMODE with {-2^31, -1, 29, 64, 256, 65535, 2^30, 2^31-1} β€” all rejected (mode walk via get_mode_param() β†’ NULL β†’ EINVAL, vga.c:533, vga.c:893).
  • FBIO_MODEINFO with vi_mode {-1, 29, INT_MAX} β€” rejected.
  • FBIO_GET/SETPALETTE, FBIOGET/FBIOPUTCMAP with index=-1/300/INT_MAX, count=-1/INT_MAX/2^30, index+count>256 β€” all rejected by the get_palette/set_palette gate (vga.c:1853, 1885).
  • FBIO_SETDISPSTART INT_MAX/INT_MIN and FBIO_SETLINEWIDTH INT_MAX/-1/0 are accepted but land only in CRTC register bytes and the va_line_width int (vga.c:750-752, 786-800) β€” display corruption by the console user on themselves, no kernel-memory sink (checked all consumers: planar_fill/genfbread use window_size/maxsize, not line_width multiplies that reach memory ops unclamped).
  • Guest stayed up after the full battery (dmesg tail clean).
  • Permission model: /dev/ttyv0..N are 0600 root:wheel (console-login users get the tty chowned β€” the historical syscons model); /dev/vga0 does not exist (FB_INSTALL_CDEV is not compiled into X86_64_GENERIC; when compiled it is 02660 root:wheel, vga_isa.c:156).
  • State save/restore is NOT user-reachable at all: no FBIO_*STATE ioctls exist; vga_save_state/load_state are reached only from isavga_suspend/resume with kernel-sized buffers (vga_isa.c:190-207), and vga_save_state itself enforces size < sizeof(adp_state_t) β†’ EINVAL (vga.c:1432).

Fix

fix.diff: validate ch/count against the 512-glyph (0x4000/32) font page in both functions. Applies cleanly with git apply.

VERDICT.md
↓ download raw

DF-4158 VERDICT

status: not_reproduced (by design β€” no user-reachable trigger exists today) | impact: none (hardening)

Was it reproduced?

Attempted on the live guest and no impact could be produced, because the missing bounds check is not reachable with attacker-chosen values on this kernel:

  1. vga_load_font/vga_save_font (vga.c:1189, 1241) have no FBIO ioctl anywhere in the tree (grep across sys/: no FBIOPUTFONT handler; fb.c:741-765 returns ENODEV for font/palette-adjacent unknowns via the default arm).
  2. All call sites are kernel-internal with clamped arguments: - syscons.c:1653/1691/1727/3403-3417/4201-4207, scvidctl.c:674-678: ch=0, count=256, fontsize∈{8,14,16} β†’ max byte offset 8192 = the 0x4000-byte... (256 glyphs Γ— 32 B = 0x2000) well inside the font page. - scvgarndr.c:380: ch=sc->mouse_char, count=4, fontsize=32 β†’ max offset (mouse_char+3)32+128 ≀ 25132+128 = 8160 < 0x4000. mouse_char is user-settable via MOUSE_MOUSECHAR but pre-clamped to ≀ (unsigned char)-1-4 = 251 (scmouse.c:881). - scmouse.c:888: same clamped ch, count=4.
  3. vgafuzz (run.log) additionally proves the actually reachable vga.c sinks reject or safely consume every hostile scalar: modes, palette index/count, dispstart, linewidth. FBIO_SETLINEWIDTH INT_MAX is accepted but lands only in one CRTC byte + the va_line_width int β€” I traced every consumer of va_line_width (set_display_start arithmetic β†’ CRTC bytes; planar/packed_fill length β†’ window_size-bounded loops; genfbread β†’ window_size/modulo) and none turns it into an unclamped memory op. FBIO_SETDISPSTART likewise only programs CRTC/ATC bytes.

Guest survived the whole battery (dmesg tail clean, subsequent ssh runs fine). /dev/vga0 absent (FB_INSTALL_CDEV not compiled; would be 02660 root:wheel when compiled β€” vga_isa.c:156). /dev/ttyv* 0600 root:wheel; unprivileged maxx gets EPERM on open (run.log); the historical model is that a vty is chowned to the user who logs in on it.

Why file it at all

This is the classic shape that later becomes a real overflow the day one new caller forwards an unchecked value (user-influenced scalars like mouse_char already flow into this API). One-line clamp per function makes the invariant local. Info severity, no demonstrated impact β€” consistent with not_reproduced/impact none.

Fix validation

Not meaningful (nothing to observe pre/post on this kernel). fix.diff applies cleanly (git apply --check); it validates ch β‰₯ 0 && count β‰₯ 0 && ch+count ≀ 512 (0x4000-byte page / 32-byte glyph) in both functions.

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

not_testable
baseline no→ patch + rebuild →patched clean

Nothing observable to diff on this kernel (no userland path can reach the font functions with unchecked values), so baseline/patched comparison is not meaningful. fix.diff applies cleanly (git apply --check) and adds the ch/count bounds gate to both functions.

findings/poc/DF-4158/fix.diff (git apply --check clean, two hunks: save_font + load_font)
↓ fix.diffper-fix-DF-4158

Confirmed kernel references

Detail

Evidence (decisive lines)

vga.c:1189-1233+1241-1286 (missing ch/count validation), scmouse.c:881-889 (user-influenced mouse_char flows in, clamped upstream), run.log (vgafuzz results + unpriv EPERM + /dev/vga absent), build.log

PoC changes

authored fresh exerciser (no seed); expected-accept labels adjusted after observing FBIO_SETLINEWIDTH INT_MAX lands only in hw regs

Verified recommended fix

Validate ch/count against the 512-glyph (0x4000/32-byte) font page in vga_save_font and vga_load_font

Verdict

vga_save_font/vga_load_font perform no internal validation of the glyph index/count, making them a latent OOB-write/OOB-read API (ch*32 int overflow -> wild KVA via bcopy_toio/bcopy_fromio) that one unshackled future caller could trigger; today every caller is kernel-internal and clamped (mouse_char pre-clamped to <=251 by MOUSE_MOUSECHAR), so no user-reachable trigger exists and none could be produced on the live guest. The accompanying vgafuzz run on the guest (root on /dev/ttyv0) proves the entire user-reachable vga.c ioctl surface rejects or safely consumes hostile scalars: all hostile FBIO_SETMODE/FBIO_MODEINFO values rejected (get_mode_param NULL gate), all hostile palette index/count combinations rejected (base/count gate), FBIO_SETDISPSTART/FBIO_SETLINEWIDTH extremes accepted but landing only in CRTC register bytes and the va_line_width int with no unclamped memory-op consumer. Guest stayed up throughout.