Talos Vulnerability Report

TALOS-2025-2233

The Biosig Project libbiosig GDF parsing integer overflow to heap-based buffer overflow vulnerability

August 25, 2025
CVE Number

CVE-2025-52581

SUMMARY

An integer overflow vulnerability exists in the GDF parsing functionality of The Biosig Project libbiosig 3.9.0 and Master Branch (35a819fa). A specially crafted GDF 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-190 - Integer Overflow or Wraparound

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 General Data Format for Biosignals, a file format for encoding biosignals from various different kinds of medical devices, such as ECG and EEG. The GDF standard aims to unify features from various older formats, and in particular was created in response to the limitations of the older European Data Format for Biosignals (EDF), such as fixes for Y2K bugs and support for data types besides integers. GDF’s potential applications in the medical space are very broad, and implementing its rich feature set necessitates additional code complexity that may increase the threat surface during parsing. Although GDF 2.0 has been available since 2011, this specific vulnerability is exercised via a payload in the older version 1 format (specifically 1.25) that libbiosig still supports. A cursory review of the libbiosig codebase reveals an emphasis placed on this particular file format relative to the numerous others supported by the library, with dedicated macros baked in for builds supporting this format exclusively, and a dedicated read_header function that currently only supports the GDF file format.

To determine if the input file is valid GDF, getfiletype runs the following check:

	else if (!memcmp(Header1,"GDF",3) && (hdr->HeadLen > 255)) {                                                    // [1]
		hdr->TYPE = GDF;                                                                                            // [2]
		char tmp[6]; tmp[5] = 0;
		memcpy(tmp,hdr->AS.Header+3, 5);
		hdr->VERSION 	= strtod(tmp,NULL);
	}

In line with the GDF specification, the format is identified by reading the first 8 bytes of the header, encoded in ASCII. Valid GDF files require that the first three characters are always “GDF” and that the size of the header in bytes is at least 256 * (1 + NS), where NS is the number of channels, explaining the two checks performed in code above [1]. If these minimum checks pass, libbiosig classifies the input file as GDF by setting the hdr->TYPE field [2] and extracts the version number from the 5 bytes immediately follwing the “GDF” magic bytes, per the spec.

