CVE-2025-53557
A heap-based buffer overflow vulnerability exists in the MFER parsing functionality of The Biosig Project libbiosig 3.9.0 and Master Branch (35a819fa). A specially crafted MFER file can lead to arbitrary code execution. An attacker can provide a malicious file to trigger this vulnerability.
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 3.9.0
The Biosig Project libbiosig Master Branch (35a819fa)
libbiosig - https://biosig.sourceforge.net/index.html
9.8 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE-122 - Heap-based Buffer Overflow
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.
Within libbiosig, the sopen_extended
function is the common entry point for file parsing, regardless of the specific file type:
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
*/
The general flow of sopen_extended
is as one might expect: initialize generic strctures, determine the relevant file type, parse the file, and finally populate the generic structures that can be utilized by whatever is calling sopen_extended
. To determine the file type, sopen_extended
calls getfiletype
, which attempts to fingerprint the file based on the presence of various magic bytes within the header. Libbiosig also allows for these heuristics to be bypassed by setting the file type manually, but that approach is more applicable when writing data to a file; this vulnerability concerns the code path taken when reading from a file.
The file type used to exercise this vulnerability is the Medical waveform Format Encoding Rules (MFER), a file format for encoding medical waveforms from various different kinds of medical devices, such as ECG and EEG. The MFER standard aims to abstract away the encoding of the waveform data itself, independent of the specifics of the recording device.
To determine if the input file is valid MFER, getfiletype
runs the following check:
else if (!memcmp(Header1,"@ MFER ",8))
hdr->TYPE = MFER;
else if (!memcmp(Header1,"@ MFR ",6))
hdr->TYPE = MFER;
Put simply, libbiosig classifies an input file as MFER if the first few bytes match one of two magic byte sequences and stores the file classification in the struct member hdr->TYPE
.
Further along in sopen_extended
, after getfiletype
has returned, hdr->TYPE
is checked again and, if it’s MFER, processing unique to the format is then performed. The encoding rules for MFER, as described in ISO-22066-1-2022, specify data should be structured in the form Tag, Data Length, Value, or TLV. Broadly speaking, the Tag portion classifies the data, the Data Length portion encodes the length of the data in octets (bytes), and the Value portion encodes the actual payload. This is reflected in the MFER processing code below, with the relevant blocks for parsing the Tag [1], Length [2], and Value [3] fields annotated appropriately:
/* TAG */ // [1]
uint8_t tag = hdr->AS.Header[0];
ifseek(hdr,1,SEEK_SET);
int curPos = 1;
size_t N_EVENT=0; // number of events, memory is allocated for in the event table.
while (!ifeof(hdr)) {
uint32_t len, val32=0;
int32_t chan=-1;
uint8_t tmplen;
if (tag==255)
break;
else if (tag==63) {
/* CONTEXT */ // [4]
curPos += ifread(buf,1,1,hdr);
chan = buf[0] & 0x7f;
while (buf[0] & 0x80) {
curPos += ifread(buf,1,1,hdr);
chan = (chan<<7) + (buf[0] & 0x7f);
}
}
/* LENGTH */ // [2]
curPos += ifread(&tmplen,1,1,hdr);
char FlagInfiniteLength = 0;
if ((tag==63) && (tmplen==0x80)) {
FlagInfiniteLength = -1; //Infinite Length
len = 0;
}
else if (tmplen & 0x80) {
tmplen &= 0x7f;
curPos += ifread(&buf,1,tmplen,hdr);
len = 0;
k = 0;
while (k<tmplen)
len = (len<<8) + buf[k++];
}
else
len = tmplen;
if (VERBOSE_LEVEL>7)
fprintf(stdout,"MFER: tag=%3i chan=%2i len=%i %3i curPos=%i %li\n",tag,chan,tmplen,len,curPos,iftell(hdr));
/* VALUE */ // [3]
if (tag==0) {
if (len!=1) fprintf(stderr,"Warning MFER tag0 incorrect length %i!=1\n",len);
curPos += ifread(buf,1,len,hdr);
}
else if (tag==1) {
...
The primary job of the TAG block is simply to extract the tag from the first octet of the header, which is then stored in the tag
variable. For frames using tag 63 [4], this block also performs some additional processing to extract the current channel from the input file, which is done by reading one byte at a time until an octet is encountered with its most significat bit set to 0. The lower 7 bits of each byte read this way are concatenated together and the end result is stored as a 32-bit integer (chan
). This will soon become relevant, and in fact the vulnerability only manifests for input files that use tag 63.
From there, the LENGTH block reads the length from the input file into len
, which can be encoded as a single octet, multiple octets, or an “indefinite data length” flag, where the data is instead terminated via a special “end-of-contents” frame. Finally, the bulk of the processing is performed in the VALUE block. The meaning of the data is dependent on the tag, so this code largely consists of numerous conditional statements that perform unique processing depending on the value of tag
. As stated previously, the relevant code path for this vulnerability is taken when tag is 63:
else if (tag==63) {
uint8_t tag2=255, len2=255;
count = 0;
while ((count<len) && !(FlagInfiniteLength && len2==0 && tag2==0)){
curPos += ifread(&tag2,1,1,hdr); // [5]
curPos += ifread(&len2,1,1,hdr); // [6]
if (VERBOSE_LEVEL==9)
fprintf(stdout,"MFER: tag=%3i chan=%2i len=%-4i tag2=%3i len2=%3i curPos=%i %li count=%4i\n",tag,chan,len,tag2,len2,curPos,iftell(hdr),(int)count);
if (FlagInfiniteLength && len2==0 && tag2==0) break;
count += (2+len2);
curPos += ifread(&buf,1,len2,hdr); // [7]
The processing of frames using tag 63 can best be understood using a “nested” frame model, where the top level frame contains one or more subframes with their own Tag and Length fields. The code above interprets the first two bytes of each “subframe” as the tag and length, respectively, and stores them in the variables tag2
[5] and len2
[6]. From there, the payload of the subframe is stored in buf
by reading a number of bytes from the input file equal to len2
[7]. Reading of subframes continues so long as the bytes read hasn’t exceeded the length of the top level frame.
Like the top level frame, subframe processing varies depending on value of tag2
. This vulnerability manifests in multiple locations in the source code. For this analysis, the manifestation of the vulnerability exercised by the attached POC will be used, which occurs when tag2
is 4:
if (tag2==4) {
// SPR
if (len2>4) fprintf(stderr,"Warning MFER tag63-4 incorrect length %i>4\n",len2);
int64_t SPR = *(int64_t*) mfer_swap8b(buf, len2, SWAP); // [8]
hdr->SPR = (chan==0) ? SPR : lcm(SPR, hdr->SPR);
hdr->CHANNEL[chan].SPR = SPR; // [9]
Based on the code above, a tag2
value of 4 seems to indicate a subframe whose payload encodes the SPR (Samples Per Record) value, which is read from buf
and stored in the appropriately named SPR
variable after some byte reordering [8]. The vulnerability is exercised when libbiosig attempts to write the value of SPR
to the SPR
field for the current channel (chan
) in the global hdr
structure [9]. The hdr->CHANNEL
array is heap-allocated and libbiosig fails to validate chan
against the size of this array, resulting in a heap-based buffer overflow when chan
exceeds the allocated size. This is particularly problematic because the initial size of hdr->CHANNEL
is actually very small - set via a call to constructHDR
near the start of sopen_extended
:
if (hdr==NULL)
hdr = constructHDR(0,0); // initializes fields that may stay undefined during SOPEN
Looking at the body of constructHDR
confirms that the first argument encodes the number of channels (NS
), which controls the initial size of the hdr->CHANNEL
array via its call to calloc
[10]:
/****************************************************************************/
/** INIT HDR **/
/****************************************************************************/
#define Header1 ((char*)hdr->AS.Header)
HDRTYPE* constructHDR(const unsigned NS, const unsigned N_EVENT)
{
/*
HDR is initialized, memory is allocated for
NS channels and N_EVENT number of events.
The purpose is to define all parameters at an initial step.
No parameters must remain undefined.
*/
HDRTYPE* hdr = (HDRTYPE*)malloc(sizeof(HDRTYPE));
...
hdr->NS = NS;
...
// define variable header
hdr->CHANNEL = (CHANNEL_TYPE*)calloc(hdr->NS, sizeof(CHANNEL_TYPE)); // [10]
...
return(hdr);
}
The behavior of calloc
when the number of elements (in this case, hdr->NS
) is 0 is technically implementation-defined, but for many platforms (including our x86-64 Linux test platform), it results in an allocation of the smallest size possible.
libbiosig attempts to synchronize the size of hdr->CHANNEL
to the number of channels (hdr->NS
), but the only place this is done for MFER file processing is when the tag is 5:
else if (tag==5) //0x05: number of channels
{
uint16_t oldNS=hdr->NS;
if (len>4) fprintf(stderr,"Warning MFER tag5 incorrect length %i>4\n",len);
curPos += ifread(buf,1,len,hdr);
hdr->NS = *(int64_t*) mfer_swap8b(buf, len, SWAP);
if (VERBOSE_LEVEL>7) fprintf(stdout,"MFER: TLV %i %i %i \n",tag,len,(int)hdr->NS);
hdr->CHANNEL = (CHANNEL_TYPE*)realloc(hdr->CHANNEL, hdr->NS*sizeof(CHANNEL_TYPE));
This fails to mitigate the vulnerability because there is no guarantee that every input MFER file will contain a tag 5 frame, and even if one is provided, no checks are performed to ensure that chan
does not exceed the value read for hdr->NS
.
The vulnerability can be confirmed dynamically by supplying an input MFER file containing a tag 63 frame with an encoded channel number (chan
) that exceeds the size allocated for hdr->CHANNEL
, such as the attached POC. When supplying the attached POC to libbiosig and attaching a debugger, we can quickly compare the value of chan
to the size of hdr->CHANNEL
right before the out-of-bounds write occurs:
Breakpoint 2, sopen_extended (FileName=<optimized out>, MODE=<optimized out>, hdr=0x519000001980, biosig_options=<optimized out>)
at biosig.c:8976
8976 hdr->CHANNEL[chan].SPR = SPR;
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:8976
8971 if (tag2==4) {
8972 // SPR
8973 if (len2>4) fprintf(stderr,"Warning MFER tag63-4 incorrect length %i>4\n",len2);
8974 int64_t SPR = *(int64_t*) mfer_swap8b(buf, len2, SWAP);
8975 hdr->SPR = (chan==0) ? SPR : lcm(SPR, hdr->SPR);
► 8976 hdr->CHANNEL[chan].SPR = SPR;
8977
8978 if (VERBOSE_LEVEL>7) fprintf(stdout,"MFER: TLV %i %i %i %i %i %i %i %i %i\n",tag,len, chan, tag2,len2, buf[0], buf[1], (int)hdr->SPR, (int)hdr->CHANNEL[chan].SPR);
8979
8980 }
8981 else if (tag2==9) { //leadname
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0 0x7ffff733f146 sopen_extended+258646
1 0x5555555554c8 main+511
2 0x7ffff6a2a1ca __libc_start_call_main+122
3 0x7ffff6a2a28b __libc_start_main+139
4 0x555555555205 _start+37
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> p/x chan
$1 = 0x12 // [11]
pwndbg> p/x hdr->CHANNEL
$2 = 0x502000000190
pwndbg> p/x &hdr->CHANNEL[chan].SPR
$3 = 0x502000001af8
pwndbg> p/x (0x502000001af8 - 0x502000000190)
$4 = 0x1968 // [12]
pwndbg> heap -v 0x502000000180
Allocated chunk | PREV_INUSE
Addr: 0x502000000180
prev_size: 0x00
size: 0x20 (with flag bits: 0x21) // [13]
Based on the output above, chan
is set to 0x12 (18) [11]. It’s important to note that chan
is used as the index into the hdr->CHANNEL
array, not the total offset in bytes. To get the total offset from the start of hdr->CHANNEL
, we can subtract the address of hdr->CHANNEL
from the location libbiosig will attempt to write to (hdr->CHANNEL[chan].SPR
) [12]. The final offset ends up being 0x1968 (6504) bytes, far larger than the allocated size of hdr->CHANNEL
, which is only 16 bytes (GDB displays the size as 0x20, but this includes 16 bytes of heap metadata, making the usable size only 16 bytes) [13].
Continuing execution from here with AddressSanitizer enabled confirms a heap-based buffer overflow condition:
pwndbg> c
Continuing.
=================================================================
==49541==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000001af8 at pc 0x7ffff7344864 bp 0x7fffffffa7f0 sp 0x7fffffffa7e0
WRITE of size 4 at 0x502000001af8 thread T0
#0 0x7ffff7344863 in sopen_extended /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:8976
#1 0x5555555554c7 in main harness.cpp:38
#2 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#3 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#4 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 71453a64ceb3bfa137fcadc13ca9ee9004d4faa3)
Address 0x502000001af8 is a wild pointer inside of access range of size 0x000000000004.
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:8976 in sopen_extended
Shadow bytes around the buggy address:
0x502000001800: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001880: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001900: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001980: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001a00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x502000001a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa[fa]
0x502000001b00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001b80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001c00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001c80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001d00: 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
==49541==ABORTING
[Inferior 1 (process 49541) exited with code 01]
As already demonstrated, the location where this out-of-bounds write occurs is primarily based on the value of chan
, which is read from the input file and is thus largely attacker-controlled. The write location is also controlled in part by the value of tag2
, as this will control which specific member of the CHANNEL_TYPE
structure is written to. That said, tag2
, like chan
is also read from the input file, making it attacker-controlled. In the majority of the enumerated locations where this class of vulnerability manifests, the data written is also controlled by the attacker, as it is usually read directly from the input file or at least computed based on input file data. The end result is a heap-based buffer overflow vulnerability where the attacker often controls both the ‘where’ (the destination of the write) and the ‘what’ (the data written). Depending on the setup of the heap, this flaw can potentially lead to arbitrary code execution.
==49541==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000001af8 at pc 0x7ffff7344864 bp 0x7fffffffa7f0 sp 0x7fffffffa7e0
WRITE of size 4 at 0x502000001af8 thread T0
#0 0x7ffff7344863 in sopen_extended /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:8976
#1 0x5555555554c7 in main harness.cpp:38
#2 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#3 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#4 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 71453a64ceb3bfa137fcadc13ca9ee9004d4faa3)
Address 0x502000001af8 is a wild pointer inside of access range of size 0x000000000004.
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:8976 in sopen_extended
Shadow bytes around the buggy address:
0x502000001800: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001880: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001900: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001980: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001a00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x502000001a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa[fa]
0x502000001b00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001b80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001c00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001c80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x502000001d00: 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
==49541==ABORTING
[Inferior 1 (process 49541) exited with code 01]
2025-08-06 - Vendor Disclosure
2025-08-24 - Vendor Patch Release
2025-08-25 - Public Release
Discovered by Mark Bereza and Lilith >_> of Cisco Talos.