Lucene search

K
talosTalos IntelligenceTALOS-2023-1823
HistoryJan 08, 2024 - 12:00 a.m.

GTKWave LXT2 zlib block decompression out-of-bounds write vulnerability

2024-01-0800:00:00
Talos Intelligence
www.talosintelligence.com
11
gtkwave
lxt2
zlib
block decompression
vulnerability
out-of-bounds write
arbitrary code execution
security
file format
memory buffer
cwe-119
gui
mime types
parsing.

CVSS3

7.8

Attack Vector

LOCAL

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

REQUIRED

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

AI Score

7.7

Confidence

High

EPSS

0.001

Percentile

23.1%

Talos Vulnerability Report

TALOS-2023-1823

GTKWave LXT2 zlib block decompression out-of-bounds write vulnerability

January 8, 2024
CVE Number

CVE-2023-38657

SUMMARY

An out-of-bounds write vulnerability exists in the LXT2 zlib block decompression 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.

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.

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-&gt;handle = fopen(name, "rb"))) {
             lxt2_rd_close(lt);
             lt = NULL;
         } else {
             lxtint16_t id = 0, version = 0;
             ...
[3]          if (!fread(&id, 2, 1, lt-&gt;handle)) {
                 id = 0;
             }
             if (!fread(&version, 2, 1, lt-&gt;handle)) {
                 id = 0;
             }
             if (!fread(&lt-&gt;granule_size, 1, 1, lt-&gt;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-&gt;numfacbytes, 4, 1, lt-&gt;handle);
         lt-&gt;numfacbytes = rcf ? lxt2_rd_get_32(&lt-&gt;numfacbytes, 0) : 0;
         rcf = fread(&lt-&gt;longestname, 4, 1, lt-&gt;handle);
         lt-&gt;longestname = rcf ? lxt2_rd_get_32(&lt-&gt;longestname, 0) : 0;
         rcf = fread(&lt-&gt;zfacnamesize, 4, 1, lt-&gt;handle);
         lt-&gt;zfacnamesize = rcf ? lxt2_rd_get_32(&lt-&gt;zfacnamesize, 0) : 0;
         rcf = fread(&lt-&gt;zfacname_predec_size, 4, 1, lt-&gt;handle);
         lt-&gt;zfacname_predec_size = rcf ? lxt2_rd_get_32(&lt-&gt;zfacname_predec_size, 0) : 0;
         rcf = fread(&lt-&gt;zfacgeometrysize, 4, 1, lt-&gt;handle);
         lt-&gt;zfacgeometrysize = rcf ? lxt2_rd_get_32(&lt-&gt;zfacgeometrysize, 0) : 0;
         rcf = fread(&lt-&gt;timescale, 1, 1, lt-&gt;handle);
         if (!rcf) lt-&gt;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

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-&gt;uncompressed_siz, 4, 1, lt-&gt;handle);
         b-&gt;uncompressed_siz = rcf ? lxt2_rd_get_32(&b-&gt;uncompressed_siz, 0) : 0;
         rcf = fread(&b-&gt;compressed_siz, 4, 1, lt-&gt;handle);
         b-&gt;compressed_siz = rcf ? lxt2_rd_get_32(&b-&gt;compressed_siz, 0) : 0;
         rcf = fread(&b-&gt;start, 8, 1, lt-&gt;handle);
         b-&gt;start = rcf ? lxt2_rd_get_64(&b-&gt;start, 0) : 0;
         rcf = fread(&b-&gt;end, 8, 1, lt-&gt;handle);
         b-&gt;end = rcf ? lxt2_rd_get_64(&b-&gt;end, 0) : 0;
         ...
         if ((b-&gt;uncompressed_siz) && (b-&gt;compressed_siz) && (b-&gt;end)) {
             /* fprintf(stderr, LXT2_RDLOAD"block [%d] %lld / %lld\n", lt-&gt;numblocks, b-&gt;start, b-&gt;end); */
             fseeko(lt-&gt;handle, b-&gt;compressed_siz, SEEK_CUR);

             lt-&gt;numblocks++;
[7]          if (lt-&gt;block_curr) {
                 lt-&gt;block_curr-&gt;next = b;
                 lt-&gt;block_curr = b;
                 lt-&gt;end = b-&gt;end;
             } else {
                 lt-&gt;block_head = lt-&gt;block_curr = b;
                 lt-&gt;start = b-&gt;start;
                 lt-&gt;end = b-&gt;end;
             }
         } else {
             free(b);
             break;
         }

         pos += b-&gt;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 is as follows:

  • uncompressed_siz - unsigned big endian 32-bit
  • compressed_siz - unsigned big endian 32-bit
  • start_time - unsigned big endian 64-bit
  • end_time - unsigned big endian 64-bit
  • compressed data of size compressed_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;

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

[8]              if ((striped_kill = (gzid[0] != 0x1f) || (gzid[1] != 0x8b))) {
                     lxtint32_t clen, unclen, iter = 0;
                     char *pnt;
                     off_t fspos = b-&gt;filepos;

                     lxtint32_t zlen = 16;
[9]                  char *zbuff = malloc(zlen);
                     struct z_stream_s strm;

[10]                 real_uncompressed_siz = b-&gt;uncompressed_siz;
                     pnt = b-&gt;mem = malloc(b-&gt;uncompressed_siz);
                     b-&gt;uncompressed_siz = 0;

                     lxt2_rd_regenerate_process_mask(lt);

                     while (iter != 0xFFFFFFFF) {
                         size_t rcf;

                         clen = unclen = iter = 0;
[11]                     rcf = fread(&clen, 4, 1, lt-&gt;handle);
                         clen = rcf ? lxt2_rd_get_32(&clen, 0) : 0;
                         rcf = fread(&unclen, 4, 1, lt-&gt;handle);
                         unclen = rcf ? lxt2_rd_get_32(&unclen, 0) : 0;
                         rcf = fread(&iter, 4, 1, lt-&gt;handle);
                         iter = rcf ? lxt2_rd_get_32(&iter, 0) : 0;

                         fspos += 12;
                         if ((iter == 0xFFFFFFFF) || (lt-&gt;process_mask_compressed[iter / LXT2_RD_PARTIAL_SIZE])) {
[12]                         if (clen &gt; zlen) {
                                 if (zbuff) free(zbuff);
                                 zlen = clen * 2;
                                 zbuff = malloc(zlen ? zlen : 1 /* scan-build */);
                             }
                             ...

If the block does not start with the gzip magic [8], the block is decompressed directly using zlib.
To do this, zbuff is allocated to contain the compressed contents of the block, currently with a size of 16 bytes [9].
At [10], pnt/b-&gt;mem are allocated using the size declared in the b-&gt;uncompressed_siz field, which was taken directly from the file [6]. This buffer is the destination buffer for the uncompressed contents of this block.

Then, clen, unclen and iter fields are extracted as 32-bit big-endian integers from the file [11].
clen represents the compressed length of the block. The check at [12] makes sure thereโ€™s enough space in zbuff; otherwise it allocates a bigger buffer.

                             ...
[13]                         if (!fread(zbuff, clen, 1, lt-&gt;handle)) {
                                 clen = 0;
                             }

[14]                         strm.avail_in = clen - 10;
                             strm.avail_out = unclen;
                             strm.total_in = strm.total_out = 0;
                             strm.zalloc = NULL;
                             strm.zfree = NULL;
                             strm.opaque = NULL;
                             strm.next_in = (unsigned char *)(zbuff + 10);
                             strm.next_out = (unsigned char *)(pnt);

                             if ((clen != 0) && (unclen != 0)) {
                                 inflateInit2(&strm, -MAX_WBITS);
[15]                             while (Z_OK == inflate(&strm, Z_NO_FLUSH))
                                     ;
                                 inflateEnd(&strm);
                             }

[16]                         if ((strm.total_out != unclen) || (clen == 0) || (unclen == 0)) {
                                 fprintf(stderr, LXT2_RDLOAD "short read on subblock %ld vs " LXT2_RD_LD " (exp), ignoring\n", strm.total_out, unclen);
                                 free(b-&gt;mem);
                                 b-&gt;mem = NULL;
                                 b-&gt;short_read_ignore = 1;
                                 b-&gt;uncompressed_siz = real_uncompressed_siz;
                                 break;
                             }
                             ...

At [13] the uncompressed data is read into zbuff.
At [14] the zlib struct z_stream_s strm structure is populated. avail_in and next_in refer to the compressed data and show that the first 10 bytes of the compressed block are discarded. avail_out contains unclen, which is the expected size of the data after decompression. This will be stored into pnt (next_out).
The block is decompressed at [15], and at [16] the code checks that the resulting number of decompressed bytes actually matches unclen.

While the unclen check is performed correctly, the pnt buffer is allocated using b-&gt;uncompressed_siz, which is never checked to be equal to unclen. This allows a smaller b-&gt;uncompressed_siz value to be specified (for example 1, which ends up allocating pnt with a size of 1 byte). unclen can instead reflect the actual size of the decompressed buffer (which can be of arbitrary size), so that the check at [16] passes and the compression completes without errors. This allows a large amount of data to decompress in a small pnt buffer at [15], which ends up writing out-of-bounds in the heap, leading to arbitrary code execution.

Crash Information

LXTLOAD | 1 facilities
LXTLOAD | Read 1 block header OK
LXTLOAD | [0] start time
LXTLOAD | [40] end time
LXTLOAD |
LXTLOAD | block [0] processing 0 / 40
LXTLOAD | short read on subblock 3120 vs 4147 (exp), ignoring
double free or corruption (out)
Aborted
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.


Vulnerability Reports Next Report

TALOS-2023-1824

Previous Report

TALOS-2023-1822

CVSS3

7.8

Attack Vector

LOCAL

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

REQUIRED

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

AI Score

7.7

Confidence

High

EPSS

0.001

Percentile

23.1%