diff --git a/test/pyxtest/proto/xi.py b/test/pyxtest/proto/xi.py index aad42e89b..820b6941e 100644 --- a/test/pyxtest/proto/xi.py +++ b/test/pyxtest/proto/xi.py @@ -7,6 +7,8 @@ from dataclasses import dataclass # XI (v1) minor opcodes XChangeDeviceControl = 35 +XChangeDeviceProperty = 37 +XGetDeviceProperty = 39 # XI2 minor opcodes XIQueryVersion = 47 @@ -176,7 +178,6 @@ class XIChangePropertyRequest: 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 = ( @@ -188,12 +189,7 @@ class XIChangePropertyRequest: 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 - ) + length = total_bytes // 4 header = struct.pack( f"{byte_order}BBH HBB II I", @@ -237,6 +233,85 @@ class XIGetPropertyRequest: ) +@dataclass +class XChangeDevicePropertyRequest: + """XChangeDeviceProperty request (XI v1, minor opcode 37). + + Wire format (xChangeDevicePropertyReq): + opcode(1) + ReqType(1) + length(2) + property(4) + type(4) + + deviceid(1) + format(1) + mode(1) + pad(1) + nUnits(4) + Total header: 20 bytes, followed by property data. + """ + + opcode: int + deviceid: int + property_atom: int + type_atom: int + format: int = 32 + mode: int = PropModeReplace + data: bytes = b"" + num_items: 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 = total_bytes // 4 + + header = struct.pack( + f"{byte_order}BBH II BBBx I", + self.opcode, + XChangeDeviceProperty, + length, + self.property_atom, + self.type_atom, + self.deviceid, + self.format, + self.mode, + num_items, + ) + return header + self.data + b"\x00" * pad_len + + +@dataclass +class XGetDevicePropertyRequest: + """XGetDeviceProperty request (XI v1, minor opcode 39). + + Wire format (xGetDevicePropertyReq): + opcode(1) + ReqType(1) + length(2) + property(4) + type(4) + + longOffset(4) + longLength(4) + deviceid(1) + delete(1) + pad(2) + Total: 24 bytes = 6 words. + """ + + opcode: int + deviceid: int + property_atom: int + type_atom: int = 0 # AnyPropertyType + offset: int = 0 + req_length: int = 0xFFFF + delete: bool = False + + def to_bytes(self, byte_order: str = "<") -> bytes: + return struct.pack( + f"{byte_order}BBH II II BBxx", + self.opcode, + XGetDeviceProperty, + 6, # 24 bytes = 6 words + self.property_atom, + self.type_atom, + self.offset, + self.req_length, + self.deviceid, + 1 if self.delete else 0, + ) + + @dataclass class XChangeDeviceControlRequest: """XChangeDeviceControl request (XI v1, minor opcode 35). diff --git a/test/pyxtest/test_xi.py b/test/pyxtest/test_xi.py index f3afa5446..859f0a652 100644 --- a/test/pyxtest/test_xi.py +++ b/test/pyxtest/test_xi.py @@ -23,6 +23,19 @@ def xi_xclient(xclient): return xclient +@pytest.fixture +def xi_xclient_swapped(xclient_swapped): + """Provide a byte-swapped xclient with XI2 initialized.""" + ext = xclient_swapped.query_extension(Extension.XI) + if not ext: + pytest.skip("XInput extension not available") + + req = xi.XIQueryVersionRequest(opcode=ext.opcode) + xclient_swapped.send_request(req.to_bytes(">")) + xclient_swapped.recv_response(timeout=5.0) + return xclient_swapped + + class TestXIPassiveGrab: """Tests for XIPassiveGrabDevice/UngrabDevice vulnerabilities.""" @@ -237,6 +250,85 @@ class TestXIChangeProperty: f"Expected (1, 2, 10, 20, 30), got {values}" ) + @pytest.mark.swapped_client + @pytest.mark.parametrize( + "change_cls,get_cls", + [ + (xi.XIChangePropertyRequest, xi.XIGetPropertyRequest), + (xi.XChangeDevicePropertyRequest, xi.XGetDevicePropertyRequest), + ], + ids=["xi2", "xi1"], + ) + def test_change_property_data_format32_swapped( + self, xserver, xi_xclient_swapped, change_cls, get_cls + ): + """ + SProcXIChangeProperty and SProcXChangeDeviceProperty did not + byte-swap the property data payload for format=32 properties. + + Set a format=32 property from a byte-swapped client with known + values, then read it back. Without the fix, the stored values + have the wrong byte order, causing a round-trip mismatch. + + Fixed in commit 243ef9bc2 ("Xi: Swap property data in + SProcXChangeDeviceProperty/SProcXIChangeProperty"). + """ + conn = xi_xclient_swapped + ext = conn.query_extension(Extension.XI) + + prop_atom = conn.intern_atom("_TEST_SWAP_FORMAT32") + type_atom = conn.intern_atom("INTEGER") + + test_values = [0x12345678, 0xDEADBEEF, 42] + data = b"" + for v in test_values: + data += struct.pack(">I", v) + + req = change_cls( + opcode=ext.opcode, + deviceid=xi.VirtualCorePointer, + property_atom=prop_atom, + type_atom=type_atom, + format=32, + mode=xi.PropModeReplace, + data=data, + ) + conn.send_request(req.to_bytes(">")) + conn.flush_responses(timeout=0.5) + + assert xserver.is_alive, "Server crashed during ChangeProperty" + + req = get_cls( + opcode=ext.opcode, + deviceid=xi.VirtualCorePointer, + property_atom=prop_atom, + type_atom=type_atom, + ) + conn.send_request(req.to_bytes(">")) + resp = conn.recv_response(timeout=2.0) + + assert isinstance(resp, X11Reply), f"Expected a reply, got {resp}" + + # Both XI1 and XI2 GetProperty replies share the same layout + # for the fields we care about: + # bytes 16-19: num_items (CARD32) + # byte 20: format (CARD8) + # bytes 32+: property data + num_items = struct.unpack_from(">I", resp.data, 16)[0] + fmt = resp.data[20] + assert num_items == len(test_values), ( + f"Expected {len(test_values)} items, got {num_items}" + ) + assert fmt == 32, f"Expected format 32, got {fmt}" + + values = struct.unpack_from(f">{num_items}I", resp.data, 32) + assert values == tuple(test_values), ( + f"Property data round-trip failed: expected " + f"{[hex(v) for v in test_values]}, got " + f"{[hex(v) for v in values]} - property data not byte-swapped " + f"in SProc handler for {change_cls.__name__}" + ) + class TestXIChangeDeviceControl: @pytest.mark.swapped_client