mirror of
https://gitlab.freedesktop.org/xorg/xserver.git
synced 2026-06-07 02:58:22 +02:00
pyxtest: add tests for XI property and passive grab CVEs
Commit541ab2ecd4("Xi/randr: fix handling of PropModeAppend/Prepend") Commit8f454b793e("Xi: avoid integer truncation in length check of ProcXIChangeProperty") Commit51eb63b0ee("Xi: disallow passive grabs with a detail > 255") Assisted-by: Claude:claude-claude-opus-4-6 Part-of: <https://gitlab.freedesktop.org/xorg/xserver/-/merge_requests/2187>
This commit is contained in:
parent
bea8d65fc8
commit
fea5cc4b54
3 changed files with 466 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
227
test/pyxtest/proto/xi.py
Normal file
227
test/pyxtest/proto/xi.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
238
test/pyxtest/test_xi.py
Normal file
238
test/pyxtest/test_xi.py
Normal file
|
|
@ -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("<H", resp.data, 8)[0]
|
||||
assert num_modifiers > 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("<III", 10, 20, 30)
|
||||
req = xi.XIChangePropertyRequest(
|
||||
opcode=opcode,
|
||||
deviceid=xi.VirtualCorePointer,
|
||||
property_atom=prop_atom,
|
||||
type_atom=type_atom,
|
||||
format=32,
|
||||
mode=xi.PropModeReplace,
|
||||
data=initial_data,
|
||||
)
|
||||
xi_xclient.send_request(req.to_bytes())
|
||||
xi_xclient.flush_responses(timeout=0.5)
|
||||
|
||||
# Step 2: Prepend values [1, 2]
|
||||
prepend_data = struct.pack("<II", 1, 2)
|
||||
req = xi.XIChangePropertyRequest(
|
||||
opcode=opcode,
|
||||
deviceid=xi.VirtualCorePointer,
|
||||
property_atom=prop_atom,
|
||||
type_atom=type_atom,
|
||||
format=32,
|
||||
mode=xi.PropModePrepend,
|
||||
data=prepend_data,
|
||||
)
|
||||
xi_xclient.send_request(req.to_bytes())
|
||||
xi_xclient.flush_responses(timeout=0.5)
|
||||
|
||||
assert xserver.is_alive, (
|
||||
"Server crashed - OOB write in XIChangeProperty prepend (CVE-2023-5367)"
|
||||
)
|
||||
|
||||
# Step 3: Read back and verify
|
||||
req = xi.XIGetPropertyRequest(
|
||||
opcode=opcode,
|
||||
deviceid=xi.VirtualCorePointer,
|
||||
property_atom=prop_atom,
|
||||
type_atom=type_atom,
|
||||
)
|
||||
xi_xclient.send_request(req.to_bytes())
|
||||
resp = xi_xclient.recv_response(timeout=2.0)
|
||||
|
||||
assert isinstance(resp, X11Reply), f"Expected a reply, got {resp}"
|
||||
num_items = struct.unpack_from("<I", resp.data, 16)[0]
|
||||
assert num_items == 5, (
|
||||
f"Expected 5 items (2 prepended + 3 original), got {num_items}"
|
||||
)
|
||||
|
||||
values = struct.unpack_from(f"<{num_items}I", resp.data, 32)
|
||||
assert values == (1, 2, 10, 20, 30), (
|
||||
f"Expected (1, 2, 10, 20, 30), got {values}"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue