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,0×32,0,0x31,0,0×30,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 (countHeadLen) { // [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=, MODE=, hdr=0x519000001980, biosig_options=) 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.