Talos Vulnerability Report

TALOS-2025-2232

The Biosig Project libbiosig ISHNE ECG Annotations file parsing heap-based buffer overflow vulnerability

August 25, 2025
CVE Number

CVE-2025-53853

SUMMARY

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.

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 3.9.0
The Biosig Project libbiosig Master Branch (35a819fa)

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-122 - Heap-based Buffer Overflow

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.

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.

TIMELINE

2025-08-06 - Vendor Disclosure
2025-08-24 - Vendor Patch Release
2025-08-25 - Public Release

Credit

Discovered by Mark Bereza and Lilith >_> of Cisco Talos.