Talos Vulnerability Report

TALOS-2023-1789

GTKWave FST fstReaderIterBlocks2 chain_table parsing heap-based buffer overflow vulnerabilities

January 8, 2024
CVE Number

CVE-2023-35969,CVE-2023-35970

SUMMARY

Multiple heap-based buffer overflow vulnerabilities exist in the fstReaderIterBlocks2 chain_table parsing functionality of GTKWave 3.3.115. A specially crafted .fst file can lead to arbitrary code execution. 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.8 - CVSS:3.1/AV:L/AC:L/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.

Trace files can be shared within teams or organizations. For example, they can be shared 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 also sets up mime types for its supported extensions. It is 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. It is a big (about 65KB) structure, 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 that 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);
                     ...
                     }
             }
     ...

After parsing the header, fstReaderInit then proceeds to parse other blocks that are not relevant for this advisory.

Upon return from fstReaderOpen, GTKWave eventually calls fstReaderIterBlocks2 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;

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

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

[11]         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++;
[12]         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 [10], and only the blocks with a sectype [11] containing VCDATA are parsed [12]. These blocks are FST_BL_VCDATA, FST_BL_VCDATA_DYN_ALIAS and FST_BL_VCDATA_DYN_ALIAS2.

Later on, the code extracts vc_maxhandle:

        fstReaderFseeko(xc, xc->f, (fst_off_t)frame_clen, SEEK_CUR); /* skip past compressed data */

[13]    vc_maxhandle = fstReaderVarint64(xc->f);
        vc_start = ftello(xc->f); /* points to '!' character */
        packtype = fgetc(xc->f);

Right after this, the chain_clen is extracted [14], which is the size of the chain section, extracted at [15].

        indx_pntr = blkpos + seclen - 24 - tsec_clen - 8;
        fstReaderFseeko(xc, xc->f, indx_pntr, SEEK_SET);
[14]    chain_clen = fstReaderUint64(xc->f);
        indx_pos = indx_pntr - chain_clen;
        ...
[15]    chain_cmem = (unsigned char *)malloc(chain_clen);
        if (!chain_cmem) goto block_err;
        fstReaderFseeko(xc, xc->f, indx_pos, SEEK_SET);
        fstFread(chain_cmem, chain_clen, 1, xc->f);

        if (vc_maxhandle > vc_maxhandle_largest) {
            free(chain_table);
            free(chain_table_lengths);

            vc_maxhandle_largest = vc_maxhandle;
[16]        chain_table = (fst_off_t *)calloc((vc_maxhandle + 1), sizeof(fst_off_t));
            chain_table_lengths = (uint32_t *)calloc((vc_maxhandle + 1), sizeof(uint32_t));
        }

The chain_table and chain_table_lengths buffers are cached across the parsing of the different FST blocks. However, initially chain_table and chain_table_lengths will be zero, so the initialization happens at [16], allocating vc_maxhandle+1 items. vc_maxhandle should correspond to the number of VC elements in VCDATA.

The chain section and VCDATA have different formats depending on the sectype.
In the case of a FST_BL_VCDATA sectype, chain_cmem will contain a series of offsets into VCDATA, to point at each VC element. The code below is extracing offsets and lengths for each VC element:

     pnt = chain_cmem;
     idx = 0;
     pval = 0;

     if (sectype == FST_BL_VCDATA_DYN_ALIAS2) {
        ...
[17] } else {
         do {
             int skiplen;
[18]         uint64_t val = fstGetVarint32(pnt, &skiplen);

[19]         if (!val) {
                 pnt += skiplen;
                 val = fstGetVarint32(pnt, &skiplen);
                 chain_table[idx] = 0;            /* need to explicitly zero as calloc above might not run */
                 chain_table_lengths[idx] = -val; /* because during this loop iter would give stale data! */
                 idx++;
[20]         } else if (val & 1) {
                 pval = chain_table[idx] = pval + (val >> 1);
                 if (idx) {
                     chain_table_lengths[pidx] = pval - chain_table[pidx];
                 }
                 pidx = idx++;
[21]         } else {
                 fstHandle loopcnt = val >> 1;
                 for (i = 0; i < loopcnt; i++) {
                     chain_table[idx++] = 0;
                 }
             }

             pnt += skiplen;
         } while (pnt != (chain_cmem + chain_clen));
     }

     chain_table[idx] = indx_pos - vc_start;
     chain_table_lengths[pidx] = chain_table[idx] - chain_table[pidx];

For FST_BL_VCDATA and FST_BL_VCDATA_DYN_ALIAS sections, we enter the block at [17]. A varint is read [18]:

- if 0 [19] the element is set to 0
- if the LSB is 1 [20], the remaining 31 bits are the offset of the VC element in VCDATA
- otherwise [21], the remaining 31 bits represent the number of elements to zero out (skips a number of chain_table indexes)

