{"schema":"threatline.record/2","record":{"id":"GHSA-5xjc-c64q-m8r6","aliases":["GHSA-5xjc-c64q-m8r6"],"summary":"FreeRDP — a hardening commit in 3.32.0 frees a pool-owned stream with the owned-stream deallocator, giving a malicious server a one-PDU client-side use-after-free","details":"## 1. Summary\r\n\r\n**`totalSize` and `chunkSize` are independent parameters on the RDP Virtual Channel path, and neither\r\nside relates them.** The sender's `freerdp_channel_send_packet` (`libfreerdp/core/channels.c:298-299`)\r\ntakes them as two separate arguments and validates only `:301 if (totalSize > UINT32_MAX) return FALSE;`\r\nbefore writing `totalSize` at `:316` and `chunkSize` bytes of payload at `:319`. The receiver's\r\n`freerdp_channel_process` (`:115-149`) reads `totalLength` **raw** at `:139` and then applies exactly one\r\nlength check, at `:142`: `if (packetLength != chunkLength)`. That is a **TPKT self-consistency** check,\r\nnot a relation between the two fields — the in-tree comment at `:134-137` says so outright: *\"check\r\nchunkLength against packetLength, which is the TPKT header size.\"*\r\n\r\n**A 72-byte body inside an 87-byte TPKT frame satisfies that check exactly — `87 = 4 + 3 + 8 + 72` — which\r\nis precisely why the only length check passes while `totalLength` goes unexamined.** Four bytes of TPKT\r\nheader, three of X.224, eight of MCS `SendDataIndication` header, seventy-two of Virtual Channel PDU\r\nbody. The frame is perfectly well-formed. The *semantic* lie is in `totalLength`, which nothing verifies.\r\n\r\nWith `totalLength = 1` and a 64-byte chunk, `channels/drdynvc/client/drdynvc_main.c` takes a\r\n**pool-owned** stream and then destroys it with the **owned-stream** deallocator:\r\n\r\n```c\r\n// master 599262d191 — channels/drdynvc/client/drdynvc_main.c\r\n:1709      drdynvc->data_in = StreamPool_Take(mgr->pool, dataLength);   // pool-owned\r\n...\r\n:1722          Stream_Release(drdynvc->data_in);                        // the CORRECT deallocator\r\n...\r\n:1729      if ((Stream_GetPosition(data_in) > totalLength) || (drdynvc->totalLength != totalLength))\r\n:1730      {\r\n:1731          Stream_Free(drdynvc->data_in, TRUE);                     // WRONG: owned-stream free\r\n:1732          drdynvc->data_in = nullptr;\r\n:1733          return ERROR_INVALID_DATA;\r\n:1734      }\r\n```\r\n\r\n**The correct call is already in the same function, nine lines above the wrong one.** The pool keeps its\r\nentry; the `wStream` behind it is gone. At session teardown `dvcman_free` (`:640`) calls\r\n`StreamPool_Free` → `StreamPool_Clear` → `discard_entry`, and `discard_entry` reads\r\n`entry->s->isAllocatedStream` out of the freed object:\r\n\r\n```c\r\n// master 599262d191 — winpr/libwinpr/utils/collections/StreamPool.c:61-75\r\n:70      if (discardStream && entry->s)\r\n:71          Stream_Free(entry->s, entry->s->isAllocatedStream);\r\n```\r\n\r\nThat is the use-after-free: a 4-byte READ 48 bytes inside a 56-byte region, reported by AddressSanitizer,\r\nreached from one server-sent PDU with no authentication, no race and no prior state.\r\n\r\n**One PDU. No race. No credentials. The trigger differs from a well-formed control by a single byte.**\r\n\r\n**The wire path is executed, not inferred.** §7 records a three-container lab in which an unmodified\r\nASAN `xfreerdp` speaks TLS to a **stock** `freerdp-proxy` carrying an **out-of-tree** 221-line plugin\r\n(public installed APIs only), which speaks TLS to a **pristine** upstream `sfreerdp` sample server.\r\n**Zero lines of FreeRDP source are modified anywhere in the attack path.** The plaintext bytes are\r\ncaptured by FreeRDP's own `/dump:record` facility, which wraps `rdpTransportIo::ReadPdu` — below the\r\nparser, after TLS decryption, nothing patched.\r\n\r\n**This is a regression that the hardening commit itself created, reverting a decision the vendor made in\r\n2020.** It ships in exactly one release: **3.32.0, the current latest**, published 2026-09-23.\r\n\r\n\r\n## 2. Affected versions\r\n\r\n**3.32.0 and master are affected. Nothing earlier is.**\r\n\r\nThe floor is set by **content**, not by `git tag --contains`, which is not proof. Counting\r\n`Stream_Free(drdynvc->data_in` in `channels/drdynvc/client/drdynvc_main.c`, read from\r\n`git show <ref>:<path>` blob content at every 3.2x/3.3x tag:\r\n\r\n| ref | `Stream_Free(drdynvc->data_in` | `StreamPool_Take` | `Stream_Release(drdynvc->data_in` |\r\n|---|---|---|---|\r\n| 3.24.2 | 0 | 13 | 3 |\r\n| 3.25.0 | 0 | 13 | 3 |\r\n| 3.26.0 | 0 | 13 | 3 |\r\n| 3.27.0 | 0 | 13 | 3 |\r\n| 3.27.1 | 0 | 13 | 3 |\r\n| 3.28.0 | 0 | 13 | 3 |\r\n| 3.29.0 | 0 | 13 | 3 |\r\n| 3.30.0 | 0 | 13 | 3 |\r\n| 3.31.0 | 0 | 13 | 3 |\r\n| 3.31.1 | 0 | 13 | 3 |\r\n| **3.32.0** | **1** (line 1730) | 13 | 3 |\r\n| **master `599262d191`** | **1** (line 1731) | 13 | 3 |\r\n\r\nThe two corroborating counts are **identical at every ref**. That matters: the hardening commit **added a\r\nfree without removing a release**. It is a net addition, not a substitution — which is exactly the shape\r\nof a copy-paste regression rather than a deliberate change of ownership model.\r\n\r\n**A regression shipping in exactly one release, and that release is the current latest.** 3.32.0 was\r\npublished 2026-09-23 and GitHub marks it \"Latest\". There is no 3.32.x point release, no 3.33.0 and no\r\n4.x — the newest tag in the repository is 3.32.0, so there is nothing else to re-floor.\r\n\r\n**Ref identities, stated per-object-type because they differ:**\r\n\r\n- `master` = **COMMIT** `599262d19136633c7f3fe961e37cfd8dd4e1a753`, 2026-09-26 18:29:53 +0200,\r\n  *\"Merge pull request #13527 from akallabeth/file-clip-fix\"*.\r\n- `3.32.0` = an annotated **TAG OBJECT** `a78d22111c42fbff1e01f31c395d1d16df119501` (tagger\r\n  `akallabeth`, message *\"release 3.32.0\"*, PGP-signed) which points to **COMMIT**\r\n  `d27a4f7c1c63b62a5e60e5d939ad116cfb58ffc1`, authored 2026-09-23 09:09:12 +0200, committed 09:30:40 +0200.\r\n- `3.31.1` = **TAG OBJECT** `7d4c92dc842ac71713095bcbac5d6c35611dcde7`.\r\n- Local clone HEAD used for every blob read: `32394214af7965c890d8b43be803d0e34d28f869`.\r\n\r\n**This is CVE-eligible and the table above is what settles it.** `SECURITY.md:15` reads: *\"Since our\r\ndevelopment branch is, like the protocol itself, a moving target we won't request CVEs for issues that\r\nare *only* found on the development branch.\"* That clause does **not** bite here, because the defect ships\r\nin released tag 3.32.0, not only on master. `SECURITY.md:19-24` lists `3.x.x | stable-3.0` and\r\n`- | master` as supported, so 3.32.0 is squarely in a supported version.\r\n\r\n**Only master was exercised over the wire** (§7) and **only master was exercised in the two-session host**\r\n(§9). 3.32.0 was measured separately by the in-process five-arm matrix (§8), and 3.31.1 is the executed\r\nnegative control there. The two-session result has *not* been reproduced against the tag; see §12.\r\n\r\n## 3. The commit that introduced it, and the 2020 decision it reverted\r\n\r\n### 3.1 The 2020 decision — this is the strongest part of the provenance\r\n\r\nCommit **`5b2a4434d8becd7b527d5b329eee9915ff8ca77a`**, *\"drdynvc channel cleanup.\"*, `akallabeth`,\r\n2020-05-12, performed the **exact opposite** conversion on this very pointer, in one hunk:\r\n\r\n```diff\r\n-\t\t\tStream_Free(drdynvc->data_in, TRUE);\r\n+\t\t\tStream_Release(drdynvc->data_in);\r\n...\r\n-\t\tdrdynvc->data_in = Stream_New(NULL, totalLength);\r\n+\t\tdrdynvc->data_in = StreamPool_Take(mgr->pool, totalLength);\r\n```\r\n\r\nThe same commit created the pool (`dvcman->pool = StreamPool_New(TRUE, 10);`) and converted a dozen other\r\n`Stream_Free` sites in the file to `Stream_Release`. **The vendor itself established `Stream_Release` as\r\nthe correct deallocator for `data_in` at the moment it moved that buffer onto the pool.** This also fixes\r\nthe \"pool since 2020\" claim to a specific commit and date.\r\n\r\n### 3.2 The 2026 commit that put the line back\r\n\r\nCommit **`2362eb9885a2ad6119911aaf539e8bb773ad6f14`**, *\"[channels] abort channel data read\"*,\r\nArmin Novak, authored 2026-09-20 19:24:15 +0200, committed 2026-09-21 15:26:13 +0200. Its message reads\r\nin full: *\"When a server sends incorrect data, abort after totalLength has been received.\"* It is an\r\nancestor of 3.32.0 and **not** an ancestor of 3.31.1.\r\n\r\n`git diff 2362eb9885a2^1 2362eb9885a2 --numstat`:\r\n\r\n| file | + | − |\r\n|---|---|---|\r\n| `channels/client/addin.c` | **7** | 0 |\r\n| `channels/drdynvc/client/drdynvc_main.c` | **7** | 0 |\r\n| `channels/encomsp/client/encomsp_main.c` | 3 | 0 |\r\n| `channels/rdpdr/client/rdpdr_main.c` | 3 | 0 |\r\n| `channels/rdpsnd/client/rdpsnd_main.c` | 3 | 0 |\r\n| `channels/remdesk/client/remdesk_main.c` | 2 | 0 |\r\n| `libfreerdp/core/server.c` | 3 | 3 |\r\n\r\n**The two `+7` hunks are byte-identical except for the struct name.** The two added blocks, laid side by\r\nside — each column is the added text exactly as the diff prints it, nothing elided:\r\n\r\n```diff\r\n--- a/channels/client/addin.c            --- a/channels/drdynvc/client/drdynvc_main.c\r\n+\tif (Stream_GetPosition(data_in) > totalLength)   +\tif (Stream_GetPosition(data_in) > totalLength)\r\n+\t{                                                +\t{\r\n+\t\tStream_Free(internals->data_in, TRUE);       +\t\tStream_Free(drdynvc->data_in, TRUE);\r\n+\t\tinternals->data_in = nullptr;                +\t\tdrdynvc->data_in = nullptr;\r\n+\t\treturn ERROR_INVALID_DATA;                   +\t\treturn ERROR_INVALID_DATA;\r\n+\t}                                                +\t}\r\n```\r\n\r\n`addin.c` is correct: it allocates with `Stream_New(nullptr, dataLength)` at `:672`, so `Stream_Free` is\r\nits right deallocator, and the file already contained three other `Stream_Free(internals->data_in, TRUE)`\r\nsites to copy from. `drdynvc` has used the **pool** since 2020. The hunk was copied into a file where the\r\nownership model is the opposite.\r\n\r\n### 3.3 Only two of the six channels got a free at all — and the other four show the right shape\r\n\r\nThis is the sharpest framing of the defect, and it comes straight out of the diff. **Four of the six\r\nchannels received the bare-return form. Only `addin.c` and `drdynvc` received the free-and-null form.**\r\nVerbatim, the `+3`/`+3`/`+3`/`+2` hunks:\r\n\r\n```diff\r\n// rdpdr_main.c   @@ -2072,6 +2072,9 @@\r\n+\tif (Stream_GetPosition(data_in) > totalLength)\r\n+\t\treturn ERROR_INVALID_DATA;\r\n\r\n// encomsp_main.c @@ -982,6 +982,9 @@\r\n+\tif (Stream_GetPosition(data_in) > totalLength)\r\n+\t\treturn ERROR_INVALID_DATA;\r\n\r\n// rdpsnd_main.c  @@ -1211,6 +1211,9 @@\r\n+\tif (Stream_GetPosition(plugin->data_in) > totalLength)\r\n+\t\treturn ERROR_INVALID_DATA;\r\n\r\n// remdesk_main.c @@ -718,6 +718,8 @@\r\n+\tif (Stream_GetPosition(data_in) > totalLength)\r\n+\t\treturn ERROR_INVALID_DATA;\r\n```\r\n\r\n**`rdpdr_main.c` is the decisive one**, because it is the only sibling that shares drdynvc's pool\r\nallocator: it allocates its reassembly buffer with `StreamPool_Take` and it has **zero** `Stream_Free`\r\ncall sites anywhere in the file, at either ref. The bare return is correct there precisely because the\r\npool still owns the buffer. **The author knew the right shape for a pool-backed channel and applied it to\r\nthe other pool-backed channel in the same changeset.**\r\n\r\n### 3.4 The whole family, enumerated rather than sampled\r\n\r\nAt both refs we enumerated every file tree-wide containing `StreamPool_Take` and every file containing a\r\n`*->data_in` reassembly buffer, then listed each member's allocator and what `2362eb9885a2` added:\r\n\r\n| channel | reassembly allocator | what the commit added | `Stream_Free` sites in the file | correct? |\r\n|---|---|---|---|---|\r\n| `channels/client/addin.c` | `Stream_New` (`:672` at master) | **+7**, `Stream_Free(internals->data_in, TRUE)` (`:693`) | 4 | yes — it owns the buffer |\r\n| **`channels/drdynvc/client/drdynvc_main.c`** | **`StreamPool_Take` (`:1709`)** | **+7, `Stream_Free(drdynvc->data_in, TRUE)` (`:1731`)** | **3** (only one touches `data_in` — this one) | **NO** |\r\n| `channels/rdpdr/client/rdpdr_main.c` | **`StreamPool_Take`** | +3, **bare return** (`:2076-2077`) | **0** | yes |\r\n| `channels/encomsp/client/encomsp_main.c` | `Stream_New` (`:965`) | +3, **bare return** | 7 | yes |\r\n| `channels/rdpsnd/client/rdpsnd_main.c` | `Stream_New` | +3, **bare return** | 4 | yes |\r\n| `channels/remdesk/client/remdesk_main.c` | `Stream_New` | +2, **bare return** | 8 | yes |\r\n\r\n**drdynvc is the only member of the family where the allocator and the deallocator disagree.**\r\n\r\nAnd the likely mechanism of the mistake is visible in the counts: `addin.c` already contained **three**\r\nother `Stream_Free(internals->data_in, TRUE)` sites for the author to pattern-match against. `drdynvc_main.c`\r\ncontained **none** — it contained three `Stream_Release(drdynvc->data_in)` sites instead (`:1707`, `:1722`,\r\n`:2076` at master). The byte-identical hunk went into the one file whose established idiom for that\r\npointer was the opposite call.\r\n\r\n### 3.5 The maintainer was in this exact function two days after the release and did not see it\r\n\r\nExactly one commit has touched `drdynvc_main.c` between tag 3.32.0 and master:\r\n**`190167212b9af27d375b09e75a8e3e41aa7d0284`**, *\"[channels,totalLength] correct and tighthen length\r\nchecks\"*, 2026-09-24. It edits `:1707` (adding `drdynvc->totalLength = totalLength;`) and the guard\r\ncondition immediately below, and leaves `Stream_Free` untouched:\r\n\r\n```diff\r\n \t\tdrdynvc->data_in = StreamPool_Take(mgr->pool, dataLength);\r\n+\t\tdrdynvc->totalLength = totalLength;\r\n \t}\r\n...\r\n-\tif (Stream_GetPosition(data_in) > totalLength)\r\n+\tif ((Stream_GetPosition(data_in) > totalLength) || (drdynvc->totalLength != totalLength))\r\n \t{\r\n \t\tStream_Free(drdynvc->data_in, TRUE);\r\n```\r\n\r\nThat commit tightened the guard and widened the set of inputs that reach the wrong free. It is why master\r\nhas a two-clause guard and the tag has one.\r\n\r\n## 4. The invariant delta, at exact `file:line`, per ref\r\n\r\n**The invariant:** a `wStream` obtained from a `wStreamPool` must be returned with `Stream_Release`, which\r\ndecrements the entry's reference count and hands the buffer back to the pool. `Stream_Free` is the\r\ndeallocator for a stream the caller *owns* — it calls `free()` on the buffer and on the `wStream` itself\r\nwhile the pool's entry still points at both.\r\n\r\n**master and 3.32.0 do not share these line numbers, and the guard text differs. Citing one ref's numbers\r\nagainst the other would be simply wrong.**\r\n\r\n| what | master `599262d191` (COMMIT) | tag 3.32.0 (TAG OBJECT `a78d22111c` → COMMIT `d27a4f7c1c`) |\r\n|---|---|---|\r\n| `Stream_Release` in the FIRST-flag branch | `:1707` | `:1707` |\r\n| `StreamPool_Take(mgr->pool, dataLength)` | `:1709` | `:1709` |\r\n| `drdynvc->totalLength = totalLength;` | `:1710` | *(absent)* |\r\n| **`Stream_Release` — the correct deallocator** | **`:1722`** | **`:1721`** |\r\n| the guard | `:1729`, **two** clauses | `:1728`, **one** clause |\r\n| **`Stream_Free(drdynvc->data_in, TRUE)` — the defect** | **`:1731`** | **`:1730`** |\r\n| `drdynvc->data_in = nullptr;` | `:1732` | `:1731` |\r\n\r\nAt 3.32.0 the guard is `if (Stream_GetPosition(data_in) > totalLength)` only, with no\r\n`drdynvc->totalLength != totalLength` clause. **At 3.32.0, line 1731 is `drdynvc->data_in = nullptr;` — a\r\nmaintainer sent to `3.32.0:1731` would not find the defect.** Our 72-byte input still trips the narrower\r\n3.32.0 guard: after `Stream_Write` of 64 bytes, `Stream_GetPosition` is 64 and `totalLength` is 1.\r\n\r\n**The sink, also per ref** (`winpr/libwinpr/utils/collections/StreamPool.c`):\r\n\r\n| what | master | 3.32.0 |\r\n|---|---|---|\r\n| `discard_entry` | `:61-75` | `:56-70` |\r\n| **the use-after-free READ** — `Stream_Free(entry->s, entry->s->isAllocatedStream)` | **`:71`** | **`:66`** |\r\n| `StreamPool_Clear` | `:379`, discards at `:386` / `:398` | `:365`, discards at `:372` / `:384` |\r\n| `StreamPool_Free` | `:449` | `:435` |\r\n| `Stream_New` (the allocation the ASAN trace names) | `stream.c:109` | `stream.c:100` |\r\n| `StreamPool_Take` | `:257` | `:257` |\r\n\r\n**Why the read is a read of freed memory and not merely a stale pointer:** `discard_entry` dereferences\r\n`entry->s` to fetch `entry->s->isAllocatedStream` as the second argument to `Stream_Free`. The pool's\r\nentry array still holds the pointer, because `Stream_Free` does not touch the pool. `isAllocatedStream`\r\nsits at offset **48** in the 56-byte `wStream` on LP64 — which is exactly the \"48 bytes inside of 56-byte\r\nregion\" that AddressSanitizer reports. On ILP32 the same field sits at offset **24** in a 32-byte\r\n`wStream`, and the i386 arm reports \"24 bytes inside of 32-byte region\". We verified the offsets by\r\ncompiling the real `<winpr/stream.h>` with one compiler twice.\r\n\r\n**Reachability from the receive path**, read from the blob at master:\r\n\r\n```\r\ntransport_check_fds (libfreerdp/core/transport.c:1530)\r\n  → rdp_recv_callback (rdp.c:2273) → rdp_recv_pdu (:1889) → rdp_recv_tpkt_pdu (:1842)\r\n  → freerdp_channel_process (libfreerdp/core/channels.c:149)\r\n  → freerdp_channels_data (libfreerdp/core/client.c:635)\r\n  → drdynvc_virtual_channel_open_event_ex (drdynvc_main.c:1796)\r\n  → drdynvc_virtual_channel_event_data_received (:1687)  ... frees at :1731, returns ERROR_INVALID_DATA\r\n```\r\n\r\nThe error is not swallowed. `:1819-1821` calls `setChannelError(drdynvc->rdpcontext, error,\r\n\"drdynvc_virtual_channel_open_event reported an error\")`, which the client's event loop observes and turns\r\ninto a disconnect. Teardown then walks:\r\n\r\n```\r\nfreerdp_disconnect (libfreerdp/core/freerdp.c:659)\r\n  → freerdp_channels_close (libfreerdp/core/client.c:995)\r\n  → drdynvc_virtual_channel_event_terminated (drdynvc_main.c:2098)\r\n  → dvcman_free (:640) → StreamPool_Free → StreamPool_Clear → discard_entry   ← the UAF\r\n```\r\n\r\n**So the defect's own error return is what drives the process to the sink.** No second PDU is needed.\r\n\r\n**And drdynvc loads on a stock client with no user flag.** `client/common/cmdline.c:6200` builds\r\n`dynChannels[]`, whose first entry at `:6202` is `{ FreeRDP_BOOL_UNUSED, AINPUT_CHANNEL_NAME, nullptr }`\r\nwith the in-tree comment `/* always loaded */`. The add loop at `:6260-6270` adds any entry whose\r\n`settingId` is `FreeRDP_BOOL_UNUSED`, unconditionally. That makes `DynamicChannelCount > 0`, which sets\r\n`FreeRDP_SupportDynamicChannels` at `:6520`, which loads the drdynvc static channel addin at `:6526`.\r\nThere is no platform branch anywhere in that chain.\r\n\r\n## 5. Reproduction over the wire — executed, not inferred\r\n\r\nThis is the headline. **Zero lines of FreeRDP source are modified in the attack path.**\r\n\r\n### 5.1 Topology\r\n\r\n```\r\n  unmodified ASAN xfreerdp  --TLS-->  stock freerdp-proxy  --TLS-->  pristine upstream sfreerdp\r\n   (/dump:record on)                  + out-of-tree plugin              (sample server)\r\n```\r\n\r\nThree containers, one private Docker bridge, `PORTS=[]` on all three, both ends ours, synthetic\r\nhostnames (`*.lab.invalid`), synthetic credentials and a synthetic 35-byte marker string\r\n(`R180-SYNTHETIC-MARKER-NO-REAL-DATA-`, repeated to 64 bytes). No host outside the lab was contacted.\r\n\r\nThe attacker is a **221-line out-of-tree plugin** loaded by the stock proxy through its public plugin\r\ninterface. It uses only public installed APIs — `freerdp/server/proxy/proxy_modules_api.h`,\r\n`freerdp/channels/wtsvc.h` (`WTSChannelGetId`), `freerdp/peer.h` (`freerdp_peer::SendChannelPacket`) —\r\nand it reads the two parameters straight out of a retained input file:\r\n\r\n```\r\n  [0..3]  UINT32 LE totalLength  ->  SendChannelPacket totalSize\r\n  [4..7]  UINT32 LE dataFlags    ->  SendChannelPacket flags\r\n  [8..n)  chunk payload          ->  SendChannelPacket data / chunkSize\r\n```\r\n\r\n**The proxy is the vehicle because it terminates TLS on both legs.** A naive byte-patching TCP relay\r\ncannot see plaintext channel PDUs after the X.224 exchange, so it cannot mangle one. That is a property of\r\nour measurement apparatus, not of the defect: any party that speaks RDP as the server to a FreeRDP client\r\ncan send this PDU with one call to a public API.\r\n\r\n### 5.2 The three cases, run in order, same binaries throughout\r\n\r\n| case | what the plugin emits | client outcome | `asan.client.txt` |\r\n|---|---|---|---|\r\n| **A — baseline passthrough** | nothing | drdynvc joined (id 1007), session ACTIVE, **real desktop bits painted** (`gdi_surface_bits` 1024×768), held the full 40 s cap → `exit=124` | **0 bytes** |\r\n| **B — control, well-formed** | `SendChannelPacket(totalSize=64, flags=0x1, chunkSize=64)` → TRUE | session continued, held the full 40 s → `exit=124` | **0 bytes** |\r\n| **C — defect, malformed** | `SendChannelPacket(totalSize=1, flags=0x1, chunkSize=64)` → TRUE | `drdynvc_virtual_channel_open_event reported an error. Error was 13` → teardown → `exit=1` | **7122 bytes, heap-use-after-free** |\r\n\r\n**A clean 0/0, 0/1, 1/0 separation.** `13` is `ERROR_INVALID_DATA` — precisely the return at the defect\r\nline. The only thing that changes between B and C is the input file.\r\n\r\n### 5.3 The plaintext bytes, as the client itself recorded them\r\n\r\nCaptured with upstream FreeRDP's **own** `/dump:record` feature (`libfreerdp/core/streamdump.c`), which\r\nwraps `rdpTransportIo::ReadPdu` — **below the RDP parser, after TLS decryption, nothing patched**. This\r\nis the client's own record of what came off its socket.\r\n\r\n`C-DEFECT-malformed/rx.dump`, framing header at **`0x160f`**, Virtual Channel PDU body at **`0x161e`**\r\n(we re-derived both offsets with `xxd` on the retained file):\r\n\r\n```\r\n00001600: 0000 0019 4645 5557 0000 0000 0000 0003  ....FEUW........\r\n00001610: 0000 5702 f080 6800 0803 ef70 8048 0100  ..W...h....p.H..\r\n00001620: 0000 0100 0000 5231 3830 2d53 594e 5448  ......R180-SYNTH\r\n```\r\n\r\nDecoded:\r\n\r\n| offset | bytes | meaning |\r\n|---|---|---|\r\n| `0x160f` | `03 00 00 57` | TPKT header: version 3, reserved 0, **length 0x0057 = 87** |\r\n| `0x1613` | `02 f0 80` | X.224 Data TPDU (LI=2, class 0, EOT) |\r\n| `0x1616` | `68 00 08 03 ef 70 80 48` | MCS `SendDataIndication`; **channelId `0x03ef` = 1007**; PER length `0x0048` = **72** |\r\n| `0x161e` | `01 00 00 00` | `totalLength` = **1** — the lie |\r\n| `0x1622` | `01 00 00 00` | `dataFlags` = `CHANNEL_FLAG_FIRST` |\r\n| `0x1626` | 64 bytes | the synthetic marker payload |\r\n\r\n**`87 = 4 + 3 + 8 + 72`, exactly.** The frame is self-consistent, so `channels.c:142`\r\n(`packetLength != chunkLength`) **passes**, and `totalLength` is never examined. That single sentence is\r\nthe whole finding.\r\n\r\nThe control's body sits at the **same offset `0x161e`** in its own dump, with `40 00 00 00` (=64) in place\r\nof `01 00 00 00`. **`cmp -l` on the two 72-byte input files reports exactly ONE differing byte** — offset\r\n1, octal `100` (0x40) versus octal `001` (0x01). The differing *field* is the 4-byte little-endian\r\n`totalLength`; its upper three bytes are `0x00` in both. **Say \"one byte of the four-byte `totalLength`\r\nfield\".** Any reviewer who diffs the two files will check this, and one byte is both the true claim and\r\nthe stronger one.\r\n\r\nInput hashes, unchanged across every round:\r\n\r\n```\r\n8f15d99f35e43efc916723b9d40bafa731374f6247f070844ec99bdbaa2cacbc  input_defect_drdynvc.bin\r\n36cec4c2908606ed52d2cbc0d3354ae8b311a26982de3589eedc5a3ba1ba5d77  input_control_wellformed.bin\r\n```\r\n\r\n### 5.4 The ASAN trace is rooted in the socket read\r\n\r\nCase C, verbatim structure:\r\n\r\n```\r\nREAD of size 4 at ... — 48 bytes inside of 56-byte region\r\n  #0 discard_entry                              winpr/.../StreamPool.c:71\r\n  #1 StreamPool_Clear                           StreamPool.c:398\r\n  #2 StreamPool_Free                            StreamPool.c:454\r\n  #3 dvcman_free                                drdynvc_main.c:640\r\n  #4 drdynvc_virtual_channel_event_terminated   drdynvc_main.c:2098\r\nfreed by:      drdynvc_main.c:1731        (via channels.c:149 <- transport_check_fds)\r\nallocated by:  StreamPool_Take StreamPool.c:257 from drdynvc_main.c:1709\r\n                                          (via channels.c:149 <- transport_check_fds)\r\nSUMMARY: AddressSanitizer: heap-use-after-free StreamPool.c:71 in discard_entry\r\n```\r\n\r\n**Both the allocation and the free run through `transport_check_fds`** — i.e. both are driven by the real\r\nsocket read, not by a harness call.\r\n\r\n### 5.5 drdynvc was proven in use at four independent layers\r\n\r\nNot assumed:\r\n\r\n- **proxy front leg:** `Accepted channel: drdynvc (1007)`\r\n- **plugin:** `WTSChannelGetId(peer, drdynvc) = 1007`, logged at every activation\r\n- **client:** `drdynvc_VirtualChannelEntryEx`; `rdp_client_skip_mcs_channel_join drdynvc [1007]`;\r\n  `dvcman_load_addin` × 4 (`ainput`, `rdpgfx`, `disp`, `rdpsnd`) plus four listeners — so the DVCMAN and\r\n  therefore its `StreamPool` exist **before** the emit\r\n- **wire:** MCS `SendDataIndication` channelId `0x03EF` = 1007\r\n\r\nThe plugin also **refuses to emit** if `WTSChannelGetId` returns 0, so a false negative on an\r\nunnegotiated channel is impossible by construction.\r\n\r\n### 5.6 Lab conveniences — disclosed, not hidden. None is part of the attack.\r\n\r\n- **`/rfx` on the client is a BACKEND REQUIREMENT.** The `sfreerdp` *sample* server refuses clients that\r\n  offer neither RemoteFX nor NSCodec. `/gfx` forces drdynvc to be negotiated. Neither is needed to reach\r\n  the defect; a real server negotiates drdynvc for its own reasons.\r\n- **The sample backend needs its `test_icon.*` assets in the working directory** (`bmp`, `jpg`, `png`,\r\n  `ppm`, `webp`). Backend requirement, not part of the attack.\r\n- **`/cert:ignore` on the client and `CertificatePolicy=allow` on the proxy** are for the lab's\r\n  self-signed certificates. Pure lab convenience. Note that the *defect* is reached **after** TLS, so\r\n  certificate validation is irrelevant to it — a client that validates a legitimate server's certificate\r\n  is equally exposed to a compromised or malicious server behind it.\r\n- **The 2000 ms delay and \"emit on the 2nd `ServerPeerActivate`\" are LAB DETERMINISM, identical in B and\r\n  C.** They exist so the client's DVCMAN is certainly built before the emit. **Not a protocol\r\n  requirement**, and not a precondition of the defect.\r\n- **`Xvfb`** for the X11 client. Display plumbing only.\r\n- **Honest limit on the pcap:** the client↔proxy leg is TLS-wrapped, so the 72 mangled bytes are **not**\r\n  readable in plaintext inside `wire.pcap`. The pcap proves real TCP segments flowed between the two\r\n  containers on port 3389 at the right times. The plaintext byte-level proof is the client's own\r\n  `/dump:record` transport record plus the ASAN backtrace rooted in the socket read. An attempt to make\r\n  the leg plaintext (`/sec:rdp -encryption` with `ServerRdpSecurity`) **failed** — the peer still\r\n  negotiated RDP standard security with RC4-128 — and that failed attempt is retained.\r\n\r\n## 8. The sibling-control matrix — five arms, one transcript, defect last\r\n\r\nIn-process, no socket, no listener, benign synthetic markers, `ulimit -t 30` plus a hard `timeout` per\r\nrun. The point of this matrix is that **the controls fire before the defect, in the same run, on the same\r\nbinaries.** Same five arms at master `599262d191`, at tag 3.32.0, and at the 3.31.1 negative control.\r\n\r\n**At master and at 3.32.0:**\r\n\r\n| arm | channel | input | allocator / discard | result |\r\n|---|---|---|---|---|\r\n| A | `addin.c` | well-formed (`total=64`, chunk 64) | `Stream_New(:672)` / `Stream_Free(:693)` | `rc=0x00000000`, clean |\r\n| B | `addin.c` | **malformed** (`total=1`) | `Stream_New(:672)` / `Stream_Free(:693)` — correct pairing | **`rc=0x0000000D`, clean** |\r\n| **C** | **`rdpdr`** | **malformed** | **`StreamPool_Take(:2050)` / bare return (`:2076-2077`)** | **`rc=0x0000000D`, clean** |\r\n| D | `drdynvc` | well-formed | `StreamPool_Take(:1709)` / `Stream_Free` | `rc=0x00000000`, clean |\r\n| **E** | **`drdynvc`** | **malformed** | **`StreamPool_Take(:1709)` / `Stream_Free`** | **`rc=0x0000000D` → heap-use-after-free** |\r\n\r\n> *\"--- every arm above completed with NO sanitizer event ---\"*, printed by the harness before arm E runs.\r\n\r\n**ARM C is the strongest control in this report.** Same pool allocator, same guard, same commit, same\r\nmalformed input, same error code — and it discards correctly, so nothing fires. **All three controls\r\nreturn the identical error code as the defect arm**, which removes \"your malformed input just breaks\r\nthings\" as an explanation. Arms D and E differ in one wire field.\r\n\r\n**The negative control was executed, not inferred.** At **3.31.1** the same five arms with the same\r\nmalformed input give `rc=0x00000000` on every arm, **no sanitizer event, exit code 0** — the harness even\r\nprints *\"--- reached end of main: no sanitizer event fired (UNEXPECTED at an affected ref) ---\"*, which is\r\nhow we know the arm ran rather than silently skipped. Combined with the content table in §4, that is a\r\nregression floored by execution at the release below and by content at every release below that.\r\n\r\n**Per-ref sink lines, from the two transcripts:** at master the free is `drdynvc_main.c:1731`, the sink\r\n`StreamPool.c:71` inside `StreamPool_Clear:398`, allocation via `Stream_New stream.c:109`. At 3.32.0 the\r\nfree is `:1730`, the sink `StreamPool.c:66` inside `StreamPool_Clear:384`, allocation via\r\n`Stream_New stream.c:100`. Both report *\"48 bytes inside of 56-byte region\"*.\r\n\r\n**Stock builds: the abort, 6/6.** With `-O2 -DNDEBUG` and no sanitizer, both refs give `exit=134` →\r\nkilled by signal 6 on every repetition (3 reps × 2 refs), and the FATAL line names the assertion, e.g.\r\n`Stream_EnsureValidity: s->pointer >= s->buffer\r\n[./winpr/libwinpr/utils/stream.c:Stream_EnsureValidity:160]` at 3.32.0 (`:169` at master). **The double\r\nfree is not reached in a stock glibc build** — see 2.5, 2.6 and 2.7 for what that does and does not mean.\r\n\r\n*One honest detail a reproducer will notice:* **which** of the four `STREAM_ASSERT`s inside\r\n`Stream_EnsureValidity` fires varies between repetitions — some reps abort on\r\n`s->pointer >= s->buffer` (3.32.0 `:160`, master `:169`), others on `cur <= s->capacity` (3.32.0 `:163`,\r\nmaster `:172`). The outcome is a deterministic abort every time; the specific field that first looks\r\ninconsistent is not deterministic, because it depends on what the allocator has already written into the\r\nfreed 56 bytes. That is the same reuse effect measured directly in 2.6.\r\n\r\n### 6.1 Platform breadth, measured from identical source bytes\r\n\r\nThe three files under test carry the same on-disk sha256 in every container\r\n(`drdynvc_main.c 4d285635e2a8…`, `StreamPool.c 90110e4db238…`, `stream.c 6458d326f3ce…`), so every arm\r\nbelow ran on identical bytes.\r\n\r\n| platform | toolchain | ASAN result | stock result |\r\n|---|---|---|---|\r\n| glibc x86-64 | GCC | UAF, `StreamPool.c:66`, 48 in 56 | **SIGABRT (6), 5/5** |\r\n| **musl x86-64** (Alpine 3.20.10 / musl 1.2.5) | **clang 17** | **same UAF**, byte-for-byte the same finding | **SIGSEGV (11), 5/5, zero assertion firings** |\r\n| **i386 / ILP32** | GCC `-m32` | **same UAF**, \"24 bytes inside of 32-byte region\" | *not run* |\r\n\r\n**The \"this is a glibc/GCC artifact\" refutation lane is closed:** two libcs, two compilers, two word\r\nsizes, one finding. GATE 17 holds inside the musl transcript too (arms A–D clean, defect last).\r\n\r\n**The 32-bit arithmetic does not differ.** Evaluating the guard with upstream's operand types gives the\r\nsame answer on both ABIs for every reachable input: `pos=64/total=1` → fires; `64/64` → does not;\r\n`64/0xFFFFFFFF` (the largest legal wire value) → does not; `0xFFFFFFFF/0xFFFFFFFE` → fires. The only\r\ndivergent probe row is `pos=0x100000000`, which ILP32 cannot represent and which the guard itself makes\r\nunreachable, since it runs on every chunk so position can never exceed `totalLength ≤ 0xFFFFFFFF` without\r\nfiring earlier.\r\n\r\n**Lab conveniences in this section, disclosed:** the musl arm uses clang 17 with `-shared-libasan` because\r\nAlpine's GCC ships no `libasan` (identical source bytes, different compiler); the staged trees carry CRLF\r\nbecause `git archive` applied the repository's own `.gitattributes` text conversion, so on-disk hashes are\r\nnot git blob hashes and a maintainer reproducing from a plain `git checkout` on Linux will see LF files\r\nwith different file hashes and **the same line numbers**; sections 2.6 and 2.7's instrumented runs require\r\nthe one-line `abort()` removal plus printf-only instrumentation, **both diffs retained**, while every\r\nother arm and every baseline uses pristine source; `-fsanitize-recover=address` is a sanitizer option,\r\nnot a source change. Retained as a negative: `halt_on_error=0` is inert without\r\n`-fsanitize-recover=address` — the first attempt still aborted after one error.\r\n\r\n## 7. Impact\r\n\r\n### 7.1 The mechanism, stated precisely\r\n\r\nA malicious or compromised RDP server — or a party in a position to inject one PDU into the channel\r\nstream — sends a single, perfectly well-framed Virtual Channel PDU on the drdynvc channel whose\r\n`totalLength` is smaller than the chunk it carries. The client frees a pool-owned stream with the\r\nowned-stream deallocator and returns `ERROR_INVALID_DATA`, which its own error plumbing turns into a\r\ndisconnect, whose teardown walks the pool and reads the freed object. **No authentication. No race. No\r\nprior state. One PDU.**\r\n\r\n### 7.2 The single-session case: honest and modest\r\n\r\nFor plain `xfreerdp` the victim loses a session a malicious server could have dropped anyway, and gets a\r\ncore dump instead of a clean close. See 2.3. We are not going to dress that up.\r\n\r\n### 7.3 The multi-session case: measured, and this is where the impact lives\r\n\r\nWe built a two-session host — one process, two `rdpContext`s, two threads, two independent TCP+TLS\r\nconnections, modelled on FreeRDP's own sample client — and a second proxy plugin (228 lines, public ABI\r\nonly) that reads the front peer's `FreeRDP_Username` and emits **only** to the targeted user, logging for\r\nevery other peer: *\"NOT THE TARGET … this session is the IN-RUN CONTROL and receives nothing.\"*\r\n\r\nEach session logs in as a distinct synthetic user, keeps its own heartbeat / loop / socket-wakeup /\r\nkeepalive counters, and sends a synchronize input event every second — so \"alive\" means \"still transacting\r\non its own socket\", not \"blocked in a wait\". A monitor thread prints both sessions side by side twice a\r\nsecond. The host prints a FINAL VERDICT TABLE and `host exiting normally` on a clean finish; **the absence\r\nof those lines is the process-death signal.**\r\n\r\n**The instant of death (CB2, the citable clean-backend ASAN arm, pid 4594):**\r\n\r\n```\r\n[00:22:55.871][t=  8.02] [MON] S1(labalice) conn=1 fin=0 hb=16 loops=48 wake=17 ka=8\r\n                              || S2(labbob) conn=1 fin=0 hb=4 loops=24 wake=17 ka=2\r\n[00:22:56.040] emit to labbob ONLY: SendChannelPacket(channelId=1007, totalSize=1, chunkSize=64) -> TRUE\r\n[00:22:56:040] [S2] drdynvc_virtual_channel_open_event reported an error. Error was 13\r\n[00:22:56.040]      [S2] LOOP EXIT  reason=\"freerdp_check_event_handles FALSE\" hb=4 loops=25 ka=2\r\n[00:22:56:051] StreamPool_Clear: Clearing StreamPool, but there are 1 streams currently in use\r\n---- LAST LINE. No [S1] LOOP EXIT. No FINAL VERDICT TABLE. No \"host exiting normally\". ==4594==ABORTING.\r\n```\r\n\r\n`grep -cE \"\\[S1\\] LOOP EXIT|FINAL VERDICT|host exiting normally\"` on that log returns **0**. S1 was at\r\n`hb=16 loops=48 ka=8` and healthy at the last monitor line. Baseline and control arms print the verdict\r\ntable, exit 0, and leave a **0-byte** ASAN log.\r\n\r\n**The production-build answer — the load-bearing part.** A second host built against a fresh\r\n`CMAKE_BUILD_TYPE=Release, -O3 -DNDEBUG`, no-sanitizer tree (`__asan_report` symbol count 0 in all three\r\nlibraries) takes the defect arm to `exit=134` → killed by signal 6, `timeout: the monitored command dumped\r\ncore`, 3/3 reps. The chain is FreeRDP's own code, from the log:\r\n\r\n```\r\nStreamPool_Clear: \"1 streams currently in use\"\r\n  → [FATAL][com.winpr.wStream] Stream_EnsureValidity: s->pointer >= s->buffer\r\n        [./winpr/libwinpr/utils/stream.c:Stream_EnsureValidity:169]\r\n  → winpr backtrace: Stream_Free ← StreamPool_Clear ← StreamPool_Free\r\n        ← libfreerdp-client ← libfreerdp ← freerdp_disconnect\r\n  → abort()\r\n```\r\n\r\nS1 was at `hb=16 loops=50 wake=19 ka=8` at the last monitor line in that arm and never reached its LOOP\r\nEXIT. Stock controls: both sessions to the cap, verdict table printed, exit 0.\r\n**So the refuter cannot dismiss the process death as a sanitizer artifact.**\r\n\r\n**The wire agrees.** A pcap taken inside the client container on its own interface shows two distinct\r\nclient source ports — two genuinely independent TCP connections. From the `C-DEFECT-malformed-to-S2` arm\r\n(source ports 41024 = S1, 52914 = S2):\r\n\r\n```\r\n00:02:02.072  41024 > proxy  len 26        <- S1 keepalive\r\n00:02:02.083  52914 > proxy  len 26        <- S2 keepalive\r\n00:02:02.972  proxy > 52914  len 109       <- THE MALFORMED PDU, on S2's socket ONLY\r\n00:02:02.973  52914 > proxy  [F.] then [R] <- S2 torn down\r\n00:02:03.075  41024 > proxy  len 26        <- S1 STILL SENDING. It has not failed.\r\n00:02:03.253  41024 > proxy  [F.] then [R] <- S1 torn down 280 ms later, by the dying process\r\n```\r\n\r\nS1 sends **one more keepalive after S2 is already gone**, then is torn down with no application log line\r\nof its own. It had not failed. It was killed. *(This port-level trace is read from a first-pass arm, so\r\n2.10's discount applies to it; the citable CB1–CB4 arms each retain their own `wire.pcap` and `tcpdump.log`\r\nand the host-log evidence above is from CB2.)*\r\n\r\n**Symmetry.** Swapping the target (arm D2) swaps the roles: the first session takes error 13 and the\r\nsecond — explicitly logged as the in-run control, alive at `hb=5 loops=27 ka=3` — never exits, no verdict\r\ntable, UAF. **Not an artifact of connection order.**\r\n\r\n**The ASAN trace is identical in all four clean-backend ASAN defect reps** and rooted in the real socket\r\nread: allocated `drdynvc_main.c:1709` (`StreamPool_Take` → `Stream_New`); freed `drdynvc_main.c:1731`\r\n(reached via `transport_check_fds` → `rdp_recv_callback` → `freerdp_channel_process channels.c:149` →\r\n`drdynvc_virtual_channel_open_event_ex:1796`); used `StreamPool.c:71 discard_entry` ←\r\n`StreamPool_Clear:398` ← `StreamPool_Free:454` ← `dvcman_free drdynvc_main.c:640` ←\r\n`freerdp_channels_close client.c:995` ← `freerdp_disconnect freerdp.c:659`.\r\n\r\n\r\n## 8. Remediation\r\n\r\n### 8.1 The one-line fix, using a call that is already in the same function\r\n\r\nReplace the owned-stream deallocator with the pool deallocator that sits nine lines above it. At master\r\n`599262d191`:\r\n\r\n```diff\r\n     if ((Stream_GetPosition(data_in) > totalLength) || (drdynvc->totalLength != totalLength))\r\n     {\r\n-        Stream_Free(drdynvc->data_in, TRUE);\r\n+        Stream_Release(drdynvc->data_in);\r\n         drdynvc->data_in = nullptr;\r\n         return ERROR_INVALID_DATA;\r\n     }\r\n```\r\n\r\nAt tag 3.32.0 the same change applies at **`:1730`**, inside the single-clause guard at `:1728`, with the\r\ncorrect sibling at **`:1721`**.\r\n\r\n`Stream_Release` is not a novel choice here: it is what `2362eb9885a2` should have written, it is what\r\n`5b2a4434d` deliberately put there in 2020 (§5.1), and `drdynvc_main.c` already calls it on this exact\r\npointer at `:1707`, `:1722` and `:2076` at master.\r\n\r\n### 8.2 The pattern to match, from the same commit\r\n\r\n**Four of the six channels in `2362eb9885a2` already carry the correct shape** — `rdpdr`, `encomsp`,\r\n`rdpsnd` and `remdesk` all got the bare return, freeing nothing:\r\n\r\n```c\r\n\tif (Stream_GetPosition(data_in) > totalLength)\r\n\t\treturn ERROR_INVALID_DATA;\r\n```\r\n\r\n`rdpdr_main.c` is the one to match, because it is the only sibling that shares drdynvc's pool allocator\r\nand it has zero `Stream_Free` sites in the whole file.\r\n\r\nEither shape closes the defect. `Stream_Release` plus `data_in = nullptr` (§11.1) preserves the existing\r\nintent of `2362eb9885a2` — drop the partial reassembly and refuse the rest of the message — and is the\r\nsmaller behavioural change; the bare return matches `rdpdr` exactly but leaves the stale partial buffer on\r\nthe pool for the next `CHANNEL_FLAG_FIRST` to release at `:1707`. **We have no preference between them and\r\nwe are not opening a pull request.** Describing the fix is as far as we go.\r\n\r\n### 8.3 Worth a look while you are in there\r\n\r\n- **The guard added by `190167212` widened the input set that reaches this line.** Whatever fix is chosen\r\n  should be applied on master, where that commit lives, and backported to the 3.32.0 shape.\r\n- **Consider whether `Stream_Free` should refuse a pooled stream.** `wStream` already carries\r\n  `isAllocatedStream` and a `pool` pointer; a `WINPR_ASSERT(!s->pool)` at the top of `Stream_Free`, or an\r\n  early `Stream_Release` redirect, would turn this whole class of mix-up into a loud failure at the point\r\n  of the mistake rather than a silent one at pool teardown. This is the third time this pointer has been\r\n  the subject of a `Stream_Free`/`Stream_Release` decision (2020 fixed it, 2026 reintroduced it), which\r\n  suggests the API shape invites it.\r\n- **`STREAM_ASSERT` is what makes the shipped glibc outcome a clean abort rather than a corrupt heap.**\r\n  It is unconditional today (`stream.c` contains zero `NDEBUG`). Our measurements in 2.6 show what\r\n  happens without it. If there is ever pressure to compile it out for release builds, that measurement is\r\n  the argument against.\r\n- **No supported configuration turns drdynvc off.** `ainput` is marked `/* always loaded */` at\r\n  `cmdline.c:6202` and is added unconditionally, which forces `SupportDynamicChannels` on and loads the\r\n  channel (§6). We did not re-measure a hardening matrix in this round and say so in §12.\r\n\r\n\r\n### 9 Credit\r\n\r\n\r\n> Credit: **Mike Read (github - Michael-JRead)**\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-5xjc-c64q-m8r6","source_modified_at":"2026-10-05T20:30:44Z","fetched_at":"2026-10-24T23:06:24.700873Z","package":{"ecosystem":"c","name":"FreeRDP"},"range":" 3.24.2 <= 3.32.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-5xjc-c64q-m8r6","source_modified_at":"2026-10-05T20:30:44Z","fetched_at":"2026-10-24T23:06:24.700873Z","package":{"ecosystem":"c","name":"FreeRDP"},"version":"3.32.1"}],"cvss":6.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-05T20:30:44Z","modified_at":"2026-10-05T20:30:44Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/FreeRDP/FreeRDP/security/advisories/GHSA-5xjc-c64q-m8r6","fetched_at":"2026-10-24T23:06:24.700873Z","source_modified_at":"2026-10-05T20:30: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-10-05T20:30:44Z","content_hash":"57f63c895e79d4c74e2e2f6872ba36732076a587db754ed95a0bb5d3a729e117"}}
