mirror of
https://github.com/X11Libre/xserver.git
synced 2026-11-15 05:23:40 +00:00
__glXDisp_RenderLarge() allocates the large command buffer based on
cmdlen (from hdr->length, the total command size embedded in the
payload), but copies dataBytes (from req->dataBytes, the payload size
of the current sub-request) into it. The existing length check only
validates that the X11 request length is consistent with dataBytes,
not that dataBytes fits within the cmdlen-sized buffer.
A malicious client can set hdr->length to a small value (e.g. 12 bytes)
while sending a large dataBytes (up to ~256KB or more with
BIG-REQUESTS), causing the memcpy to overflow the cmdlen-sized buffer.
Add a check that dataBytes <= cmdlen before the buffer allocation and
copy on the first request.
This vulnerability was discovered by:
Anonymous working with TrendAI Zero Day Initiative
ZDI-CAN-31833
CVE-2026-93517
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 2abe4632d7)
361 lines
14 KiB
Python
361 lines
14 KiB
Python
# SPDX-License-Identifier: MIT
|
|
#
|
|
# Security tests for GLX extension vulnerabilities.
|
|
|
|
import struct
|
|
import time
|
|
|
|
import pytest
|
|
from proto import glx
|
|
from xclient import Extension, X11Error, X11Reply
|
|
|
|
# The initial context tag array size in GlxAllocContextTag is 16.
|
|
INITIAL_TAG_SLOTS = 16
|
|
|
|
|
|
@pytest.fixture
|
|
def glx_xclient(xclient):
|
|
"""Provide an xclient with GLX extension available."""
|
|
ext = xclient.query_extension(Extension.GLX)
|
|
if not ext:
|
|
pytest.skip("GLX extension not available")
|
|
return xclient, ext.opcode
|
|
|
|
|
|
class TestGLXChangeDrawableAttributes:
|
|
"""Tests for GLX ChangeDrawableAttributes length validation."""
|
|
|
|
@pytest.mark.asan
|
|
def test_reversed_length_check_oob_read(self, xserver, glx_xclient):
|
|
"""
|
|
ZDI-CAN-30165: __glXDisp_ChangeDrawableAttributes used a
|
|
reversed comparison operator (<) instead of (>) when validating
|
|
the request length against numAttribs. The check tested whether
|
|
the computed request size was LESS THAN client->req_len, but
|
|
should have tested GREATER THAN. With the reversed operator, a
|
|
request claiming many more attribute pairs than actually present
|
|
passes validation.
|
|
|
|
DoChangeDrawableAttributes then iterates numAttribs attribute
|
|
pairs starting past the request header, reading beyond the
|
|
actual request data (OOB read). If a GLX_EVENT_MASK key is
|
|
found in the overread data, its value is written to
|
|
pGlxDraw->eventMask (OOB write).
|
|
|
|
Attack: Create a GLX context bound to the root window (which
|
|
auto-creates a GLXDrawable), then send ChangeDrawableAttributes
|
|
with length=3 (12 bytes) but numAttribs=2100. The computed
|
|
size 4203 > 3 would fail with the correct check, but 4203 < 3
|
|
is false, so the reversed check passes.
|
|
|
|
Fixed by changing < to > in both glxcmds.c and glxcmdsswap.c.
|
|
"""
|
|
xclient, opcode = glx_xclient
|
|
|
|
# Step 1: Create a direct context on the root visual.
|
|
ctx_id = xclient.alloc_id()
|
|
req = glx.CreateContextRequest(
|
|
opcode=opcode,
|
|
context_id=ctx_id,
|
|
visual=xclient.root_visual,
|
|
screen=0,
|
|
share_list=0,
|
|
is_direct=1,
|
|
)
|
|
xclient.send_request(req.to_bytes())
|
|
resp = xclient.recv_response(timeout=2.0)
|
|
if isinstance(resp, X11Error):
|
|
pytest.skip("GLX CreateContext failed (no GLX support for root visual)")
|
|
|
|
# Step 2: MakeCurrent to bind the context and auto-create
|
|
# a GLXDrawable for the root window.
|
|
req = glx.MakeCurrentRequest(
|
|
opcode=opcode,
|
|
drawable=xclient.root_window,
|
|
context_id=ctx_id,
|
|
old_context_tag=0,
|
|
)
|
|
xclient.send_request(req.to_bytes())
|
|
resp = xclient.recv_response(timeout=2.0)
|
|
if isinstance(resp, X11Error):
|
|
pytest.skip("GLX MakeCurrent failed")
|
|
|
|
# Step 3: ChangeDrawableAttributes with reversed length check.
|
|
# length=3 (12 bytes) but numAttribs=2100.
|
|
# Computed size: (12 + 2100*8) / 4 = 4203 words.
|
|
# Reversed check: 4203 < 3 → FALSE → passes!
|
|
# Correct check: 4203 > 3 → TRUE → BadLength.
|
|
req = glx.ChangeDrawableAttributesRequest(
|
|
opcode=opcode,
|
|
drawable=xclient.root_window,
|
|
num_attribs=0,
|
|
attribs=b"",
|
|
length_override=3,
|
|
num_attribs_override=2100,
|
|
)
|
|
xclient.send_request(req.to_bytes())
|
|
time.sleep(0.5)
|
|
|
|
assert xserver.is_alive, (
|
|
"Server crashed - GLX ChangeDrawableAttributes reversed "
|
|
"length check (ZDI-CAN-30165)"
|
|
)
|
|
|
|
|
|
class TestGLXChangeDrawableAttributesSwap:
|
|
"""Tests for GLX ChangeDrawableAttributes swap handler length validation."""
|
|
|
|
@pytest.mark.swapped_client
|
|
@pytest.mark.asan
|
|
def test_swap_oversized_request_rejected(self, xserver, xclient_swapped):
|
|
"""
|
|
__glXDispSwap_ChangeDrawableAttributes used a manual '>'
|
|
comparison instead of REQUEST_FIXED_SIZE. The manual check
|
|
only rejected undersized requests, silently accepting oversized
|
|
ones. REQUEST_FIXED_SIZE rejects both.
|
|
|
|
Send a request with correct numAttribs=0 but extra trailing
|
|
data (oversized). On a fixed server with REQUEST_FIXED_SIZE,
|
|
this is rejected with BadLength. On the old code with the
|
|
manual '>' check, the oversized request was accepted.
|
|
|
|
Fixed by replacing the manual check with REQUEST_FIXED_SIZE.
|
|
"""
|
|
conn = xclient_swapped
|
|
|
|
ext = conn.query_extension(Extension.GLX)
|
|
if not ext:
|
|
pytest.skip("GLX extension not available")
|
|
|
|
# Step 1: Create a direct context on the root visual.
|
|
ctx_id = conn.alloc_id()
|
|
req = glx.CreateContextRequest(
|
|
opcode=ext.opcode,
|
|
context_id=ctx_id,
|
|
visual=conn.root_visual,
|
|
screen=0,
|
|
share_list=0,
|
|
is_direct=1,
|
|
)
|
|
conn.send_request(req.to_bytes(">"))
|
|
resp = conn.recv_response(timeout=2.0)
|
|
if isinstance(resp, X11Error):
|
|
pytest.skip("GLX CreateContext failed")
|
|
|
|
# Step 2: MakeCurrent to create a GLXDrawable for root window.
|
|
req = glx.MakeCurrentRequest(
|
|
opcode=ext.opcode,
|
|
drawable=conn.root_window,
|
|
context_id=ctx_id,
|
|
old_context_tag=0,
|
|
)
|
|
conn.send_request(req.to_bytes(">"))
|
|
resp = conn.recv_response(timeout=2.0)
|
|
if isinstance(resp, X11Error):
|
|
pytest.skip("GLX MakeCurrent failed")
|
|
|
|
# Step 3: ChangeDrawableAttributes with numAttribs=0 but extra
|
|
# 8 bytes of trailing data. The correct length for numAttribs=0
|
|
# is 3 words (12 bytes). We send 5 words (20 bytes).
|
|
req = glx.ChangeDrawableAttributesRequest(
|
|
opcode=ext.opcode,
|
|
drawable=conn.root_window,
|
|
num_attribs=0,
|
|
attribs=b"\x00" * 8, # 8 extra bytes
|
|
)
|
|
conn.send_request(req.to_bytes(">"))
|
|
time.sleep(0.5)
|
|
|
|
assert xserver.is_alive, (
|
|
"Server crashed - GLX ChangeDrawableAttributes swap handler"
|
|
)
|
|
|
|
|
|
class TestGLXRenderLarge:
|
|
"""Tests for GLX RenderLarge buffer overflow."""
|
|
|
|
@pytest.mark.asan
|
|
def test_render_large_databytes_exceeds_cmdlen(self, xserver, glx_xclient):
|
|
"""
|
|
ZDI-CAN-31833: Heap buffer overflow in __glXDisp_RenderLarge
|
|
from unchecked dataBytes vs cmdlen.
|
|
|
|
``__glXDisp_RenderLarge`` reassembles large GL commands split
|
|
across multiple sub-requests. On the first sub-request
|
|
(requestNumber=1), the large command buffer is allocated to
|
|
``cmdlen`` bytes (from ``hdr->length``, the GL rendering
|
|
command header inside the payload). However, the subsequent
|
|
``memcpy`` copies ``dataBytes`` (from ``req->dataBytes``, the
|
|
X11 request payload size) into the buffer. The existing
|
|
validation only checks that the X11 request length is
|
|
consistent with dataBytes, not that dataBytes fits within the
|
|
cmdlen-sized buffer.
|
|
|
|
A malicious client sends RenderLarge with requestNumber=1
|
|
and a small GL opcode (cmdlen=12 bytes from hdr->length)
|
|
but a large dataBytes (e.g. 4096). The buffer is allocated
|
|
at 12 bytes but 4096 bytes are copied into it.
|
|
|
|
Fixed in commit 04c67974f8 ("glx: validate dataBytes against
|
|
cmdlen in RenderLarge first request").
|
|
"""
|
|
xclient, opcode = glx_xclient
|
|
|
|
# Step 1: Create a GLX context on the root visual
|
|
ctx_id = xclient.alloc_id()
|
|
req = glx.CreateContextRequest(
|
|
opcode=opcode,
|
|
context_id=ctx_id,
|
|
visual=xclient.root_visual,
|
|
screen=0,
|
|
share_list=0,
|
|
is_direct=1,
|
|
)
|
|
xclient.send_request(req.to_bytes())
|
|
resp = xclient.recv_response(timeout=2.0)
|
|
if isinstance(resp, X11Error):
|
|
pytest.skip("GLX CreateContext failed (no GLX support for root visual)")
|
|
|
|
# Step 2: MakeCurrent to get a context tag
|
|
req = glx.MakeCurrentRequest(
|
|
opcode=opcode,
|
|
drawable=xclient.root_window,
|
|
context_id=ctx_id,
|
|
old_context_tag=0,
|
|
)
|
|
xclient.send_request(req.to_bytes())
|
|
resp = xclient.recv_response(timeout=2.0)
|
|
if isinstance(resp, X11Error):
|
|
pytest.skip("GLX MakeCurrent failed")
|
|
assert isinstance(resp, X11Reply), f"Expected MakeCurrent reply, got {resp}"
|
|
context_tag = struct.unpack_from("<I", resp.data, 8)[0]
|
|
|
|
# Step 3: Send RenderLarge with small cmdlen but large dataBytes.
|
|
#
|
|
# The GL render large header is 8 bytes:
|
|
# length (CARD32): total command size = 12 (small)
|
|
# opcode (CARD32): GL opcode = 1 (glCallList, fixed size 8+4=12)
|
|
#
|
|
# We set dataBytes to a large value (4096) but the actual data
|
|
# in the X11 request is also 4096 bytes. The GL header says
|
|
# the total command is only 12 bytes. Without the fix, the
|
|
# server allocates 12 bytes then copies 4096 bytes into it.
|
|
gl_cmdlen = 12 # small GL command
|
|
gl_opcode = 1 # glCallList
|
|
gl_header = struct.pack("<II", gl_cmdlen, gl_opcode)
|
|
|
|
overflow_size = 4096
|
|
# Fill payload: GL header + padding to overflow_size
|
|
payload = gl_header + b"\x41" * (overflow_size - len(gl_header))
|
|
|
|
req = glx.RenderLargeRequest(
|
|
opcode=opcode,
|
|
context_tag=context_tag,
|
|
request_number=1,
|
|
request_total=2, # claim there will be 2 sub-requests
|
|
data_bytes=overflow_size,
|
|
data=payload,
|
|
)
|
|
xclient.send_request(req.to_bytes())
|
|
time.sleep(0.5)
|
|
|
|
assert xserver.is_alive, (
|
|
"Server crashed - GLX RenderLarge dataBytes > cmdlen "
|
|
"heap buffer overflow (ZDI-CAN-31833)"
|
|
)
|
|
|
|
|
|
class TestGlxMakeCurrent:
|
|
"""Tests for CommonMakeCurrent vulnerabilities."""
|
|
|
|
@pytest.mark.asan
|
|
def test_make_current_context_tag_realloc_uaf(self, xserver, glx_xclient):
|
|
"""
|
|
ZDI-CAN-30561: Use-after-free in CommonMakeCurrent due to
|
|
GlxAllocContextTag realloc invalidating oldTag pointer.
|
|
|
|
CommonMakeCurrent() stores a pointer oldTag into the
|
|
cl->contextTags array. In the unfixed code, CommonMakeNewCurrent()
|
|
is called before GlxFreeContextTag(oldTag). CommonMakeNewCurrent()
|
|
calls GlxAllocContextTag() which may realloc() the contextTags
|
|
array if all slots are full, leaving oldTag as a dangling pointer.
|
|
The subsequent GlxFreeContextTag(oldTag) then writes into freed
|
|
memory.
|
|
|
|
To trigger the realloc, all 16 initial context tag slots must be
|
|
simultaneously occupied. This is achieved by creating 17 distinct
|
|
GLX contexts and making each of the first 16 current (with
|
|
oldContextTag=0) so each gets its own tag slot. The 17th
|
|
MakeCurrent switches from the first context to the last with a
|
|
valid oldContextTag, which forces GlxAllocContextTag to grow the
|
|
array while oldTag still points into the old allocation.
|
|
"""
|
|
xclient, opcode = glx_xclient
|
|
num_contexts = INITIAL_TAG_SLOTS + 1
|
|
|
|
# Create num_contexts distinct GLX contexts. Each must be a
|
|
# separate context so that MakeCurrent with oldContextTag=0
|
|
# doesn't get rejected (a context can only be current once).
|
|
contexts = []
|
|
for _ in range(num_contexts):
|
|
ctx = xclient.alloc_id()
|
|
contexts.append(ctx)
|
|
req = glx.CreateContextRequest(
|
|
opcode=opcode,
|
|
context_id=ctx,
|
|
visual=xclient.root_visual,
|
|
screen=0,
|
|
is_direct=1,
|
|
)
|
|
xclient.send_request(req)
|
|
|
|
# CreateContext has no reply on success; errors would be queued.
|
|
errors = xclient.flush_responses(timeout=2.0)
|
|
for resp in errors:
|
|
if isinstance(resp, X11Error):
|
|
pytest.skip(f"GLX CreateContext failed (error {resp.error_code})")
|
|
|
|
# Fill all 16 initial tag slots by making each context current
|
|
# without releasing the previous one (oldContextTag=0).
|
|
# In GLX, different contexts can be current simultaneously
|
|
# from the server's perspective (each gets its own tag slot).
|
|
tags = []
|
|
for i in range(INITIAL_TAG_SLOTS):
|
|
req = glx.MakeCurrentRequest(
|
|
opcode=opcode,
|
|
drawable=xclient.root_window,
|
|
context_id=contexts[i],
|
|
old_context_tag=0,
|
|
)
|
|
xclient.send_request(req)
|
|
resp = xclient.recv_response(timeout=2.0)
|
|
if isinstance(resp, X11Reply):
|
|
tag = struct.unpack_from("<I", resp.data, 8)[0]
|
|
tags.append(tag)
|
|
elif isinstance(resp, X11Error):
|
|
pytest.skip(
|
|
f"GLX MakeCurrent failed at context {i} (error {resp.error_code})"
|
|
)
|
|
|
|
assert len(tags) == INITIAL_TAG_SLOTS
|
|
|
|
# All 16 tag slots are now occupied. A MakeCurrent that switches
|
|
# from contexts[0] (tag 1) to contexts[16] will:
|
|
# 1. CommonLoseCurrent(oldTag) - tell vendor to release context
|
|
# 2. In the unfixed code: CommonMakeNewCurrent() ->
|
|
# GlxAllocContextTag() finds all 16 slots full ->
|
|
# realloc(16->32), invalidating oldTag
|
|
# 3. GlxFreeContextTag(oldTag) writes to freed memory -> UAF
|
|
req = glx.MakeCurrentRequest(
|
|
opcode=opcode,
|
|
drawable=xclient.root_window,
|
|
context_id=contexts[INITIAL_TAG_SLOTS],
|
|
old_context_tag=tags[0],
|
|
)
|
|
xclient.send_request(req)
|
|
time.sleep(0.5)
|
|
|
|
assert xserver.is_alive, (
|
|
"Server crashed - use-after-free in CommonMakeCurrent "
|
|
"due to GlxAllocContextTag realloc (ZDI-CAN-30561)"
|
|
)
|