diff --git a/test/pyxtest/meson.build b/test/pyxtest/meson.build index d7f7bfc3c..9c298c534 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_xi.py', ] test_list_data = configuration_data() diff --git a/test/pyxtest/proto/xi.py b/test/pyxtest/proto/xi.py new file mode 100644 index 000000000..20f889c50 --- /dev/null +++ b/test/pyxtest/proto/xi.py @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: MIT +# +# XI/XI2 (XInput) extension protocol request builders + +import struct +from dataclasses import dataclass + +# XI2 minor opcodes +XIQueryVersion = 47 +XIPassiveGrabDevice = 54 +XIPassiveUngrabDevice = 55 +XIChangeProperty = 57 +XIGetProperty = 59 + +XI2_MAJOR = 2 +XI2_MINOR = 4 + +# Grab types +XIGrabtypeButton = 0 +XIGrabtypeKeycode = 1 +XIGrabtypeEnter = 2 +XIGrabtypeFocusIn = 3 + +# Grab modes +XIGrabModeSync = 0 +XIGrabModeAsync = 1 + +# Special values +XIAllDevices = 0 +XIAllMasterDevices = 1 +XIAnyModifier = 1 << 31 + +# Grab status codes (returned as X11 error codes when used as +# ProcXIPassiveGrabDevice return values) +XIAlreadyGrabbed = 1 + +# Property modes +PropModeReplace = 0 +PropModePrepend = 1 +PropModeAppend = 2 + +# Virtual core device IDs (always present) +VirtualCorePointer = 2 +VirtualCoreKeyboard = 3 + + +@dataclass +class XIQueryVersionRequest: + """XIQueryVersion request.""" + + opcode: int + major: int = XI2_MAJOR + minor: int = XI2_MINOR + + def to_bytes(self, byte_order: str = "<") -> bytes: + return struct.pack( + f"{byte_order}BBHHH", + self.opcode, + XIQueryVersion, + 2, # 8 bytes = 2 words + self.major, + self.minor, + ) + + +@dataclass +class XIPassiveGrabDeviceRequest: + """XIPassiveGrabDevice request.""" + + opcode: int + grab_window: int + detail: int + deviceid: int = XIAllMasterDevices + grab_type: int = XIGrabtypeButton + grab_mode: int = XIGrabModeAsync + paired_device_mode: int = XIGrabModeAsync + owner_events: bool = False + mask: bytes = b"\x00" * 4 + modifiers: list[int] | None = None + + def to_bytes(self, byte_order: str = "<") -> bytes: + mods = self.modifiers if self.modifiers is not None else [XIAnyModifier] + num_modifiers = len(mods) + + mask_padded = self.mask + b"\x00" * ((4 - len(self.mask) % 4) % 4) + mask_len = len(mask_padded) // 4 + + # Header: 32 bytes, mask: mask_len*4, modifiers: num_modifiers*4 + total = 32 + len(mask_padded) + num_modifiers * 4 + length = total // 4 + + header = struct.pack( + f"{byte_order}BBH IIII HHH BBBB H", + self.opcode, + XIPassiveGrabDevice, + length, + 0, # time = CurrentTime + self.grab_window, + 0, # cursor = None + self.detail, + self.deviceid, + num_modifiers, + mask_len, + self.grab_type, + self.grab_mode, + self.paired_device_mode, + 1 if self.owner_events else 0, + 0, # pad + ) + + mod_data = b"" + for mod in mods: + mod_data += struct.pack(f"{byte_order}I", mod) + + return header + mask_padded + mod_data + + +@dataclass +class XIPassiveUngrabDeviceRequest: + """XIPassiveUngrabDevice request.""" + + opcode: int + grab_window: int + detail: int + deviceid: int = XIAllMasterDevices + grab_type: int = XIGrabtypeButton + modifiers: list[int] | None = None + + def to_bytes(self, byte_order: str = "<") -> bytes: + mods = self.modifiers if self.modifiers is not None else [XIAnyModifier] + num_modifiers = len(mods) + + # Header is 20 bytes, followed by num_modifiers * 4 bytes + length = (20 + num_modifiers * 4) // 4 + + header = struct.pack( + f"{byte_order}BBH II HH Bx H", + self.opcode, + XIPassiveUngrabDevice, + length, + self.grab_window, + self.detail, + self.deviceid, + num_modifiers, + self.grab_type, + # pad0, pad1 + 0, + ) + + mod_data = b"" + for mod in mods: + mod_data += struct.pack(f"{byte_order}I", mod) + + return header + mod_data + + +@dataclass +class XIChangePropertyRequest: + """XIChangeProperty request.""" + + opcode: int + deviceid: int + property_atom: int + type_atom: int + format: int = 32 + mode: int = PropModeReplace + data: bytes = b"" + num_items: int | None = None + length_override: int | None = None + + def to_bytes(self, byte_order: str = "<") -> bytes: + num_items = ( + self.num_items + if self.num_items is not None + else (len(self.data) // (self.format // 8) if self.data else 0) + ) + + total_bytes = 20 + len(self.data) + pad_len = (4 - total_bytes % 4) % 4 + total_bytes += pad_len + + length = ( + self.length_override + if self.length_override is not None + else total_bytes // 4 + ) + + header = struct.pack( + f"{byte_order}BBH HBB II I", + self.opcode, + XIChangeProperty, + length, + self.deviceid, + self.mode, + self.format, + self.property_atom, + self.type_atom, + num_items, + ) + return header + self.data + b"\x00" * pad_len + + +@dataclass +class XIGetPropertyRequest: + """XIGetProperty request.""" + + opcode: int + deviceid: int + property_atom: int + type_atom: int = 0 # AnyPropertyType + offset: int = 0 + length: int = 0xFFFF + delete: bool = False + + def to_bytes(self, byte_order: str = "<") -> bytes: + return struct.pack( + f"{byte_order}BBH HBx II II", + self.opcode, + XIGetProperty, + 6, # 24 bytes = 6 words + self.deviceid, + 1 if self.delete else 0, + self.property_atom, + self.type_atom, + self.offset, + self.length, + ) diff --git a/test/pyxtest/test_xi.py b/test/pyxtest/test_xi.py new file mode 100644 index 000000000..7c1a0d2c2 --- /dev/null +++ b/test/pyxtest/test_xi.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: MIT +# +# Security tests for XI/XI2 (XInput) extension vulnerabilities. + +import struct + +import pytest + +from proto import xi +from xclient import Extension, X11Error, X11Reply + + +@pytest.fixture +def xi_xclient(xclient): + """Provide an xclient with XI2 initialized.""" + ext = xclient.query_extension(Extension.XI) + if not ext: + pytest.skip("XInput extension not available") + + req = xi.XIQueryVersionRequest(opcode=ext.opcode) + xclient.send_request(req.to_bytes()) + xclient.recv_response(timeout=5.0) + return xclient + + +class TestXIPassiveGrab: + """Tests for XIPassiveGrabDevice/UngrabDevice vulnerabilities.""" + + def test_passive_grab_detail_above_255(self, xserver, xi_xclient): + """ + CVE-2022-46341 / ZDI-CAN-19381: OOB write via oversized detail + in XIPassiveGrabDevice. + + The ``detail`` field is 32 bits on the wire but the server's + grab mask arrays are only 256 bits wide. A detail > 255 + causes an OOB array access in the grab handling code. + + The fix pretends that details > 255 are already grabbed, + returning a reply with XIAlreadyGrabbed status for every + requested modifier. + + Fixed in commit 51eb63b0ee15 ("Xi: disallow passive grabs with a + detail > 255"). + """ + opcode = xi_xclient.query_extension(Extension.XI).opcode + + wid = xi_xclient.create_window() + + req = xi.XIPassiveGrabDeviceRequest( + opcode=opcode, + grab_window=wid, + detail=256, # OOB: valid range is 0-255 + grab_type=xi.XIGrabtypeButton, + ) + xi_xclient.send_request(req.to_bytes()) + resp = xi_xclient.recv_response(timeout=2.0) + + assert xserver.is_alive, ( + "Server crashed - OOB in XIPassiveGrabDevice (CVE-2022-46341)" + ) + # The server returns a normal reply with every modifier marked + # as XIAlreadyGrabbed. + assert isinstance(resp, X11Reply), f"Expected a reply, got {resp}" + num_modifiers = struct.unpack_from(" 0, "Expected at least one failed modifier" + for i in range(num_modifiers): + status = resp.data[32 + i * 8 + 4] + assert status == xi.XIAlreadyGrabbed, ( + f"Modifier {i}: expected XIAlreadyGrabbed ({xi.XIAlreadyGrabbed}), " + f"got status {status}" + ) + + def test_passive_ungrab_detail_oob_write(self, xserver, xi_xclient): + """ + CVE-2022-46341 / ZDI-CAN-19381: OOB write in + ProcXIPassiveUngrabDevice via oversized detail value. + + The ``detail`` field is 32 bits on the wire but + DeleteDetailFromMask() uses it to index into a 256-bit + (8 x CARD32) mask array. A detail > 255 writes past the end + of the allocated mask, corrupting heap memory. + + The fix rejects detail > 255 early, returning BadValue. + + Fixed in commit 51eb63b0ee15 ("Xi: disallow passive grabs with a + detail > 255"). + """ + opcode = xi_xclient.query_extension(Extension.XI).opcode + + wid = xi_xclient.create_window() + + req = xi.XIPassiveUngrabDeviceRequest( + opcode=opcode, + grab_window=wid, + detail=256, # OOB: valid range is 0-255 + grab_type=xi.XIGrabtypeButton, + ) + xi_xclient.send_request(req.to_bytes()) + resp = xi_xclient.recv_response(timeout=2.0) + + assert xserver.is_alive, ( + "Server crashed - OOB write in XIPassiveUngrabDevice (CVE-2022-46341)" + ) + # The fix returns BadValue (error code 2) + assert isinstance(resp, X11Error), f"Expected an error reply, got {resp}" + assert resp.error_code == 2, ( + f"Expected BadValue (2), got error code {resp.error_code}" + ) + + +class TestXIChangeProperty: + """Tests for XIChangeProperty vulnerabilities.""" + + def test_num_items_overflow_truncation(self, xserver, xi_xclient): + """ + CVE-2022-46344 / ZDI-CAN-19405: Integer truncation in + ProcXIChangeProperty length check. + + ``totalSize = num_items * (format / 8)`` was computed as a 32-bit + int. With format=32 and num_items=0x40000000, the multiplication + overflows to 0, passing the REQUEST_FIXED_SIZE check. The server + would then read num_items elements from beyond the request buffer. + + The fix changed totalSize from ``int`` to ``uint64_t``. + + Fixed in commit 8f454b793e1f ("Xi: avoid integer truncation in + length check of ProcXIChangeProperty"). + """ + opcode = xi_xclient.query_extension(Extension.XI).opcode + + prop_atom = xi_xclient.intern_atom("_TEST_OVERFLOW") + type_atom = xi_xclient.intern_atom("INTEGER") + + # format=32 (4 bytes per item), num_items=0x40000000 + # totalSize as int32: 0x40000000 * 4 = 0x100000000 → truncates to 0 + # totalSize as uint64: 0x100000000 → fails REQUEST_FIXED_SIZE + req = xi.XIChangePropertyRequest( + opcode=opcode, + deviceid=xi.VirtualCorePointer, + property_atom=prop_atom, + type_atom=type_atom, + format=32, + mode=xi.PropModeReplace, + num_items=0x40000000, + data=b"", # no actual data + ) + xi_xclient.send_request(req.to_bytes()) + resp = xi_xclient.recv_response(timeout=2.0) + + assert xserver.is_alive, ( + "Server crashed - integer truncation in XIChangeProperty (CVE-2022-46344)" + ) + # The server should reject with BadLength (16). Without the fix + # the truncated totalSize (0) passes REQUEST_FIXED_SIZE and the + # server tries to allocate 4 GB, failing with BadAlloc (11) + # instead. + assert isinstance(resp, X11Error), f"Expected an error, got {resp}" + assert resp.error_code == 16, ( + f"Expected BadLength (16), got error code {resp.error_code} - " + f"integer truncation not caught by length check" + ) + + def test_prepend_property_size_and_offset(self, xserver, xi_xclient): + """ + CVE-2023-5367 / ZDI-CAN-22153: Incorrect size and offset + calculation when prepending to XI device properties. + + Two bugs in XIChangeDeviceProperty (and the identical RandR code): + 1. new_value.size was set to ``len`` instead of ``total_len`` + (new + existing), so the property lost the old data's size. + 2. The old_data offset for PropModePrepend used + ``prop_value->size`` instead of ``len``, placing old data at + the wrong position and writing out of bounds. + + This test sets a property, then prepends to it and reads back + the result. On a fixed server, the property contains all values + in the correct order. + + Fixed in commit 541ab2ecd41d ("Xi/randr: fix handling of + PropModeAppend/Prepend"). + """ + opcode = xi_xclient.query_extension(Extension.XI).opcode + + prop_atom = xi_xclient.intern_atom("_TEST_PREPEND") + type_atom = xi_xclient.intern_atom("INTEGER") + + # Step 1: Set initial property with values [10, 20, 30] + initial_data = struct.pack("