xserver/test/pyxtest/test_render.py
Octavia Togami bb59d6895f Fix use-after-free caused by duplicate glyphs in one glyphset
We make two glyphsets, GS1 and GS2, both with the same format depth.
GS1 contains any glyph different from the others, let's call it U
(unique). GS2 contains two glyphs with the same content, or at least
sha1 hash. Let's call them A and B.

We first register GS1 and GS2. GS1 has no problems, but before the
fix, registering GS2 breaks accounting in `globalGlyphs`. Since there
is no check for duplicate glyphs within a single set, we make two
`GlyphNew` entries for A and B. Now in the loop at the end, we go over
each entry and call `AddGlyph` and `FreeGlyph`:
1. AddGlyph(GS2, A, idA) inserts A into `globalGlyphs`
2. FreeGlyph(A) drops A's refcnt by one, but as it's being held in
   `globalGlyphs` it's kept alive.
3. AddGlyph(GS2, B, idB) finds A in `globalGlyphs` and reuses it
   instead of inserting B
4. FreeGlyph(B) drops B's refcnt by one (to zero), and B is freed.
   Before the fix, the lookup in `globalGlyphs` finds A's entry, and
   since it isn't a DeletedGlyph, it erroneously removes the
   `gr->glyph` pointing to `A` and decrements `tableEntries`. This
   results in `tableEntries` being off-by-one as `A` is still alive.

Now, we free the glyphsets in a particular order. Freeing GS1 results
in calling FreeGlyph(U), which will decrement U's refcnt to zero, and
free U. This clears out the entry in `globalGlyphs` for U and
decrements `tableEntries`, which due to the earlier error is now zero.
When `FreeGlyphSet` sees this, it clears the `globalGlyphs` for that
format depth. Now freeing A or GS2 (which transitively frees A) will
try to check the `globalGlyphs` at that format depth, but because it
was cleared out this crashes.

The fix here is to change FreeGlyph to only free a glyph from
`globalGlyphs` that is exactly the glyph being freed, so that
FreeGlyph(B) doesn't touch `globalGlyphs` at all. This keeps a proper
count in `tableEntries` and prevents `globalGlyphs[fdepth]` from being
freed before A is freed.

Closes: #1881
Part-of: <https://gitlab.freedesktop.org/xorg/xserver/-/merge_requests/2264>
(cherry picked from commit 81edc9caf557f7a44c5b308742814457de0eb6c0)
(cherry picked from commit 716d876142280cd66bdcf000f81428dadd5d3937)
Signed-off-by: Octavia Togami <octy@octyl.net>
2026-10-12 15:37:47 +02:00

225 lines
7.5 KiB
Python

