xserver/test/pyxtest/test_present.py
Peter Hutterer 5d3b8520ec present: unlink notifies from window list in present_clear_window_notifies
When a window is destroyed, present_clear_window_notifies()
does not remove the notify list node from the window. The notify
may then be freed as part of the window_priv, leaving dangling
pointers in place.

When the vblank owning the notify is later torn down,
present_free_window_notify() calls xorg_list_del() which
writes through these dangling pointers (use-after-free write).

This happens when Present cross-window notifies are used: a
PresentPixmap on window A can reference window B as a notify target.
An untriggered wait_fence keeps the vblank alive so that B can be
destroyed first, creating the dangling-pointer window.

Fix this by calling the free function we have (and moving the window
reset into that function too). present_clear_window_notifies is only
called from one place anyway.

This vulnerability was discovered by:
  Anonymous working with TrendAI Zero Day Initiative

ZDI-CAN-31830

CVE-2026-93515

Signed-off-by: Peter Hutterer <peter.hutterer@who-t.net>
Assisted-by: Claude:claude-opus-4-6
Part-of: <https://gitlab.freedesktop.org/xorg/xserver/-/merge_requests/2341>
(cherry picked from commit 1b6955c310)
2026-10-20 19:49:20 +02:00

234 lines
8.1 KiB
Python

