CVE-2026-20777
A heap-based buffer overflow vulnerability exists in the Nicolet WFT parsing functionality of The Biosig Project libbiosig 3.9.2 and Master Branch (db9a9a63). A specially crafted .wft 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.2
The Biosig Project libbiosig Master Branch (db9a9a63)
libbiosig - https://biosig.sourceforge.net/index.html
8.1 - CVSS:3.1/AV:N/AC:H/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 Nicolet WFT file format, a data file format used by Nicolet digital oscilloscopes, which the format specification describes as a “compact 16-bit binary file format containing an ASCII header to retain scale factors, channel titles, time/date, etc.”
To determine if the input file is using the Nicolet WFT format, getfiletype runs the following check:
else if (!memcmp(Header1, MAGIC_NUMBER_NICOLET_WFT, 8)) { // WFT/Nicolet format // [1]
hdr->TYPE = WFT;
if (VERBOSE_LEVEL>7) fprintf(stdout,"%s (line %i) %s \n", __FILE__, __LINE__, __func__);
}
The value of the MAGIC_NUMBER_NICOLET_WFT constant [1] is defined further up in getfiletype:
const uint8_t MAGIC_NUMBER_NICOLET_WFT[] = {0x33,0,0x32,0,0x31,0,0x30,0};
Put simply, libbiosig classifies an input file as Nicolet WFT if the first eight bytes match the magic byte sequence 0x33 0x00 0x32 0x00 0x31 0x00 0x30 0x00. 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 WFT, processing unique to the format is then performed:
else if (hdr->TYPE==WFT) {
// WFT/Nicolet
hdr->HeadLen = atol((char*)hdr->AS.Header+8); // [2]
if (count<hdr->HeadLen) { // [3]
hdr->AS.Header = (uint8_t*)realloc(hdr->AS.Header, hdr->HeadLen); // [4]
count += ifread(hdr->AS.Header+count,1,hdr->HeadLen-count,hdr); // [5]
}
uint16_t gdftyp=3; // int16_t
// File_size
char *next = strchr(hdr->AS.Header+8,0)+1; // [7]
while (*next==32) next++; // [8]
hdr->FILE.size = atol(next); // [6]
if (VERBOSE_LEVEL>7) fprintf(stdout,"%s line %d: %s(...) <%s>:\n", __FILE__,__LINE__,__func__,next);
// File format version
next = strchr(next,0)+1;
while (*next==32) next++;
hdr->VERSION = atol(next);
. . .
This code block starts by extracting the size of the header from offset 8 into the file, which corresponds to the WFT field Header_size, and storing it in hdr->HeadLen [2]. If the number of bytes already processed by libbiosig (count) is less than this size [3], hdr->AS.Header (the heap-allocated buffer storing bytes read the input file header) is resized [4] and any unread header bytes are read into this buffer, updating count accordingly [5].
Next, the size of the whole file (corresponding to the WFT field File_size) is extracted and stored in hdr->FILE.size [6]. Unlike Header_size, which was read from a fixed offset into the file, the location of File_size is computed dynamically by seeking to the next null byte [7] and skipping over any subsequent ASCII space (0x20) characters [8]. This strategy largely matches the WFT specification document, which states: “All [file header] fields are left justified ASCII character strings, followed by a null byte, followed by spaces if needed to fill the allotted space.” It should be noted, however, that despite being null-terminated, WFT file header fields are fixed-size: “The individual file header fields are fixed in length and are ASCII alphanumeric strings, each terminated by a null (00) byte.” Thus, it should be possible to more safely read these fields using fixed sizes and fixed offsets, which will become relevant in the Mitigation section.
This strategy of moving to the next field via a combination of seeking past the next null byte and skipping over any padding spaces is used by sopen_extended to read the remaining WFT filed header fields, with the local variable next holding the pointer to the offset in hdr->AS.Header containing the next field to be read.
Unfortunately, a weakness of this parsing strategy becomes apparent when sopen_extended attempts to copy the value of the Nicolet_Digitizer_Type field into the heap-allocated hdr->ID.Manufacturer._field buffer [9]:
// nicolet_digitizer_type
next = strchr(next,0)+1;
while (*next==32) next++;
const char nicolet_digitizer_type = *next;
strcpy(hdr->ID.Manufacturer._field, "Nicolet"); // [12]
strcpy(hdr->ID.Manufacturer._field+8, next); // [9]
hdr->ID.Manufacturer.Name = hdr->ID.Manufacturer._field; // [10]
hdr->ID.Manufacturer.Model = hdr->ID.Manufacturer._field+8; // [11]
hdr->ID.Manufacturer.Version = NULL;
hdr->ID.Manufacturer.SerialNumber = NULL;
Based on its usage above, hdr->ID.Manufacturer appears to be a struct containing information about the manufacturer, with the _field member containing the internal buffer used to store the data pointed to by other struct members, such as Name (at offset 0 ) [10] and Model (at offset 8) [11]. The value of the Name member is fixed to “Nicolet” for WFT files, and is manually written to the start of the hdr->ID.Manufacturer._field buffer via a call to strcpy [12]. The value of the Model member, on the other hand, is read from the input file’s Nicolet_Digitizer_Type field (or, at least, what libbiosig believes to be in that field given its parsing logic), and is also copied into hdr->ID.Manufacturer._field via strcpy, placing it at offset 8 into the buffer, just after the “Nicolet” string [9].
This second call to strcpy is where the vulnerability occurs, caused by the length of the source string not being checked prior to copying it into a fixed-sized destination buffer. In this case, the destination is hdr->ID.Manufacturer._field+8, which is an 8-byte offset into hdr->ID.Manufacturer._field, a fixed-size buffer defined in biosig-dev.h:
struct {
/* see
SCP: section1, tag14,
MFER: tag23: "Manufacturer^model^version number^serial number"
*/
const char* Name;
const char* Model;
const char* Version;
const char* SerialNumber;
char _field[MAX_LENGTH_MANUF+1]; /* buffer */ // [13]
} Manufacturer;
The size of the buffer comes from the constant MAX_LENGTH_MANUF, with an extra byte added, likely to accommodate a null terminator [13]. MAX_LENGTH_MANUF is defined further up in biosig-dev.h:
#define MAX_LENGTH_MANUF 128 // max length of manufacturer field: MFER<128
Thus, the destination buffer for the vulnerable call to strcpy has a total size of 129 bytes. Since the first byte is copied 8 bytes past the start of this buffer, any source string longer than 121 bytes (including the null terminator) will trigger a buffer overflow. If we exclude the terminating null character (matching the value returned by a call to strlen), the max length becomes 120 bytes.
The source string for the copy is next, a local variable pointing to the next field to be parsed in the hdr->AS.Header buffer. Since strcpy only stops copying after encountering a null byte, if the next null byte is located more than 120 bytes past the current position of next in memory, a heap-based buffer overflow will occur.
This can be demonstrated dynamically by supplying the attached POC file to libbiosig and attaching a debugger:
Breakpoint 2, sopen_extended (FileName=<optimized out>, MODE=<optimized out>, hdr=0x519000001980, biosig_options=<optimized out>)
at biosig.c:5747
5747 strcpy(hdr->ID.Manufacturer._field+8, next);
────────────────────────────────────────────────────────────────[ SOURCE (CODE) ]────────────────────────────────────────────────────────────────
In file: /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:5747
5742 // nicolet_digitizer_type
5743 next = strchr(next,0)+1;
5744 while (*next==32) next++;
5745 const char nicolet_digitizer_type = *next;
5746 strcpy(hdr->ID.Manufacturer._field, "Nicolet");
► 5747 strcpy(hdr->ID.Manufacturer._field+8, next);
5748 hdr->ID.Manufacturer.Name = hdr->ID.Manufacturer._field;
5749 hdr->ID.Manufacturer.Model = hdr->ID.Manufacturer._field+8;
5750 hdr->ID.Manufacturer.Version = NULL;
5751 hdr->ID.Manufacturer.SerialNumber = NULL;
5752
──────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────
► 0 0x7ffff732d131 sopen_extended+210465
1 0x5555555554c8 main+511
2 0x7ffff6a2a1ca __libc_start_call_main+122
3 0x7ffff6a2a28b __libc_start_main+139
4 0x555555555205 _start+37
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
pwndbg> p/x (size_t)strlen(next)
$1 = 0x2df // [14]
pwndbg> p/x &(hdr->ID.Manufacturer._field)
$2 = 0x519000001b98
pwndbg> p/x 0x519000001b98 + 8
$3 = 0x519000001ba0 // [15]
pwndbg> p/x hdr
$4 = 0x519000001980 // [16]
pwndbg> p/x (0x519000001ba0 - 0x519000001980)
$5 = 0x220 // [17]
pwndbg> p/x 0x220 + 0x2df + 1
$7 = 0x500 // [18]
pwndbg> heap 0x519000001970
Allocated chunk | PREV_INUSE
Addr: 0x519000001970
Size: 0x400 (with flag bits: 0x401) // [19]
By halting execution right before the vulnerability is triggered, we can inspect the final state of relevant variables and buffers. Running strlen on next confirms that the length of the source string (0x2df = 735) [14] exceeds 120 bytes, which we calculated to be the largest string the destination buffer can hold. We can also compute the address of the strcpy destination [15] and subtract from it the address of hdr [16] to determine the exact offset of the destination address within the hdr structure: 0x220 [17]. This is useful since the hdr->ID.Manufacturer._field buffer isn’t allocated on the heap directly, but is instead part of a larger heap allocation for the entire hdr struct, which is performed in constructHDR. By adding to this offset the length of the source string plus an additional byte for the terminating null character, we can calculate how far past the start of hdr the vulnerable call to strcpy will attempt to copy to, which ends up being 0x500 bytes [18]. Finally, we can inspect the heap to determine the size of the chunk allocated for hdr, which ends up being 0x400 bytes. Thus, this POC will not only overflow the destination buffer, it will also overflow the entire heap chunk, potentially allowing the metadata of neighboring heap chunks to be modified.
Sure enough, attempting to step forward from here immediately triggers AddressSanitizer with a heap-buffer-overflow condition:
==161232==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x519000001d78 at pc 0x7ffff78a7923 bp 0x7fffffffa7e0 sp 0x7fffffff9f88
WRITE of size 736 at 0x519000001d78 thread T0
#0 0x7ffff78a7922 in strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:563
#1 0x7ffff732d149 in sopen_extended /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:5747
#2 0x5555555554c7 in main harness.cpp:38
#3 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#4 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#5 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 306248de9588a689c6260fb7e53686ad06b99425)
0x519000001d78 is located 0 bytes after 1016-byte region [0x519000001980,0x519000001d78)
allocated by thread T0 here:
#0 0x7ffff78fd9c7 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x7ffff72c5fcd in constructHDR /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:1170
#2 0x555555555411 in main harness.cpp:35
#3 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#4 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#5 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 306248de9588a689c6260fb7e53686ad06b99425)
SUMMARY: AddressSanitizer: heap-buffer-overflow ../../../../src/libsanitizer/asan/asan_interceptors.cpp:563 in strcpy
Shadow bytes around the buggy address:
0x519000001a80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001b00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001b80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001c00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001c80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x519000001d00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00[fa]
0x519000001d80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x519000001e00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x519000001e80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001f00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001f80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
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
It should be noted that the default configuration of libbiosig, at least for some platforms, has _FORTIFY_SOURCE enabled, which in our testing successfully detects the buffer overflow condition and terminates execution, resulting in output similar to the following:
*** buffer overflow detected ***: terminated
Program received signal SIGABRT, Aborted.
When enabled and functioning, this prevents exploitation of the vulnerability and effectively reduces its impact to denial-of-service. While this mitigates the vulnerability, it does not eliminate it, as some platforms may not support _FORTIFY_SOURCE and some environments may require the library to be built with that feature disabled, which can be achieved via compiler flags.
Since the data written by strcpy comes from the input file, this vulnerability allows for a heap-based buffer overflow where the data written is controlled by the attacker. Depending on the setup of the heap, this flaw can potentially lead to arbitrary code execution.
==161232==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x519000001d78 at pc 0x7ffff78a7923 bp 0x7fffffffa7e0 sp 0x7fffffff9f88
WRITE of size 736 at 0x519000001d78 thread T0
#0 0x7ffff78a7922 in strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:563
#1 0x7ffff732d149 in sopen_extended /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:5747
#2 0x5555555554c7 in main harness.cpp:38
#3 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#4 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#5 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 306248de9588a689c6260fb7e53686ad06b99425)
0x519000001d78 is located 0 bytes after 1016-byte region [0x519000001980,0x519000001d78)
allocated by thread T0 here:
#0 0x7ffff78fd9c7 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x7ffff72c5fcd in constructHDR /home/mbereza/Projects/BioSig/biosig-code/biosig4c++/biosig.c:1170
#2 0x555555555411 in main harness.cpp:35
#3 0x7ffff6a2a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#4 0x7ffff6a2a28a in __libc_start_main_impl ../csu/libc-start.c:360
#5 0x555555555204 in _start (/home/mbereza/Projects/BioSig/repro_master/harness+0x1204) (BuildId: 306248de9588a689c6260fb7e53686ad06b99425)
SUMMARY: AddressSanitizer: heap-buffer-overflow ../../../../src/libsanitizer/asan/asan_interceptors.cpp:563 in strcpy
Shadow bytes around the buggy address:
0x519000001a80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001b00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001b80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001c00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001c80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x519000001d00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00[fa]
0x519000001d80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x519000001e00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x519000001e80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001f00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x519000001f80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
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
A simple fix may involve replacing this vulnerable call to strcpy with a call to strncpy, limiting the number of bytes copied to the size available in the destination buffer. A more comprehensive solution may involve changing the parsing logic for WFT file header fields to enforce the fixed offsets/sizes of fields as described in the format specification instead of delineating fields based on null characters alone. As it stands, the fields as parsed by libbiosig could wind up at arbitrarily large offsets within the input file and with arbitrarily large sizes. This is both contrary to the spec and gives attackers a lot more flexibility with what data gets written where. Enforcement of these fixed sizes could inform the sizes needed for any buffers used to store the fields, helping to avoid similar vulnerabilities from cropping up in the future.
2026-02-12 - Vendor Disclosure
2026-02-15 - Vendor Patch Release
2026-03-03 - Public Release
Discovered by Mark Bereza and Lilith >_> of Cisco Talos.