Automated Threat Modeling

FreeRDP CVE-2026-121953: a client-side rdpgfx use-after-free, with PoC and patch analysis

A summary of CVE-2026-121953, a client-side use-after-free in FreeRDP's rdpgfx dynamic virtual channel, with the maintainer PoC, patch details, and detection guidance.

CVE Analysis

11 min read

Gaurav Sarraf
Gaurav Sarraf

2026-11-04

FreeRDP CVE-2026-121953: a client-side rdpgfx use-after-free, with PoC and patch analysis
Experimental AI-Generated Content

This CVE analysis is an experimental publication that is completely AI-generated. The content may contain errors or inaccuracies and is subject to change as more information becomes available. We are continuously refining our process.

If you have feedback, questions, or notice any errors, please reach out to us.

[email protected]

Introduction

A malicious or redirected RDP server can crash a connecting FreeRDP client, and possibly do more, by abusing how the graphics dynamic virtual channel tracks its callback. For organizations running FreeRDP-based clients (thin clients, kiosks, Linux desktops using xfreerdp or sdl-freerdp, and products that embed libfreerdp), this flaw flips the usual threat model: the server, not the client, is the attacker.

FreeRDP is an open source implementation of the Remote Desktop Protocol released under the Apache License 2.0. It started as a fork of rdesktop in May 2009 and provides both client and server implementations across multiple operating systems. It is widely used as an integration component, with documented use in Remmina and Apache Guacamole, which makes it relevant well beyond a standalone client binary.

Technical Information

The weakness is classified as CWE-416, Use After Free, and it lives in the client's rdpgfx dynamic virtual channel.

The root cause is a mismatch between how many channels the DVC manager will admit and how many callbacks the graphics listener can store. The manager, dvcman_create_channel in channels/drdynvc/client/drdynvc_main.c (lines 819 to 850), enforces uniqueness only on ChannelId, not on ChannelName. The graphics listener, though, keeps a single callback slot, set in generic_on_new_channel_connection in channels/client/generic_dynvc.c.

Because of that mismatch, a server that has completed the RDP handshake, where the client loaded rdpgfx via /gfx, can create two DVC channels under the same registered name Microsoft::Windows::RDS::Graphics but with different IDs. The second creation overwrites the listener's reference to the first callback.

When the server then closes the second channel, rdpgfx_on_close (rdpgfx_main.c lines 2401 to 2432) frees that callback but does not clear listener_callback->channel_callback. That is the specific omission: comparable handlers such as rdpei_on_close and video_control_on_close clear their listener slots, but this path does not. The original channel is still running, so the listener holds a dangling pointer.

A following DATA PDU on the original channel reaches rdpgfx_recv_end_frame_pdu (rdpgfx_main.c lines 1312 to 1364), which unconditionally acknowledges the frame through rdpgfx_send_frame_acknowledge_pdu (rdpgfx_main.c lines 617 to 644). That function dereferences the dangling callback->channel in freed memory.

The same root cause has a secondary sink in rdpsnd (AUDIO_PLAYBACK_DVC), which was identified but not separately exercised.

Attack flow

  1. The server completes the RDP handshake and the client loads rdpgfx.
  2. The server creates the graphics channel with ID 0x0a, establishing the original active channel.
  3. The server creates the same channel name with ID 0x0b, replacing the listener's callback reference.
  4. The server closes channel 0x0b, freeing the referenced callback but leaving a dangling listener reference.
  5. The server sends data on channel 0x0a, including an END_FRAME, reaching the frame acknowledgement path and accessing freed memory.

The demonstrated outcome is memory corruption and a client crash. The advisory notes that if the freed block is reused, callback->channel can become attacker-controlled data, enabling an indirect call through a controlled pointer, but successful code execution is not demonstrated.

Proof of Concept

The FreeRDP maintainers published a complete, working proof of concept in the official advisory GHSA-6wqx-rv3q-vcxp. It is a crash and memory corruption PoC demonstrating a client-side heap use-after-free, not a weaponized remote code execution exploit, though the advisory notes the indirect call possibility described above.

