test/pyxtest: add test for ScreenSaver CreateSaverWindow UAF (ZDI-CAN-30168)

Add screensaver protocol builders for SetAttributes, UnsetAttributes, and
ForceScreenSaver, then add a regression test that reproduces the
CreateSaverWindow use-after-free.

The test sequence:
1. SetAttributes(root, 100x100, mask=0) - creates screen private with attr
2. ForceScreenSaver(Active) - creates the saver window
3. UnsetAttributes(root) - clears pPriv->attr to NULL
4. ForceScreenSaver(Active) - re-enters CreateSaverWindow

Without the fix, step 4 triggers CheckScreenPrivate which finds all fields
empty (attr=NULL, events=NULL, hasWindow=FALSE, installedMap=None), frees
pPriv, and sets the screen private to NULL. The function then dereferences
the freed pPriv->attr pointer, causing a use-after-free.

Assisted-by: Claude:claude-opus-4-6
Part-of: <https://gitlab.freedesktop.org/xorg/xserver/-/merge_requests/2228>
This commit is contained in:
Peter Hutterer 2026-04-20 18:43:30 +10:00
parent 471650430b
commit 57129a43b7
3 changed files with 170 additions and 1 deletions

View file

@ -7,6 +7,9 @@ from dataclasses import dataclass
# ScreenSaver minor opcodes
ScreenSaverQueryVersion = 0
ScreenSaverSelectInput = 1
ScreenSaverSetAttributes = 3
ScreenSaverUnsetAttributes = 4
ScreenSaverSuspend = 5
@ -27,3 +30,73 @@ class SuspendRequest:
length,
self.suspend,
)
@dataclass
class SetAttributesRequest:
"""ScreenSaverSetAttributes request (28 bytes = 7 words, mask=0).
xScreenSaverSetAttributesReq:
reqType(1) + saverReqType(1) + length(2)
drawable(4)
x(2) + y(2)
width(2) + height(2)
borderWidth(2) + c_class(1) + depth(1)
visualID(4)
mask(4)
[values...]
"""
opcode: int
drawable: int
x: int = 0
y: int = 0
width: int = 100
height: int = 100
border_width: int = 0
c_class: int = 0 # CopyFromParent
depth: int = 0 # CopyFromParent
visual_id: int = 0 # CopyFromParent
mask: int = 0
values: bytes = b""
def to_bytes(self, byte_order: str = "<") -> bytes:
total_bytes = 28 + len(self.values)
pad_len = (4 - total_bytes % 4) % 4
total_bytes += pad_len
length = total_bytes // 4
header = struct.pack(
f"{byte_order}BBHIhhHHHBBII",
self.opcode,
ScreenSaverSetAttributes,
length,
self.drawable,
self.x,
self.y,
self.width,
self.height,
self.border_width,
self.c_class,
self.depth,
self.visual_id,
self.mask,
)
return header + self.values + b"\x00" * pad_len
@dataclass
class UnsetAttributesRequest:
"""ScreenSaverUnsetAttributes request (8 bytes = 2 words)."""
opcode: int
drawable: int
def to_bytes(self, byte_order: str = "<") -> bytes:
return struct.pack(
f"{byte_order}BBH I",
self.opcode,
ScreenSaverUnsetAttributes,
2, # length = 2 words
self.drawable,
)

View file

@ -10,6 +10,11 @@ CreateWindow = 1
CreatePixmap = 53
InternAtom = 16
QueryExtension = 98
ForceScreenSaverOpcode = 115
ScreenSaverReset = 0
ScreenSaverActive = 1
def _pad(data: bytes) -> bytes:
@ -116,3 +121,18 @@ class QueryExtensionRequest:
)
+ padded
)
@dataclass
class ForceScreenSaver:
"""X11 ForceScreenSaver request."""
mode: int
def to_bytes(self, byte_order: str = "<") -> bytes:
return struct.pack(
f"{byte_order}BBH",
ForceScreenSaverOpcode,
self.mode,
1, # length = 1 word
)

View file

@ -6,10 +6,19 @@ import time
import pytest
from proto import screensaver
from proto import screensaver, x11
from xclient import Extension
@pytest.fixture
def screensaver_xclient(xclient):
"""Provide an xclient with the MIT-SCREEN-SAVER extension queried."""
ext = xclient.query_extension(Extension.MIT_SCREEN_SAVER)
if not ext:
pytest.skip("MIT-SCREEN-SAVER extension not available")
return xclient, ext.opcode
class TestScreenSaverSuspend:
"""Tests for SProcScreenSaverSuspend vulnerabilities."""
@ -44,3 +53,70 @@ class TestScreenSaverSuspend:
assert xserver.is_alive, (
"Server crashed - SProcScreenSaverSuspend (CVE-2021-4010)"
)
class TestCreateSaverWindow:
"""Tests for CreateSaverWindow use-after-free via CheckScreenPrivate."""
@pytest.mark.asan
def test_create_saver_window_uaf(self, xserver, screensaver_xclient):
"""
ZDI-CAN-30168: CreateSaverWindow stores pPriv in a local
variable at function entry. When an existing saver window is
being replaced, it sets pPriv->hasWindow = FALSE and calls
CheckScreenPrivate(). If pPriv->attr is NULL (cleared by a
prior UnsetAttributes), pPriv->events is NULL, and
pPriv->installedMap is None, CheckScreenPrivate frees pPriv
and sets the screen private to NULL. The function then
dereferences the freed pPriv->attr pointer on the next line.
Attack sequence:
1. SetAttributes (creates pPriv with pPriv->attr set)
2. ForceScreenSaver(Active) (creates saver window)
3. UnsetAttributes (sets pPriv->attr = NULL)
4. ForceScreenSaver(Active) (re-enters CreateSaverWindow UAF)
Fixed by re-fetching pPriv from the screen private after
CheckScreenPrivate returns.
"""
conn, opcode = screensaver_xclient
# Step 1: SetAttributes(root, 100x100, mask=0)
# Creates pPriv with pPriv->attr set.
req = screensaver.SetAttributesRequest(
opcode=opcode,
drawable=conn.root_window,
width=100,
height=100,
mask=0,
)
conn.send_request(req.to_bytes())
conn.flush_responses(timeout=0.5)
# Step 2: ForceScreenSaver(Active)
# Activates the screen saver, creating the saver window.
req = x11.ForceScreenSaver(mode=x11.ScreenSaverActive)
conn.send_request(req.to_bytes())
conn.flush_responses(timeout=0.5)
time.sleep(0.2)
# Step 3: UnsetAttributes(root)
# Sets pPriv->attr = NULL but does not destroy the saver window.
req = screensaver.UnsetAttributesRequest(
opcode=opcode,
drawable=conn.root_window,
)
conn.send_request(req.to_bytes())
conn.flush_responses(timeout=0.5)
# Step 4: ForceScreenSaver(Active) again → triggers UAF.
# CreateSaverWindow: cleanup block frees pPriv via
# CheckScreenPrivate, then reads pPriv->attr from freed memory.
req = x11.ForceScreenSaver(mode=x11.ScreenSaverActive)
conn.send_request(req.to_bytes())
time.sleep(0.5)
assert xserver.is_alive, (
"Server crashed - CreateSaverWindow UAF (ZDI-CAN-30168)"
)