CVE-2023-35956,CVE-2023-35957,CVE-2023-35958,CVE-2023-35955
Multiple heap-based buffer overflow vulnerabilities exist in the fstReaderIterBlocks2 VCDATA 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.
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 uses a graphical user interface to convert the traces 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 also sets up mime types for its supported extensions.
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 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, a big (about 65KB) structure that’s 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 which 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
.
...
mem_required_for_traversal = fstReaderUint64(xc->f);
mem_for_traversal = (unsigned char *)malloc(mem_required_for_traversal + 66); /* add in potential fastlz overhead */
...
Later 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.
Later on, the code extracts the start of the VCDATA section [13] and the compression type [14]:
fstReaderFseeko(xc, xc->f, (fst_off_t)frame_clen, SEEK_CUR); /* skip past compressed data */
vc_maxhandle = fstReaderVarint64(xc->f);
[13] vc_start = ftello(xc->f); /* points to '!' character */
[14] packtype = fgetc(xc->f);
Right after this, the chain_clen
is extracted, which is the size of the chain
section, extracted at [16].
indx_pntr = blkpos + seclen - 24 - tsec_clen - 8;
fstReaderFseeko(xc, xc->f, indx_pntr, SEEK_SET);
[15] chain_clen = fstReaderUint64(xc->f);
indx_pos = indx_pntr - chain_clen;
...
[16] 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;
[17] chain_table = (fst_off_t *)calloc((vc_maxhandle + 1), sizeof(fst_off_t));
[18] chain_table_lengths = (uint32_t *)calloc((vc_maxhandle + 1), sizeof(uint32_t));
}
The chain_table
is cached across the parsing of the different FST blocks. However, initially chain_table
will be zero, so the initialization happens at [17], allocating vc_maxhandle+1
items. vc_maxhandle
corresponds 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 serie 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) {
...
[19] } else {
do {
int skiplen;
uint64_t val = fstGetVarint32(pnt, &skiplen);
[20] 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++;
[21] } else if (val & 1) {
pval = chain_table[idx] = pval + (val >> 1);
if (idx) {
chain_table_lengths[pidx] = pval - chain_table[pidx];
}
pidx = idx++;
[22] } 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 [19]. A varint is read:
- if 0 [20] the element is set to 0
- if the LSB is 1 [21], the remaining 31 bits are the offset of the VC element in VCDATA
- otherwise, the remaining 31 bits represent the number of elements to zero out
The important thing to understand from the above encoding is that we can define semi-arbitrary offsets and lengths by controlling chain_cmem
.
After the chain_table
has been parsed, it is used to extract VCDATA.
mc_mem_len = 16384;
mc_mem = (unsigned char *)malloc(mc_mem_len); /* buffer for compressed reads */
/* check compressed VC data */
if (idx > xc->maxhandle) idx = xc->maxhandle;
[23] for (i = 0; i < idx; i++) {
if (chain_table[i]) {
int process_idx = i / 8;
int process_bit = i & 7;
if (xc->process_mask[process_idx] & (1 << process_bit)) {
int rc = Z_OK;
uint32_t val;
uint32_t skiplen;
uint32_t tdelta;
[24] fstReaderFseeko(xc, xc->f, vc_start + chain_table[i], SEEK_SET);
val = fstReaderVarint32WithSkip(xc->f, &skiplen);
if (val) {
[25] unsigned char *mu = mem_for_traversal + traversal_mem_offs; /* uncomp: dst */
unsigned char *mc; /* comp: src */
[26] unsigned long destlen = val;
[27] unsigned long sourcelen = chain_table_lengths[i];
if (mc_mem_len < chain_table_lengths[i]) {
free(mc_mem);
mc_mem = (unsigned char *)malloc(mc_mem_len = chain_table_lengths[i]);
}
mc = mc_mem;
[28] fstFread(mc, chain_table_lengths[i], 1, xc->f);
[29] switch (packtype) {
case '4':
[30] rc = (destlen == (unsigned long)LZ4_decompress_safe_partial((char *)mc, (char *)mu, sourcelen, destlen, destlen)) ? Z_OK : Z_DATA_ERROR;
break;
case 'F':
[31] fastlz_decompress(mc, sourcelen, mu, destlen); /* rc appears unreliable */
break;
default:
[32] rc = uncompress(mu, &destlen, mc, sourcelen);
break;
}
/* data to process is for(j=0;j<destlen;j++) in mu[j] */
headptr[i] = traversal_mem_offs;
length_remaining[i] = val;
traversal_mem_offs += val;
} else {
int destlen = chain_table_lengths[i] - skiplen;
unsigned char *mu = mem_for_traversal + traversal_mem_offs;
[33] fstFread(mu, destlen, 1, xc->f);
/* data to process is for(j=0;j<destlen;j++) in mu[j] */
headptr[i] = traversal_mem_offs;
length_remaining[i] = destlen;
traversal_mem_offs += destlen;
}
The point of the code above is to decompress or copy each element of VCDATA into the mem_for_traversal
buffer.
For each element [23] in VCDATA, the file cursor is set to the relative element within VCDATA [24], using the chain table previously built. The first value in VCDATA tells us if we’re dealing with a compressed or uncompressed VC element.
If val
is not 0, then there’s compression and val
represents destlen
(see below).
Then mu
buffer (u
stands for uncompressed) is set to mem_for_traversal
(traversal_mem_offs
is initially 0). Recall that we have full control on the size of mem_for_traversal
via a specific field (mem_required_for_traversal
) in the .fst
file, and this field does not depend nor influence on any of the chain table fields.
The mc
buffer (c
stands for compressed) is also allocated, with a size of sourcelen
.
At [26], the destlen
for the mu
buffer is set from val
. This is a 32-bit varint. This is the uncompressed size of the current VC element.
At [27], the sourcelen
is set from the precalculated chain_table
. This is the compressed size of the current VC element.
At [28], the compressed VC element is read into mc
.
At [29], depending on the compression type, the mc
(compressed) buffer is decompressed into mu
at [30], [31] and [32].
Since all three decompression commands don’t check the actual remaining size of the mu
buffer, they can all write out-of-bounds.
Similarly, if there’s no compression when reading val
at [24], we’ll land at [33]. In this case, mu
is simply populated directly from file, and again destlen
represents the uncompressed size of the VC element. destlen
however is completely unrelated to mem_required_for_traversal
, which is the size of the destination buffer mu
, so this can also write out-of-bounds.
fstReaderIterBlocks2
mu LZ4_decompress_safe_partial
By not checking whether destlen
fits within the remaining length of the mu
buffer, LZ4_decompress_safe_partial
[30] will write out-of-bounds on the heap, leading to arbitrary code execution.
An attacker can control the mu
buffer size, and at the same time manipulate the chain_table
by supplying a small (about 20 bytes) LZ4-compressed block of data, which can be easily decompress in +1000 bytes.
fstReaderIterBlocks2
mu fastlz_decompress
By not checking whether destlen
fits within the remaining length of the mu
buffer, fastlz_decompress
[31] will write out-of-bounds on the heap, leading to arbitrary code execution.
An attacker can control the mu
buffer size and at the same time manipulate the chain_table
by supplying a small (about 20 bytes) fastlz-compressed block of data, which can be easily decompress in +1000 bytes.
fstReaderIterBlocks2
mu uncompress
By not checking whether destlen
fits within the remaining length of the mu
buffer, uncompress
[32] will write out-of-bounds on the heap, leading to arbitrary code execution.
An attacker can control the mu
buffer size and at the same time manipulate the chain_table
by supplying a small (about 20 bytes) zlib-compressed block of data, which can be easily decompress in +1000 bytes.
fstReaderIterBlocks2
mu fstFread
By not checking whether destlen
fits within the remaining length of the mu
buffer, fstFread
[33] will write out-of-bounds on the heap, leading to arbitrary code execution.
An attacker can control the mu
buffer size and at the same time manipulate the chain_table
by supplying an arbitrarily long block of data (for example at the end of the FST file in an unknown block), which will write over mu
and out of its bounds.
==12292==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf63018ea at pc 0xf79df7dc bp 0xffffd218 sp 0xffffcdf0
WRITE of size 4999 at 0xf63018ea thread T0
#0 0xf79df7db in __interceptor_fread ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1043
#1 0x565802e6 in fstFread fst/fstapi.c:290
#2 0x565a31e8 in fstReaderIterBlocks2 fst/fstapi.c:5574
#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)
0xf63018ea is located 0 bytes to the right of 74-byte region [0xf63018a0,0xf63018ea)
allocated by thread T0 here:
#0 0xf7a55ffb in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x5659f28e in fstReaderIterBlocks2 fst/fstapi.c:5103
#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 ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:1043 in __interceptor_fread
Shadow bytes around the buggy address:
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 03 fa fa fa fa fa
0x3ec60330: fd fd fd fd fd fd fd fd fd fa fa fa fa fa 00 00
0x3ec60340: 00 00 00 00 00 00 03 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
0x3ec60360: fd fd fd 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
==12292==ABORTING
Fixed in version 3.3.118, available from https://sourceforge.net/projects/gtkwave/files/gtkwave-3.3.118/
2023-07-10 - Initial Vendor Contact
2023-07-18 - Vendor Disclosure
2023-12-31 - Vendor Patch Release
2024-01-08 - Public Release
Discovered by Claudio Bozzato of Cisco Talos.