# SPDX-License-Identifier: MIT
#
# Security tests for Render extension vulnerabilities.
import time
import pytest
from proto import render
from xclient import Extension, X11Error, X11Reply
@pytest.fixture
def render_xclient(xclient):
"""Provide an xclient with Render initialized."""
ext = xclient.query_extension(Extension.RENDER)
if not ext:
pytest.skip("RENDER extension not available")
req = render.QueryVersionRequest(opcode=ext.opcode)
xclient.send_request(req)
xclient.recv_response(timeout=5.0)
return xclient, ext.opcode
@pytest.fixture
def render_xclient_swapped(xclient_swapped):
"""Provide a byte-swapped xclient with Render initialized."""
ext = xclient_swapped.query_extension(Extension.RENDER)
if not ext:
pytest.skip("RENDER extension not available")
req = render.QueryVersionRequest(opcode=ext.opcode)
xclient_swapped.send_request(req)
xclient_swapped.recv_response(timeout=5.0)
return xclient_swapped, ext.opcode
def _find_a8_format(conn, opcode, byte_order="<"):
"""Find the PictFormat ID for A8 (depth=8, type=Direct, alpha=0/0xff).
Returns the format ID or None if not found.
"""
req = render.QueryPictFormatsRequest(opcode=opcode)
conn.send_request(req)
resp = conn.recv_response(timeout=5.0)
assert isinstance(resp, X11Reply), "QueryPictFormats failed"
reply = render.QueryPictFormatsReply.from_reply(resp.data, byte_order)
for fmt in reply.formats:
# A8: type=Direct(1), depth=8, alpha=0, alpha_mask=0xff
if (
fmt.type == 1
and fmt.depth == 8
and fmt.alpha == 0
and fmt.alpha_mask == 0xFF
):
return fmt.id
return None
class TestRenderGlyphs:
"""Tests for Render glyph vulnerabilities."""
def test_duplicate_glyphs_use_after_free(self, xserver, render_xclient):
"""
Issue #1881: Use-after-free caused by duplicate glyphs in one
glyphset.
Two glyphsets GS1 and GS2 share the same A8 format. GS1 has one
unique glyph U; GS2 has two glyphs A and B with identical pixel
data (same sha1 hash). When GS2 is registered, AddGlyph inserts
A into globalGlyphs, then reuses A for B (same hash). FreeGlyph
on B erroneously removes A's entry from globalGlyphs and
decrements tableEntries. This off-by-one means freeing GS1
(which frees U and drops tableEntries to zero) causes
globalGlyphs for that depth to be freed. Subsequent freeing of
GS2 (which frees A) accesses the freed globalGlyphs and crashes.
"""
conn, opcode = render_xclient
a8_format = _find_a8_format(conn, opcode)
if a8_format is None:
pytest.skip("No A8 PictFormat available")
# Create GS1 with one unique glyph (glyph ID 100)
gs1 = conn.alloc_id()
req = render.CreateGlyphSetRequest(
opcode=opcode, glyph_set_id=gs1, format_id=a8_format
)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
# 4x1 A8 glyph: 4 bytes of pixel data, unique content
glyph_info = (4, 1, 0, 0, 4, 0) # width, height, x, y, xOff, yOff
unique_data = bytes([0x11, 0x22, 0x33, 0x44])
req = render.AddGlyphsRequest(
opcode=opcode,
glyph_set_id=gs1,
glyphs=[(100, glyph_info, unique_data)],
)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
# Create GS2 with two glyphs (IDs 200, 201) that have identical data
gs2 = conn.alloc_id()
req = render.CreateGlyphSetRequest(
opcode=opcode, glyph_set_id=gs2, format_id=a8_format
)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
dup_data = bytes([0xAA, 0xBB, 0xCC, 0xDD])
req = render.AddGlyphsRequest(
opcode=opcode,
glyph_set_id=gs2,
glyphs=[
(200, glyph_info, dup_data),
(201, glyph_info, dup_data),
],
)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
# Free GS1 first (drops U, decrements tableEntries to zero
# before the fix → frees globalGlyphs[depth])
req = render.FreeGlyphSetRequest(opcode=opcode, glyph_set_id=gs1)
conn.send_request(req)
conn.flush_responses(timeout=0.5)
# Free GS2 (frees A, accesses freed globalGlyphs → crash
# before the fix)
req = render.FreeGlyphSetRequest(opcode=opcode, glyph_set_id=gs2)
conn.send_request(req)
time.sleep(0.5)
assert xserver.is_alive, (
"Server crashed - use-after-free from duplicate glyphs "
"in globalGlyphs (issue #1881)"
)
class TestRenderSetPictureFilter:
@pytest.mark.swapped_client
def test_set_picture_filter_convolution_params_swapped(
self, xserver, render_xclient_swapped
):
"""
SProcRenderSetPictureFilter was missing SwapLongs() for the
xFixed filter parameter values.
Set a 3x1 convolution filter with params [3.0, 1.0, 1.0, 1.0, 1.0]
(in xFixed: [0x00030000, 0x00010000, ...]). Without the swap,
the server sees garbled width/height and rejects with BadMatch.
With the swap, it succeeds.
Fixed in commit c98273d0bc00 ("render: add missing byte-swap of
filter params in SProcRenderSetPictureFilter").
"""
conn, opcode = render_xclient_swapped
# Get a PictFormat that matches the root depth.
req = render.QueryPictFormatsRequest(opcode=opcode)
conn.send_request(req)
resp = conn.recv_response(timeout=5.0)
assert isinstance(resp, X11Reply), "QueryPictFormats failed"
reply = render.QueryPictFormatsReply.from_reply(resp.data, ">")
format_id = 0
for fmt in reply.formats:
if fmt.depth == conn.root_depth:
format_id = fmt.id
break
if format_id == 0:
pytest.skip("No PictFormat matching root depth")
pix = conn.create_pixmap(width=10, height=10)
pic = conn.alloc_id()
req = render.CreatePictureRequest(
opcode=opcode,
picture_id=pic,
drawable=pix,
format_id=format_id,
)
conn.send_request(req)
errors = conn.flush_responses(timeout=0.5)
create_errors = [r for r in errors if isinstance(r, X11Error)]
assert len(create_errors) == 0, f"CreatePicture failed: {create_errors}"
# Set convolution filter: 3x1 kernel with all weights = 1.0
# xFixed 3.0 = 0x00030000, xFixed 1.0 = 0x00010000
# params: [width=3, height=1, k0=1.0, k1=1.0, k2=1.0]
req = render.SetPictureFilterRequest(
opcode=opcode,
picture=pic,
filter_name="convolution",
params=[
0x00030000,
0x00010000,
0x00010000,
0x00010000,
0x00010000,
],
)
conn.send_request(req)
responses = conn.flush_responses(timeout=1.0)
assert xserver.is_alive, "Server crashed"
# With the fix: no error (filter set successfully).
# Without the fix: BadMatch because
# convolutionFilterValidateParams rejects the garbled params.
errors = [r for r in responses if isinstance(r, X11Error)]
assert len(errors) == 0, (
f"SetPictureFilter returned error(s): {errors} - "
"filter params not byte-swapped → BadMatch"
)