From 5d3b8520ec6c5e920a3b0cde5798a0efde46ac57 Mon Sep 17 00:00:00 2001 From: Peter Hutterer Date: Wed, 26 Aug 2026 17:04:47 +1000 Subject: [PATCH] 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 Assisted-by: Claude:claude-opus-4-6 Part-of: (cherry picked from commit 1b6955c3108e6b43433aefe860070708e6f43d1b) --- Xext/present/present_notify.c | 7 +-- test/pyxtest/proto/x11.py | 90 +++++++++++++++++++++++++++++++++++ test/pyxtest/test_present.py | 87 ++++++++++++++++++++++++++++++++- 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/Xext/present/present_notify.c b/Xext/present/present_notify.c index eac6897022..f17b580d3c 100644 --- a/Xext/present/present_notify.c +++ b/Xext/present/present_notify.c @@ -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(¬ify->window_list); } diff --git a/test/pyxtest/proto/x11.py b/test/pyxtest/proto/x11.py index a96874f85d..47707b8748 100644 --- a/test/pyxtest/proto/x11.py +++ b/test/pyxtest/proto/x11.py @@ -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.""" diff --git a/test/pyxtest/test_present.py b/test/pyxtest/test_present.py index 6a4b0429d2..7a86e11c09 100644 --- a/test/pyxtest/test_present.py +++ b/test/pyxtest/test_present.py @@ -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)" + )