CVE-2025-52461
An out-of-bounds read vulnerability exists in the Nex parsing functionality of The Biosig Project libbiosig 3.9.0 and Master Branch (35a819fa). A specially crafted .nex file can lead to an information leak. 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
8.2 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:H
CWE-125 - Out-of-bounds Read
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 Plexon NeuroExplorer Nex file format, a file format designed to store signal data with a focus on neurophysiology. Although designed for use with Plexon’s NeuroExplorer software, it is supported by some third-party health data processing libraries, including libbiosig.
To determine if the input file is valid a Nex file, getfiletype
runs the following check:
else if (!memcmp(Header1,"NEX1",3))
hdr->TYPE = NEX1;
Put simply, libbiosig classifies an input file as Nex by comparing the first few bytes of the header against the string NEX1
. Interestingly, only the first 3 bytes are compared, meaning any file starting with NEX
will match, likely to accomodate newer versions of the Nex format. The file classification is then stored in the struct member hdr->TYPE
.
Further along in sopen_extended
, after getfiletype
has returned, hdr->TYPE
is checked again and, if it’s NEX1
, processing unique to the format is then performed:
else if (hdr->TYPE==NEX1) {
if (VERBOSE_LEVEL>7) fprintf(stdout,"%s (line %i)\n",__func__,__LINE__);
if (count < 284) {
hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header,284);
count += ifread(hdr->AS.Header + count, 1, 284 - count, hdr);
}
uint8_t v = hdr->AS.Header[3]-'0'; // [1]
const int H1LEN = (v==1) ? (4 + 4 + 256 + 8 + 4*4 + 256)
: (4 + 4 + 256 + 8 + 8 + 4 + 8 + 64);
const int H2LEN = (v==1) ? (4 + 4 + 64 + 6*4 + 4*8 + 12 + 16 + 52)
: (4 + 4 + 64 + 2*8 + 2*4 + 8 + 32 + 4*8 + 4*4 + 60);
uint32_t k = leu32p(hdr->AS.Header + 280); // [2]
if (count < H1LEN + k * H2LEN) {
hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header, H1LEN + k * H2LEN); // [3]
count += ifread(hdr->AS.Header + count, 1, H1LEN + k * H2LEN - count, hdr); // [4]
}
hdr->HeadLen = count; // [5]
hdr->VERSION = leu32p(hdr->AS.Header + 4) / 100.0;
hdr->SampleRate = lef64p(hdr->AS.Header + 264);
hdr->SPR = 1;
hdr->EVENT.SampleRate = lef64p(hdr->AS.Header + 264);
The code block for processing NEX files begins by computing some header lengths based on the value of v
[1], which corresponds to the Nex format version encoded in the fourth byte of the header (after being converted from ASCII to an integer). It appears that NEX1
files use different header lengths than other versions of the Nex format, but in either case these lengths are stored in the variables H1LEN
and H2LEN
. Based on their usage, H1LEN
appears to encode the length of the header’s static fields, while H2LEN
seems to encode the length of the “per-channel” fields, as this length is consistently multiplied by the number of channels when used. The number of channels itself is read from a fixed offset into the input file and stored in the variable k
[2].
Thus, the total length of the header is computed to be H1LEN + k * H2LEN
, and the size of hdr->AS.Header
, which is used to store the header data, is resized to accomodate this length [3]. Libbiosig then attempts to read this many bytes from the input file into hdr->AS.Header
[4]. The total size is also reflected in the struct member hdr->HeadLen
[5].
Problems arise, however, during the processing of the “per-channel” fields, which is done via a for loop:
hdr->NS = k; // [6]
hdr->CHANNEL = (CHANNEL_TYPE*)realloc(hdr->CHANNEL, hdr->NS*sizeof(CHANNEL_TYPE));
if (VERBOSE_LEVEL>7) fprintf(stdout,"%s (line %i)\n",__func__,__LINE__);
for (k=0; k < hdr->NS; k++) {
if (VERBOSE_LEVEL>7) fprintf(stdout,"%s (line %i): VarHdr # %i\n",__func__,__LINE__, k);
CHANNEL_TYPE *hc = hdr->CHANNEL+k; // [7]
uint32_t type = leu32p(hdr->AS.Header + H1LEN + k*H2LEN);
hc->OnOff = (type==5);
strncpy(hc->Label, hdr->AS.Header + H1LEN + k*H2LEN + 8, min(64,MAX_LENGTH_LABEL));
hc->Label[min(64, MAX_LENGTH_LABEL)] = 0;
hc->Transducer[0] = 0;
size_t n;
if (v==5) {
hc->GDFTYP = (leu32p(hdr->AS.Header + H1LEN + k*H2LEN + 92)==1) ? 16 : 3;
hc->PhysDimCode = PhysDimCode(hdr->AS.Header + H1LEN + k*H2LEN + 5*8 + 64);
n = leu64p(hdr->AS.Header + 80 + H1LEN + k*H2LEN);
hc->Cal = lef64p(hdr->AS.Header + 64+8*5+32 + H1LEN + k*H2LEN);
hc->Off = lef64p(hdr->AS.Header + 64+8*5+40 + H1LEN + k*H2LEN);
hc->SPR = leu64p(hdr->AS.Header + 64+8*5+48 + H1LEN + k*H2LEN);
hc->bufptr = hdr->AS.Header + leu64p(hdr->AS.Header + 64+8 + H1LEN + k*H2LEN); // [8]
}
else {
hc->GDFTYP = 3;
hc->PhysDimCode = PhysDimCode("mV");
n = leu32p(hdr->AS.Header + 76 + H1LEN + k*H2LEN);
hc->Cal = lef64p(hdr->AS.Header + 64+8*4+3*8 + H1LEN + k*H2LEN);
hc->Off = lef64p(hdr->AS.Header + 64+8*4+3*8+20 + H1LEN + k*H2LEN);
hc->SPR = leu32p(hdr->AS.Header + 64+8*4+4*8 + H1LEN + k*H2LEN);
hc->bufptr = hdr->AS.Header+leu32p(hdr->AS.Header + 64+8 + H1LEN + k*H2LEN); // [9]
}
if (VERBOSE_LEVEL>7) fprintf(stdout,"%s (line %i): VarHdr # %i %i %i %i \n",__func__,__LINE__, k,v,type,(int)n);
switch (type) {
case 2:
//case 6:
case 0:
case 1:
hdr->EVENT.N += n;
}
//if (hc->OnOff) hdr->SPR = lcm(hdr->SPR, hc->SPR);
}
After storing the total number of channels in hdr->NS
[6], this loop iterates over each of the channels, storing the current channel index in the variable k
. In each iteration, hc
contains the handle to the CHANNEL_TYPE
data structure for the current channel, obtained by accessing the index k
in the hdr->CHANNEL
array [7]. The bulk of the loop is dedicated to populating various fields in the CHANNEL_TYPE
structure for hc
using data read from the input file. Of particular note is the struct member bufptr
, which holds a pointer to hdr->AS.Header
(the buffer containing the input file data), offset by an unsigned integer (64-bit for NEX5
files, 32-bit otherwise) read from the input file [8] [9]. Although this does not constitute a vulnerability on its own, allowing users to specify an arbitrary offset into a buffer is often dangerous, especially when the offset can be as large as 64 bits.
Unfortunately, libbiosig does not appear to perform any bounds checking before using hc->bufptr
in the code meant to populate the event table:
if (hdr->EVENT.N > 0) {
size_t N=hdr->EVENT.N;
hdr->EVENT.N=0;
reallocEventTable(hdr,N);
N = 0;
for (k=0; k < hdr->NS; k++) { // [10]
if (VERBOSE_LEVEL>7) fprintf(stdout,"%s (line %i): VarHdr # %i\n",__func__,__LINE__, k);
CHANNEL_TYPE *hc = hdr->CHANNEL+k; // [11]
uint32_t type = leu32p(hdr->AS.Header + H1LEN + k*H2LEN);
size_t n,l;
uint16_t gdftyp = 5;
if (v==5) {
n = leu64p(hdr->AS.Header + 80 + H1LEN + k*H2LEN);
if (leu32p(hdr->AS.Header + 88 + H1LEN + k*H2LEN))
gdftyp=7;
}
else
n = leu32p(hdr->AS.Header + 76 + H1LEN + k*H2LEN);
switch (type) {
case 2:
if (gdftyp==5) {
for (l=0; l<n; l++)
hdr->EVENT.DUR[N+l] = leu32p(hc->bufptr+4*(l+n)); // [12]
}
else {
for (l=0; l<n; l++)
hdr->EVENT.DUR[N+l] = leu64p(hc->bufptr+8*(l+n)); // [13]
}
case 0:
case 1:
//case 6:
if (gdftyp==5) {
for (l=0; l<n; l++)
hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l); // [14]
}
else {
for (l=0; l<n; l++)
hdr->EVENT.POS[N+l] = leu64p(hc->bufptr+8*l); // [15]
}
for (l=0; l<n; l++) {
hdr->EVENT.TYP[N+l] = type;
hdr->EVENT.CHN[N+l] = k;
//hdr->EVENT.TimeStamp[N+l] = 0;
}
}
N+=n;
}
hdr->EVENT.N=N;
}
Similar to the channel processing code, the code used to populate the event table uses a for loop that iterates over the channels [10], with the variable hc
once again containing a handle to the CHANNEL_TYPE
structure for the current channel [11]. The vulnerability manifests in the calls to leu32p
and leu64p
that use hc->bufptr
as an argument [12] [13] [14] [15]. This is because leu32p
and leu64p
attempt to read an unsigned integer (32-bit or 64-bit, respectively) from the memory address supplied as the first and only argument:
static inline uint32_t leu32p(const void* i) {
uint32_t a;
memcpy(&a, i, sizeof(a));
return (le32toh(a));
}
static inline uint64_t leu64p(const void* i) {
uint64_t a;
memcpy(&a, i, sizeof(a));
return (le64toh(a));
}
As mentioned previously, hc->bufptr
is an address computed by reading an unsigned integer from the input file and using that as an offset into hdr->AS.Header
, the heap buffer used to store the input file data. If the offset read from the input file is larger than the size of hdr->AS.Header
, attempting to access hc->bufptr
will result in an out-of-bounds read.
This vulnerability condition can be observed by using the attached POC as the input file to libbiosig and attaching a debugger:
10177 hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l);
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:10177
10172 case 0:
10173 case 1:
10174 //case 6:
10175 if (gdftyp==5) {
10176 for (l=0; l<n; l++)
► 10177 hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l);
10178 }
10179 else {
10180 for (l=0; l<n; l++)
10181 hdr->EVENT.POS[N+l] = leu64p(hc->bufptr+8*l);
10182 }
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0 0x7ffff7e834ee sopen_extended+82350
1 0x555555555283 main+186
2 0x7ffff762a1ca __libc_start_call_main+122
3 0x7ffff762a28b __libc_start_main+139
4 0x555555555105 _start+37
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> p/x k
$1 = 0xf3
pwndbg> p/x l
$2 = 0x0 // [16]
pwndbg> p/x hc->bufptr
$3 = 0x8000f671be07 // [17]
pwndbg> p/x hdr->AS.Header
$4 = 0x7ffff733c010 // [18]
pwndbg> p/x (0x8000f671be07 - 0x7ffff733c010)
$5 = 0xff3dfdf7 // [19]
pwndbg> heap -v 0x7ffff733c000
IS_MMAPED
Addr: 0x7ffff733c000
prev_size: 0x00
size: 0x3e000 (with flag bits: 0x3e002) // [20]
In the GDB output above, execution has been halted right before the call to leu32p
that results in the out-of-bounds read: hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l);
. Since l
is 0 [16], leu32p
will attempt to read from hc->bufptr
without any additional offset. hc->bufptr
itself points to the address 0x8000f671be07 [17], while the heap buffer hdr->AS.Header
is located at 0x7ffff733c010 [18]. This correpsonds to an offset of 0xff3dfdf7 [19] bytes into hdr->AS.Header
, far larger than its allocated size of 0x3e000 [20]. As expected, continuing execution from here results in a segfault when the program attempts to dereference the address pointed to by hc->bufptr
[21]:
Program received signal SIGSEGV, Segmentation fault.
sopen_extended (FileName=<optimized out>, MODE=<optimized out>, hdr=0x55555556e920, biosig_options=<optimized out>) at biosig.c:10177
10177 hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l);
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
─────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]──────────────────────────────────────────────
RAX 0xb5fe21
RBX 0x7d2d26ee62
RCX 0x8000f671be07
RDX 0x81f4a6e3b998
RDI 0
RSI 0
R8 0xee97fd01
R9 0x7fffef700010 ◂— 0
R10 0x7ffff78046a8 (stdout) —▸ 0x7ffff78045c0 (_IO_2_1_stdout_) ◂— 0xfbad2a84
R11 0xf3
R12 0x14688
R13 0xe79c
R14 0x55555556e920 —▸ 0x55555556f3f0 ◂— '../biosig_fuzzing_setup/fuzzing/triage/NEX_format_clusterfuck/crash-ffa22670656323493531e65131b414859e4ae6b3'
R15 0x7ffff733aeb8 ◂— 0
RBP 0x7fffffffd600 —▸ 0x7fffffffd650 —▸ 0x7fffffffd6f0 —▸ 0x7fffffffd750 ◂— 0
RSP 0x7fffffffcd90 ◂— 0xa /* '\n' */
RIP 0x7ffff7e834ee (sopen_extended+82350) ◂— mov r8d, dword ptr [rcx + rdi*4]
──────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────
► 0x7ffff7e834ee <sopen_extended+82350> mov r8d, dword ptr [rcx + rdi*4] <Cannot dereference [0x8000f671be07]> // [21]
0x7ffff7e834f2 <sopen_extended+82354> mov dword ptr [rdx + rdi*4], r8d
0x7ffff7e834f6 <sopen_extended+82358> add rdi, 1
0x7ffff7e834fa <sopen_extended+82362> cmp rax, rdi
0x7ffff7e834fd <sopen_extended+82365> jne sopen_extended+82350 <sopen_extended+82350>
0x7ffff7e834ff <sopen_extended+82367> mov rdi, qword ptr [r14 + 0x2c0]
0x7ffff7e83506 <sopen_extended+82374> lea rcx, [rbx + rbx]
0x7ffff7e8350a <sopen_extended+82378> xor edx, edx EDX => 0
0x7ffff7e8350c <sopen_extended+82380> add rdi, rcx
0x7ffff7e8350f <sopen_extended+82383> add rcx, qword ptr [r14 + 0x2d8]
0x7ffff7e83516 <sopen_extended+82390> mov word ptr [rdi + rdx*2], si
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:10177
10172 case 0:
10173 case 1:
10174 //case 6:
10175 if (gdftyp==5) {
10176 for (l=0; l<n; l++)
► 10177 hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l);
10178 }
10179 else {
10180 for (l=0; l<n; l++)
10181 hdr->EVENT.POS[N+l] = leu64p(hc->bufptr+8*l);
10182 }
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0 0x7ffff7e834ee sopen_extended+82350
1 0x555555555283 main+186
2 0x7ffff762a1ca __libc_start_call_main+122
3 0x7ffff762a28b __libc_start_main+139
4 0x555555555105 _start+37
Since the offset into the buffer is read from the input file, it is attacker-controlled, making this vulnerability potentially useful to leaking information. Less precise uses can result in denial-of-service, as demonstrated by the crash condition above.
Program received signal SIGSEGV, Segmentation fault.
sopen_extended (FileName=<optimized out>, MODE=<optimized out>, hdr=0x55555556e920, biosig_options=<optimized out>) at biosig.c:10177
10177 hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l);
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
─────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]──────────────────────────────────────────────
RAX 0xb5fe21
RBX 0x7d2d26ee62
RCX 0x8000f671be07
RDX 0x81f4a6e3b998
RDI 0
RSI 0
R8 0xee97fd01
R9 0x7fffef700010 ◂— 0
R10 0x7ffff78046a8 (stdout) —▸ 0x7ffff78045c0 (_IO_2_1_stdout_) ◂— 0xfbad2a84
R11 0xf3
R12 0x14688
R13 0xe79c
R14 0x55555556e920 —▸ 0x55555556f3f0 ◂— '../biosig_fuzzing_setup/fuzzing/triage/NEX_format_clusterfuck/crash-ffa22670656323493531e65131b414859e4ae6b3'
R15 0x7ffff733aeb8 ◂— 0
RBP 0x7fffffffd600 —▸ 0x7fffffffd650 —▸ 0x7fffffffd6f0 —▸ 0x7fffffffd750 ◂— 0
RSP 0x7fffffffcd90 ◂— 0xa /* '\n' */
RIP 0x7ffff7e834ee (sopen_extended+82350) ◂— mov r8d, dword ptr [rcx + rdi*4]
──────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]───────────────────────────────────────────────────────
► 0x7ffff7e834ee <sopen_extended+82350> mov r8d, dword ptr [rcx + rdi*4] <Cannot dereference [0x8000f671be07]>
0x7ffff7e834f2 <sopen_extended+82354> mov dword ptr [rdx + rdi*4], r8d
0x7ffff7e834f6 <sopen_extended+82358> add rdi, 1
0x7ffff7e834fa <sopen_extended+82362> cmp rax, rdi
0x7ffff7e834fd <sopen_extended+82365> jne sopen_extended+82350 <sopen_extended+82350>
0x7ffff7e834ff <sopen_extended+82367> mov rdi, qword ptr [r14 + 0x2c0]
0x7ffff7e83506 <sopen_extended+82374> lea rcx, [rbx + rbx]
0x7ffff7e8350a <sopen_extended+82378> xor edx, edx EDX => 0
0x7ffff7e8350c <sopen_extended+82380> add rdi, rcx
0x7ffff7e8350f <sopen_extended+82383> add rcx, qword ptr [r14 + 0x2d8]
0x7ffff7e83516 <sopen_extended+82390> mov word ptr [rdi + rdx*2], si
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:10177
10172 case 0:
10173 case 1:
10174 //case 6:
10175 if (gdftyp==5) {
10176 for (l=0; l<n; l++)
► 10177 hdr->EVENT.POS[N+l] = leu32p(hc->bufptr+4*l);
10178 }
10179 else {
10180 for (l=0; l<n; l++)
10181 hdr->EVENT.POS[N+l] = leu64p(hc->bufptr+8*l);
10182 }
────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────
00:0000│ rsp 0x7fffffffcd90 ◂— 0xa /* '\n' */
01:0008│-868 0x7fffffffcd98 ◂— 0xa /* '\n' */
02:0010│-860 0x7fffffffcda0 ◂— 8
03:0018│-858 0x7fffffffcda8 ◂— 8
04:0020│-850 0x7fffffffcdb0 ◂— 6
05:0028│-848 0x7fffffffcdb8 ◂— 6
06:0030│-840 0x7fffffffcdc0 ◂— 4
07:0038│-838 0x7fffffffcdc8 ◂— 4
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0 0x7ffff7e834ee sopen_extended+82350
1 0x555555555283 main+186
2 0x7ffff762a1ca __libc_start_call_main+122
3 0x7ffff762a28b __libc_start_main+139
4 0x555555555105 _start+37
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.