Talos Vulnerability Report

TALOS-2024-1917

The Biosig Project libbiosig BrainVision Header Parsing double-free vulnerability

February 20, 2024
CVE Number

CVE-2024-22097

SUMMARY

A double-free vulnerability exists in the BrainVision Header Parsing functionality of The Biosig Project libbiosig Master Branch (ab0ee111) and 2.5.0. 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)) {
        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);

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. 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); // [3]
while (pos < hdr->HeadLen) {    // [4]
    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 // [5]
        ;
    else if (!strncmp(t,"[Common Infos]",14)) // [6]
        seq = 1;
    else if (!strncmp(t,"[Binary Infos]",14)) // [7]
        seq = 2;

The first line of the file is skipped at [3], and then we keep looping lines until the end of the file [4], pretty simple. As seen at [5], comments are denoted by a semi-colon at the start, and a couple of sample tags are given at [6] and [7]. For our current vulnerability, we focus in on the [Channel Info] tag:

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

        /* open data file */
        if (FLAG_ASCII) hdr = ifopen(hdr,"rt");    // [8]
        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;
        free(tmpfile);  // [9] 

As mentioned before, this file format actually consists of multiple files, so normally one would use the [Common Infos] tag to change the sequence number to 0x1, and then on the next line pass in a DataFile= string or MarkerFile= string in order to change the filename to a different file before hitting [Channel Infos], but for our purposes it does not particularly matter. At [8], whatever the current hdr->FileName is gets opened, and then we read in some data, but more importantly at [9], the tmpfile buffer that we used to keep track of the old filename gets freed no matter what. Please note that there is nothing stopping our file from including as many [Channel Info] tags as we want, and nowhere else in the BrainVision code can this tmpfile buffer ever be allocated again. This results in an arbitrary amount of frees on a single address which, after careful heap manipulation, can quickly lead to code execution.

Crash Information

Running: ../fuzzing/triage/brainvision_tmpfile_doublefree/crash-0a9524ebb6a30db2352880f4d9430e02f217d78d
=================================================================
==4261==ERROR: AddressSanitizer: attempting double-free on 0x6030000001c0 in thread T0:
    #0 0x560ba06f78a2 in __interceptor_free (/biosig/stable_release/biosig_fuzzer.bin+0xdb8a2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #1 0x7efe479c751f in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:6219:5
    #2 0x560ba073535f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20
    #3 0x560ba065b4d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #4 0x560ba064524f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x560ba064afa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x560ba0674dc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x7efe47429d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #8 0x7efe47429e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #9 0x560ba063fb14 in _start (/biosig/stable_release/biosig_fuzzer.bin+0x23b14) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)

0x6030000001c0 is located 0 bytes inside of 20-byte region [0x6030000001c0,0x6030000001d4)
freed by thread T0 here:
    #0 0x560ba06f78a2 in __interceptor_free (/biosig/stable_release/biosig_fuzzer.bin+0xdb8a2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #1 0x7efe479c751f in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:6219:5
    #2 0x560ba073535f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20
    #3 0x560ba065b4d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #4 0x560ba064524f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x560ba064afa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x560ba0674dc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x7efe47429d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

previously allocated by thread T0 here:
    #0 0x560ba06f7d38 in __interceptor_calloc (/biosig/stable_release/biosig_fuzzer.bin+0xdbd38) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #1 0x7efe479c63e9 in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:6144:26
    #2 0x560ba073535f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20
    #3 0x560ba065b4d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #4 0x560ba064524f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x560ba064afa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x560ba0674dc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x7efe47429d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

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