CVE-2023-38649,CVE-2023-38648
Multiple out-of-bounds write vulnerabilities exist in the VZT vzt_rd_get_facname decompression 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 these vulnerabilities.
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
and facgeometry
structures are extracted. They can be compressed with either gzip, bzip2 or lzma, depending on the first 2 bytes within the structure buffer.
For this advisory, we’re interested in the extraction and parsing of facnames
:
[5] 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);
rc = LZMA_read(lt->zhandle, m, lt->zfacname_predec_size);
LZMA_close(lt->zhandle);
lt->zhandle = NULL;
break;
}
...
[6] lt->zfacnames = m;
[7] lt->faccache = calloc(1, sizeof(struct vzt_rd_facname_cache));
lt->faccache->old_facidx = lt->numfacs; /* causes vzt_rd_get_facname to initialize its unroll ptr as this is always invalid */
[8] lt->faccache->bufcurr = malloc(lt->longestname + 1);
lt->faccache->bufprev = malloc(lt->longestname + 1);
Once facnames
is extracted inside the switch at [5], the pointer to the extracted contents is saved to lt->zfacnames
.
faccache
is allocated [7] to store two buffers [8], bufcurr
and bufprev
, which are used to decompress the prefix-compressed names stored in facnames.
Before delving into the decompression, let’s see an example of a few prefix-compressed facname entries:
"\x00\x00" + "anything-A" + "\x00" + "\x00\x09" + "B" + "\x00"
We can say one entry is composed of mainly 3 parts:
In the example above we have 2 compressed elements:
The clone count for the first string is 0, because there’s no previous element and nothing to copy. We then append the string “anything-A” and a null terminator to the current empty string, leading to the final name “anything-A”.
The clone count for the second string is 0x09, which means we have to copy the first 9 bytes from the previous name, leading to the string “anything-“. Then we append the current string “B” and the null terminator, leading to the final name “anything-B”.
Hence the facnames
in the example contain the names “anything-A” and “anything-B”.
The function that performs this decompression is vzt_rd_get_facname
, which is called upon, return from the vzt_rd_init_smp
function, to look up facility names based on the index facidx
:
char *vzt_rd_get_facname(struct vzt_rd_trace *lt, vztint32_t facidx) {
char *pnt;
unsigned int clonecnt, j;
if (lt) {
if ((facidx == (lt->faccache->old_facidx + 1)) || (!facidx)) {
if (!facidx) {
lt->faccache->n = lt->zfacnames;
lt->faccache->bufcurr[0] = 0;
lt->faccache->bufprev[0] = 0;
}
if (facidx != lt->numfacs) {
[9] pnt = lt->faccache->bufcurr;
lt->faccache->bufcurr = lt->faccache->bufprev;
lt->faccache->bufprev = pnt;
[10] clonecnt = vzt_rd_get_16(lt->faccache->n, 0);
lt->faccache->n += 2;
pnt = lt->faccache->bufcurr;
for (j = 0; j < clonecnt; j++) {
[11] *(pnt++) = lt->faccache->bufprev[j];
}
[12] while ((*(pnt++) = vzt_rd_get_byte(lt->faccache->n++, 0)))
;
lt->faccache->old_facidx = facidx;
return (lt->faccache->bufcurr);
} else {
return (NULL); /* no more left */
}
...
At [9] the buffers bufcurr
and bufprev
are swapped. This is used to keep the previous name in the bufprev
buffer.
At [10] the “clone count” (clonecnt
) field is read and the number of bytes specified by clonecnt
are saved into bufcurr
(pointed by pnt
) [11].
Finally, the rest of the string is copied to bufcurr
[12] and the final decompressed string is returned.
The issue in this logic is that bufprev
and bufcurr
are allocated at [8] with a size of longestname + 1
, which is a field defined in the input file. However, the size of these buffers is never taken into consideration when writing to them at [11] or [12]. This allows those loops to write out-of-bounds in the heap, leading to arbitrary code execution.
A simple way to trigger this issue is by specifying a longestname
field of 0, and a facname
member with a big clonecnt
field, or a long string to copy.
The prefix copy loop at [11] does not check that the writes are performed within the bounds of the pnt
buffer, which may allow out-of-bounds write in the heap, leading to arbitrary code execution.
The string copy loop at [12] does not check that the writes are performed within the bounds of the pnt
buffer, which may allow out-of-bounds write in the heap, leading to arbitrary code execution.
==401159==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5e00631 at pc 0x5655f871 bp 0xffffd6f8 sp 0xffffd6ec
WRITE of size 1 at 0xf5e00631 thread T0
#0 0x5655f870 in vzt_rd_get_facname src/helpers/vzt_read.c:1139
#1 0x5656c13c in process_vzt src/helpers/vzt2vcd.c:245
#2 0x5656cf15 in main src/helpers/vzt2vcd.c:464
#3 0xf7611294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#4 0xf7611357 in __libc_start_main_impl ../csu/libc-start.c:381
#5 0x565574f6 in _start (vzt2vcd+0x24f6)
0xf5e00631 is located 0 bytes to the right of 1-byte region [0xf5e00630,0xf5e00631)
allocated by thread T0 here:
#0 0xf7a55ffb in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x565643bd in vzt_rd_init_smp src/helpers/vzt_read.c:1811
#2 0x565697f3 in vzt_rd_init src/helpers/vzt_read.c:2194
#3 0x5656be5b in process_vzt src/helpers/vzt2vcd.c:176
#4 0x5656cf15 in main src/helpers/vzt2vcd.c:464
#5 0xf7611294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
SUMMARY: AddressSanitizer: heap-buffer-overflow src/helpers/vzt_read.c:1139 in vzt_rd_get_facname
Shadow bytes around the buggy address:
0x3ebc0070: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x3ebc0080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x3ebc0090: fa fa 00 04 fa fa 00 05 fa fa 00 04 fa fa 00 04
0x3ebc00a0: fa fa 00 04 fa fa 04 fa fa fa 04 fa fa fa 04 fa
0x3ebc00b0: fa fa 04 fa fa fa 04 fa fa fa 04 fa fa fa fd fd
=>0x3ebc00c0: fa fa fd fa fa fa[01]fa fa fa 01 fa fa fa 00 00
0x3ebc00d0: fa fa fd fa fa fa 01 fa fa fa 04 fa fa fa 00 04
0x3ebc00e0: fa fa 00 03 fa fa 00 04 fa fa 00 05 fa fa 00 04
0x3ebc00f0: fa fa 00 05 fa fa 00 00 fa fa fa fa fa fa fa fa
0x3ebc0100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x3ebc0110: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
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 right redzone: f3
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.