Talos Vulnerability Report

TALOS-2024-1918

The Biosig Project libbiosig BrainVisionMarker Parsing Out-of-bounds Write vulnerability

February 20, 2024
CVE Number

CVE-2024-23305

SUMMARY

An out-of-bounds write vulnerability exists in the BrainVisionMarker Parsing functionality of The Biosig Project libbiosig 2.5.0 and Master Branch (ab0ee111). A specially crafted .vmrk 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-787 - Out-of-bounds Write

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 .vmrk file specifically. To determine if we’ve provided a BrainVision .vmrk file, the getfiletype function checks the following:

const char* MAGIC_NUMBER_BRAINVISIONMARKER = "Brain Vision Data Exchange Marker File, Version";
// [...]

else if (!memcmp(Header1,MAGIC_NUMBER_BRAINVISIONMARKER,strlen(MAGIC_NUMBER_BRAINVISIONMARKER)))
        hdr->TYPE = BrainVisionMarker;

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==BrainVisionMarker) {

    while (!ifeof(hdr)) {  // [1]
        size_t bufsiz  = max(count*2, 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);

Nothing special yet; at [1] we see the loop for reading in the entire file into the hdr->AS.Header buffer. Continuing on in sopen_extended we can now see the basis for how the format is parsed:

// [...]
char *t,*t1="    ";
t  = Header1;
t += strcspn(Header1,"\x0A\x0D");
t += strspn(t,"\x0A\x0D"); // [2]

// skip first line
size_t N_EVENT=0;
hdr->EVENT.N=0;
do {          // [3]
    t1 = t;
    t += strcspn(t,"\x0A\x0D");
    t += strspn(t,"\x0A\x0D");
    t[-1]=0;

if (VERBOSE_LEVEL>8) fprintf(stdout,"%i <%s>\n",seq,t1);

if (!strncmp(t1,";",1)) // [4]
    ;
else if (!strncmp(t1,"[Common Infos]",14)) // [5]
    seq = 1;
else if (!strncmp(t1,"[Marker Infos]",14)) // [6]
    seq = 2;

// [...]
    }
while (strlen(t1)>0);

The first line of the file is skipped at [2], and then we keep looping lines until the end of the file [3], pretty simple. As seen at [4], comments are denoted by a semi-colon at the start, and a couple of sample tags are given at [5] and [6]. Continuing on at the same point within sopen_extended:

if (!strncmp(t1,";",1))
    ;
else if (!strncmp(t1,"[Common Infos]",14))
    seq = 1;
else if (!strncmp(t1,"[Marker Infos]",14))  // [7]
    seq = 2;

else if (seq==1)
    ;
else if ((seq==2) && !strncmp(t1,"Mk",2)) {  // [8]
    int p1 = strcspn(t1,"=");                // [9]
    int p2 = p1 + 1 + strcspn(t1+p1+1,",");  // [10]
    int p3 = p2 + 1 + strcspn(t1+p2+1,",");
    int p4 = p3 + 1 + strcspn(t1+p3+1,",");
    int p5 = p4 + 1 + strcspn(t1+p4+1,",");
    int p6 = p5 + 1 + strcspn(t1+p5+1,",");

    if (VERBOSE_LEVEL>8) fprintf(stdout,"  %i %i %i %i %i %i \n",p1,p2,p3,p4,p5,p6);

    t1[p1]=0;  // [11]
    t1[p2]=0;
    t1[p3]=0;
    t1[p4]=0;
    t1[p5]=0;

Assuming that our file contains a line that starts with “[Marker Infos]” [7], we put our state machine into the second state, allowing us to pass in another line in our file that starts with “Mk” in order to hit the branch at [8]. This codepath looks at everything after an equals sign [9], and then gathers the indexes of all the comma characters after that [10]. Once that’s done, the parser turns those commas into null bytes such that atol calls can be used later on in the code on everything in between these separators.

As some might have already noted, a curious thing occurs if our Mk= line is at the end of our file and also if it does not contain enough commas. Given that each of the strcspn calls start looking at t1 + pX + 1, they naturally skip over the previous commas in the line, but strcspn will also return a valid index if it ends up hitting a null terminator as well, which means that each of these calls will search past the end of the string for a comma as well, resulting in out-of-bounds heap reading if our Mk= line is at the end of the file. While this in itself would be useless, the turning of commas into null bytes [11] makes this vulnerability actually useful, as we can corrupt heap data multiple times. Assuming careful heap manipulation, this vulnerability can lead to code execution.

Crash Information

==4283==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x621000003901 at pc 0x55dc11839438 bp 0x7ffe4e086bf0 sp 0x7ffe4e0863b8
READ of size 1 at 0x621000003901 thread T0
    #0 0x55dc11839437 in __interceptor_strcspn (/biosig/stable_release/biosig_fuzzer.bin+0x72437) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #1 0x7f49393c5839 in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:6086:23
    #2 0x55dc118e035f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20
    #3 0x55dc118064d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #4 0x55dc117f024f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x55dc117f5fa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x55dc1181fdc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x7f4938e29d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
    #8 0x7f4938e29e3f in __libc_start_main csu/../csu/libc-start.c:392:3
    #9 0x55dc117eab14 in _start (/biosig/stable_release/biosig_fuzzer.bin+0x23b14) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)

0x621000003901 is located 0 bytes to the right of 4097-byte region [0x621000002900,0x621000003901)
allocated by thread T0 here:
    #0 0x55dc118a2b4e in malloc (/biosig/stable_release/biosig_fuzzer.bin+0xdbb4e) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #1 0x7f493939e35e in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:3830:29
    #2 0x55dc118e035f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20
    #3 0x55dc118064d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #4 0x55dc117f024f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x55dc117f5fa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x55dc1181fdc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x7f4938e29d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

SUMMARY: AddressSanitizer: heap-buffer-overflow (/biosig/stable_release/biosig_fuzzer.bin+0x72437) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0) in __interceptor_strcspn
Shadow bytes around the buggy address:
  0x0c427fff86d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c427fff86e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c427fff86f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c427fff8700: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0c427fff8710: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0c427fff8720:[01]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c427fff8730: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c427fff8740: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c427fff8750: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c427fff8760: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c427fff8770: 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
==4283==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.