From 356830248342ebe88285db5e42c89509629b7196 Mon Sep 17 00:00:00 2001 From: Peter Hutterer Date: Wed, 29 Apr 2026 05:40:41 +0000 Subject: [PATCH] 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: --- test/pyxtest/meson.build | 1 + test/pyxtest/proto/x11.py | 107 ++++++++++++++++++++++++++++++++++++++ test/pyxtest/test_font.py | 92 ++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 test/pyxtest/test_font.py diff --git a/test/pyxtest/meson.build b/test/pyxtest/meson.build index 2e9b5932f..69355a099 100644 --- a/test/pyxtest/meson.build +++ b/test/pyxtest/meson.build @@ -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', diff --git a/test/pyxtest/proto/x11.py b/test/pyxtest/proto/x11.py index c1872ff91..4c83af730 100644 --- a/test/pyxtest/proto/x11.py +++ b/test/pyxtest/proto/x11.py @@ -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("= 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.""" diff --git a/test/pyxtest/test_font.py b/test/pyxtest/test_font.py new file mode 100644 index 000000000..492d2f7e6 --- /dev/null +++ b/test/pyxtest/test_font.py @@ -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)