# SPDX-License-Identifier: MIT
#
# Tests for Present extension.
import time
import pytest
from proto import present, sync, x11
from xclient import Extension, X11Error
@pytest.fixture
def present_xclient(xclient):
"""Provide an xclient with Present initialized."""
ext = xclient.query_extension(Extension.PRESENT)
if not ext:
pytest.skip("Present extension not available")
req = present.QueryVersionRequest(opcode=ext.opcode)
xclient.send_request(req)
xclient.recv_response(timeout=5.0)
return xclient
@pytest.fixture
def present_xclient_swapped(xclient_swapped):
"""Provide a byte-swapped xclient with Present initialized."""
ext = xclient_swapped.query_extension(Extension.PRESENT)
if not ext:
pytest.skip("Present extension not available")
req = present.QueryVersionRequest(opcode=ext.opcode)
xclient_swapped.send_request(req)
xclient_swapped.recv_response(timeout=5.0)
return xclient_swapped
class TestPresentSelectInput:
@pytest.mark.swapped_client
def test_present_select_input_eid_swapped(self, xserver, present_xclient_swapped):
"""
sproc_present_select_input was missing swapl(&stuff->eid).
Without the swap, the eid fails LEGAL_NEW_RESOURCE because
the client-bits portion of the garbled XID doesn't match
clientAsMask → BadIDChoice error.
Fixed in commit a5ac3c871219 ("present: add missing byte
swapping for various fields").
"""
conn = present_xclient_swapped
ext = conn.query_extension(Extension.PRESENT)
win = conn.create_window()
eid = conn.alloc_id()
# Use a non-zero event_mask so the server reaches
# LEGAL_NEW_RESOURCE(eid). With event_mask=0 the server
# returns Success immediately without validating the eid.
PresentConfigureNotifyMask = 1
req = present.SelectInputRequest(
opcode=ext.opcode,
eid=eid,
window=win,
event_mask=PresentConfigureNotifyMask,
)
conn.send_request(req)
responses = conn.flush_responses(timeout=1.0)
assert xserver.is_alive, "Server crashed"
# With the fix: no error (void request succeeds silently).
# Without the fix: BadIDChoice error.
errors = [r for r in responses if isinstance(r, X11Error)]
assert len(errors) == 0, (
f"PresentSelectInput returned error(s): {errors} - "
"eid not swapped → BadIDChoice"
)
class TestPresentNotify:
"""Tests for PresentPixmap notify array byte-swap fix.
Fix: present: Fix missing byte swaps in sproc_present_pixmap()
The xPresentNotify array following the fixed header was not
byte-swapped at all. Each entry has window (CARD32) and serial
(CARD32) fields that need swapl(). Without swapping, a
byte-swapped client's window IDs are garbled, causing
dixLookupWindow to fail with BadWindow.
Fixed in commit 925edb6c9e ("present: Fix missing byte swaps in
sproc_present_pixmap()").
"""
@pytest.mark.swapped_client
def test_present_pixmap_notifies_window_swapped(
self, xserver, present_xclient_swapped
):
"""
sproc_present_pixmap was missing byte swaps for the variable-length
xPresentNotify array.
Send a PresentPixmap request with a notify entry whose window
field is a valid window created by this client. Without the swap,
the window ID is garbled and dixLookupWindow fails with BadWindow.
With the swap, the window ID is correctly interpreted.
Fixed in commit 925edb6c9e ("present: Fix missing byte swaps in
sproc_present_pixmap()").
"""
conn = present_xclient_swapped
ext = conn.query_extension(Extension.PRESENT)
win = conn.create_window()
pixmap = conn.create_pixmap()
# The notify window is the same window as the main request window.
# With the fix, the window ID in the notify is correctly swapped
# and the lookup succeeds. Without the fix, the garbled ID causes
# BadWindow.
notify = present.PresentNotify(window=win, serial=1)
req = present.PixmapRequest(
opcode=ext.opcode,
window=win,
pixmap=pixmap,
serial=0,
notifies=[notify],
)
conn.send_request(req)
responses = conn.flush_responses(timeout=1.0)
assert xserver.is_alive, "Server crashed"
# With the fix: either success (no error for void request) or
# a non-BadWindow error (e.g. BadMatch from the present
# implementation). The key point is no BadWindow (error 3).
# Without the fix: BadWindow because the notify's window ID
# was not byte-swapped.
bad_window_errors = [
r
for r in responses
if isinstance(r, X11Error) and r.error_code == x11.BadWindow
]
assert len(bad_window_errors) == 0, (
f"PresentPixmap returned BadWindow error(s): "
f"{bad_window_errors} - notify window IDs not "
"byte-swapped in sproc_present_pixmap"
)
@pytest.mark.asan
def test_cross_window_notify_uaf(self, xserver, present_xclient):
"""
ZDI-CAN-31830: Use-after-free write in Present cross-window
notify teardown.
A PresentPixmap request can include a notify list referencing
windows other than the target window. Each notify entry is
linked into both the vblank's notifies array and the
referenced window's notify list. When the notify-target
window (winB) is destroyed, we expect the notifies
to be cleaned up so that when the vblank is later torn
down (e.g. by destroying winA), we don't run into
any dangling pointers.
The bug requires an untriggered wait_fence to keep the vblank
alive across the window destructions.
"""
conn = present_xclient
present_ext = conn.query_extension(Extension.PRESENT)
if not present_ext:
pytest.skip("Present extension not available")
sync_ext = conn.query_extension(Extension.SYNC)
if not sync_ext:
pytest.skip("SYNC extension not available")
# Negotiate SYNC version
req = sync.InitializeRequest(opcode=sync_ext.opcode)
conn.send_request(req)
conn.recv_response(timeout=5.0)
# Create two windows and a pixmap
win_a = conn.create_window()
win_b = conn.create_window()
pixmap = conn.create_pixmap()
# Create an untriggered fence to keep the vblank alive
fence_id = conn.alloc_id()
req = sync.CreateFenceRequest(
opcode=sync_ext.opcode,
drawable=conn.root_window,
fence_id=fence_id,
initially_triggered=0,
)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
# PresentPixmap on winA with notify targeting winB and an
# untriggered wait_fence. The fence prevents the vblank from
# completing, keeping the notify entries alive.
notify = present.PresentNotify(window=win_b, serial=1)
req = present.PixmapRequest(
opcode=present_ext.opcode,
window=win_a,
pixmap=pixmap,
serial=0,
wait_fence=fence_id,
notifies=[notify],
)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
# Destroy winB: present_clear_window_notifies runs, but
# without the fix it does not unlink the notify node.
req = x11.DestroyWindowRequest(window=win_b)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
# Destroy winA: triggers vblank teardown which calls
# present_destroy_notifies -> xorg_list_del on the dangling
# node -> UAF write.
req = x11.DestroyWindowRequest(window=win_a)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
time.sleep(0.5)
assert xserver.is_alive, (
"Server crashed - Present cross-window notify "
"use-after-free (ZDI-CAN-31830)"
)