Talos Vulnerability Report

TALOS-2023-1821

GTKWave LXT2 lxt2_rd_trace value elements allocation integer overflow vulnerability

January 8, 2024
CVE Number

CVE-2023-35057

SUMMARY

An integer overflow vulnerability exists in the LXT2 lxt2_rd_trace value elements allocation functionality of GTKWave 3.3.115. A specially crafted .lxt2 file can lead to memory corruption. A victim would need to open a malicious file to trigger this vulnerability.

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-190 - Integer Overflow or Wraparound

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

LXT2 (InterLaced eXtensible Trace Version 2) files are parsed by the functions found in lxt2_read.c. These functions are used in the lxt2vcd file conversion utility, rtlbrowse, lxt2miner, and by the GUI portion of GTKwave, which are thus all affected by the issue described in this report.

To parse LXT2 files, the function lxt2_rd_init is called:

     struct lxt2_rd_trace *lxt2_rd_init(const char *name) {
[1]      struct lxt2_rd_trace *lt = (struct lxt2_rd_trace *)calloc(1, sizeof(struct lxt2_rd_trace));
         lxtint32_t i;

[2]      if (!(lt->handle = fopen(name, "rb"))) {
             lxt2_rd_close(lt);
             lt = NULL;
         } else {
             lxtint16_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 LXT2 file.

         ...
[4]      rcf = fread(&lt->numfacbytes, 4, 1, lt->handle);
         lt->numfacbytes = rcf ? lxt2_rd_get_32(&lt->numfacbytes, 0) : 0;
         rcf = fread(&lt->longestname, 4, 1, lt->handle);
         lt->longestname = rcf ? lxt2_rd_get_32(&lt->longestname, 0) : 0;
         rcf = fread(&lt->zfacnamesize, 4, 1, lt->handle);
         lt->zfacnamesize = rcf ? lxt2_rd_get_32(&lt->zfacnamesize, 0) : 0;
         rcf = fread(&lt->zfacname_predec_size, 4, 1, lt->handle);
         lt->zfacname_predec_size = rcf ? lxt2_rd_get_32(&lt->zfacname_predec_size, 0) : 0;
         rcf = fread(&lt->zfacgeometrysize, 4, 1, lt->handle);
         lt->zfacgeometrysize = rcf ? lxt2_rd_get_32(&lt->zfacgeometrysize, 0) : 0;
         rcf = fread(&lt->timescale, 1, 1, lt->handle);
         if (!rcf) lt->timescale = 0; /* no swap necessary */
         ...

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

For this advisory, we’re interested in the extraction and parsing of facgeometry:

     fseeko(lt->handle, pos = pos + lt->zfacnamesize, SEEK_SET);
     /* fprintf(stderr, LXT2_RDLOAD"seeking to geometry at %d (0x%08x)\n", pos, pos); */
     lt->zhandle = gzdopen(dup(fileno(lt->handle)), "rb");

[5]  t = lt->numfacs * 4 * sizeof(lxtint32_t);
[6]  m = (char *)malloc(t);
[7]  rc = gzread(lt->zhandle, m, t);
     gzclose(lt->zhandle);
     lt->zhandle = NULL;
     if (rc != t) {
         fprintf(stderr, LXT2_RDLOAD "*** geometry section mangled %d (act) vs %d (exp)\n", rc, t);
         ...
     }

The decompressed facgeometry structure is pointed by m [6], decompressed at [7] using gzread. The expected decompressed size is expected to be numfacs * 16 [5].

Each geometry has 4 fields associated: rows, msb, lsb, flags. These are extracted in the following code, together with len and value arrays, which are calculated based on the 4 fields just mentioned.

     ...
     for (i = 0; i < lt->numfacs; i++) {
         lt->rows[i] = lxt2_rd_get_32(m + i * 16, 0);
[8]      lt->msb[i] = lxt2_rd_get_32(m + i * 16, 4);
         lt->lsb[i] = lxt2_rd_get_32(m + i * 16, 8);
         lt->flags[i] = lxt2_rd_get_32(m + i * 16, 12);

         if (!(lt->flags[i] & LXT2_RD_SYM_F_INTEGER)) {
[9]          lt->len[i] = (lt->msb[i] <= lt->lsb[i]) ? (lt->lsb[i] - lt->msb[i] + 1) : (lt->msb[i] - lt->lsb[i] + 1);
         } else {
             lt->len[i] = 32;
         }
[10]     lt->value[i] = calloc(lt->len[i] + 1, sizeof(char));
     }
     ...

In the code above, several fields are extracted from m (the decompressed facgeometry). In particular, msb and lsb [8] are used to fill a len array, which is used to keep the signed difference between msb and lsb [9].
Additionally, the lt->value[i] element is filled with a pointer to a buffer that has its size calculated as lt->len[i] + 1 [10].
We can already notice the issue at this point: if lt->len[i] is 0xffffffff, the size operation will overflow and calloc(0, 1) will be called, resulting in the allocation of a very small buffer (depending on the heap implementation).
In order to have lt->len[i] set to 0xffffffff, it’s enough to have an msb of 0x80000000 and an lsb of 0x7ffffffe, which leads to the operation 0x7ffffffe - 0x80000000 + 1 == -1. Other combinations of msb and lsb are possible.

Upon return from the current lxt2_rd_init function, the blocks are parsed inside lxt2_rd_iter_blocks by walking the linked list created at [7].

 int lxt2_rd_iter_blocks(struct lxt2_rd_trace *lt,
                         void (*value_change_callback)(struct lxt2_rd_trace **lt, lxtint64_t *time, lxtint32_t *facidx, char **value),
                         void *user_callback_data_pointer) {
     struct lxt2_rd_block *b;
     int blk = 0, blkfinal = 0;
     int processed = 0;
     struct lxt2_rd_block *bcutoff = NULL, *bfinal = NULL;
     int striped_kill = 0;
     unsigned int real_uncompressed_siz = 0;
     unsigned char gzid[2];
     lxtint32_t i;

     ...
     b = lt->block_head;
     blk = 0;
     ...
     while (b) {
             ...
             fseeko(lt->handle, b->filepos, SEEK_SET);
             gzid[0] = gzid[1] = 0;
             if (!fread(&gzid, 2, 1, lt->handle)) {
                 gzid[0] = gzid[1] = 0;
             }
             fseeko(lt->handle, b->filepos, SEEK_SET);

[11]         if ((striped_kill = (gzid[0] != 0x1f) || (gzid[1] != 0x8b))) {
                ...
             } else {
                 int rc;

                 b->mem = malloc(b->uncompressed_siz);
[12]             lt->zhandle = gzdopen(dup(fileno(lt->handle)), "rb");
                 rc = gzread(lt->zhandle, b->mem, b->uncompressed_siz);
                 gzclose(lt->zhandle);
                 lt->zhandle = NULL;
[13]             if (((lxtint32_t)rc) != b->uncompressed_siz) {
                     fprintf(stderr, LXT2_RDLOAD "short read on block %d vs " LXT2_RD_LD " (exp), ignoring\n", rc, b->uncompressed_siz);
                     free(b->mem);
                     b->mem = NULL;
                     b->short_read_ignore = 1;
                 } else {
                     lt->block_mem_consumed += b->uncompressed_siz;
                 }
             }

             bfinal = b;
             blkfinal = blk;
         }

         if (b->mem) {
[14]         lxt2_rd_process_block(lt, b);
             ...
         }

         blk++;
         b = b->next;
     }

If the block starts with the gzip magic [11], gzdopen is used to decompress the block [12]. If the decompression is successful [13], lxt2_rd_process_block() is called to parse the decompressed block contents [14] (which are pointed to by b->mem).

lxt2_rd_process_block() parses the whole block structure, extracting several fields. Eventually, it calls lxt2_rd_iter_radix():

 void lxt2_rd_iter_radix(struct lxt2_rd_trace *lt, struct lxt2_rd_block *b) {
     unsigned int which_time;
     int offset;
     void **top_elem;
     granmsk_t msk = ~LXT2_RD_GRAN_1VAL;
     lxtint32_t x;

     for (which_time = 0; which_time < lt->num_time_table_entries; which_time++, msk <<= 1)
         while ((top_elem = lt->radix_sort[which_time])) {
             lxtint32_t idx = top_elem - lt->next_radix;

[15]         switch (lt->fac_curpos_width) {
                 case 1:
                     vch = lxt2_rd_get_byte(lt->fac_curpos[idx], 0);
                     break;
                 case 2:
                     vch = lxt2_rd_get_16(lt->fac_curpos[idx], 0);
                     break;
                 case 3:
                     vch = lxt2_rd_get_24(lt->fac_curpos[idx], 0);
                     break;
                 case 4:
                 default:
                     vch = lxt2_rd_get_32(lt->fac_curpos[idx], 0);
                     break;
             }

             ...

[16]         switch (vch) {
                 case LXT2_RD_ENC_0:
                 case LXT2_RD_ENC_1:
[17]                 memset(lt->value[idx], '0' + (vch - LXT2_RD_ENC_0), lt->len[idx]);
                     break;

                 case LXT2_RD_ENC_INV:
[20]                 for (i = 0; i < lt->len[idx]; i++) {
                         lt->value[idx][i] ^= 1;
                     }
                     break;

                 case LXT2_RD_ENC_LSH0:
                 case LXT2_RD_ENC_LSH1:
[18]                 memmove(lt->value[idx], lt->value[idx] + 1, lt->len[idx] - 1);
                     lt->value[idx][lt->len[idx] - 1] = '0' + (vch - LXT2_RD_ENC_LSH0);
                     break;

                 case LXT2_RD_ENC_RSH0:
                 case LXT2_RD_ENC_RSH1:
[18]                 memmove(lt->value[idx] + 1, lt->value[idx], lt->len[idx] - 1);
                     lt->value[idx][0] = '0' + (vch - LXT2_RD_ENC_RSH0);
                     break;

                 case LXT2_RD_ENC_ADD1:
                 case LXT2_RD_ENC_ADD2:
                 case LXT2_RD_ENC_ADD3:
                 case LXT2_RD_ENC_ADD4:
                     x = lxt2_rd_expand_bits_to_integer(lt->len[idx], lt->value[idx]);
                     x += (vch - LXT2_RD_ENC_ADD1 + 1);
[19]                 memcpy(lt->value[idx], lxt2_rd_expand_integer_to_bits(lt->len[idx], x), lt->len[idx]);
                     break;
                 ...

At [15], values from lt->fac_curpos are extracted directly from the uncompressed portion of the block, so vch can be arbitrarily controlled.
Depending on vch [16], one of the many operations (here truncated for brevity) are used to modify the lt->value[idx] array.
These operations happen via memset [17], memmove [18], memcpy [19], or by loops [20]. These are all write operations that assume lt->value[idx] has a size of lt->len[idx]. This is normally true. However, as lt->value[idx] may actually be much smaller because of the integer overflow at [10], all these commands can write out-of-bounds, 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.
The attached PoC demonstrates the out-of-bounds write happening in the loop at [20].

Crash Information

==842579==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5f00511 at pc 0x56558a71 bp 0xffffd458 sp 0xffffd44c
WRITE of size 1 at 0xf5f00511 thread T0
    #0 0x56558a70 in lxt2_rd_iter_radix src/helpers/lxt2_read.c:214
    #1 0x5655fec3 in lxt2_rd_process_block src/helpers/lxt2_read.c:729
    #2 0x565697ca in lxt2_rd_iter_blocks src/helpers/lxt2_read.c:1604
    #3 0x5656b5b1 in process_lxt src/helpers/lxt2vcd.c:299
    #4 0x5656bee6 in main src/helpers/lxt2vcd.c:458
    #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 0x565573a6 in _start (lxt2vcd+0x23a6)

0xf5f00511 is located 0 bytes to the right of 1-byte region [0xf5f00510,0xf5f00511)
allocated by thread T0 here:
    #0 0xf7a55bab in __interceptor_calloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:77
    #1 0x56562a37 in lxt2_rd_init src/helpers/lxt2_read.c:925
    #2 0x5656aec9 in process_lxt src/helpers/lxt2vcd.c:183
    #3 0x5656bee6 in main src/helpers/lxt2vcd.c:458
    #4 0xf7659294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

SUMMARY: AddressSanitizer: heap-buffer-overflow src/helpers/lxt2_read.c:214 in lxt2_rd_iter_radix
Shadow bytes around the buggy address:
  0x3ebe0050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x3ebe0060: fa fa fd fa fa fa fd fd fa fa fd fa fa fa fd fd
  0x3ebe0070: fa fa fd fa fa fa fd fd fa fa fd fa fa fa fd fd
  0x3ebe0080: fa fa fd fd fa fa 00 04 fa fa 00 05 fa fa 00 04
  0x3ebe0090: fa fa 00 04 fa fa 00 04 fa fa 04 fa fa fa 00 fa
=>0x3ebe00a0: fa fa[01]fa fa fa 04 fa fa fa 04 fa fa fa 04 fa
  0x3ebe00b0: fa fa 04 fa fa fa 04 fa fa fa 04 fa fa fa 04 fa
  0x3ebe00c0: fa fa fd fd fa fa fd fa fa fa 00 00 fa fa fd fa
  0x3ebe00d0: fa fa 01 fa fa fa 01 fa fa fa 02 fa fa fa 00 04
  0x3ebe00e0: fa fa 00 03 fa fa 00 04 fa fa 00 05 fa fa 00 04
  0x3ebe00f0: fa fa 00 05 fa fa 00 00 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
VENDOR RESPONSE

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

TIMELINE

2023-08-11 - Vendor Disclosure
2023-12-31 - Vendor Patch Release
2024-01-08 - Public Release

Credit

Discovered by Claudio Bozzato of Cisco Talos.