Further along in sopen_extended, after getfiletype has returned, hdr->TYPE is checked again and if it’s GDF, read_header is called [3]:

	if (hdr->TYPE == GDF) {

		struct stat FileBuf;
		if (stat(hdr->FileName,&FileBuf)==0) hdr->FILE.size = FileBuf.st_size;

			if ( read_header(hdr) ) {                                                                               // [3]
			return (hdr);
		}

The read_header function appears to be exclusive to the processing of GDF files:

/****************************************************************************
*                     READ_HEADER_1
*
*
****************************************************************************/
int read_header(HDRTYPE *hdr) {
/*
	input:
		hdr must be an open file able to read from
		hdr->TYPE must be unknown, otherwise no FileFormat evaluation is performed
		hdr->FILE.size
	output:
		defines whole header structure and event table
	return value:
	0	no error
	-1	error reading header 1
	-2	error reading header 2
	-3	error reading event table
*/

	if (VERBOSE_LEVEL>7) fprintf(stdout,"%s (line %i): %i %i %f\n",__func__,__LINE__, (int)hdr->FILE.size, (int)hdr->HeadLen, hdr->VERSION);

	size_t count = hdr->HeadLen;
	if (hdr->HeadLen<=512) {
		ifseek(hdr, count, SEEK_SET);
		hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header, 513);
		count += ifread(hdr->AS.Header+hdr->HeadLen, 1, 512-count, hdr);
		getfiletype(hdr);
	}
		char tmp[6];
		strncpy(tmp,(char*)hdr->AS.Header+3,5); tmp[5]=0;
		hdr->VERSION 	= atof(tmp);

	// currently, only GDF is supported
	if ( (hdr->TYPE != GDF) || (hdr->VERSION < 0.01) )
		return ( -1 );

Further down in read_header is where the vulnerability can be found:

			// READ EVENTTABLE
			hdr->AS.rawEventData = (uint8_t*)realloc(hdr->AS.rawEventData,8);
			size_t c = ifread(hdr->AS.rawEventData, sizeof(uint8_t), 8, hdr);
				uint8_t *buf = hdr->AS.rawEventData;

			if (c<8) {
				hdr->EVENT.N = 0;
			}
			else if (hdr->VERSION < 1.94) {
				hdr->EVENT.N = leu32p(buf + 4);                                                                     // [6]
			}
			else {
				hdr->EVENT.N = buf[1] + (buf[2] + buf[3]*256)*256;                                                  // [7]
			}

			if (VERBOSE_LEVEL > 7)
				fprintf(stdout,"EVENT.N = %i,%i\n",hdr->EVENT.N,(int)c);

			char flag = buf[0];
			int sze = (flag & 2) ? 12 : 6;                                                                          // [8]
			if (flag & 4) sze+=8;

			hdr->AS.rawEventData = (uint8_t*)realloc(hdr->AS.rawEventData,8+hdr->EVENT.N*sze);                      // [4]
			c = ifread(hdr->AS.rawEventData+8, sze, hdr->EVENT.N, hdr);                                             // [5]

The root cause of the vulnerability is an integer overflow that can occur when computing the expression 8+hdr->EVENT.N*sze, which is used as the new size for the heap-allocated buffer hdr->AS.rawEventData via a call to realloc [4]. The result of this computation is implicitly cast to the type size_t (a 32-bit unsigned integer in this case) when used as the size argument for realloc, meaning that sufficiently large values of hdr->EVENT.N and/or sze will result in the allocation size being overflowed. This quickly becomes problematic, as both hdr->EVENT.N (the number of events) [6] [7] and sze (the size of each event) [8] are computed from the file’s contents, making them attacker-controlled. An exploit condition occurs shortly thereafter, when the incorrectly-sized hdr->AS.rawEventData buffer is populated using data read from hdr via a call to ifread (a wrapper for fread) [5].

In this call to fread, hdr-EVENT.N is used as the number of objects to be read, while sze is used as the size of each object, making the total size of data read equal to hdr->EVENT.N * sze. fread then attempts to write this data to the hdr->AS.rawEventData heap buffer, starting at an offset of 8 bytes. Under normal circumstances, the newly-allocated size of the hdr->AS.rawEventData buffer should perfectly accomodate the size of the written data, but triggering the integer overflow condition results in a substantially smaller allocation, which in turn causes this call to ifread to write past the end of the buffer.

In our testing, we were able to craft a malicious GDF file that exercises this code path and produces values for hdr->EVENT.N and sze that successfully trigger the integer overflow condition:

Breakpoint 2, read_header (hdr=hdr@entry=0x519000001980) at biosig.c:3686
3686                            hdr->AS.rawEventData = (uint8_t*)realloc(hdr->AS.rawEventData,8+hdr->EVENT.N*sze);
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:3686
3681
3682                         char flag = buf[0];
3683                         int sze = (flag & 2) ? 12 : 6;
3684                         if (flag & 4) sze+=8;
3685
► 3686                         hdr->AS.rawEventData = (uint8_t*)realloc(hdr->AS.rawEventData,8+hdr->EVENT.N*sze);
3687                         c = ifread(hdr->AS.rawEventData+8, sze, hdr->EVENT.N, hdr);
3688                         ifseek(hdr, hdr->HeadLen, SEEK_SET);
3689                         if (c < hdr->EVENT.N) {
3690                                 biosigERROR(hdr, B4C_INCOMPLETE_FILE, "reading GDF eventtable failed");
3691                                 return(-3);
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0   0x7ffff72e8db5 read_header+1637
1   0x7ffff7319059 sopen_extended+102761
2   0x5555555554c8 main+511
3   0x7ffff6a2a1ca __libc_start_call_main+122
4   0x7ffff6a2a28b __libc_start_main+139
5   0x555555555205 _start+37
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> p/x sze
$1 = 0x14                                                                                                           // [10]
pwndbg> p/x (HDR_STRUCT *)hdr->EVENT.N
$2 = 0xc000000f                                                                                                     // [9]

The GDB output above confirms that right before the call to realloc, hdr->EVENT.N is equal to 0xc000000f (3221225487) [9], and sze is equal to 0x14 (20) [10]. When multiplied together, the result should exceed the maximum size of an unsigned 32-bit integer. To confirm this, the arguments to realloc were also inspected using GDB:

pwndbg> s
0x00007ffff78fc3e4 in realloc () from /lib/x86_64-linux-gnu/libasan.so.8
──────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────
► 0x7ffff78fc3e4 <realloc>                        jmp    __interceptor_realloc@plt   <__interceptor_realloc@plt>
	↓
0x7ffff783a4b0 <__interceptor_realloc@plt>      endbr64
0x7ffff783a4b4 <__interceptor_realloc@plt+4>    jmp    qword ptr [rip + 0x15f77e]  <__interceptor_realloc>
	↓
0x7ffff78fd5e0 <__interceptor_realloc>          endbr64
0x7ffff78fd5e4 <__interceptor_realloc+4>        push   rbp
0x7ffff78fd5e5 <__interceptor_realloc+5>        mov    rbp, rsp     RBP => 0x7fffffffa720 —▸ 0x7fffffffa820 —▸ 0x7fffffffd5c0 —▸ 0x7fffffffd680 ◂— ...
0x7ffff78fd5e8 <__interceptor_realloc+8>        push   r14
0x7ffff78fd5ea <__interceptor_realloc+10>       push   r13
0x7ffff78fd5ec <__interceptor_realloc+12>       push   r12
0x7ffff78fd5ee <__interceptor_realloc+14>       mov    r12, rsi     R12 => 0x134
0x7ffff78fd5f1 <__interceptor_realloc+17>       push   rbx
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0   0x7ffff78fc3e4 realloc
1   0x7ffff72e8dc1 read_header+1649
2   0x7ffff7319059 sopen_extended+102761
3   0x5555555554c8 main+511
4   0x7ffff6a2a1ca __libc_start_call_main+122
5   0x7ffff6a2a28b __libc_start_main+139
6   0x555555555205 _start+37
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> info reg rdi rsi
rdi            0x5020000001b0      88098369175984                                                                   // [11]
rsi            0x134               308                                                                              // [12]

On our test platform (Linux x86-64), the arguments passed to realloc are stored in the registers rdi and rsi. Thus, in the GDB output above, 0x5020000001b0 is the pointer to the memory area to be reallocated [11] and 0x134 (308) is the new allocation size in bytes [12]. Since 308 is substantially smaller than hdr->EVENT.N (3221225487), it is evident that the integer overflow condition has occured.

To confirm the exploit condition, the new address assigned to hdr->AS.rawEventData by realloc was noted and the program was allowed to continue, quickly resulting in a heap-based buffer overflow condition that was caught by AddressSanitizer:

pwndbg> p/x hdr->AS.rawEventData
$2 = 0x512000000040                                                                                                 // [13]
pwndbg> c
Continuing.
=================================================================
==219301==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x512000000174 at pc 0x7ffff787ed7f bp 0x7fffffffa720 sp 0x7fffffff9ec8
WRITE of size 2360 at 0x512000000174 thread T0                                                                      // [14]
	#0 0x7ffff787ed7e in fread ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:996  // [16]
	#1 0x7ffff72e8e0f in read_header /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:3687
	#2 0x7ffff7319058 in sopen_extended /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:4046
	#3 0x5555555554c7 in main harness.cpp:38
	#4 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
	#5 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
	#6 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 3972e4805659faea250601a1b5068af2cb2a1ca5)

0x512000000174 is located 0 bytes after 308-byte region [0x512000000040,0x512000000174)                             // [15]
allocated by thread T0 here:
	#0 0x7ffff78fc778 in realloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:85
	#1 0x7ffff72e8dc0 in read_header /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:3686
	#2 0x7ffff7319058 in sopen_extended /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:4046
	#3 0x5555555554c7 in main harness.cpp:38
	#4 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
	#5 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
	#6 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 3972e4805659faea250601a1b5068af2cb2a1ca5)

SUMMARY: AddressSanitizer: heap-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:996 in fread
Shadow bytes around the buggy address:
0x511ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x511fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x511fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x512000000000: fa fa fa fa fa fa fa fa 00 00 00 00 00 00 00 00
0x512000000080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x512000000100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00[04]fa
0x512000000180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x512000000200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x512000000280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x512000000300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x512000000380: 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
==219301==ABORTING
[Inferior 1 (process 219301) exited with code 01]

In the GDB output above, the new address of hdr->AS.rawEventData is 0x512000000040 [13]. The heap-based buffer overflow occurs when the program attempts to write to the address 0x512000000174 [14], which is the first byte past the end of the buffer [15]. The offending stack trace [16] confirms that the erroneous write occurs in the call to fread from inside read_header (line 3687 in biosig.c), exactly matching the exploit condition outlined earlier.

Since the data written past the end of this buffer is read from hdr, which itself is populated using data from the input file, this data is attacker-controlled. 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 GDF file as input to libbiosig. Depending on the setup of the heap, this flaw can potentially lead to arbitrary code execution.

Crash Information

Program received signal SIGABRT, Aborted.
__pthread_kill_implementation (no_tid=0, signo=6, threadid=<optimized out>) at ./nptl/pthread_kill.c:44
warning: 44     ./nptl/pthread_kill.c: No such file or directory
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
─────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]──────────────────────────────────────────────
RAX  0
RBX  0x35d46
RCX  0x7ffff769eb2c (pthread_kill+284) ◂— mov r14d, eax
RDX  6
RDI  0x35d46
RSI  0x35d46
R8   0
R9   0x7ffff7ffb440 (tunable_list) ◂— 'glibc.malloc.mxfast'
R10  8
R11  0x246
R12  6
R13  0x7fffffffd300 ◂— 0x1a1a1a1a1a1a1a1a
R14  0x16
R15  0x7fffffffd300 ◂— 0x1a1a1a1a1a1a1a1a
RBP  0x7fffffffd1c0 —▸ 0x7fffffffd1e0 —▸ 0x7fffffffd2a0 —▸ 0x7fffffffd3c0 —▸ 0x7fffffffd3d0 ◂— ...
RSP  0x7fffffffd180 ◂— 0x1103120300030d03
RIP  0x7ffff769eb2c (pthread_kill+284) ◂— mov r14d, eax
──────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────
► 0x7ffff769eb2c <pthread_kill+284>    mov    r14d, eax           R14D => 0
0x7ffff769eb2f <pthread_kill+287>    neg    r14d
0x7ffff769eb32 <pthread_kill+290>    cmp    eax, 0xfffff000     0x0 - 0xfffff000     EFLAGS => 0x207 [ CF PF af zf sf IF df of ]
0x7ffff769eb37 <pthread_kill+295>    mov    eax, 0              EAX => 0
0x7ffff769eb3c <pthread_kill+300>    cmovbe r14d, eax
0x7ffff769eb40 <pthread_kill+304>    jmp    pthread_kill+176            <pthread_kill+176>
	↓
0x7ffff769eac0 <pthread_kill+176>    mov    rax, qword ptr [rbp - 0x38]     RAX, [0x7fffffffd188] => 0x5a21e5dead330600
0x7ffff769eac4 <pthread_kill+180>    sub    rax, qword ptr fs:[0x28]        RAX => 0 (0x5a21e5dead330600 - 0x5a21e5dead330600)
0x7ffff769eacd <pthread_kill+189>    jne    pthread_kill+341            <pthread_kill+341>

0x7ffff769ead3 <pthread_kill+195>    add    rsp, 0x18     RSP => 0x7fffffffd198 (0x7fffffffd180 + 0x18)
0x7ffff769ead7 <pthread_kill+199>    mov    eax, r14d     EAX => 0
────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────
00:0000│ rsp 0x7fffffffd180 ◂— 0x1103120300030d03
01:0008│-038 0x7fffffffd188 ◂— 0x5a21e5dead330600
02:0010│-030 0x7fffffffd190 ◂— 0x30d0301031103
03:0018│-028 0x7fffffffd198 ◂— 6
04:0020│-020 0x7fffffffd1a0 —▸ 0x7ffff7c95740 ◂— 0x7ffff7c95740
05:0028│-018 0x7fffffffd1a8 —▸ 0x7fffffffd300 ◂— 0x1a1a1a1a1a1a1a1a
... ↓        2 skipped
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0   0x7ffff769eb2c pthread_kill+284
1   0x7ffff769eb2c pthread_kill+284
2   0x7ffff769eb2c pthread_kill+284
3   0x7ffff764527e raise+30
4   0x7ffff76288ff abort+223
5   0x7ffff76297b6 _IO_peekc_locked.cold
6   0x7ffff76a8ff5 None
7   0x7ffff76ab164 None
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
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.