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
- The server completes the RDP handshake and the client loads rdpgfx.
- The server creates the graphics channel with ID
0x0a, establishing the original active channel. - The server creates the same channel name with ID
0x0b, replacing the listener's callback reference. - The server closes channel
0x0b, freeing the referenced callback but leaving a dangling listener reference. - The server sends data on channel
0x0a, including anEND_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 reallibfreerdp-client3engine using the standardfreerdp_client_context_new/freerdp_client_settings_parse_command_line/freerdp_client_startpath, so the vulnerable client code runs unmodified.poc_server.c, which drives the reallibfreerdp-server3engine and emits the malicious raw MS-RDPEDYC PDUs.reproduce.sh, which automates the build and run. Two independent fresh replays reportedREPRO_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:
CREATE_REQUESTChannelName=Microsoft::Windows::RDS::Graphics, ChannelId=0x0aCREATE_REQUESTsame name, ChannelId=0x0bCLOSE_REQUESTChannelId=0x0bDATAChannelId=0x0a, payload =zgfx_compress_to_stream-compressed RDPGFXEND_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
- NVD entry for CVE-2026-121953
- CVE Record: CVE-2026-121953
- FreeRDP GitHub Security Advisory GHSA-6wqx-rv3q-vcxp
- VulnCheck advisory
- GitHub advisory API (GHSA-6wqx-rv3q-vcxp)
- Fix commit f682dc83 (generic DVC)
- Fix commit d991c95b (rdpsnd)
- Pull Request #13612 (Hardening what else)
- FreeRDP 3.32.2 release notes
- FreeRDP security overview
- VulDB entry
- Ubuntu USN-7341-1
- Ubuntu USN-8432-1
- Amazon Linux ALAS2023-2026-3342
- FreeRDP project site
- History of the FreeRDP project



