Talos Vulnerability Report

TALOS-2024-1920

The Biosig Project libbiosig .egi parsing heap-based buffer overflow vulnerability

February 20, 2024
CVE Number

CVE-2024-21795

SUMMARY

A heap-based buffer overflow vulnerability exists in the .egi parsing functionality of The Biosig Project libbiosig 2.5.0 and Master Branch (ab0ee111). A specially crafted .egi file can lead to arbitrary code execution. An attacker can provide 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.

The Biosig Project libbiosig 2.5.0
The Biosig Project libbiosig Master Branch (ab0ee111)

PRODUCT URLS

libbiosig - https://biosig.sourceforge.net/index.html

CVSSv3 SCORE

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

CWE

CWE-122 - Heap-based Buffer Overflow

DETAILS

Libbiosig is an open source library designed to process various types of medical signal data (EKG, EEG, etc) within a vast variety of different file formats. Libbiosig is also at the core of biosig APIs in Octave and Matlab, sigviewer, and other scientific software utilized for interpreting biomedical signal data.

When reading in or writing out data of any filetype, libbiosig will always end up hitting the sopen_extended function:

HDRTYPE* sopen_extended(const char* FileName, const char* MODE, HDRTYPE* hdr, biosig_options_type *biosig_options) {
/*
    MODE="r"
        reads file and returns HDR
    MODE="w"
        writes HDR into file
 */

This is where the vast majority of parsing logic is for most file types, albeit with some exceptions to this generalization which end up calling more specific sopen_* functions. Regardless, unless specifically stated, it’s safe to assume we’re somewhere in this extremely large function. The general flow of sopen_extended is as one might expect: initialize generic structures, figure out what file type we’re dealing with, parse the filetype, and finally populate the generic structures that can be utilized by whatever is calling sopen_extended. To determine file type, sopen_extended calls getfiletype, which goes through a list of magic byte comparisons. Alternatively we could force a particular file type, but this is generally more useful when writing data to a file.

Moving on from the generic overview, we can get to be more specific. For today’s example we’re dealing with the parsing code of the .egi file format, a format seemingly adapted for compatibility with the commercial MagStim EGI Net Station software suite. To determine if a file is a .egi file, getfiletype runs the following check:

  else if ((beu32p(hdr->AS.Header) > 1) && (beu32p(hdr->AS.Header) < 8) && !hdr->AS.Header[6]  && !hdr->AS.Header[8]  && !hdr->AS.Header[10]  && !hdr->AS.Header[12]  && !hdr->AS.Header[14]  && !hdr->AS.Header[26] ) {
        /* sanity check: the high byte of month, day, hour, min, sec and bits must be zero */
            hdr->TYPE = EGI;
            hdr->VERSION = hdr->AS.Header[3];
        }

Since there doesn’t seem to be any sort of magic bytes, there’s just a simple sanity check on a set of high bytes. Regardless, assuming we hit this code branch, the code flow in sopen_extended looks as such:

else if (hdr->TYPE==EGI) {

    fprintf(stdout,"Reading EGI is under construction\n"); // [1]

    uint16_t NEC = 0;   // specific for EGI format
    uint16_t gdftyp = 3;

    // BigEndian
    hdr->FILE.LittleEndian = 0;
    hdr->VERSION    = beu32p(hdr->AS.Header); // [2]
    // [...] 
    
    hdr->SampleRate = beu16p(hdr->AS.Header+20);
    hdr->NS         = beu16p(hdr->AS.Header+22);
    // uint16_t  Gain  = beu16p(Header1+24);    // not used
    uint16_t  Bits  = beu16p(hdr->AS.Header+26);
    uint16_t PhysMax= beu16p(hdr->AS.Header+28);
    size_t POS;

It’s appropriately stated that reading this file format is under construction [1], but since that fprintf doesn’t stop us from hitting this code path based on our file input, it doesn’t particularly matter for our purposes. Tangent aside, out of the fields read in this portion, only the hdr->VERSION is important [2], since it determines how far we must read to get to our other important input data. Continuing immediately after, still within sopen_extended:

    if (hdr->AS.Header[3] & 0x01)   // [3]
    {   // Version 3,5,7
        POS = 32; 
        for (k=0; k < beu16p(hdr->AS.Header+30); k++) { // [4]
            char tmp[256];
            int  len = hdr->AS.Header[POS]; // [5]
            strncpy(tmp,Header1+POS,len);
            tmp[len]=0;
            if (VERBOSE_LEVEL>7)
                fprintf(stdout,"EGI categorie %i: <%s>\n",(int)k,tmp);

            POS += *(hdr->AS.Header+POS);   // skip EGI categories
            if (POS > count-8) { 
                hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header,2*count);
                count += ifread(hdr->AS.Header,1,count,hdr);
            }
        }

        hdr->NRec= beu16p(hdr->AS.Header+POS); 
        hdr->SPR = beu32p(hdr->AS.Header+POS+2);
        NEC = beu16p(hdr->AS.Header+POS+6); // EGI.N // [6]
        POS += 8;
    }
    else
    {   // Version 2,4,6
        hdr->NRec = beu32p(hdr->AS.Header+30);
        NEC = beu16p(hdr->AS.Header+34);    // EGI  // [7]
        hdr->SPR  = 1;
        /* see also end-of-sopen
        hdr->AS.spb = hdr->SPR+NEC;
        hdr->AS.bpb = (hdr->NS + NEC)*GDFTYP_BITS[hdr->CHANNEL[0].GDFTYP]>>3;
        */
        POS = 36;
    }

Assuming our hdr->VERSION is odd, we hit the more complex branch at [3]. Depending on hdr->AS.Header+0x1E [4], up to 0xFFFF fields can be skipped over, each of which can be 0xFF bytes long [5]. Again, this portion only matters because it determines where we read in the important field, NEC at [6], which is read in at wherever our POS is and then add 0x6. Assuming that the hdr->VERSION is even, then our task of finding the NEC is easier, since it’s at a static hdr->AS.Header+0x22 [7]. In either case, now that we have our uint16_t NEC variable populated, let’s see why it’s important. Immediately following the above code, still in sopen_extended:

    /* read event code description */
    hdr->AS.auxBUF = (uint8_t*) realloc(hdr->AS.auxBUF,5*NEC);
    hdr->EVENT.CodeDesc = (typeof(hdr->EVENT.CodeDesc)) realloc(hdr->EVENT.CodeDesc,257*sizeof(*hdr->EVENT.CodeDesc));  // [8]
    hdr->EVENT.CodeDesc[0] = "";    // typ==0, is always empty
    hdr->EVENT.LenCodeDesc = NEC+1;
    for (k=0; k < NEC; k++) {                            // [9]
        memcpy(hdr->AS.auxBUF+5*k,Header1+POS,4);        // [10]
        hdr->AS.auxBUF[5*k+4]=0;
        hdr->EVENT.CodeDesc[k+1] = (char*)hdr->AS.auxBUF+5*k;  // [11]
        POS += 4;
    }

At [8], we allocate a static length buffer of size 257*sizeof(*hdr->EVENT.CodeDesc), which ends up being 0x808 on 64-bit machines and 0x404 on 32-bit machines, since hdr->EVENT.CodeDesc is a Char **. Next, based on our paramount NEC variable, we loop around [9] a variable amount of times, each time writing five bytes into our appropriately allocated hdr->AS.auxBUF [10], and then writing a pointer in the hdr->EVENT.CodeDesc buffer that points to these four bytes. Hopefully the flaw in this logic can be seen, as if we pass in any NEC variable that is greater than 0x101 , then we will start to write out-of-bounds from our statically sized hdr->EVENT.CodeDesc heap buffer, a flaw that can potentially lead to arbitrary code execution, depending on the setup of the heap.

Crash Information

=================================================================
==24242==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x61d000000888 at pc 0x7ffff75daf89 bp 0x7fffffffad30 sp 0x7fffffffad28
WRITE of size 8 at 0x61d000000888 thread T0
[Detaching after fork from child process 24246]
    #0 0x7ffff75daf88 in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:7579:29
    #1 0x55555566d35f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20
    #2 0x5555555934d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #3 0x55555557d24f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #4 0x555555582fa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x5555555acdc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x7ffff7029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #7 0x7ffff7029e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #8 0x555555577b14 in _start (/biosig/stable_release/biosig_fuzzer.bin+0x23b14) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)

0x61d000000888 is located 0 bytes to the right of 2056-byte region [0x61d000000080,0x61d000000888)
allocated by thread T0 here:
    #0 0x55555562ff76 in __interceptor_realloc (/biosig/stable_release/biosig_fuzzer.bin+0xdbf76) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #1 0x7ffff75dada7 in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:7573:55
    #2 0x55555566d35f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20
    #3 0x5555555934d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #4 0x55555557d24f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x555555582fa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x5555555acdc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x7ffff7029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

SUMMARY: AddressSanitizer: heap-buffer-overflow /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:7579:29 in sopen_extended
Shadow bytes around the buggy address:
  0x0c3a7fff80c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c3a7fff80d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c3a7fff80e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c3a7fff80f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c3a7fff8100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c3a7fff8110: 00[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c3a7fff8120: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c3a7fff8130: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c3a7fff8140: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c3a7fff8150: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c3a7fff8160: 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
==24242==ABORTING
[Thread 0x7ffff3df9640 (LWP 24245) exited]
[Inferior 1 (process 24242) exited with code 01]
VENDOR RESPONSE

The vendor provided a new release at: https://biosig.sourceforge.net/download.html

TIMELINE

2024-02-05 - Initial Vendor Contact
2024-02-05 - Vendor Disclosure
2024-02-19 - Vendor Patch Release
2024-02-20 - Public Release

Credit

Discovered by Lilith >_> of Cisco Talos.