The PoC ships two full C source files plus a harness:

  • client_headless.c, a thin, displayless driver of the real libfreerdp-client3 engine using the standard freerdp_client_context_new / freerdp_client_settings_parse_command_line / freerdp_client_start path, so the vulnerable client code runs unmodified.
  • poc_server.c, which drives the real libfreerdp-server3 engine and emits the malicious raw MS-RDPEDYC PDUs.
  • reproduce.sh, which automates the build and run. Two independent fresh replays reported REPRO_RESULT: reproduced.

It was verified against commit 651ab269524adbda4d673efa60d2e391ef7555a7, with affected versions at or below 3.32.1 and patched in 3.32.2.

Reproduction steps:

# malicious server ./poc_server cert.pem key.pem 3389 # client, with graphics pipeline enabled so rdpgfx is loaded ./client_headless /v:127.0.0.1:3389 /cert:ignore /gfx

Once drdynvc reaches the READY state, the server sends four raw DVC PDUs on the static drdynvc channel (channelId 1007), one per packet:

  1. CREATE_REQUEST ChannelName=Microsoft::Windows::RDS::Graphics, ChannelId=0x0a
  2. CREATE_REQUEST same name, ChannelId=0x0b
  3. CLOSE_REQUEST ChannelId=0x0b
  4. DATA ChannelId=0x0a, payload = zgfx_compress_to_stream-compressed RDPGFX END_FRAME

The client admits the duplicate name channel, then aborts (exit code 1) under AddressSanitizer with a heap-use-after-free:

==1948==ERROR: AddressSanitizer: heap-use-after-free on address 0x7c32845e6290 ...
READ of size 8 at 0x7c32845e6290 thread T6
    #0 rdpgfx_send_frame_acknowledge_pdu channels/rdpgfx/client/rdpgfx_main.c:643:20
    #1 rdpgfx_recv_end_frame_pdu channels/rdpgfx/client/rdpgfx_main.c:1364:16
    #2 rdpgfx_recv_pdu channels/rdpgfx/client/rdpgfx_main.c:2187:17
    #3 rdpgfx_on_data_received channels/rdpgfx/client/rdpgfx_main.c:2327:16
    #4 drdynvc_process_data channels/drdynvc/client/drdynvc_main.c:1589:11

freed by thread T6 here:
    #1 rdpgfx_on_close channels/rdpgfx/client/rdpgfx_main.c:2432:2
    #2 dvcman_channel_close channels/drdynvc/client/drdynvc_main.c:543:14
    #3 drdynvc_process_close_request channels/drdynvc/client/drdynvc_main.c:1627:2

previously allocated by thread T6 here:
    #1 generic_on_new_channel_connection channels/client/generic_dynvc.c:42:40
    #2 dvcman_create_channel channels/drdynvc/client/drdynvc_main.c:865:9
    #3 drdynvc_process_create_request channels/drdynvc/client/drdynvc_main.c:1409:6

SUMMARY: AddressSanitizer: heap-use-after-free channels/rdpgfx/client/rdpgfx_main.c:643:20 in rdpgfx_send_frame_acknowledge_pdu

The freed region is 56 bytes, read at offset 48.

The malicious PDU construction in poc_server.c encodes the DVC command nibbles directly on the wire:

#define PDU_CREATE_REQUEST 0x01 #define PDU_DATA 0x03 #define PDU_CLOSE_REQUEST 0x04 /* DYNVC_CREATE_REQUEST: Cmd=0x1|cbChId=0, ChannelId (1 byte), Name (NUL terminated) */ buf[len++] = (PDU_CREATE_REQUEST << 4) | 0x00; buf[len++] = dvcId; /* 0x0a then 0x0b, same name */ memcpy(buf + len, name, nameLen); /* "Microsoft::Windows::RDS::Graphics" */ /* DYNVC_CLOSE_REQUEST: Cmd=0x4|cbChId=0, ChannelId (1 byte) */ buf[0] = (PDU_CLOSE_REQUEST << 4) | 0x00; buf[1] = dvcId; /* 0x0b */

The sequence is driven once drdynvc is READY: send_create(id=0x0a) then send_create(id=0x0b) then send_close(0x0b) then send_data_end_frame(0x0a), with the END_FRAME payload built via zgfx_compress_to_stream.

Patch Information

