Talos Vulnerability Report


GTKWave VZT vzt_rd_get_facname decompression out-of-bounds write vulnerabilities

January 8, 2024
CVE Number



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"))) {
             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(&lt->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(&lt->numfacs, 4, 1, lt->handle);
[4]      lt->numfacs = rcf ? vzt_rd_get_32(&lt->numfacs, 0) : 0;
         rcf = fread(&lt->numfacbytes, 4, 1, lt->handle);
         lt->numfacbytes = rcf ? vzt_rd_get_32(&lt->numfacbytes, 0) : 0;
         rcf = fread(&lt->longestname, 4, 1, lt->handle);
         lt->longestname = rcf ? vzt_rd_get_32(&lt->longestname, 0) : 0;
         rcf = fread(&lt->zfacnamesize, 4, 1, lt->handle);
         lt->zfacnamesize = rcf ? vzt_rd_get_32(&lt->zfacnamesize, 0) : 0;
         rcf = fread(&lt->zfacname_predec_size, 4, 1, lt->handle);
         lt->zfacname_predec_size = rcf ? vzt_rd_get_32(&lt->zfacname_predec_size, 0) : 0;
         rcf = fread(&lt->zfacgeometrysize, 4, 1, lt->handle);
         lt->zfacgeometrysize = rcf ? vzt_rd_get_32(&lt->zfacgeometrysize, 0) : 0;
         rcf = fread(&lt->timescale, 1, 1, lt->handle);

Several fields are then read from the file [4]:

  • numfacs: the number of facilities (elements in facnames)
  • numfacbytes: unused
  • longestname: keeps the longest length of all defined facilities’ names
  • zfacnamesize: 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);
             lt->zhandle = NULL;

         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);
             lt->zhandle = NULL;

         case VZT_RD_IS_LZMA:
             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);
             lt->zhandle = NULL;


[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:

  • the “clone count”, which is the number of bytes to copy from the previous name
  • the string to append after any of the previous bytes were cloned
  • a null terminator

In the example above we have 2 compressed elements:

  1. clone count: 0x00, string “anything-A”, NULL
  2. clone count: 0x09, string “B”, NULL

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.

CVE-2023-38648 - clonecnt prefix copy loop

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.

CVE-2023-38649 - string copy loop

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.

Crash Information

==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.