CVE-2023-36861
An out-of-bounds write vulnerability exists in the VZT LZMA_read_varint functionality of GTKWave 3.3.115. A specially crafted .vzt file can lead to arbitrary code execution. A victim would need to open a malicious file to trigger this vulnerability.
The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.
GTKWave 3.3.115
GTKWave - https://gtkwave.sourceforge.net
7.8 - CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CWE-119 - Improper Restriction of Operations within the Bounds of a Memory Buffer
GTKWave is a wave viewer, often used to analyze FPGA simulations and logic analyzer captures. It includes a GUI to view and analyze traces, as well as convert across several file formats (.lxt
, .lxt2
, .vzt
, .fst
, .ghw
, .vcd
, .evcd
) either by using the UI or its command line tools. GTKWave is available for Linux, Windows and MacOS. Trace files can be shared within teams or organizations, for example to compare results of simulation runs across different design implementations, to analyze protocols captured with logic analyzers or just as a reference when porting design implementations.
GTKWave sets up mime types for its supported extensions. So, for example, it’s enough for a victim to double-click on a wave file received by e-mail to cause the gtkwave program to be executed and load a potentially malicious file.
VZT (Verilog Zipped Trace) files are parsed by the functions found in vzt_read.c
. These functions are used in the vzt2vcd
file conversion utility, vztminer
, and by the GUI portion of GTKwave. Thus both are affected by the issue described in this report.
To parse VZT files, the function vzt_rd_init_smp
is called:
struct vzt_rd_trace *vzt_rd_init_smp(const char *name, unsigned int num_cpus) {
[1] struct vzt_rd_trace *lt = (struct vzt_rd_trace *)calloc(1, sizeof(struct vzt_rd_trace));
...
[2] if (!(lt->handle = fopen(name, "rb"))) {
vzt_rd_close(lt);
lt = NULL;
} else {
vztint16_t id = 0, version = 0;
...
[3] if (!fread(&id, 2, 1, lt->handle)) {
id = 0;
}
if (!fread(&version, 2, 1, lt->handle)) {
id = 0;
}
if (!fread(<->granule_size, 1, 1, lt->handle)) {
id = 0;
}
...
At [1] the lt
structure is initialized. This is the structure that will contain all the information about the input file.
The input file is opened [2] and 3 fields are read [3] to make sure the input file is a supported VZT file.
...
rcf = fread(<->numfacs, 4, 1, lt->handle);
[4] lt->numfacs = rcf ? vzt_rd_get_32(<->numfacs, 0) : 0;
...
rcf = fread(<->numfacbytes, 4, 1, lt->handle);
lt->numfacbytes = rcf ? vzt_rd_get_32(<->numfacbytes, 0) : 0;
rcf = fread(<->longestname, 4, 1, lt->handle);
lt->longestname = rcf ? vzt_rd_get_32(<->longestname, 0) : 0;
rcf = fread(<->zfacnamesize, 4, 1, lt->handle);
lt->zfacnamesize = rcf ? vzt_rd_get_32(<->zfacnamesize, 0) : 0;
rcf = fread(<->zfacname_predec_size, 4, 1, lt->handle);
lt->zfacname_predec_size = rcf ? vzt_rd_get_32(<->zfacname_predec_size, 0) : 0;
rcf = fread(<->zfacgeometrysize, 4, 1, lt->handle);
lt->zfacgeometrysize = rcf ? vzt_rd_get_32(<->zfacgeometrysize, 0) : 0;
rcf = fread(<->timescale, 1, 1, lt->handle);
...
Several fields are then read from the file [4]:
numfacs
: the number of facilities (elements in facnames
)numfacbytes
: unusedlongestname
: keeps the longest length of all defined facilities’ nameszfacnamesize
: compressed size of facnames
zfacname_predec_size
: decompressed size of facnames
zfacgeometrysize
: compressed size of facgeometry
Then, the facnames
structure is extracted. This structure can be compressed with either gzip, bzip2 or lzma, depending on the first 2 bytes within this structure:
switch (vzt_rd_det_gzip_type(lt->handle)) {
case VZT_RD_IS_GZ:
lt->zhandle = gzdopen(dup(fileno(lt->handle)), "rb");
m = (char *)malloc(lt->zfacname_predec_size);
rc = gzread(lt->zhandle, m, lt->zfacname_predec_size);
gzclose(lt->zhandle);
lt->zhandle = NULL;
break;
case VZT_RD_IS_BZ2:
lt->zhandle = BZ2_bzdopen(dup(fileno(lt->handle)), "rb");
m = (char *)malloc(lt->zfacname_predec_size);
rc = BZ2_bzread(lt->zhandle, m, lt->zfacname_predec_size);
BZ2_bzclose(lt->zhandle);
lt->zhandle = NULL;
break;
case VZT_RD_IS_LZMA:
default:
lt->zhandle = LZMA_fdopen(dup(fileno(lt->handle)), "rb");
m = (char *)malloc(lt->zfacname_predec_size);
[5] rc = LZMA_read(lt->zhandle, m, lt->zfacname_predec_size);
LZMA_close(lt->zhandle);
lt->zhandle = NULL;
break;
}
If the file type is lzma, LZMA_read
[5] is called:
size_t LZMA_read(void *handle, void *mem, size_t len) {
#ifdef _WAVE_HAVE_XZ
struct lzma_handle_t *h = (struct lzma_handle_t *)handle;
size_t rc = 0;
char hdr[2] = {0, 0};
size_t srclen, dstlen;
if (h) {
top:
switch (h->state) {
case LZMA_STATE_READ_INIT:
...
case LZMA_STATE_READ_GETBLOCK:
[6] dstlen = LZMA_read_varint(h);
if (!dstlen) {
return (0);
}
if (dstlen > h->blksiz) /* reallocate buffers if ones in stream data are larger */
{
if (h->dmem) {
free(h->dmem);
}
if (h->mem) {
free(h->mem);
}
h->blksiz = dstlen;
h->mem = malloc(h->blksiz);
h->dmem = malloc(h->blksiz);
}
[7] srclen = LZMA_read_varint(h);
...
The lzma-compressed data is parsed using states. When reading a block, the state is LZMA_STATE_READ_GETBLOCK
. In this state, blocks are decompressed based on dstlen
[6] and srclen
[7], which represent to the size of blocks to decompress.
Both variables are read as a variable integer using the function LZMA_read_varint
:
static size_t LZMA_read_varint(struct lzma_handle_t *h) {
[8] unsigned char buf[16];
int idx = 0;
size_t rc = 0;
for (;;) {
[9] h->read_cnt += read(h->fd, buf + idx, 1);
[10] if (buf[idx++] & 0x80) break;
}
do {
idx--;
rc <<= 7;
rc |= (buf[idx] & 0x7f);
} while (idx);
return (rc);
}
This encoding is to LEB128, except the termination bit is 1 and not 0.
To decode varints, a fixed-size buffer of 16 bytes is allocated on the stack at [8]. Then, a loop extracts one byte from the file, saving it to buf[idx]
[9]. The loop only stops when the character read has the MSB set to 1 [10].
Since there’s no check that idx
doesn’t get bigger than 16, this loop can write out-of-bounds on the stack, leading to arbitrary code execution.
Note that lzma-compressed structures can be found in facnames
, facgeometry
and any other block later defined with the VZT file, which can all be used to trigger this issue.
==401084==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xffffd4d0 at pc 0xf79df56f bp 0xffffd468 sp 0xffffd040
WRITE of size 1 at 0xffffd4d0 thread T0
#0 0xf79df56e in __interceptor_read ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1025
#1 0x5656ddb1 in LZMA_read_varint ../liblzma/LzmaLib.c:81
#2 0x5656f8dd in LZMA_read ../liblzma/LzmaLib.c:304
#3 0x56563fdf in vzt_rd_init_smp src/helpers/vzt_read.c:1791
#4 0x565697f3 in vzt_rd_init src/helpers/vzt_read.c:2194
#5 0x5656be5b in process_vzt src/helpers/vzt2vcd.c:176
#6 0x5656cf15 in main src/helpers/vzt2vcd.c:464
#7 0xf7611294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#8 0xf7611357 in __libc_start_main_impl ../csu/libc-start.c:381
#9 0x565574f6 in _start (vzt2vcd+0x24f6)
Address 0xffffd4d0 is located in stack of thread T0 at offset 48 in frame
#0 0x5656dcec in LZMA_read_varint ../liblzma/LzmaLib.c:74
This frame has 1 object(s):
[32, 48) 'buf' (line 75) <== Memory access at offset 48 overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1025 in __interceptor_read
Shadow bytes around the buggy address:
0x3ffffa40: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ffffa50: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ffffa60: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ffffa70: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ffffa80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x3ffffa90: 00 00 00 00 f1 f1 f1 f1 00 00[f3]f3 00 00 00 00
0x3ffffaa0: 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1 f1 f1
0x3ffffab0: 02 f2 00 00 00 00 00 00 00 00 00 00 00 f3 f3 f3
0x3ffffac0: f3 f3 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ffffad0: 00 00 00 00 00 00 f1 f1 f1 f1 02 f2 02 f2 04 f3
0x3ffffae0: f3 f3 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Fixed in version 3.3.118, available from https://sourceforge.net/projects/gtkwave/files/gtkwave-3.3.118/
2023-08-02 - Vendor Disclosure
2023-12-31 - Vendor Patch Release
2024-01-08 - Public Release
Discovered by Claudio Bozzato of Cisco Talos.