FreeRDP fixed this bug in 3.32.2, which shipped on 2026-10-12. The maintainer advisory (GHSA-6wqx-rv3q-vcxp, linked from the NVD entry) lists <= 3.32.1 as vulnerable and 3.32.2 as patched. The advisory does not name a fix commit, but the code addressing the root cause is in two commits by Armin Novak, both dated 2026-10-06, merged 2026-10-07 through PR #13612 (Hardening what else), in the 3.32.2 milestone:

  • f682dc83c218b069c999e4eb13187521ebead77a: [channels,generic] prevent duplicate channel load (channels/client/generic_dynvc.c, +55/-7)
  • d991c95b89c344ebda395289a8b4d4fd8b11118b: [channels,rdpsnd] prevent duplicate channel load (channels/rdpsnd/client/rdpsnd_main.c, +16/-4)

The reporter suggested two fixes: make DVC names unique in dvcman_create_channel, or set the slot to NULL in rdpgfx_on_close. The maintainers did neither. They changed the plugin layer so a plugin with a live channel turns down any new connection. Because a second instance can never be created, the slot is never overwritten while a sibling channel is still using it.

The ASan trace shows the freed rdpgfx callback was allocated by generic_on_new_channel_connection, so rdpgfx goes through the generic helper. The commit adds a per-plugin connected flag and checks it before allocating anything:

+ if (generic_get_connected_state(plugin)) + { + WLog_Print(plugin->log, WLOG_ERROR, "Channel already connected, terminating."); + return ERROR_DEVICE_ALREADY_ATTACHED; + }

The flag is set to TRUE only after a connection succeeds. To clear it on close without changing every plugin, the commit wraps the plugin's own close handler so each callback's OnClose is replaced with generic_on_close, which calls the original handler (for rdpgfx, rdpgfx_on_close) and then clears the flag:

+ const UINT rc = + IFCALLRESULT(CHANNEL_RC_OK, plugin->channel_callbacks->OnClose, pChannelCallback); + generic_update_connected_state(plugin, FALSE);

The flag's storage is unusual. GENERIC_DYNVC_PLUGIN has no private field for it, so the commit sizes dynvc_name at strlen(name) + 2 bytes and keeps the flag in the byte just after the name's NUL terminator. The original comment says: Since we do not have a private struct append the connected state after the name of the channel. This leaves the public struct layout unchanged, which matters for an ABI-stable point release.

The rdpsnd plugin has its own struct, so that commit adds a plain BOOL connected field. rdpsnd_on_new_channel_connection now returns ERROR_DEVICE_ALREADY_ATTACHED if a channel is already up, sets rdpsnd->connected = TRUE after storing the callback, and rdpsnd_on_close resets the flag to FALSE after free(pChannelCallback).

On a patched client, the second CREATE 0x0b is rejected in the plugin's new connection handler before any callback is allocated. The listener's slot still points at channel 0x0a's live callback, so nothing is freed and nothing is left dangling.

Two limits are worth noting for backporters. The rejection happens in each plugin, not in the DVC manager, so dvcman_create_channel still enforces only unique ChannelIds, and plugins with custom connection handlers are protected only if they have their own guard, as rdpsnd now does. The commits also do not touch rdpgfx_on_close itself; they stop the slot from being overwritten but do not add the NULL clearing the reporter proposed. Apply both commits when backporting, because a plugin missing its guard can still have its slot overwritten.

Detection Methods

No vendor-published Sigma, YARA, Snort/Suricata or EDR rules exist for this CVE, and there are no IP, domain, or hash IoCs. The detection material comes from the advisory: the triggering message sequence, the client log lines from the PoC run, and the ASan crash report. These support behavioural detections, not a ready-made signature.

Message sequence. The attack happens entirely inside the RDP session, carried in MS-RDPEDYC Dynamic Virtual Channel messages travelling inside the drdynvc static channel (ID 1007 in the PoC). The server sends four messages: a CREATE_REQUEST for Microsoft::Windows::RDS::Graphics with ChannelId 0x0a; a second CREATE_REQUEST with the same name and a different ChannelId 0x0b; a CLOSE_REQUEST for 0x0b; and a DATA message on 0x0a carrying a zgfx-compressed RDPGFX END_FRAME. The main sign of attack is a repeated channel name with a different ChannelId while the first channel is still open, especially when the second channel is closed immediately and data then arrives on the first. In the PoC server, the first byte of each message (command nibble shifted left by 4) is 0x10 for create requests, 0x40 for close requests, and 0x30 for data. Hunting logic should flag repeated names for any DVC, not just graphics, because the advisory names rdpsnd (AUDIO_PLAYBACK_DVC) as a second sink with the same root cause.

