{"schema":"threatline.record/2","record":{"id":"GHSA-grvc-qhhp-7h6m","aliases":["GHSA-grvc-qhhp-7h6m"],"summary":"Server-chosen MS-RDPEVOR video geometry lets a malicious RDP server pick an out-of-range blit destination in the FreeRDP client","details":"This issue was found by Anthropic using Claude to study the security of open-source software, validated manually\r\nby me from Ada Logics.\r\n\r\n## Summary\r\n\r\nThe MS-RDPEGT geometry dynamic virtual channel lets the server choose where a video overlay is\r\nplaced. The client copies the server-chosen signed origin straight into the unsigned\r\n`VideoSurface::x` with no range check against the local desktop, and `gdiVideoShowSurface()`\r\nperforms its blit clamp in wrapping 32-bit unsigned arithmetic. A server that sends\r\n`topLevelLeft = -256` makes `surface->x` become `0xFFFFFF00`; the clamp test\r\n`destinationWidth + surface->x < gdi->width` then wraps to `0 < 1024`, the clamp is skipped, and\r\nthe full width is copied to `nXDst = 0xFFFFFF00`. `freerdp_image_scale()` takes its equal-size\r\nfast path into `freerdp_image_copy_no_overlap()`, whose row offset is `nXDst * bytesPerPixel`\r\ncomputed in 64-bit — about 16 GiB — and the decoded H.264 picture rows are written there.\r\n\r\nOn the upstream-default build (`WITH_VERBOSE_WINPR_ASSERT=ON`) the `WINPR_ASSERTING_INT_CAST` in\r\n`video_PresentationRequest()` turns the negative origin into a fatal abort instead, which is\r\nstill a server-triggered termination of the connecting client.\r\n\r\nAffected component: client, `/video` channel (MS-RDPEVOR) plus the geometry channel (MS-RDPEGT).\r\nThis requires the user to connect with `/video`, which is not a default.\r\n\r\n## Affected code\r\n\r\nAt master `a0114ebd34c71334a028f267acf6da391edbbe24`:\r\n\r\n`libfreerdp/gdi/video.c:89-94` — `gdiVideoShowSurface()`\r\n```c\r\nconst UINT32 width = (destinationWidth + surface->x < (UINT32)gdi->width)\r\n                         ? destinationWidth\r\n                         : (UINT32)gdi->width - surface->x;\r\nconst UINT32 height = (destinationHeight + surface->y < (UINT32)gdi->height)\r\n                          ? destinationHeight\r\n                          : (UINT32)gdi->height - surface->y;\r\n```\r\n`nXDst`/`nYDst` are `surface->x`/`surface->y` verbatim; the `nXDst > INT32_MAX` test at\r\n`video.c:105-107` runs after `freerdp_image_scale()` at `video.c:100` has already copied.\r\n\r\n`channels/video/client/video_main.c:426-430` and `:499-501` — the origin\r\n```c\r\npresentation->surface->x =\r\n    WINPR_ASSERTING_INT_CAST(uint32_t, geometry->topLevelLeft + geometry->left);\r\n```\r\n`topLevelLeft` / `left` are `INT32`s read verbatim from the wire in `geometry_recv_pdu()`\r\n(`channels/geometry/client/geometry_main.c:251-259`); only the RGNDATA rectangles are range\r\nchecked there, not these fields.\r\n\r\nThe only guard is the assert layer. `WINPR_ASSERTING_INT_CAST` (`winpr/include/winpr/cast.h:179`)\r\nis two `WINPR_ASSERT`s plus a plain cast; with `WITH_VERBOSE_WINPR_ASSERT=OFF` it becomes the libc\r\n`assert()`, which `NDEBUG` removes in a Release build. FreeRDP's own Android client build sets\r\n`-DWITH_VERBOSE_WINPR_ASSERT:BOOL=OFF` (`client/Android/cmake/ExternalFreeRDP.cmake:45`), as do the\r\n`config-qa-static` and `config-abi` CI presets, and the macro degenerates to a plain cast on\r\nnon-GNU/Clang compilers (`winpr/include/winpr/cast.h:189-190`). The `WINPR_ASSERT`s inside\r\n`freerdp_image_scale()` (`color.c:1297-1306`) and `freerdp_image_copy_no_overlap()`\r\n(`color.c:1986-1995`) are the same layer.\r\n\r\nCWE-190 (integer overflow / wraparound) leading to CWE-787 (out-of-bounds write); CWE-129\r\n(improper validation of array index) for the unchecked geometry origin.\r\n\r\n## Primitive\r\n\r\nWrite, attacker-chosen bytes (the decoded picture), `width * 4` bytes per row for `height` rows.\r\nThe displacement is not tunable: the wraparound that creates the bug requires\r\n`surface->x >= 0xFFFFFF00`, so the write always lands at least ~16 GiB past\r\n`gdi->primary_buffer`. In a normal 64-bit address space that page is unmapped, so the realistic\r\noutcome is a crash of the client rather than usable corruption. We are reporting this as a\r\nserver-triggered client denial of service.\r\n\r\n## Evidence\r\n\r\nValidated end-to-end at release-3.23.0 (`b933ae18d9ad2a1d73c610868fcc30eb61654070`), assert-disabled\r\nRelease build, ASan, using FreeRDP's own `sfreerdp` client against a malicious server built on the\r\npublic FreeRDP server API:\r\n\r\n```\r\n[server] -> GEOMETRY_UPDATE id=0x1122334455667788 topLevelLeft=-256 left=0 (104 bytes)\r\n[server] -> TSMM_PRESENTATION_REQUEST START id=1 256x256 -> 256x256 mapping=0x1122334455667788\r\n[server] -> TSMM_VIDEO_DATA id=1 sampleNumber=1 cbSample=710\r\n\r\n==31==ERROR: AddressSanitizer: SEGV on unknown address 0x770ab26ad420 (pc 0x7b06c57f6c10 ...)\r\n==31==The signal is caused by a WRITE memory access.\r\n    #0 in FreeRDPWriteColor_int build/./libfreerdp/primitives/../codec/color.h:42:11\r\n    #1 in generic_image_copy_no_overlap_convert build/./libfreerdp/primitives/prim_copy.c:252:4\r\n    #2 in generic_image_copy_no_overlap build/./libfreerdp/primitives/prim_copy.c\r\n    #3 in avx2_image_copy_no_overlap build/./libfreerdp/primitives/sse/prim_copy_avx2.c\r\n    #4 in freerdp_image_copy_no_overlap build/./libfreerdp/codec/color.c:1890:9\r\n    #5 in freerdp_image_scale build/./libfreerdp/codec/color.c:1237:10\r\n    #6 in gdiVideoShowSurface build/./libfreerdp/gdi/video.c:100:8\r\n    #7 in video_VideoData build/./channels/video/client/video_main.c:876:9\r\n    #8 in video_data_on_data_received build/./channels/video/client/video_main.c:999:9\r\n    #9 in drdynvc_process_data build/./channels/drdynvc/client/drdynvc_main.c:1432:11\r\n   #11 in drdynvc_virtual_channel_client_thread build/./channels/drdynvc/client/drdynvc_main.c:1689:17\r\nrsi = 0x00000003fffffc00      <- xDstOffset = 0xFFFFFF00 * 4 = 16 GiB\r\nSUMMARY: AddressSanitizer: SEGV build/./libfreerdp/primitives/../codec/color.h:42:11 in FreeRDPWriteColor_int\r\n```\r\n\r\nOn the upstream-default build of the same tree, the same three PDUs abort the client first:\r\n\r\n```\r\n[FATAL][com.freerdp.winpr.assert] - [winpr_int_assert]:\r\n  ... [channels/video/client/video_main.c:video_PresentationRequest:500]\r\n>>> RESULT: client killed by SIGABRT\r\n```\r\n\r\n## Status at current master, and a caveat we want to flag\r\n\r\nThe `gdiVideoShowSurface()` and `video_main.c` code is unchanged at\r\n`a0114ebd34c71334a028f267acf6da391edbbe24`. On the upstream-default build the PoC still terminates\r\nthe client there with the `SIGABRT` shown above.\r\n\r\nThe out-of-bounds write itself is currently not reached on master, but not because of any bound on\r\nthe geometry. `video_main.c:884-886` decodes with `rect = {0, 0, surface->alignedWidth,\r\nsurface->alignedHeight}`, and `VideoClient_CreateCommonContext()` (`libfreerdp/gdi/video.c:174-175`)\r\ncomputes `alignedWidth = w + 32 - w % 16`, which is always greater than `w`.\r\n`log_decompress()` (`libfreerdp/codec/h264.c:164-167`) validates that rect against\r\n`h264->width/height`, which `h264_context_reset()` set to the presentation's\r\n`SourceWidth/SourceHeight` (`= w`). The rect can therefore never be valid, `avc420_decompress()`\r\nreturns `-1016`, and every frame is dropped:\r\n\r\n```\r\n[WARN][com.freerdp.codec] - [areRectsValid]: Rectangle 0 {0x0-288x288} outside of bounding frame 256x256\r\n```\r\n\r\nThat check arrived with `6482b7a92` (3.24.0), `f6380daa9` (3.29.0) and `bea10804a` (3.31.1). Before\r\n`6482b7a92`, `avc420_decompress()` did no rect validation, which is why the write reproduces at\r\n3.23.0. As a side effect it looks like the `/video` channel decodes nothing at all in 3.24.0 and\r\nlater; we mention it because it is the only thing currently standing between the unchecked geometry\r\nand the copy, and it is not a bound anyone intended.\r\n\r\n## Reproduction\r\n\r\n[ANT-2026-0H3M36C1-e2e.zip](https://github.com/user-attachments/files/32196424/ANT-2026-0H3M36C1-e2e.zip)\r\n\r\nReproducer attached as `ANT-2026-0H3M36C1-e2e.zip`.\r\n\r\n```\r\nunzip ANT-2026-0H3M36C1-e2e.zip && cd ANT-2026-0H3M36C1\r\n\r\n# end-to-end out-of-bounds write (release-3.23.0, the Dockerfile default)\r\ndocker build -t ant-0h3m36c1 docker-e2e/ && docker run --rm ant-0h3m36c1\r\n\r\n# current master: same PoC, upstream-default build -> SIGABRT in video_PresentationRequest\r\ndocker build --build-arg TARGET_COMMIT=a0114ebd34c71334a028f267acf6da391edbbe24 \\\r\n    -t ant-0h3m36c1-head docker-e2e/ && docker run --rm ant-0h3m36c1-head\r\n```\r\n\r\nNo special `docker run` flags are needed. The image builds FreeRDP twice from the same unmodified\r\ncheckout of `${TARGET_COMMIT}` (upstream-default assert-on, and assert-off Release matching the\r\nAndroid client configuration), compiles a malicious server against the installed\r\n`freerdp-server3`/`freerdp3`/`winpr3` pkg-config modules, generates a self-signed certificate and a\r\n256x256 H.264 keyframe with `ffmpeg`, then runs `sfreerdp` against that server in both\r\nconfigurations. No FreeRDP source is patched, stubbed or reimplemented.\r\n\r\nThe malicious server opens `Microsoft::Windows::RDS::Geometry::v08.01`,\r\n`Microsoft::Windows::RDS::Video::Control::v08.01` and `Microsoft::Windows::RDS::Video::Data::v08.01`\r\nand sends, in order, a `GEOMETRY_UPDATE` with `topLevelLeft = -256` / `left = 0`, a\r\n`TSMM_PRESENTATION_REQUEST` (START, H.264, `SourceWidth == ScaledWidth == SourceHeight ==\r\nScaledHeight == 256`, so the equal-size direct-copy path is taken), and one `TSMM_VIDEO_DATA`.\r\n\r\n## Credit\r\n\r\nFound by Anthropic using Claude to study the security of open-source software, validated manually\r\nby Ada Logics. Please credit Claude and Ada Logics in any advisory.\r\n\r\nHandled under Anthropic's coordinated vulnerability disclosure policy\r\n(https://www.anthropic.com/coordinated-vulnerability-disclosure, 90 days). Reference\r\nANT-2026-0H3M36C1; questions to security-cvd@anthropic.com.\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-grvc-qhhp-7h6m","source_modified_at":"2026-09-28T08:05:31Z","fetched_at":"2026-10-23T21:10:45.942435Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":"<= 3.31.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-grvc-qhhp-7h6m","source_modified_at":"2026-09-28T08:05:31Z","fetched_at":"2026-10-23T21:10:45.942435Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.0"}],"cvss":null,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-28T08:05:31Z","modified_at":"2026-09-28T08:05:31Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-grvc-qhhp-7h6m","fetched_at":"2026-10-23T21:10:45.942435Z","source_modified_at":"2026-09-28T08:05:31Z"}],"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-28T08:05:31Z","content_hash":"969a7bf26e375fb80e9617039be24cd1aeda85f07f8c5904d351d929a4b862a9"}}