At [21] lies the problem. Since we control val, we can choose how many zeros to write to chain_table. Recall that the chain_table size is controlled by vc_maxhandle [16], and there are no checks to make sure that idx stays within the bounds of chain_table in this loop. This clearly allows writing out-of-bounds in the heap.
After the loop ends, we also have the chance to write arbitrary values in the heap, after idx has been offset arbitrarily, by providing an odd value and writing it in the block at [20].

CVE-2023-35969 - fstReaderIterBlocks2 chain_table legacy

By not checking whether idx fits within the size of the chain_table buffer, the loop in [21] will write out-of-bounds on the heap, leading to arbitrary code execution.
An attacker can control the allocation size of the chain_table buffer, and at the same time manipulate the chain_mem section in order to write NULLs out-of-bounds, shifting idx arbitrarily. An attacker can then write arbitrary values at the shifted memory offset by supplying more chain_mem varints.

CVE-2023-35970 - fstReaderIterBlocks2 chain_table FST_BL_VCDATA_DYN_ALIAS2

FST_BL_VCDATA_DYN_ALIAS2 is handled by an earlier condition block, but the issue and exploitation strategy is exactly the same:

     if (sectype == FST_BL_VCDATA_DYN_ALIAS2) {
         uint32_t prev_alias = 0;

         do {
             int skiplen;

             if (*pnt & 0x01) {
[23]             int64_t shval = fstGetSVarint64(pnt, &skiplen) >> 1;
                 if (shval > 0) {
                     pval = chain_table[idx] = pval + shval;
                     if (idx) {
                         chain_table_lengths[pidx] = pval - chain_table[pidx];
                     }
                     pidx = idx++;
                 } else if (shval < 0) {
                     chain_table[idx] = 0;                          /* need to explicitly zero as calloc above might not run */
                     chain_table_lengths[idx] = prev_alias = shval; /* because during this loop iter would give stale data! */
                     idx++;
                 } else {
                     chain_table[idx] = 0;                  /* need to explicitly zero as calloc above might not run */
                     chain_table_lengths[idx] = prev_alias; /* because during this loop iter would give stale data! */
                     idx++;
                 }
             } else {
                 uint64_t val = fstGetVarint32(pnt, &skiplen);

                 fstHandle loopcnt = val >> 1;
[22]             for (i = 0; i < loopcnt; i++) {
                     chain_table[idx++] = 0;
                 }
             }

             pnt += skiplen;
         } while (pnt != (chain_cmem + chain_clen));

By not checking whether idx fits within the size of the chain_table buffer, the loop at [22] will write out-of-bounds on the heap, leading to arbitrary code execution.
An attacker can control the allocation size of the chain_table buffer, and at the same time manipulate the chain_mem section in order to write NULLs out-of-bounds, shifting idx arbitrarily. An attacker can then write arbitrary values at the shifted memory offset by supplying more chain_mem varints.

A small difference between this and the legacy version is that a 64-bit varint is read at [23], while in the legacy format a 32-bit varint is read.

Crash Information

==12830==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf6301878 at pc 0x565a1d70 bp 0xffffd238 sp 0xffffd22c
WRITE of size 8 at 0xf6301878 thread T0
    #0 0x565a1d6f in fstReaderIterBlocks2 fst/fstapi.c:5455
    #1 0x5659ea84 in fstReaderIterBlocks fst/fstapi.c:4999
    #2 0x5655908d in main src/helpers/fst2vcd.c:185
    #3 0xf7659294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #4 0xf7659357 in __libc_start_main_impl ../csu/libc-start.c:381
    #5 0x56558686 in _start (fst2vcd+0x3686)

0xf6301878 is located 0 bytes to the right of 72-byte region [0xf6301830,0xf6301878)
allocated by thread T0 here:
    #0 0xf7a55bab in __interceptor_calloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:77
    #1 0x565a1777 in fstReaderIterBlocks2 fst/fstapi.c:5409
    #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: heap-buffer-overflow fst/fstapi.c:5455 in fstReaderIterBlocks2
Shadow bytes around the buggy address:
  0x3ec602b0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x3ec602c0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x3ec602d0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x3ec602e0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x3ec602f0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x3ec60300: fa fa fa fa fa fa 00 00 00 00 00 00 00 00 00[fa]
  0x3ec60310: fa fa fa fa 00 00 00 00 00 00 00 00 00 02 fa fa
  0x3ec60320: fa fa 00 00 00 00 00 00 00 00 00 fa fa fa fa fa
  0x3ec60330: fd fd fd fd fd fd fd fd fd fd fa fa fa fa 00 00
  0x3ec60340: 00 00 00 00 00 00 00 fa fa fa fa fa 00 00 00 00
  0x3ec60350: 00 00 00 00 03 fa fa fa fa fa fd fd fd fd fd fd
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
==12830==ABORTING
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.