CVE-2025-52581
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.
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-190 - Integer Overflow or Wraparound
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.
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
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
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.