CVE-2025-53853
A heap-based buffer overflow vulnerability exists in the ISHNE parsing functionality of The Biosig Project libbiosig 3.9.0 and Master Branch (35a819fa). A specially crafted ISHNE ECG annotations 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 structures, 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 ISHNE Holter Standard Output File Format, a standarized output format for encoding electrocardiographic (ECG) digital samples recorded by a Holter monitor. Although the format is named after the International Society for Holter and Noninvasive Electrocardiology (ISHNE), it aims to be a common standard for Holter monitors produced by various manufacturers. To determine if the input file is in the ISHNE format, getfiletype
runs the following check:
else if (!memcmp(Header1,"ANN 1.0",8)) // [1]
hdr->TYPE = ISHNE;
else if (!memcmp(Header1,"ISHNE1.0",8)) // [2]
hdr->TYPE = ISHNE;
In line with the ISHNE format specification, the format is identified by reading the first 8 bytes of the header, encoded in ASCII. From the code snippet above, it is clear that getfiletype
recognizes two distinct “Magic Number” sequences as identifying an ISHNE file: ANN 1.0
and ISHNE1.0
. The latter [2] is used for standard ISHNE files that encode ECG samples, while the former [1] indicates an ECG Annotations file used to annotate samples in a corresponding “standard” ISHNE file. In this case, the vulnerable code path is specific to the parsing logic for an ISHNE annotation file, which sopen_extended
tracks via the variable flagANN
[3]:
else if (hdr->TYPE==ISHNE) {
char flagANN = !strncmp((char*)hdr->AS.Header,"ANN",3); // [3]
fprintf(stderr,"Warning SOPEN(ISHNE): support for ISHNE format is experimental\n"); // [4]
While libbiosig warns that support for the ISHNE format is experimental [4], this functionality is enabled by default for both the latest release and the master branch, making it a viable attack vector.
Once sopen_extended
finishes parsing the ISHNE file header (which primarily contains patient information), the flagANN
variable is checked [5]. If it is set, sopen_extended
proceeds to process to annotations themselves via a while loop [6]. This same block contains the vulnerability:
if (flagANN) { // [5]
hdr->NRec=0;
hdr->EVENT.N = (hdr->FILE.size - hdr->HeadLen)/4; // [7]
hdr->EVENT.TYP = (typeof(hdr->EVENT.TYP)) realloc(hdr->EVENT.TYP, hdr->EVENT.N * sizeof(*hdr->EVENT.TYP)); // [8]
hdr->EVENT.POS = (typeof(hdr->EVENT.POS)) realloc(hdr->EVENT.POS, hdr->EVENT.N * sizeof(*hdr->EVENT.POS)); // [9]
/* define user specified events according to ECG Annotation format of http://thew-project.org/THEWFileFormat.htm */
hdr->EVENT.CodeDesc = (typeof(hdr->EVENT.CodeDesc)) realloc(hdr->EVENT.CodeDesc,10*sizeof(*hdr->EVENT.CodeDesc));
hdr->EVENT.CodeDesc[0]="";
hdr->EVENT.CodeDesc[1]="Normal beat";
hdr->EVENT.CodeDesc[2]="Premature ventricular contraction";
hdr->EVENT.CodeDesc[3]="Supraventricular premature or ectopic beat";
hdr->EVENT.CodeDesc[4]="Calibration Pulse";
hdr->EVENT.CodeDesc[5]="Bundle branch block beat";
hdr->EVENT.CodeDesc[6]="Pace";
hdr->EVENT.CodeDesc[7]="Artfact";
hdr->EVENT.CodeDesc[8]="Unknown";
hdr->EVENT.CodeDesc[9]="NULL";
hdr->EVENT.LenCodeDesc = 9;
uint8_t evt[4];
ifseek(hdr, lei32p(hdr->AS.Header+22), SEEK_SET);
size_t N = 0, pos=0;
while (!ifeof(hdr)) { // [6]
if (!ifread(evt, 1, 4, hdr)) break; // [14]
uint16_t typ = 8;
switch ((char)(evt[0])) {
case 'N': typ = 1; break;
case 'V': typ = 2; break;
case 'S': typ = 3; break;
case 'C': typ = 4; break;
case 'B': typ = 5; break;
case 'P': typ = 6; break;
case 'X': typ = 7; break;
case '!': typ = 0x7ffe; break;
case 'U': typ = 8; break;
default: continue;
}
pos += leu16p(evt+2); // [13]
hdr->EVENT.POS[N] = pos; // [11]
hdr->EVENT.TYP[N] = typ; // [12]
N++;
}
hdr->EVENT.N = N;
}
The root cause of the issue is the calculation of hdr->EVENT.N
[7], which can end up being 0 if hdr->FILE.size
and hdr->HeadLen
are equal or sufficiently close (hdr->EVENT.N
is an unsigned integer, so a hdr->FILE.size
that is less than 4 bytes larger than hdr->HeadLen
will result in 0 due to the nature of integer division).
hdr->EVENT.N
being set to 0 quickly becomes an issue, as it is immediately used to compute the size of two different heap-allocated buffers: hdr->EVENT.TYP
[8] and hdr->EVENT.POS
[9]. Although these allocations are done using realloc
instead of malloc
, this is the first time these buffers are allocated in this code path. While the behavior of malloc
or realloc
when called with a size of 0 is technically implementation-defined, the result is usually a buffer of the smallest size than can be allocated, as was the case in our testing. Using a specially-crafted ISHNE annotation file and GDB, it was possible to capture this exact scenario:
Breakpoint 2, sopen_extended (FileName=<optimized out>, MODE=<optimized out>, hdr=0x55555556e920, biosig_options=<optimized out>)
at biosig.c:8566
8566 hdr->EVENT.CodeDesc = (typeof(hdr->EVENT.CodeDesc)) realloc(hdr->EVENT.CodeDesc,10*sizeof(*hdr->EVENT.CodeDesc));
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:8566
8561 hdr->EVENT.N = (hdr->FILE.size - hdr->HeadLen)/4;
8562 hdr->EVENT.TYP = (typeof(hdr->EVENT.TYP)) realloc(hdr->EVENT.TYP, hdr->EVENT.N * sizeof(*hdr->EVENT.TYP));
8563 hdr->EVENT.POS = (typeof(hdr->EVENT.POS)) realloc(hdr->EVENT.POS, hdr->EVENT.N * sizeof(*hdr->EVENT.POS));
8564
8565 /* define user specified events according to ECG Annotation format of http://thew-project.org/THEWFileFormat.htm */
► 8566 hdr->EVENT.CodeDesc = (typeof(hdr->EVENT.CodeDesc)) realloc(hdr->EVENT.CodeDesc,10*sizeof(*hdr->EVENT.CodeDesc));
8567 hdr->EVENT.CodeDesc[0]="";
8568 hdr->EVENT.CodeDesc[1]="Normal beat";
8569 hdr->EVENT.CodeDesc[2]="Premature ventricular contraction";
8570 hdr->EVENT.CodeDesc[3]="Supraventricular premature or ectopic beat";
8571 hdr->EVENT.CodeDesc[4]="Calibration Pulse";
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0 0x7ffff7e8455c sopen_extended+86556
1 0x555555555283 main+186
2 0x7ffff762a1ca __libc_start_call_main+122
3 0x7ffff762a28b __libc_start_main+139
4 0x555555555105 _start+37
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> p/x hdr->FILE.size
$1 = 0xb51
pwndbg> p/x hdr->HeadLen
$2 = 0xb51
pwndbg> p/x hdr->EVENT.N
$3 = 0x0
pwndbg> p/x hdr->EVENT.POS
$4 = 0x55555556f900
pwndbg> heap -v 0x55555556f8f0
Allocated chunk | PREV_INUSE
Addr: 0x55555556f8f0
prev_size: 0x55555556f8d0
size: 0x20 (with flag bits: 0x21) // [10]
fd: 0x7ffff7803b20
bk: 0x7ffff7803b20
fd_nextsize: 0x00
bk_nextsize: 0xb61
As shown in the GDB output above, if hdr->EVENT.N
is 0, the size of the buffer allocated for hdr->EVENT.POS
will be extremely small. In this example, the total size of the allocated heap chunk was 0x20 [10], or 32 bytes. Accounting for heap metadata, this corresponds to only 16 bytes of useable memory. This, in turn, results in an out-of-bounds write inside the while loop, where sopen_extended
attempts to write to these buffers [11] [12].
0x00007ffff7e84c6b 8599 hdr->EVENT.POS[N] = pos;
─────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]──────────────────────────────────────────────
RAX 6
RBX 0x6453
RCX 0x1f930850
*RDX 0x55555556f900 ◂— 0x312d00000000
RDI 0x55555556f2f0 ◂— 0
RSI 0x1f930850
R8 0x7ffff7803b20 (main_arena+96) —▸ 0x5555555727e0 ◂— 0
R9 0x60
R10 0x7ffff7611c68 ◂— 0x11002200002fe2
R11 0x246
R12 0x7ffff7f08078 ◂— 0xfff7c5b0fff7cc2f
R13 0x7fffffffd4f0 ◂— 0x1f930850
R14 0x55555556e920 —▸ 0x55555556f490 ◂— '/home/mbereza/Share/Cisco/ishne_arb_heap_oob_write_poc'
R15 4
RBP 0x7fffffffd630 —▸ 0x7fffffffd680 —▸ 0x7fffffffd720 —▸ 0x7fffffffd780 ◂— 0
RSP 0x7fffffffcdc0 ◂— 4
*RIP 0x7ffff7e84c6b (sopen_extended+88363) ◂— mov dword ptr [rdx + r15*4], ebx
──────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────
b+ 0x7ffff7e84c64 <sopen_extended+88356> mov rdx, qword ptr [r14 + 0x2c8] RDX, [0x55555556ebe8] => 0x55555556f900 ◂— 0x312d00000000
► 0x7ffff7e84c6b <sopen_extended+88363> mov dword ptr [rdx + r15*4], ebx [0x55555556f910] <= 0x6453
0x7ffff7e84c6f <sopen_extended+88367> mov rdx, qword ptr [r14 + 0x2c0] RDX, [0x55555556ebe0] => 0x55555556f8e0 ◂— 0x8000400010005
0x7ffff7e84c76 <sopen_extended+88374> mov word ptr [rdx + r15*2], ax [0x55555556f8e8] <= 6
0x7ffff7e84c7b <sopen_extended+88379> add r15, 1 R15 => 5 (4 + 1)
0x7ffff7e84c7f <sopen_extended+88383> jmp sopen_extended+86753 <sopen_extended+86753>
↓
0x7ffff7e84621 <sopen_extended+86753> lea r13, [rbp - 0x140] R13 => 0x7fffffffd4f0 ◂— 0x1f930850
0x7ffff7e84628 <sopen_extended+86760> mov rdi, r14 RDI => 0x55555556e920 —▸ 0x55555556f490 ◂— '/home/mbereza/Share/Cisco/ishne_arb_heap_oob_write...'
0x7ffff7e8462b <sopen_extended+86763> call ifeof@plt <ifeof@plt>
0x7ffff7e84630 <sopen_extended+86768> test eax, eax
0x7ffff7e84632 <sopen_extended+86770> jne sopen_extended+88430 <sopen_extended+88430>
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:8599
8594 case '!': typ = 0x7ffe; break;
8595 case 'U': typ = 8; break;
8596 default: continue;
8597 }
8598 pos += leu16p(evt+2);
► 8599 hdr->EVENT.POS[N] = pos;
8600 hdr->EVENT.TYP[N] = typ;
8601 N++;
8602 }
8603 hdr->EVENT.N = N;
8604 }
This is particularly problematic since the value written past then end of the heap buffer is the value contained in the variable pos
, which is populated using data read directly from the input file [13]. This is because pos
is computed by calling leu16p
on the evt
buffer with an offset of 2 bytes. leu16p
is simply a memcpy
wrapper than reads a 16-bit unsigned integer from a location in memory:
static inline uint16_t leu16p(const void* i) {
uint16_t a;
memcpy(&a, i, sizeof(a));
return (le16toh(a));
}
Meanwhile, the evt
buffer itself is populated by reading the next 4 bytes from the input file, starting past the end of the ISHNE header [14], meaning that the value of pos
, and thus the value written past the end of the heap-allocated buffer, is attacker controlled.
In order for this vulnerability to be triggered, however, an attacker needs to be able to force hdr->EVENT.N
to be 0, which requires controlling the value of hdr->FILE.size
and/or hdr->HeadLen
. To start, hdr->FILE.size
is simply the size of the input file, meaning it is attacker controlled:
{
struct stat FileBuf;
stat(hdr->FileName,&FileBuf);
hdr->FILE.size = FileBuf.st_size;
}
For ISHNE files, hdr->HeadLen
is populated using data read from the input file’s header, meaning it is also attacker controlled:
hdr->HeadLen = lei32p(hdr->AS.Header+22);
if (count < hdr->HeadLen) {
hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header,hdr->HeadLen);
count += ifread(hdr->AS.Header+count,1,hdr->HeadLen-count,hdr);
}
hdr->HeadLen = count;
For context, lei32p
(similar to leu16p
) is simply a memcpy
wrapper that reads a 32-bit integer from memory, while ifread
is simply a wrapper for fread
:
size_t ifread(void* ptr, size_t size, size_t nmemb, HDRTYPE* hdr) {
#ifdef ZLIB_H
if (hdr->FILE.COMPRESSION>0)
return(gzread(hdr->FILE.gzFID, ptr, size * nmemb)/size);
else
#endif
return(fread(ptr, size, nmemb, hdr->FILE.FID));
}
Thus, an attacker can force hdr->EVENT.N
to be 0 by crafting the input file in a manner where the read in hdr->HeadLen
is equal to (or very close to) the input file’s size (hdr->FILE.size
). The end result is a vulnerability where an attacker can write arbitrary data past the end of a heap-allocated buffer by supplying a specially-crafted malicious ISHNE annotation file as input to libbiosig. Depending on the setup of the heap, this flaw can potentially lead to arbitrary code execution.
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.