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)
This commit is contained in:
Peter Hutterer 2026-08-26 17:04:47 +10:00 • committed by Enrico Weigelt
commit 5d3b8520ec
3 changed files with 180 additions and 4 deletions

View file

@ -32,14 +32,14 @@
void
present_clear_window_notifies(WindowPtr window)
{
present_notify_ptr notify;
present_notify_ptr notify, tmp;
present_window_priv_ptr window_priv = present_window_priv(window);
if (!window_priv)
return;
xorg_list_for_each_entry(notify, &window_priv->notifies, window_list) {
notify->window = NULL;
xorg_list_for_each_entry_safe(notify, tmp, &window_priv->notifies, window_list) {
present_free_window_notify(notify);
}
}
@ -50,6 +50,7 @@ present_clear_window_notifies(WindowPtr window)
void
present_free_window_notify(present_notify_ptr notify)
{
notify->window = NULL;
xorg_list_del(&notify->window_list);
}

View file

@ -470,6 +470,96 @@ class PolyText8Request:
)
@dataclass
class DestroyWindowRequest:
"""X11 DestroyWindow request (opcode 4).
Wire format:
CARD8 opcode (4)
CARD8 unused
CARD16 length (2)
CARD32 window
"""
window: int
def to_bytes(self, byte_order: str = "<") -> bytes:
return struct.pack(
f"{byte_order}BBH I",
4, # DestroyWindow opcode
0,
2, # length = 2 words
self.window,
)
@dataclass
class MapWindowRequest:
"""X11 MapWindow request (opcode 8).
Wire format:
CARD8 opcode (8)
CARD8 unused
CARD16 length (2)
CARD32 window
"""
window: int
def to_bytes(self, byte_order: str = "<") -> bytes:
return struct.pack(
f"{byte_order}BBH I",
8, # MapWindow opcode
0,
2, # length = 2 words
self.window,
)
@dataclass
class WarpPointerRequest:
"""X11 WarpPointer request (opcode 41).
Wire format:
CARD8 opcode (41)
CARD8 unused
CARD16 length (6)
CARD32 srcWindow (None = 0)
CARD32 dstWindow (None = 0)
INT16 srcX
INT16 srcY
CARD16 srcWidth
CARD16 srcHeight
INT16 dstX
INT16 dstY
"""
dst_window: int = 0
dst_x: int = 0
dst_y: int = 0
src_window: int = 0
src_x: int = 0
src_y: int = 0
src_width: int = 0
src_height: int = 0
def to_bytes(self, byte_order: str = "<") -> bytes:
return struct.pack(
f"{byte_order}BBH II hh HH hh",
41, # WarpPointer opcode
0,
6, # length = 6 words (24 bytes)
self.src_window,
self.dst_window,
self.src_x,
self.src_y,
self.src_width,
self.src_height,
self.dst_x,
self.dst_y,
)
@dataclass
class ForceScreenSaver:
"""X11 ForceScreenSaver request."""

View file

@ -2,8 +2,10 @@
#
# Tests for Present extension.
import time
import pytest
from proto import present, x11
from proto import present, sync, x11
from xclient import Extension, X11Error
@ -147,3 +149,86 @@ class TestPresentNotify:
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)"
)