Talos Vulnerability Report

TALOS-2023-1793

GTKWave FST fstReaderIterBlocks2 fstWritex len heap-based buffer overflow vulnerabilities

January 8, 2024
CVE Number

CVE-2023-36747,CVE-2023-36746

SUMMARY

Multiple heap-based buffer overflow vulnerabilities exist in the fstReaderIterBlocks2 fstWritex len functionality of GTKWave 3.3.115. A specially crafted .fst file can lead to memory corruption. A victim would need to open a malicious file to trigger these vulnerabilities.

CONFIRMED VULNERABLE VERSIONS

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

PRODUCT URLS

GTKWave - https://gtkwave.sourceforge.net

CVSSv3 SCORE

7.0 - CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

CWE

CWE-119 - Improper Restriction of Operations within the Bounds of a Memory Buffer

DETAILS

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. For example, it’s enough for a victim to double-click on a wave file received by e-mail to trigger the vulnerabilities described in this advisory.

The function fstReaderOpen is used to parse .fst files.

     void *fstReaderOpen(const char *nam)
     {
[1]  struct fstReaderContext *xc = (struct fstReaderContext *)calloc(1, sizeof(struct fstReaderContext));
 
[2]  if((!nam)||(!(xc->f=fopen(nam, "rb"))))
             {
             free(xc);
             xc=NULL;
             }
             else
             {
             int flen = strlen(nam);
             char *hf = (char *)calloc(1, flen + 6);
             int rc;
 
     #if defined(FST_UNBUFFERED_IO)
             setvbuf(xc->f, (char *)NULL, _IONBF, 0);   /* keeps gzip from acting weird in tandem with fopen */
     #endif
 
             memcpy(hf, nam, flen);
             strcpy(hf + flen, ".hier");
             xc->fh = fopen(hf, "rb");
 
             free(hf);
             xc->filename = strdup(nam);
[3]          rc = fstReaderInit(xc);

At [1] the xc structure is created. This big (about 65KB) structure is used to hold information regarding the .fst file.
At [2] the xc->f field is set to the file pointer returned by fopen after opening the file being parsed.

Then, the function fstReaderInit is called [3] to parse the various .fst sections from the input file.

     int fstReaderInit(struct fstReaderContext *xc)
     {
     fst_off_t blkpos = 0;
     fst_off_t endfile;
     uint64_t seclen;
     int sectype;
     uint64_t vc_section_count_actual = 0;
     int hdr_incomplete = 0;
     int hdr_seen = 0;
     int gzread_pass_status = 1;

     sectype = fgetc(xc->f);
     ...
     if(gzread_pass_status)
             {
             fstReaderFseeko(xc, xc->f, 0, SEEK_END);
             endfile = ftello(xc->f);

             while(blkpos < endfile)
                     {
                     fstReaderFseeko(xc, xc->f, blkpos, SEEK_SET);

[4]                  sectype = fgetc(xc->f);
                     seclen = fstReaderUint64(xc->f);

                     if(sectype == EOF)
                             {
                             break;
                             }

[5]                  if((hdr_incomplete) && (!seclen))
                             {
                             break;
                             }

[6]                  if(!hdr_seen && (sectype != FST_BL_HDR))
                             {
                             break;
                             }

                     blkpos++;
                     ...

At [4], the sectype (1 byte) is read off the file. This variable will tell us which kind of sector we’re parsing. The checks at [5] and [6] are important because we don’t want to trigger them in order to reach the vulnerable code. We need to make sure hdr_incomplete stays 0 and we start with a FST_BL_HDR sector, which will set hdr_seen to 1.

So, if the input file starts with a 0x00, we’ll land inside the following condition:

     ...
     if(sectype == FST_BL_HDR)
             {
             if(!hdr_seen)
                     {
                     int ch;
                     double dcheck;

                     xc->start_time = fstReaderUint64(xc->f);
                     xc->end_time = fstReaderUint64(xc->f);

[7]                  hdr_incomplete = (xc->start_time == 0) && (xc->end_time == 0);

                     ...
[8]                  hdr_seen = 1;

[9]                  xc->mem_used_by_writer = fstReaderUint64(xc->f);
                     xc->scope_count = fstReaderUint64(xc->f);
                     xc->var_count = fstReaderUint64(xc->f);
                     ...
                     }
             }
     ...

fstReaderUint64 is used to read a 64-bit unsigned integer (big endian) off the file, incrementing the current file offset.
We need to make sure that either start_time or end_time are not 0, to not set hdr_incomplete [7].
At [8] hdr_seen is set, so that we can pass the check at [6] when parsing the next sections.
Finally at [9], other values are read off the file and saved into the xc structure.

After parsing the header, fstReaderInit then proceeds to parse other blocks, among them, the FST_BL_GEOM block:

     else if(sectype == FST_BL_GEOM)
             {
             if(!hdr_incomplete)
                     {
                     uint64_t clen = seclen - 24;
                     uint64_t uclen = fstReaderUint64(xc->f);
                     unsigned char *ucdata = (unsigned char *)malloc(uclen);
                     unsigned char *pnt = ucdata;
                     unsigned int i;

                     xc->contains_geom_section = 1;
                     xc->maxhandle = fstReaderUint64(xc->f);
                     xc->longest_signal_value_len = 32; /* arbitrarily set at 32...this is much longer than an expanded double */

                     free(xc->process_mask);
                     xc->process_mask = (unsigned char *)calloc(1, (xc->maxhandle+7)/8);

[10]                 if(clen != uclen)
                             {
                             unsigned char *cdata = (unsigned char *)malloc(clen);
                             unsigned long destlen = uclen;
                             unsigned long sourcelen = clen;
                             int rc;

                             fstFread(cdata, clen, 1, xc->f);
                             rc = uncompress(ucdata, &destlen, cdata, sourcelen);

                             if(rc != Z_OK)
                                     {
                                     fprintf(stderr, FST_APIMESS "fstReaderInit(), geom uncompress rc = %d, exiting.\n", rc);
                                     exit(255);
                                     }

                             free(cdata);
                             }
                             else
                             {
                             fstFread(ucdata, uclen, 1, xc->f);
                             }

                     free(xc->signal_lens);
[11]                 xc->signal_lens = (uint32_t *)malloc(sizeof(uint32_t) * xc->maxhandle);
                     free(xc->signal_typs);
                     xc->signal_typs = (unsigned char *)malloc(sizeof(unsigned char) * xc->maxhandle);

                     for(i=0;i<xc->maxhandle;i++)
                             {
                             int skiplen;
[12]                         uint64_t val = fstGetVarint32(pnt, &skiplen);

                             pnt += skiplen;

                             if(val)
                                     {
[13]                                 xc->signal_lens[i] = (val != 0xFFFFFFFF) ? val : 0;
                                     xc->signal_typs[i] = FST_VT_VCD_WIRE;
                                     if(xc->signal_lens[i] > xc->longest_signal_value_len)
                                             {
                                             xc->longest_signal_value_len = xc->signal_lens[i];
                             ...

At [10], the FST_BL_GEOM section is optionally decompressed with zlib. At [11] the signal_lens buffer is allocated, with xc->maxhandle elements. Finally, the signal_lens is filled [13] by reading 32-bit LEB128-encoded integers [12] from file.

If there’s no FST_BL_GEOM section, the signal_lens is filled by fstReaderProcessHier.

Upon return from fstReaderOpen, gtkwave eventually calls fstReaderIterBlocks2.
If, however, the file is opened by fst2vcd, a call to fstReaderProcessHier happens first, so in this case the “hier” section will overwrite any signal_lens structure defined in the FST_BL_GEOM section.

The way fstReaderProcessHier is processed is not interesting for this advisory. Suffice to say it’s an alternative way to fill signal_lens, and the section can be zlib-compressed (FST_BL_HIER), lz4-compressed (FST_BL_HIER_LZ4) or lz4-compressed twice (FST_BL_HIER_LZ4DUO).

Eventually, fstReaderIterBlocks2 is called to parse all VCDATA blocks, as they haven’t been fully parsed in fstReaderInit.

fstReaderIterBlocks2 is a pretty large function, so we’ll focus on the interesting lines only:

     int fstReaderIterBlocks2(void *ctx,
                              void (*value_change_callback)(void *user_callback_data_pointer, uint64_t time, fstHandle facidx, const unsigned char *value),
                              void (*value_change_callback_varlen)(void *user_callback_data_pointer, uint64_t time, fstHandle facidx, const unsigned char *value, uint32_t len),
                              void *user_callback_data_pointer, FILE *fv) {
         struct fstReaderContext *xc = (struct fstReaderContext *)ctx;

         uint64_t previous_time = UINT64_MAX;
         uint64_t *time_table = NULL;
         uint64_t tsec_nitems;
         unsigned int secnum = 0;
         int blocks_skipped = 0;
         fst_off_t blkpos = 0;
         uint64_t seclen, beg_tim;
         uint64_t end_tim;
         uint64_t frame_uclen, frame_clen, frame_maxhandle, vc_maxhandle;
         fst_off_t vc_start;
         fst_off_t indx_pntr, indx_pos;
         fst_off_t *chain_table = NULL;
         uint32_t *chain_table_lengths = NULL;
         unsigned char *chain_cmem;
         unsigned char *pnt;
         long chain_clen;
         fstHandle idx, pidx = 0, i;
         uint64_t pval;
         uint64_t vc_maxhandle_largest = 0;
         uint64_t tsec_uclen = 0, tsec_clen = 0;
         int sectype;
         uint64_t mem_required_for_traversal;
         unsigned char *mem_for_traversal = NULL;
         uint32_t traversal_mem_offs;
         uint32_t *scatterptr, *headptr, *length_remaining;
         uint32_t cur_blackout = 0;
         int packtype;
         unsigned char *mc_mem = NULL;
         uint32_t mc_mem_len; /* corresponds to largest value encountered in chain_table_lengths[i] */
         int dumpvars_state = 0;

         ...
[14]     for (;;) {
             uint32_t *tc_head = NULL;
             traversal_mem_offs = 0;

             fstReaderFseeko(xc, xc->f, blkpos, SEEK_SET);

[15]         sectype = fgetc(xc->f);
             seclen = fstReaderUint64(xc->f);

             if ((sectype == EOF) || (sectype == FST_BL_SKIP)) {
     #ifdef FST_DEBUG
                 fprintf(stderr, FST_APIMESS "<< EOF >>\n");
     #endif
                 break;
             }

             blkpos++;
[16]         if ((sectype != FST_BL_VCDATA) && (sectype != FST_BL_VCDATA_DYN_ALIAS) && (sectype != FST_BL_VCDATA_DYN_ALIAS2)) {
                 blkpos += seclen;
                 continue;
             }

             if (!seclen) break;
         ...

As previously said, all blocks are scanned [14], and only the blocks with a sectype [15] containing VCDATA are parsed [16]. These blocks are FST_BL_VCDATA, FST_BL_VCDATA_DYN_ALIAS and FST_BL_VCDATA_DYN_ALIAS2.

 ...
 mem_required_for_traversal = fstReaderUint64(xc->f);
 mem_for_traversal = (unsigned char *)malloc(mem_required_for_traversal + 66); /* add in potential fastlz overhead */
 ...

The mem_required_for_traversal is a 64-bit (big endian) integer, taken directly from the .FST file, which is used to allocate the mem_for_traversal buffer. This buffer will be used to parse the VC (Value Change) data off the input file.

Then, the time block is extracted from file:

     /* process time block */
     {
     unsigned char *ucdata;
     unsigned char *cdata;
     unsigned long destlen /* = tsec_uclen */; /* scan-build */
     unsigned long sourcelen /*= tsec_clen */; /* scan-build */
     int rc;
     unsigned char *tpnt;
     uint64_t tpval;
     unsigned int ti;
     
     if(fstReaderFseeko(xc, xc->f, blkpos + seclen - 24, SEEK_SET) != 0) break;
     tsec_uclen = fstReaderUint64(xc->f);
     tsec_clen = fstReaderUint64(xc->f);
[17] tsec_nitems = fstReaderUint64(xc->f);

     ...

     free(time_table);
[18] time_table = (uint64_t *)calloc(tsec_nitems, sizeof(uint64_t));
     tpnt = ucdata;
     tpval = 0;
     ...
[19] tc_head = (uint32_t *)calloc(tsec_nitems /* scan-build */ ? tsec_nitems : 1, sizeof(uint32_t));

At [17] tsec_nitems is read from file, along with other fields. Then the time_table [18] and tc_head [19] arrays are allocated, based on the value of tsec_nitems.

Later on, the code processes the time_table and tc_head, to populate the temporary .hier file associated with the xc state.

[20] for (i = 0; i < tsec_nitems; i++) {
         uint32_t tdelta;
         int skiplen, skiplen2;
         uint32_t vli;

         ...
[21]     while (tc_head[i]) {
             idx = tc_head[i] - 1;
             ...

[22]         if (xc->signal_lens[idx] <= 1) {
             } else {
[23]             uint32_t len = xc->signal_lens[idx];
                 unsigned char *vdata;

[24]             vli = fstGetVarint32(mem_for_traversal + headptr[idx], &skiplen);
                 /* tdelta = vli >> 1; */ /* scan-build */
[25]             vdata = mem_for_traversal + headptr[idx] + skiplen;

[26]             if (xc->signal_typs[idx] != FST_VT_VCD_REAL) {
[27]                 if (!(vli & 1)) {
                        ...
                     } else {
                         if (value_change_callback) {
                             memcpy(xc->temp_signal_value_buf, vdata, len);
                             xc->temp_signal_value_buf[len] = 0;
                             value_change_callback(user_callback_data_pointer, time_table[i], idx + 1, xc->temp_signal_value_buf);
                         } else {
                             if (fv) {
                                 unsigned char ch_bp = (xc->signal_typs[idx] != FST_VT_VCD_PORT) ? 'b' : 'p';

                                 fstWritex(xc, &ch_bp, 1);
[28]                             fstWritex(xc, vdata, len);
                             }
                         }
                     }
                 ...
                }
             }
         }
     }

At [20] and [21] we enter the loops to scan the time table. If the signal_lens for the current idx is bigger than 1 [22], we enter the else block. It’s important to keep in mind that the signal_lens field is defined as uint32_t *signal_lens;, and that we control its value arbitrarily.
The value for the current signal_lens is stored in len [23], and a 32-bit varint is then read from file [24] into vli. Right after this varint we’ll have vdata [25].
If the current signal_typs is not FST_VT_VCD_REAL and the LSB of vli is 1, we eventually call fstWritex with vdata and len. Both vdata and len are fully controlled.

     static void fstWritex(struct fstReaderContext *xc, void *v, int len) {
         unsigned char *s = (unsigned char *)v;

         if (len) {
[30]         if (len < FST_WRITEX_MAX) {
[31]             if (xc->writex_pos + len >= FST_WRITEX_MAX) {
                     fstWritex(xc, NULL, 0);
                 }

[32]             memcpy(xc->writex_buf + xc->writex_pos, s, len);
                 xc->writex_pos += len;
             } else {
                 fstWritex(xc, NULL, 0);
                 if (write(xc->writex_fd, s, len)) {
                 };
             }
         } else {
             if (xc->writex_pos) {
[29]             if (write(xc->writex_fd, xc->writex_buf, xc->writex_pos)) {
                 };
                 xc->writex_pos = 0;
             }
         }
     }

fstWritex writes to the file descriptor xc->writex_fd (which points to the temporary .hier file), in a buffered way. It writes to xc->writex_buf as long as there’s space in writex_buf. If there’s no more space, the writex_buf contents are written to file [29].
The issue is that this function assumes the length of v is len. However, we control xc->signal_lens[idx] [22] (which is passed to fstWritex as len [28]) arbitrarily.
This alone would result in an out-of-bounds read, as we can’t specify an arbitrarily big len because of the checks at [30] and [31], which would simply lead to write to file [29].
However, note that the function defines len as a signed int, while xc->signal_lens[idx] is uint32_t.
If xc->signal_lens[idx] is larger than 0x7fffffff, a negative len will be passed to fstWritex, executing the memcpy at [32] with a negative value and with a fully controlled source (s is vdata [25]). Depending on the memcpy implementation, this memcpy call can write out-of-bounds of the xc->writex_buf buffer (allocated in heap), leading to memory corruption. Because of the multi-threaded nature of GTKWave, an attacker may be able to exploit this issue to execute arbitrary code.

This is one straightforward way to trigger this issue. There is, however, another instance of this same issue, reachable in a different way. For this reason, we list below the two issues separately.

CVE-2023-36746 - fstReaderIterBlocks2 fstWritex hier

By not checking whether xc->signal_lens[idx] fits within the vdata buffer, the call to fstWritex at [28] can lead to an out-of-bounds write in the heap, leading to memory corruption.

CVE-2023-36747 - fstReaderIterBlocks2 fstWritex beg_time

Another path to trigger this vulnerability can be found right after parsing the time block.

     frame_uclen = fstReaderVarint64(xc->f);
     frame_clen = fstReaderVarint64(xc->f);
     frame_maxhandle = fstReaderVarint64(xc->f);

     if (secnum == 0) {
[30]     if ((beg_tim != time_table[0]) || (blocks_skipped)) {
             ...
[31]         for (idx = 0; idx < frame_maxhandle; idx++) {
                 int process_idx = idx / 8;
                 int process_bit = idx & 7;

                 if (xc->process_mask[process_idx] & (1 << process_bit)) {
[32]                 if (xc->signal_lens[idx] <= 1) {
                        ...
                     } else {
[33]                     if (xc->signal_typs[idx] != FST_VT_VCD_REAL) {
                             if (value_change_callback) {
                                 memcpy(xc->temp_signal_value_buf, mu + sig_offs, xc->signal_lens[idx]);
                                 xc->temp_signal_value_buf[xc->signal_lens[idx]] = 0;
                                 value_change_callback(user_callback_data_pointer, beg_tim, idx + 1, xc->temp_signal_value_buf);
                             } else {
                                 if (fv) {
                                     char vcd_id[16];
                                     int vcdid_len = fstVcdIDForFwrite(vcd_id + 1, idx + 1);

                                     vcd_id[0] = (xc->signal_typs[idx] != FST_VT_VCD_PORT) ? 'b' : 'p';
                                     fstWritex(xc, vcd_id, 1);
[34]                                 fstWritex(xc, mu + sig_offs, xc->signal_lens[idx]);

                                     vcd_id[0] = ' '; /* collapse 3 writes into one I/O call */
                                     vcd_id[vcdid_len + 1] = '\n';
                                     fstWritex(xc, vcd_id, vcdid_len + 2);
                                 }
                             }
                         }
                         ...

If the beg_tim field in VCDATA is not 0, we enter the block at [30]. Then, the xc->signal_lens array is scanned using frame_maxhandle.
If the current signal_lens is bigger than 1 and signal_typs is not FST_VT_VCD_REAL, we eventually call fstWritex with an arbitrary len value (xc->signal_lens[idx]) at [34].

By not checking whether xc->signal_lens[idx] fits within the mu + sig_offs buffer, the call to fstWritex can lead to an out-of-bounds write in the heap, leading to memory corruption.

Crash Information

==94284==ERROR: AddressSanitizer: negative-size-param: (size=-2147483648)
    #0 0xf79dad4d in __interceptor_memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827
    #1 0x565935f6 in fstWritex fst/fstapi.c:3449
    #2 0x565a0abe in fstReaderIterBlocks2 fst/fstapi.c:5285
    #3 0x5659ea84 in fstReaderIterBlocks fst/fstapi.c:4999
    #4 0x5655908d in main src/helpers/fst2vcd.c:185
    #5 0xf7659294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #6 0xf7659357 in __libc_start_main_impl ../csu/libc-start.c:381
    #7 0x56558686 in _start (fst2vcd+0x3686)

0xf5f00571 is located 1 bytes inside of 2-byte region [0xf5f00570,0xf5f00572)
allocated by thread T0 here:
    #0 0xf7a55ffb in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
    #1 0x5659fc05 in fstReaderIterBlocks2 fst/fstapi.c:5182
    #2 0x5659ea84 in fstReaderIterBlocks fst/fstapi.c:4999
    #3 0x5655908d in main src/helpers/fst2vcd.c:185
    #4 0xf7659294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

SUMMARY: AddressSanitizer: negative-size-param ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827 in __interceptor_memcpy
AddressSanitizer: CHECK failed: asan_poisoning.cpp:183 "((beg)) < ((end))" (0xf5f00571, 0x75f00571) (tid=94284)
    #0 0xf7a61f52 in CheckUnwind ../../../../src/libsanitizer/asan/asan_rtl.cpp:67
    #1 0xf7a87dfb in __sanitizer::CheckFailed(char const*, int, char const*, unsigned long long, unsigned long long) ../../../../src/libsanitizer/sanitizer_common/sanitizer_termination.cpp:86
    #2 0xf7a59d38 in __asan_region_is_poisoned ../../../../src/libsanitizer/asan/asan_poisoning.cpp:183
    #3 0xf79dac59 in __interceptor_memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:827
    #4 0x565935f6 in fstWritex fst/fstapi.c:3449
    #5 0x565a0abe in fstReaderIterBlocks2 fst/fstapi.c:5285
    #6 0x5659ea84 in fstReaderIterBlocks fst/fstapi.c:4999
    #7 0x5655908d in main src/helpers/fst2vcd.c:185
    #8 0xf7659294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #9 0xf7659357 in __libc_start_main_impl ../csu/libc-start.c:381
    #10 0x56558686 in _start (fst2vcd+0x3686)
VENDOR RESPONSE

Fixed in version 3.3.118, available from https://sourceforge.net/projects/gtkwave/files/gtkwave-3.3.118/

TIMELINE

2023-07-10 - Initial Vendor Contact
2023-07-18 - Vendor Disclosure
2023-12-31 - Vendor Patch Release
2024-01-08 - Public Release

Credit

Discovered by Claudio Bozzato of Cisco Talos.