Talos Vulnerability Report

TALOS-2024-1919

The Biosig Project libbiosig BrainVision ASCII Header Parsing double-free vulnerability

February 20, 2024
CVE Number

CVE-2024-23809

SUMMARY

A double-free vulnerability exists in the BrainVision ASCII Header Parsing functionality of The Biosig Project libbiosig 2.5.0 and Master Branch (ab0ee111). A specially crafted .vdhr 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-415 - Double Free

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 aptly named ‘BrainVision’ file format, a storage format for EEG data. While the BrainVision file format is supposed to actually be three different files, a .vhdr header file, a .vmrk marker file, and an .eeg raw data file, the following writeup deals with the ‘.vhdr’ file specifically. To determine if we’ve provided a BrainVision .vhdr file, the getfiletype function checks the following:

const char* MAGIC_NUMBER_BRAINVISION       = "Brain Vision Data Exchange Header File";
const char* MAGIC_NUMBER_BRAINVISION1      = "Brain Vision V-Amp Data Header File Version";
// [...]

else if (!memcmp(Header1,MAGIC_NUMBER_BRAINVISION,strlen(MAGIC_NUMBER_BRAINVISION)) || ((leu32p(hdr->AS.Header)==0x42bfbbef) && !memcmp(Header1+3, MAGIC_NUMBER_BRAINVISION,38))
    hdr->TYPE = BrainVision;
else if (!memcmp(Header1,MAGIC_NUMBER_BRAINVISION1,strlen(MAGIC_NUMBER_BRAINVISION1)))
    hdr->TYPE = BrainVisionVAmp;

Assuming either of these conditions are met and our file header struct’s type is set to either of these BrainVision types, we end up at the following code inside of sopen_extended:

else if ((hdr->TYPE==BrainVision) || (hdr->TYPE==BrainVisionVAmp)) {
    /* open and read header file */
    // ifclose(hdr);
    char *filename = hdr->FileName; // keep input file name
    char* tmpfile = (char*)calloc(strlen(hdr->FileName)+5,1);     // [1]
    strcpy(tmpfile, hdr->FileName);     // Flawfinder: ignore     // [2]
    hdr->FileName = tmpfile;
    char* ext = strrchr((char*)hdr->FileName,'.')+1;

    while (!ifeof(hdr)) {                                         // [3]
        size_t bufsiz = max(2*count, PAGESIZE);
        hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header, bufsiz+1);
        count  += ifread(hdr->AS.Header+count, 1, bufsiz-count, hdr);
    }
    hdr->AS.Header[count]=0;
    hdr->HeadLen = count;
    ifclose(hdr);                                                 // [4]

At [1], we allocate a new buffer that will be used to store our original filename, which gets copied in at [2]. This is done so that eventually if we need to read in data from one of the other two BrainVision files, we can edit our current structure’s filename, import the data, and then restore our current .vhdr filename; odd design choice, but it does get the job done. The file is then read in at [3] and we close it out at [4]. Continuing on in sopen_extended we can now see the basis for how the format is parsed:

// [...]
char *t;
size_t pos;
// skip first line with <CR><LF>
const char EOL[] = "\r\n";
pos  = strcspn(Header1,EOL);    
pos += strspn(Header1+pos,EOL); // [5]
while (pos < hdr->HeadLen) {    // [6]
    t    = Header1+pos; // start of line
    pos += strcspn(t,EOL);
    Header1[pos] = 0;   // line terminator
    pos += strspn(Header1+pos+1,EOL)+1; // skip <CR><LF>

    if (VERBOSE_LEVEL>7) fprintf(stdout,"[212]: %i pos=%i <%s>, ERR=%i\n",seq,(int)pos,t,hdr->AS.B4C_ERRNUM);

    if (!strncmp(t,";",1))  // comments // [7]
        ;
    else if (!strncmp(t,"[Common Infos]",14)) // [8]
        seq = 1;
    else if (!strncmp(t,"[Binary Infos]",14)) // [9]
        seq = 2;

The first line of the file is skipped at [5], and then we keep looping lines until the end of the file [6], pretty simple. As seen at [7], comments are denoted by a semi-colon at the start, and a couple of sample tags are given at [8] and [9]. With all that out of the way, we can now delve into set of code paths that lead to our vulnerability, but since it would not be terribly succinct to examine every single branch, let us instead examine the singular code path we need to avoid:

        else if (!strncmp(t,"[Channel Infos]",14)) {  // [10]
            seq = 3;

            /* open data file */
            if (FLAG_ASCII) hdr = ifopen(hdr,"rt");  // [11]
            else            hdr = ifopen(hdr,"rb");  

            hdr->AS.bpb = (hdr->NS*GDFTYP_BITS[gdftyp])>>3;
            if (hdr->TYPE==BrainVisionVAmp) hdr->AS.bpb += 4;
            if (!npts) {
                struct stat FileBuf;
                stat(hdr->FileName,&FileBuf);
                npts = FileBuf.st_size/hdr->AS.bpb;
                }

            /* restore input file name, and free temporary file name  */
            hdr->FileName = filename;               // [12]
            free(tmpfile);
            
            if (orientation == VEC) {
                hdr->SPR = npts;
                hdr->NRec= 1; 
                hdr->AS.bpb*= hdr->SPR;
            } else {
                hdr->SPR = 1;
                hdr->NRec= npts;
            }

            hdr->CHANNEL = (CHANNEL_TYPE*) realloc(hdr->CHANNEL,hdr->NS*sizeof(CHANNEL_TYPE)); 
            for (k=0; k<hdr->NS; k++) {
                CHANNEL_TYPE *hc = hdr->CHANNEL+k; 
                hc->Label[0] = 0; 
                // [...]
            }
    }

As mentioned around [1] and [2], the filename in our hdr struct can be temporarily replaced, a feature done by the DataFile= tag. In order to actually read in this data, we must hit the above code with a [Channel Infos] line in our input file [10]. The file gets opened [11] and some channel data is read in [13], etc etc. The only thing that particularly matters from this code branch is that ifopen is called at [11] and then the original filename of our .vhdr file is restored to our hdr->FileName [12]. To importantly reiterate, this is a code path that we need to avoid in order to trigger our vulnerability. There is nothing stopping a BrainVision file from just not having a [Channel Infos] tag. Assuming we successfully avoid this code path, our header object still contains the file descriptor FILE * object that points towards our original .vhdr. Continuing down in the BrainVision parsing of sopen_extended, to after we’ve read past hdr->HeadLen:

    while (pos < hdr->HeadLen) {
            // [...]
    }
    hdr->HeadLen  = 0;

    if (FLAG_ASCII) { // [13]
        count = 0;
        size_t bufsiz  = hdr->NS*hdr->SPR*hdr->NRec*16;
        while (!ifeof(hdr)) {
                hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header,count+bufsiz+1);
                count += ifread(hdr->AS.Header+count,1,bufsiz,hdr);
        }
        ifclose(hdr);  // [14]
        hdr->AS.Header[count]=0;    // terminating null character

Assuming we’ve set the FLAG_ASCII boolean [13] (something easily controllable by the input file), we end up reading in the rest of the file and then calling ifclose(hdr) at [14]. As mentioned previously, since our filename has already been restored to the old file name, we actually end up calling ifclose(hdr) on our original file. To understand the implications of this, lets quickly run through the ifclose logic:

int ifclose(HDRTYPE* hdr) {
    hdr->FILE.OPEN = 0;
#ifdef ZLIB_H
    if (hdr->FILE.COMPRESSION)
        return(gzclose(hdr->FILE.gzFID));
    else
#endif
    return(fclose(hdr->FILE.FID));   // [15]
}

Simply put, ifclose is just a wrapper around gzclose if our file is compressed and fclose if not [15]. To avoid delving too much into glibc libio code, it suffices to say that fclose is an alias for _IO_new_fclose, and that _IO_new_fclose leads to _IO_deallocated_file:

    //   https://elixir.bootlin.com/glibc/glibc-2.31/source/libio/libioP.h#L853


/* Deallocate a stream if it is heap-allocated.  Preallocated
   stdin/stdout/stderr streams are not deallocated. */
static inline void
_IO_deallocate_file (FILE *fp)
{
  /* The current stream variables.  */
  if (fp == (FILE *) &_IO_2_1_stdin_ || fp == (FILE *) &_IO_2_1_stdout_
      || fp == (FILE *) &_IO_2_1_stderr_)
    return;
#if SHLIB_COMPAT (libc, GLIBC_2_0, GLIBC_2_1)
  if (_IO_legacy_file (fp))
    return;
#endif
  free (fp); // [16]
}

Since the stream we are closing is not stdin/stdout/stderr, _IO_deallocate_file just reduces down to a free on the FILE * object we pass in at [16]. Thus for our purposes, we can summarize the ifclose(hdr) call as free(hdr->FILE.FID). With the above, we can now clearly state the vulnerability - if we hit the ifclose(hdr) at [14], which is essentially a free, then we must recall that this hdr->FILE.FID has already been freed all the way at [4], resulting in a double-free, and potentially leading to code execution.

Crash Information

INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 147067561
INFO: Loaded 2 modules   (18270 inline 8-bit counters): 18233 [0x7fd9a9ad9200, 0x7fd9a9add939), 37 [0x564f3c7ad1c8, 0x564f3c7ad1ed), 
INFO: Loaded 2 PC tables (18270 PCs): 18233 [0x7fd9a9add940,0x7fd9a9b24cd0), 37 [0x564f3c7ad1f0,0x564f3c7ad440), 
./biosig_fuzzer.bin: Running 1 inputs 1 time(s) each.
Running: ./crashes/crash-f642fbbe7a466d06ebaffccf0981ffc6271ed64b
=================================================================
==2709847==ERROR: AddressSanitizer: attempting double-free on 0x615000000a80 in thread T0:
    #0 0x564f3c72c8a2 in __interceptor_free (/biosig/fuzzing/biosig_fuzzer.bin+0xdb8a2) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #1 0x7fd9a927ed26 in _IO_deallocate_file libio/./libio/libioP.h:862:3
    #2 0x7fd9a927ed26 in fclose libio/./libio/iofclose.c:74:3
    #3 0x564f3c6fe88f in fclose (/biosig/fuzzing/biosig_fuzzer.bin+0xad88f) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #4 0x7fd9a97af519 in ifclose /biosig/biosig-code/biosig4c++/biosig.c:538:12
    #5 0x7fd9a97af519 in sopen_extended /biosig/biosig-code/biosig4c++/biosig.c:6489:13
    #6 0x564f3c76a35f in LLVMFuzzerTestOneInput /biosig/fuzzing/./fuzz_biosig.cpp:84:20
    #7 0x564f3c6904d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/fuzzing/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #8 0x564f3c67a24f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/fuzzing/biosig_fuzzer.bin+0x2924f) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #9 0x564f3c67ffa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/fuzzing/biosig_fuzzer.bin+0x2efa6) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #10 0x564f3c6a9dc2 in main (/biosig/fuzzing/biosig_fuzzer.bin+0x58dc2) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #11 0x7fd9a9229d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #12 0x7fd9a9229e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #13 0x564f3c674b14 in _start (/biosig/fuzzing/biosig_fuzzer.bin+0x23b14) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)

0x615000000a80 is located 0 bytes inside of 472-byte region [0x615000000a80,0x615000000c58)
freed by thread T0 here:
    #0 0x564f3c6be0da in __asan_register_globals (/biosig/fuzzing/biosig_fuzzer.bin+0x6d0da) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #1 0x7fd9a98e1efe in asan.module_ctor LineFrequency.c
    #2 0x7fd9a9e9047d in call_init elf/./elf/dl-init.c:70:3

previously allocated by thread T0 here:
    #0 0x564f3c72cb4e in malloc (/biosig/fuzzing/biosig_fuzzer.bin+0xdbb4e) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6)
    #1 0x7fd9a927f64d in __fopen_internal libio/./libio/iofopen.c:65:37
    #2 0x7fd9a927f64d in fopen64 libio/./libio/iofopen.c:86:10

SUMMARY: AddressSanitizer: double-free (/biosig/fuzzing/biosig_fuzzer.bin+0xdb8a2) (BuildId: 9c04fc10974fb6b056da7181e59a8fbc6dbf20f6) in __interceptor_free
==2709847==ABORTING
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.