CVE-2023-35969,CVE-2023-35970
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.
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.
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].
fstReaderIterBlocks2
chain_table
legacyBy 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.
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.
==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
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.