test/pyxtest: add test for font alias stack overflow (ZDI-CAN-30136)

Add ListFonts, SetFontPath, and GetFontPath protocol builders to
proto/x11.py and a regression test that reproduces the
doListFontsAndAliases stack buffer overflow.

The test creates a temporary font directory with a fonts.alias file
containing an alias whose target name is 400 bytes -- exceeding the
old XLFDMAXFONTNAMELEN of 256 but under libXfont2's MAXFONTNAMELEN of
1024. It prepends this directory to the font path via SetFontPath, then
sends ListFonts matching the alias name. Without the fix, the server
would copy the oversized resolved name into a 256-byte stack buffer,
causing a stack buffer overflow.

ZDI-CAN-30136

Assisted-by: Claude:claude-opus-4-6
Part-of: <https://gitlab.freedesktop.org/xorg/xserver/-/merge_requests/2228>
This commit is contained in:
Peter Hutterer 2026-04-29 05:40:41 +00:00
parent 6671daeada
commit 3568302483
3 changed files with 200 additions and 0 deletions

View file

@ -55,6 +55,7 @@ if pytest.found()
# This needs to be kept in sync with the test_foo.py files in the tree
tests_pyxtest = [
'test_font.py',
'test_glx.py',
'test_present.py',
'test_randr.py',

View file

@ -9,6 +9,9 @@ from dataclasses import dataclass
CreateWindow = 1
CreatePixmap = 53
InternAtom = 16
ListFonts = 49
SetFontPath = 51
GetFontPath = 52
QueryExtension = 98
ChangeKeyboardMapping = 100
ForceScreenSaverOpcode = 115
@ -156,6 +159,110 @@ class ChangeKeyboardMappingRequest:
return header + sym_data
@dataclass
class ListFontsRequest:
"""X11 ListFonts request (opcode 49).
xListFontsReq (8 bytes header):
reqType(1) + pad(1) + length(2) + maxNames(2) + nbytes(2)
followed by the pattern string (padded to 4-byte boundary).
"""
pattern: str
max_names: int = 65535
def to_bytes(self, byte_order: str = "<") -> bytes:
pat_bytes = self.pattern.encode("ascii")
padded = _pad(pat_bytes)
req_len = (8 + len(padded)) // 4
header = struct.pack(
f"{byte_order}BBH HH",
ListFonts,
0, # pad
req_len,
self.max_names,
len(pat_bytes),
)
return header + padded
@dataclass
class SetFontPathRequest:
"""X11 SetFontPath request (opcode 51).
xSetFontPathReq (8 bytes header):
reqType(1) + pad(1) + length(2) + nFonts(2) + pad(2)
followed by LISTofSTR8 (length-prefixed strings).
"""
paths: list[str]
def to_bytes(self, byte_order: str = "<") -> bytes:
# Build LISTofSTR8: each string is preceded by a 1-byte length
payload = b""
for p in self.paths:
p_bytes = p.encode("ascii")
payload += bytes([len(p_bytes)]) + p_bytes
padded = _pad(payload)
req_len = (8 + len(padded)) // 4
header = struct.pack(
f"{byte_order}BBH Hxx",
SetFontPath,
0, # pad
req_len,
len(self.paths),
)
return header + padded
@dataclass
class GetFontPathRequest:
"""X11 GetFontPath request (opcode 52).
Simple 4-byte request with no parameters.
"""
def to_bytes(self, byte_order: str = "<") -> bytes:
return struct.pack(
f"{byte_order}BBH",
GetFontPath,
0, # pad
1, # length = 1 word
)
@dataclass
class GetFontPathReply:
"""Parsed xGetFontPathReply.
xGetFontPathReply layout:
type(1) + pad(1) + sequenceNumber(2) + length(4)
nPaths(2) + pad(22)
LISTofSTR8 (each entry: length-byte + string)
"""
paths: list[str]
@classmethod
def from_reply(cls, data: bytes) -> "GetFontPathReply":
"""Parse an X11Reply's raw data into a GetFontPathReply."""
paths: list[str] = []
if len(data) < 32:
return cls(paths)
n_paths = struct.unpack_from("<H", data, 8)[0]
offset = 32
for _ in range(n_paths):
if offset >= len(data):
break
slen = data[offset]
offset += 1
if offset + slen > len(data):
break
paths.append(data[offset : offset + slen].decode("ascii", errors="replace"))
offset += slen
return cls(paths)
@dataclass
class ForceScreenSaver:
"""X11 ForceScreenSaver request."""

92
test/pyxtest/test_font.py Normal file
View file

@ -0,0 +1,92 @@
# SPDX-License-Identifier: MIT
#
# Security tests for font alias handling vulnerabilities.
import os
import time
import pytest
from proto import x11
from xclient import X11Reply
class TestFontAliasOverflow:
"""Tests for doListFontsAndAliases stack buffer overflow via long alias."""
@pytest.mark.asan
def test_list_fonts_long_alias_overflow(self, xserver, xclient, tmp_path):
"""
ZDI-CAN-30136: doListFontsAndAliases copies the resolved alias
target from libXfont2 into tmp_pattern[] and c->current.pattern[],
both sized XLFDMAXFONTNAMELEN. The server defined
XLFDMAXFONTNAMELEN as 256, but libXfont2 allows alias targets up
to MAXFONTNAMELEN (1024) bytes in fonts.alias files. A
fonts.alias with a target name between 257 and 1023 bytes caused
a stack buffer overflow when the alias was resolved via
ListFonts.
Attack:
1. Create a font directory with fonts.alias containing an alias
whose target name exceeds the old 256-byte buffer (but stays
under 1024 to pass libXfont2 validation).
2. SetFontPath to include this directory.
3. ListFonts with a pattern matching the alias name.
4. Server copies oversized resolved name into the undersized
stack and struct buffers -- stack buffer overflow.
Fixed by increasing XLFDMAXFONTNAMELEN to 1024 to match
libXfont2's MAXFONTNAMELEN.
"""
# The old XLFDMAXFONTNAMELEN was 256, now 1024
# MAXFONTNAMELEN in libXfont2 is 1024
# Use a target length > 256 but < 1024 to trigger the old bug.
# The overflow must be large enough to clobber the saved return
# address on the stack; 256 + 400 = 656 bytes overflows 400
# bytes past the tmp_pattern[256] buffer which reliably reaches
# the saved RIP and crashes the server.
target_len = 656
alias_name = "pwn"
# Step 1: Create evil font directory with long alias target
evil_dir = str(tmp_path / "evilfonts")
os.makedirs(evil_dir)
# fonts.dir (empty -- 0 fonts, required for FPE init)
with open(os.path.join(evil_dir, "fonts.dir"), "w") as f:
f.write("0\n")
# fonts.alias with oversized target name
# Use XLFD-like format starting with '-' so the FPE recognizes it
long_target = "-" + "A" * (target_len - 1)
with open(os.path.join(evil_dir, "fonts.alias"), "w") as f:
f.write(f"{alias_name} {long_target}\n")
# Step 2: Get current font path so we can restore it later
req = x11.GetFontPathRequest()
xclient.send_request(req.to_bytes())
resp = xclient.recv_response(timeout=5.0)
assert isinstance(resp, X11Reply), "GetFontPath failed"
original_paths = x11.GetFontPathReply.from_reply(resp.data).paths
# Step 3: Set font path to include evil directory first
new_paths = [evil_dir] + original_paths
req = x11.SetFontPathRequest(paths=new_paths)
xclient.send_request(req.to_bytes())
xclient.flush_responses(timeout=1.0)
# Step 4: ListFonts with pattern matching the alias name.
# This triggers doListFontsAndAliases which resolves the alias
# and copies the oversized target into the stack buffer.
req = x11.ListFontsRequest(pattern=alias_name, max_names=10)
xclient.send_request(req.to_bytes())
time.sleep(0.5)
assert xserver.is_alive, (
"Server crashed - font alias stack buffer overflow (ZDI-CAN-30136)"
)
# Step 5: Restore original font path
req = x11.SetFontPathRequest(paths=original_paths)
xclient.send_request(req.to_bytes())
xclient.flush_responses(timeout=1.0)