Skip to content

Attacker-controlled heap out-of-bounds write in libvncclient Tight decoder

High
bk138 published GHSA-v9pm-47h4-jcq8 May 29, 2026

Package

libvncserver (C / CMAKE)

Affected versions

>= 0.9.12

Patched versions

None

Description

Summary

A malicious (or man-in-the-middle) VNC server can force a connecting libvncclient to write
attacker-controlled data past the end of its framebuffer. This is an out-of-bounds heap write with
attacker-controlled length, contents, and offset. It needs no authentication (the attacker
is the server), works in a default build with default settings, and fires from a single
FramebufferUpdate the moment the victim connects. It crashes any client unconditionally
(denial of service); we also demonstrated it overwriting an application callback pointer and
redirecting execution to attacker-chosen code (code execution) under the default configuration.

Details

The bug is in src/libvncclient/tight.c, HandleTightBPP() (compiled to HandleTight8/16/32).
The Tight basic-compression zlib loop writes each batch of decompressed rows straight into the
framebuffer and only checks the total row count after the loop has already finished writing:

while (compressedLen > 0) {
    ...
    numRows = (bufferSize - zs->avail_out) / rowSize;
    filterFn(client, rx, ry + rowsProcessed, numRows);   /* writes to frameBuffer */
    rowsProcessed += numRows;                             /* never compared to rh here */
    ...
}
if (rowsProcessed != rh) return FALSE;                    /* height check is post-loop, too late */

filterFn (FilterCopyBPP / FilterPaletteBPP / FilterGradientBPP) writes directly to
&client->frameBuffer[(srcy * client->width + srcx) * BPP/8], with srcy = ry + rowsProcessed.
As the server feeds a zlib stream that decompresses to more rows than the rectangle's declared
height rh, rowsProcessed (and thus srcy) runs past the bottom of the image, and the writes
go past the end of the framebuffer allocation. These filters do not route through the
application's GotBitmap/CheckRect callbacks, so an application cannot intercept the write.

The per-rectangle bounds check at src/libvncclient/rfbclient.c:~2252 validates only the
rectangle's declared width and height, never the decompressed row count, so a rectangle with
perfectly legal geometry (e.g. 16×16 inside a 16×16 framebuffer) but an over-expanding zlib
payload sails through it.

Tight is advertised to the server by default whenever the library is built with zlib + libjpeg
(rfbclient.c:~1374; CMake WITH_ZLIB / WITH_JPEG default ON).

Affected: libvncclient from 0.9.12 through current master (5ea5fc30, 0.9.15+). The
unbounded loop was introduced by commit d7b1462 (2018-01-22, "Include Tight decoding
optimizations from TurboVNC"); 0.9.12 is the first release tag containing it. Releases before
0.9.12 shipped a different (pre-TurboVNC) Tight decoder that was not assessed. Note: the recent
commit 5b27054 ("fix Tight gradient decoding overflow", May 2026) does not fix this; it
clamps the rectangle width to protect a gradient stack buffer; this write is driven by the
decompressed row count and triggers in FilterCopy.

Suggested fix: clamp the row count to the remaining height, inside the loop, before
filterFn:

numRows = (bufferSize - zs->avail_out) / rowSize;

if (numRows > rh - rowsProcessed) {
    rfbClientLog("Tight: too many scan lines after decompression.\n");
    return FALSE;
}

filterFn(client, rx, ry + rowsProcessed, numRows);

Verified: this stops the out-of-bounds write (the client logs the message and disconnects) while
well-formed Tight rectangles still decode normally. It matches the existing house style: the
sibling decoders in trle.c (while (j < h ...)) and the Tight JPEG path
(for (j = 0; j < h; j++)) already clamp to the rectangle height; only this basic-compression
path does not.

PoC

A minimal client using the default callbacks connects to a malicious VNC server (RFB 3.8,
security type "None") that sends one 16×16 Tight rectangle whose zlib payload decompresses to
far more than 16 rows. Built with AddressSanitizer on commit 5ea5fc30, the client reports:

ERROR: AddressSanitizer: heap-buffer-overflow ... WRITE of size 4
  #0 FilterCopy32            src/libvncclient/tight.c:400
  #1 HandleTight32           src/libvncclient/tight.c:344
  #2 HandleRFBServerMessage  src/libvncclient/rfbclient.c:2493
0x... is located 0 bytes after 1024-byte region   (1024 = 16*16*4, the entire framebuffer)
allocated by ... MallocFrameBuffer  src/libvncclient/vncviewer.c:105

Complete, runnable artifacts are attached privately to this advisory:

  • build.sh + harness.c: minimal default-callback client (ASan build; no CMake needed).
  • evil_server.py: the malicious VNC server (Python standard library only).
  • build_rce.sh + harness_rce.c + evil_rce.py: control-flow-hijack demonstration (a
    consumer whose framebuffer sits next to a callback pointer, as real SDL/GTK/Qt viewers do;
    the overflow silently overwrites the callback and execution is redirected to attacker code).

Steps: cd lab && bash build.sh, then python3 evil_server.py 5900 &, then
ASAN_OPTIONS=detect_leaks=0 ./build/harness 127.0.0.1:5900, which produces the trace above.

Impact

Type: out-of-bounds heap write (CWE-787 / CWE-122) reachable pre-authentication over the network.

Who is impacted: any application that uses libvncclient to connect to a VNC server. This
includes desktop viewers (the shipped SDL/GTK examples and downstreams built on them), and any
tool or service that embeds the client. The attacker is whatever server the client connects to,
or a man-in-the-middle on that connection. A victim who connects to a hostile server (or whose
connection is intercepted) gets, at minimum, a guaranteed crash. And because real viewers point
client->frameBuffer into their own objects (e.g. examples/client/SDLvncviewer.c does
client->frameBuffer = sdl->pixels;), the result is an attacker-controlled overwrite of adjacent
live heap, which we showed can be turned into code execution under the default configuration.

Scope note: aiming the overwrite at a specific address on a position-independent, ASLR-enabled
binary additionally requires an information leak, which this bug does not by itself provide. The
memory corruption and the denial of service do not.

Credit

Reported by Bas Levering (basdaniel3@gmail.com).

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

CVE ID

CVE-2026-50538

Weaknesses

Heap-based Buffer Overflow

A heap overflow condition is a buffer overflow, where the buffer that can be overwritten is allocated in the heap portion of memory, generally meaning that the buffer was allocated using a routine such as malloc(). Learn more on MITRE.

Out-of-bounds Write

The product writes data past the end, or before the beginning, of the intended buffer. Learn more on MITRE.

Credits