Network visibility. These messages are inside the encrypted RDP session (the PoC used TLS without NLA), so a passive network IDS cannot inspect them. Message-level detection only works where RDP is decrypted, such as an RDP gateway, proxy, or recording system that terminates TLS, or a lab capture where you hold the keys. There, track channel names per session and alert when a create request reuses an open name. Elsewhere you are limited to context: FreeRDP clients connecting to unfamiliar or external RDP servers, or being redirected to them. The advisory explicitly names malicious or redirected servers as the attack source.

Client-side logs. In the PoC run the client logged each channel it created. The giveaway is the same listener name appearing twice with different channel numbers:

[dvcman_create_channel]: listener Microsoft::Windows::RDS::Graphics created new channel 10
[dvcman_create_channel]: listener Microsoft::Windows::RDS::Graphics created new channel 11

If you collect FreeRDP client logs, look for two created new channel lines with the same listener name and different channel numbers in one session, and treat it as high confidence if the process ends abnormally soon after. These lines may only appear at higher logging levels, so confirm your setup records them.

Crash triage and ASan signature. On production builds the visible symptom is usually a client crash shortly after connecting with the graphics pipeline enabled. On an ASan build the summary line is:

SUMMARY: AddressSanitizer: heap-use-after-free channels/rdpgfx/client/rdpgfx_main.c:643:20 in rdpgfx_send_frame_acknowledge_pdu

The use is an 8-byte read in rdpgfx_send_frame_acknowledge_pdu, reached through rdpgfx_recv_end_frame_pdu, rdpgfx_recv_pdu, rdpgfx_on_data_received, and drdynvc_process_data on the drdynvc client thread. The free is in rdpgfx_on_close, called from dvcman_channel_close and drdynvc_process_close_request. The allocation is in generic_on_new_channel_connection, called from dvcman_create_channel and drdynvc_process_create_request. The bad read is 48 bytes into a 56 byte block. Match symbolized stack traces on rdpgfx_send_frame_acknowledge_pdu called from rdpgfx_recv_end_frame_pdu. If the freed memory was reused, the crash can instead appear as an indirect call through a bad pointer. A crash in this function after connecting to an untrusted server should be treated as possible exploitation.

Finding exposed clients. The advisory marks FreeRDP at or below 3.32.1 as vulnerable and 3.32.2 as fixed. Software inventory or SBOM queries for FreeRDP clients and libfreerdp builds below 3.32.2, plus products that embed them, show where these detections matter most. Prioritize clients that enable the graphics pipeline or connect to RDP servers outside your control.

Affected Systems and Versions

FreeRDP client versions at or below 3.32.1 are affected; the fixed version is 3.32.2. Exploitation requires that the server complete the RDP handshake and that the client load the rdpgfx dynamic virtual channel (the PoC explicitly enabled /gfx). The reproduction does not establish that the graphics channel is enabled by default in every application that embeds FreeRDP. Products that embed libfreerdp builds below 3.32.2 should be assessed individually, since integration alone does not prove that a deployment loads the affected channel.

Vendor Security History

Malicious server processing is an established security concern for FreeRDP clients rather than a new category introduced by this CVE. Ubuntu's March 11, 2025 security notice (USN-7341-1) describes multiple issues through which a malicious server could cause denial of service and, for some flaws, potentially arbitrary code execution, and supplied corrected packages. Ubuntu's June 16, 2026 notice (USN-8432-1) documents another heap write vulnerability and also repairs a regression introduced by an earlier update, completing fixes for several earlier CVEs. These cases show both ongoing remediation activity and at least one update regression that needed further repair; they do not demonstrate exploitation of CVE-2026-121953. The project follows a published disclosure process using GitHub advisories or [email protected], with private fix development and publication after validation.

References

Detect & fix
what others miss

Works with
  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps Services
  • Gerrit
  • CVS
  • Jira
  • Linear
  • Slack
  • Security Compass
Explore integrations
Security magnifying glass visualization