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).
Summary
A malicious (or man-in-the-middle) VNC server can force a connecting
libvncclientto writeattacker-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
FramebufferUpdatethe 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 toHandleTight8/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:
filterFn(FilterCopyBPP/FilterPaletteBPP/FilterGradientBPP) writes directly to&client->frameBuffer[(srcy * client->width + srcx) * BPP/8], withsrcy = ry + rowsProcessed.As the server feeds a zlib stream that decompresses to more rows than the rectangle's declared
height
rh,rowsProcessed(and thussrcy) runs past the bottom of the image, and the writesgo past the end of the framebuffer allocation. These filters do not route through the
application's
GotBitmap/CheckRectcallbacks, so an application cannot intercept the write.The per-rectangle bounds check at
src/libvncclient/rfbclient.c:~2252validates only therectangle'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; CMakeWITH_ZLIB/WITH_JPEGdefault ON).Affected:
libvncclientfrom 0.9.12 through current master (5ea5fc30, 0.9.15+). Theunbounded loop was introduced by commit
d7b1462(2018-01-22, "Include Tight decodingoptimizations 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; itclamps 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: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-compressionpath 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: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 (aconsumer 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, thenpython3 evil_server.py 5900 &, thenASAN_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
libvncclientto connect to a VNC server. Thisincludes 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->frameBufferinto their own objects (e.g.examples/client/SDLvncviewer.cdoesclient->frameBuffer = sdl->pixels;), the result is an attacker-controlled overwrite of adjacentlive 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).