Talos Vulnerability Report

TALOS-2024-1923

The Biosig Project libbiosig sopen_FAMOS_read use-after-free vulnerability

February 20, 2024
CVE Number

CVE-2024-23310

SUMMARY

A use-after-free vulnerability exists in the sopen_FAMOS_read functionality of The Biosig Project libbiosig 2.5.0 and Master Branch (ab0ee111). A specially crafted .famos 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-825 - Expired Pointer Dereference

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 our current vulnerability we deal with the imc FAMOS file format, a generic format for quick data analysis. To figure out if we’re dealing with a .famos file, getfiletype runs the following magic-byte check:

else if (!memcmp(Header1,"|CF,",4))
        hdr->TYPE = FAMOS;

Simple enough, and assuming we find a .famos file, sopen_extended hits the following branch:

#ifdef WITH_FAMOS
        else if (hdr->TYPE==FAMOS) {
            hdr->HeadLen=count;
            sopen_FAMOS_read(hdr);
    }
#endif

Worth noting that while the famos file format can be disabled with the WITH_FAMOS compiler flag, by default it is enabled. Continuing on into sopen_FAMOS_read:

EXTERN_C void sopen_FAMOS_read(HDRTYPE* hdr) {
#define Header1 ((char*)hdr->AS.Header) 

        size_t count = hdr->HeadLen;

        char *t, *t2;
        const char EOL[] = "|;\xA\xD";
        size_t pos, l1, len;
        pos  = strspn(Header1, EOL);   // [1]
        uint16_t gdftyp, CHAN=0;
        char OnOff=1;
        double Fs = NAN;
        uint32_t NoChanCurrentGroup = 0;    // number of (undefined) channels of current group 
        int level = 0;  // to check consistency of file

        char flag_AbstandFile = 0;      // interleaved format ??? used for experimental code 

        fprintf(stdout,"SOPEN(FAMOS): support is experimental. Only time series with equidistant sampling and single sampling rate are supported.\n"); // [2]

        while (pos < count-20) { // [3]
            t       = Header1+pos;  // start of line // [4]

            l1      = strcspn(t+5, ","); // [5]
            t[l1+5] = 0;
            len     = atol(t+5);  // [6]
            pos    += 6+l1;       
            t2      = Header1+pos;  // start of line // [7]
            if (count < max(pos,hdr->HeadLen)+256) { // HeadLen can be updated... //[8]
                    size_t bufsiz = 4095;
                    hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header, count+bufsiz+1); // [9]  
                    count += ifread(hdr->AS.Header+count,1,bufsiz,hdr);
            }
            pos    += len+1;
            
        // [...]
        pos += strcspn(Header1+pos,EOL);
        pos += strspn(Header1+pos,EOL);
    }

To start, our code skips over any sort of newlines or delimiter at [1], and then prints out the fact that FAMOS support is experimental [2]. Heeding this warning, our input file proceeds with caution as we enter the main logic loop at [3]. The variables t [4] is saved to denote the start of the line, l1 [5] is the length of our current line (the +5 is there since the tags denoting data type are 4 bytes long), len ends up being the length of the data of the current line, and finally t2 ends up pointing to the actual start of our data for the current line. Assuming that our count variable is smaller than the position of where we’re about to start reading plus 0xFF [8], then we end up reallocating our memory buffer and reading in more of the file into it. Starting out, count is assigned to hdr->HeadLen, and so usually ends up being the size of the file, capped out at 0x1000. Regardless, on the first iteration of the loop we will always hit the branch at [8] and reallocate a bigger hdr->AS.header than before [9]. We can see an example of this in the gdb output below:

sopen_extended.c:3830            hdr->AS.Header = (uint8_t*)malloc(PAGESIZE+1); // [10]                                                                                              
[o.O]> p/x PAGESIZE                                                                                                                                                                 
$9 = 0x1000                                                                                                                                                                         
[^.^]> c                                                                                                                                                                            
Continuing.                                                                                                                                                                         
Thread 1 hit Breakpoint 1, sopen_FAMOS_read () at ./t210/sopen_famos_read.c:43                                                                            
43      EXTERN_C void sopen_FAMOS_read(HDRTYPE* hdr) {                  
[o.o]> c                                                                                                                                                                            
Continuing.
SOPEN(FAMOS): support is experimental. Only time series with equidistant sampling and single sampling rate are supported.
Thread 1 "" hit Breakpoint 3, 0x000055555562fe74 in realloc ()
[>_>]> info reg rdi rsi
rdi            0x621000002900      107820859009280
rsi            0x1055              4181                // [11]

At [10] we clearly see the original hdr->AS.header allocation as malloc(0x10001), and then this buffer gets reallocated to size 0x1055 at [11]. All this should be well and good, but let us go on to see how this new buffer is parsed:

        if (!strncmp(t,"CF,2",4) && (level==0)) {             // [12]
            level = 1;
        }
        else if (!strncmp(t,"CK,1",4) && (level==1)) {
            level = 2;
        }
        else if (!strncmp(t,"NO,1",4) && ((level==1) || (level==2))) {  // [13]
            int p;
            // Ursprung
            p = strcspn(t2,",");     // [14]
            t2[p] = 0;               // [15]
            // NameLang
            t2 += p+1;
            p = strcspn(t2,",");
            t2[p] = 0;

The line at [12] gives the template for reading each line - the first 0x4 bytes should be an opcode of sorts, and a strncmp is used to determine what branch to use. For example, assume we’ve traversed through the state machine enough that level == 2 and also our line starts with NO,1, such that we enter the branch at [13]. Since t2 should point to our actual filedata read into the hdr->AS.header buffer that was reallocated, the line at [14] will find the number of non-comma characters and then we null terminate the comma at [15]. Simple enough, but we also now have enough information to discuss the vulnerability. A lesser known fact about the realloc function is that it can change the underlying address of the memory buffer that’s passed in, along with the size. As such, it’s critical to always use the return value of realloc, which will contain the updated pointer if it ends up changing. While the call to realloc at [9] does end up reassigning hdr->AS.Header, it does not actually update the t and t2 variables, which will be left dangling on freed memory if the hdr->AS.Header buffer ends up shifting (which it usually does). As such, the many uses of t and t2 in sopen_FAMOS_read all end up operating on freed memory for one iteration of the loop at [3] whenever the realloc at [9] moves the memory buffer, resulting in many different possible use-after-free read and write situations and potential code execution.

Crash Information

=================================================================                                                                                                                                                                                  [294/1810]
==26464==ERROR: AddressSanitizer: heap-use-after-free on address 0x621000002901 at pc 0x5555555c3dad bp 0x7fffffff9db0 sp 0x7fffffff9558                                            
READ of size 1 at 0x621000002901 thread T0                                                                                                                                          
[Detaching after fork from child process 26540]                                                                                                                                     
    #0 0x5555555c3dac in strncmp (/biosig/stable_release/biosig_fuzzer.bin+0x6fdac) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)           
    #1 0x7ffff76efea4 in sopen_FAMOS_read /biosig/stable_release/biosig-2.5.0/biosig4c++/./t210/sopen_famos_read.c:82:9                           
    #2 0x7ffff75e2c0b in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:8480:3                                            
    #3 0x55555566d35f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20                                                    
    #4 0x5555555934d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x55555557d24f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x555555582fa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x5555555acdc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)              
    #8 0x7ffff7029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16                                                                                                                   
    #9 0x7ffff7029e3f in __libc_start_main csu/../csu/libc-start.c:392:3                                                                                                            
    #10 0x555555577b14 in _start (/biosig/stable_release/biosig_fuzzer.bin+0x23b14) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)           

0x621000002901 is located 1 bytes inside of 4097-byte region [0x621000002900,0x621000003901)                                                                                        
freed by thread T0 here:                                                                                                                                                            
    #0 0x55555562ff76 in __interceptor_realloc (/biosig/stable_release/biosig_fuzzer.bin+0xdbf76) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)                                                                      
    #1 0x7ffff76efcb2 in sopen_FAMOS_read /biosig/stable_release/biosig-2.5.0/biosig4c++/./t210/sopen_famos_read.c:72:36                          
    #2 0x7ffff75e2c0b in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:8480:3                                            
    #3 0x55555566d35f in LLVMFuzzerTestOneInput /biosig/stable_release/./fuzz_biosig.cpp:84:20                                                                                   
    #4 0x5555555934d3 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x3f4d3) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #5 0x55555557d24f in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) (/biosig/stable_release/biosig_fuzzer.bin+0x2924f) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #6 0x555555582fa6 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) (/biosig/stable_release/biosig_fuzzer.bin+0x2efa6) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)
    #7 0x5555555acdc2 in main (/biosig/stable_release/biosig_fuzzer.bin+0x58dc2) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)                                             
    #8 0x7ffff7029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16                                                                                    

previously allocated by thread T0 here:                                                                                                                                             
    #0 0x55555562fb4e in malloc (/biosig/stable_release/biosig_fuzzer.bin+0xdbb4e) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0)                                           
    #1 0x7ffff759e35e in sopen_extended /biosig/stable_release/biosig-2.5.0/biosig4c++/biosig.c:3830:29                                           
    #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-use-after-free (/biosig/stable_release/biosig_fuzzer.bin+0x6fdac) (BuildId: 9ffac83f55dadf5472f09c72de5ba7a7aa4860e0) in strncmp                 
Shadow bytes around the buggy address:              
  0x0c427fff84d0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa                                                                                                                                                  
  0x0c427fff84e0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa                                                             
  0x0c427fff84f0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa                                                                                                                                                  
  0x0c427fff8500: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa                                                             
  0x0c427fff8510: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa                                                             
=>0x0c427fff8520:[fd]fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd                                                             
  0x0c427fff8530: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd                                                             
  0x0c427fff8540: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd                                                             
  0x0c427fff8550: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd                                                             
  0x0c427fff8560: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd                                                             
  0x0c427fff8570: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd                                                             
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                                  
==26464==ABORTING                                              
[Thread 0x7ffff3df9640 (LWP 26467) exited]                     
[Inferior 1 (process 26464) 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.