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