From bae088ccbdf972b104c2ae715619eda2ffcc26d4 Mon Sep 17 00:00:00 2001 From: Peter Hutterer Date: Thu, 3 Sep 2026 14:55:27 +1000 Subject: [PATCH] xkb: fix CheckKeySyms overwriting request-range symsPerKey entries CheckKeySyms() has two loops: the first processes keys in the SetMap request range, the second fills in any missing keys from the existing map. The indexing between the two was inconsistent: the first loop uses a zero-based index + req->firstKeySym, the second loop used it just as zero-based index. Example: req->firstKeySym = 50 req->nKeySyms = 10 i goes from 0 to 10, loop 1 checks [50, 60), changes the width but finishes with i at 10. loop 2 then runs over [10, 255(, copying the old keymap width The second loop overwrites the new widths with the width of the existing keymap. The subsequent SetKeySyms worked on the new width and may write fewer actions than the map width. A subsequent XkbGetMap request would then read XkbKeyNumActions() entries (derived from the new wider key_sym_map.width) from the shorter action allocation, causing an out-of-bounds heap read. The overread bytes would be copied into the GetMap reply and sent back to the client, producing a bounded heap information disclosure. This vulnerability was discovered by: WONJOON HWANG (@joon1337) working with TrendAI Zero Day Initiative ZDI-CAN-32408 CVE-2026-93524 Assisted-by: Claude:claude-opus-4-6 Part-of: (cherry picked from commit cea71d0273e4d5092aa9c6a8a770734c8935c02e) Signed-off-by: Peter Hutterer --- Xext/xkeyboard/xkb.c | 1 + test/pyxtest/test_xkb.py | 161 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/Xext/xkeyboard/xkb.c b/Xext/xkeyboard/xkb.c index a69070de0..3d26c629a 100644 --- a/Xext/xkeyboard/xkb.c +++ b/Xext/xkeyboard/xkb.c @@ -1794,6 +1794,7 @@ CheckKeySyms(ClientPtr client, wire = (xkbSymMapWireDesc *) &pSyms[wire->nSyms]; } + i = req->firstKeySym + req->nKeySyms; map = &xkb->map->key_sym_map[i]; for (; i <= (unsigned) xkb->max_key_code; i++, map++) { register int g, nG, w; diff --git a/test/pyxtest/test_xkb.py b/test/pyxtest/test_xkb.py index c2c59242a..a1ec7176c 100644 --- a/test/pyxtest/test_xkb.py +++ b/test/pyxtest/test_xkb.py @@ -989,3 +989,164 @@ class TestXkbSetMapMapWidths: assert xserver.is_alive, ( "Server crashed - mapWidths stack OOB write (ZDI-CAN-30161)" ) + + +class TestXkbSetMapDesync: + """Tests for XKB SetMap key-width/action-count desync vulnerabilities.""" + + @pytest.mark.asan + def test_setmap_width_action_count_desync(self, xserver, xkb_xclient): + """ + ZDI-CAN-32408: XKB SetMap key-width/action-count desync OOB read. + + CheckKeySyms populates symsPerKey[] with new widths for keys in the + request range, then continues for keys beyond the range starting + at index i = nKeySyms (not firstKeySym + nKeySyms). When firstKeySym + is large and nKeySyms is 1, the second loop overwrites + symsPerKey[target] with the old width from the existing map. + + CheckKeyActions then validates the wire action count against the + stale old width and accepts it. In _XkbSetMap, SetKeySyms widens + the key (resizing actions to nGroups * newWidth), but SetKeyActions + then resizes again to the old count. A subsequent XkbGetMap reads + XkbKeyNumActions (derived from the new width) entries from the + shorter allocation, causing an OOB heap read. The leaked bytes + are sent back to the client in the GetMap reply. + """ + xclient, opcode = xkb_xclient + + # Step 1: Get current keymap + reply = xclient.xkb_get_map( + opcode, + full=xkb.XkbKeyTypesMask | xkb.XkbKeySymsMask, + ) + assert reply is not None, "Initial GetMap failed" + + min_key = reply.min_key_code + max_key = reply.max_key_code + n_types = reply.total_types + first_key_sym = reply.first_key_sym + + # Find a target key near max_key_code that has nonzero syms. + # We pick a key with small n_syms so the desync between old + # and new width is large. + target_key = None + target_sym = None + for i in range(len(reply.sym_maps) - 1, -1, -1): + sm = reply.sym_maps[i] + if sm.n_syms > 0 and sm.n_syms < 63: + target_key = first_key_sym + i + target_sym = sm + break + if target_key is None or target_sym is None: + pytest.skip("Could not find a suitable target key") + + old_n_syms = target_sym.n_syms + + # Step 2: Fill action slots on earlier keys so our target's + # action allocation is tightly bounded by the heap. + fillers = [] + for i, sm in enumerate(reply.sym_maps): + kc = first_key_sym + i + if kc >= target_key: + break + if sm.n_syms > 0: + fillers.append((kc, sm.n_syms)) + fillers = fillers[:96] + + if fillers: + first_filler = fillers[0][0] + last_filler = fillers[-1][0] + filler_range = last_filler - first_filler + 1 + filler_by_key = {kc: ns for kc, ns in fillers} + counts = bytearray() + total_filler_acts = 0 + for kc in range(first_filler, last_filler + 1): + ns = filler_by_key.get(kc, 0) + counts.append(ns) + total_filler_acts += ns + filler_payload = bytes(counts) + b"\x00" * ((4 - len(counts) % 4) % 4) + filler_payload += b"\x00" * (total_filler_acts * 8) + + filler_req = xkb.SetMapRequest( + opcode=opcode, + present=xkb.XkbKeyActionsMask, + min_key_code=min_key, + max_key_code=max_key, + first_key_act=first_filler, + n_key_acts=filler_range, + total_acts=total_filler_acts, + payload=filler_payload, + ) + xclient.send_request(filler_req.to_bytes()) + time.sleep(0.05) + + # Step 3: Send the vulnerable SetMap that widens the target key + # to 63 levels while providing old_n_syms actions. + # The desync: CheckKeySyms writes the new width to symsPerKey[target], + # but its second loop (starting at i=nKeySyms=1, not firstKeySym+1) + # overwrites symsPerKey[target] with the old width from the existing + # map. CheckKeyActions then accepts old_n_syms as valid. + wide_levels = 63 + new_type_idx = n_types + + # New key type: 63 levels, no map entries, no preserve + type_wire = struct.pack("