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)