CVE-2023-34436
An out-of-bounds write vulnerability exists in the LXT2 num_time_table_entries functionality of GTKWave 3.3.115. A specially crafted .lxt2 file can lead to arbitrary code execution. A victim would need to open a malicious file to trigger this vulnerability.
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.
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(<->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(<->numfacbytes, 4, 1, lt->handle);
lt->numfacbytes = rcf ? lxt2_rd_get_32(<->numfacbytes, 0) : 0;
rcf = fread(<->longestname, 4, 1, lt->handle);
lt->longestname = rcf ? lxt2_rd_get_32(<->longestname, 0) : 0;
rcf = fread(<->zfacnamesize, 4, 1, lt->handle);
lt->zfacnamesize = rcf ? lxt2_rd_get_32(<->zfacnamesize, 0) : 0;
rcf = fread(<->zfacname_predec_size, 4, 1, lt->handle);
lt->zfacname_predec_size = rcf ? lxt2_rd_get_32(<->zfacname_predec_size, 0) : 0;
rcf = fread(<->zfacgeometrysize, 4, 1, lt->handle);
lt->zfacgeometrysize = rcf ? lxt2_rd_get_32(<->zfacgeometrysize, 0) : 0;
rcf = fread(<->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
: unusedlongestname
: keeps the longest length of all defined facilities’ nameszfacnamesize
: compressed size of facnames
zfacname_predec_size
: decompressed size of facnames
zfacgeometrysize
: compressed size of facgeometry
Then, the facnames
and facgeometry
structures are extracted. Both structures are compressed with gzip.
Right after these two structures, there’s a sequence of blocks that can be arbitrarily long.
for (;;) {
...
[5] b = calloc(1, sizeof(struct lxt2_rd_block));
[6] rcf = fread(&b->uncompressed_siz, 4, 1, lt->handle);
b->uncompressed_siz = rcf ? lxt2_rd_get_32(&b->uncompressed_siz, 0) : 0;
rcf = fread(&b->compressed_siz, 4, 1, lt->handle);
b->compressed_siz = rcf ? lxt2_rd_get_32(&b->compressed_siz, 0) : 0;
rcf = fread(&b->start, 8, 1, lt->handle);
b->start = rcf ? lxt2_rd_get_64(&b->start, 0) : 0;
rcf = fread(&b->end, 8, 1, lt->handle);
b->end = rcf ? lxt2_rd_get_64(&b->end, 0) : 0;
...
if ((b->uncompressed_siz) && (b->compressed_siz) && (b->end)) {
/* fprintf(stderr, LXT2_RDLOAD"block [%d] %lld / %lld\n", lt->numblocks, b->start, b->end); */
fseeko(lt->handle, b->compressed_siz, SEEK_CUR);
lt->numblocks++;
[7] if (lt->block_curr) {
lt->block_curr->next = b;
lt->block_curr = b;
lt->end = b->end;
} else {
lt->block_head = lt->block_curr = b;
lt->start = b->start;
lt->end = b->end;
}
} else {
free(b);
break;
}
pos += b->compressed_siz;
}
At [5] the block structure is allocated on the heap. At [6] some fields are extracted. Finally, the block is saved inside a linked list [7].
From this code we can see the file structure for a block as follows:
uncompressed_siz
- unsigned big endian 32 bitcompressed_siz
- unsigned big endian 32 bitstart_time
- unsigned big endian 64 bitend_time
- unsigned big endian 64 bitcompressed_siz
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);
[8] if ((striped_kill = (gzid[0] != 0x1f) || (gzid[1] != 0x8b))) {
...
} else {
int rc;
b->mem = malloc(b->uncompressed_siz);
[9] lt->zhandle = gzdopen(dup(fileno(lt->handle)), "rb");
rc = gzread(lt->zhandle, b->mem, b->uncompressed_siz);
gzclose(lt->zhandle);
lt->zhandle = NULL;
[10] 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) {
[11] lxt2_rd_process_block(lt, b);
...
}
blk++;
b = b->next;
}
If the block starts with the gzip magic [8], gzdopen
is used to decompress the block [9]. If the decompression is successful [10], lxt2_rd_process_block
is called to parse the decompressed block contents [11] (which are pointed to by b->mem
).
int lxt2_rd_process_block(struct lxt2_rd_trace *lt, struct lxt2_rd_block *b) {
char vld;
char *pnt;
lxtint32_t i;
int granule = 0;
char sect_typ;
lxtint32_t strtfac_gran = 0;
char granvld = 0;
...
[12] pnt = b->mem;
[13] while (((sect_typ = *pnt) == LXT2_RD_GRAN_SECT_TIME) || (sect_typ == LXT2_RD_GRAN_SECT_TIME_PARTIAL)) {
...
/* fprintf(stderr, LXT2_RDLOAD"processing granule %d\n", granule); */
pnt++;
[14] lt->num_time_table_entries = lxt2_rd_get_byte(pnt, 0);
pnt++;
[15] for (i = 0; i < lt->num_time_table_entries; i++) {
[16] lt->time_table[i] = lxt2_rd_get_64(pnt, 0);
pnt += 8;
/* fprintf(stderr, LXT2_RDLOAD"\t%d) %lld\n", i, lt->time_table[i]); */
}
}
At [12] pnt
is set to point to the uncompressed block contents. A while loop [13] then iterates over all sectors within the block, as long as the sector type is either 0 (LXT2_RD_GRAN_SECT_TIME
) or 2 (LXT2_RD_GRAN_SECT_TIME_PARTIAL
).
At [14] the num_time_table_entries
is extracted, which is any number from 0 to 255, as it’s extracted as a byte.
At [15] a loop iterates over the num_time_table_entries
number that has just been extracted, and a sequence of 64-bit big-endian integers is extracted from pnt
, moving them inside the lt->time_table
array [16].
lt->time_table
is declared as follows within lt
, which is of type struct lxt2_rd_trace
:
#define LXT2_RD_GRANULE_SIZE (64)
...
struct lxt2_rd_trace {
lxtint32_t *rows;
lxtsint32_t *msb, *lsb;
lxtint32_t *flags, *len;
...
[17] lxtint64_t time_table[LXT2_RD_GRANULE_SIZE];
...
}
At [17] we can see time_table
is an array of 64 elements. However, num_time_table_entries
can be larger than that, as it’s an arbitrarily controlled 8-bit value.
Since there are no checks that restrict the write to time_table
to a maximum of LXT2_RD_GRANULE_SIZE
, the write at [16] can write out-of-bounds on the heap, leading to arbitrary code execution.
==838280==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5103b40 at pc 0x5655f560 bp 0xffffd548 sp 0xffffd53c
WRITE of size 8 at 0xf5103b40 thread T0
#0 0x5655f55f in lxt2_rd_process_block src/helpers/lxt2_read.c:668
#1 0x565697ca in lxt2_rd_iter_blocks src/helpers/lxt2_read.c:1604
#2 0x5656b5b1 in process_lxt src/helpers/lxt2vcd.c:299
#3 0x5656bee6 in main src/helpers/lxt2vcd.c:458
#4 0xf7659294 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#5 0xf7659357 in __libc_start_main_impl ../csu/libc-start.c:381
#6 0x565573a6 in _start (lxt2vcd+0x23a6)
0xf5103b44 is located 0 bytes to the right of 964-byte region [0xf5103780,0xf5103b44)
allocated by thread T0 here:
#0 0xf7a55bab in __interceptor_calloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:77
#1 0x5655ffe4 in lxt2_rd_init src/helpers/lxt2_read.c:764
#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:668 in lxt2_rd_process_block
Shadow bytes around the buggy address:
0x3ea20710: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ea20720: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ea20730: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ea20740: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x3ea20750: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x3ea20760: 00 00 00 00 00 00 00 00[04]fa fa fa fa fa fa fa
0x3ea20770: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x3ea20780: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x3ea20790: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x3ea207a0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x3ea207b0: fa fa fa 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
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-08-11 - Vendor Disclosure
2023-12-31 - Vendor Patch Release
2024-01-08 - Public Release
Discovered by Claudio Bozzato of Cisco Talos.