Skip to content

Fix out-of-bounds reads when marshalling SDK strings - #4

Open
kscrudders wants to merge 1 commit into
JacobZuo:masterfrom
kscrudders:fix/limstr-out-of-bounds-read
Open

Fix out-of-bounds reads when marshalling SDK strings#4
kscrudders wants to merge 1 commit into
JacobZuo:masterfrom
kscrudders:fix/limstr-out-of-bounds-read

Conversation

@kscrudders

@kscrudders kscrudders commented Aug 19, 2026

Copy link
Copy Markdown

When running the meta data pull for some .nd2 files I would experience a hard craft in Matlab (versions 2022-2026, most recent version tested 2026). This has occured for many years and it is random. Restarting Matlab and running the function on the same .nd2 file would more often than not resolve the issue. The crash is more likely to happen if I am pulling the meta data from multiple files in a loop.

Using Cluade Code Opus 5 with thinking, a solution was proposed. I validated that:

  1. The change does not change the output from the functions.
  2. The hard crash error was resolved.

I've not experienced a hard crash since updating the functions as described below. And I've tested the outputs from the old version and the new version. They match for a variety of .nd2 files.

Testing script:

filePath = "H:\KLS\20260511_KLS_U2OS_BigData\01_eGFP_LNP_E\00_Test_5x5_acq.nd2";

addpath(genpath('E:\01_Matlab\Online\Jacob_Zuo'));
rmpath(genpath('C:\Users\kevin\Documents\00_Claude\01_Science\06_Refactor_ND2MetaData\ND2_Dependencies'));

meta_data = KLS_ParseND2Metadata(filePath);

rmpath(genpath('E:\01_Matlab\Online\Jacob_Zuo'));
addpath(genpath('C:\Users\kevin\Documents\00_Claude\01_Science\06_Refactor_ND2MetaData\ND2_Dependencies'));

meta_data2 = KLS_ParseND2Metadata(filePath);

isIdentical = isequaln(meta_data, meta_data2);
if isIdentical == 1
disp('Are metadata structs identical? Yes');
else
disp('Are metadata structs identical? No');
end

This is an LLM generated pull request that was tested by me.

Below is the Claude Code summary:

Summary

ND2Info, ND2Open and SeqInfo read past the end of the heap buffers returned by the ND2 SDK. This intermittently kills the whole MATLAB process with an access violation instead of raising a catchable error, which makes it look random: the same file works after a MATLAB restart, and failures get more likely the longer you loop over files.

Nd2ReadSdk.h documents Lim_FileGetAttributes, Lim_FileGetTextinfo, Lim_FileGetMetadata, Lim_FileGetFrameMetadata and Lim_FileGetExperiment as returning a LIMSTR allocated to exactly strlen+1 bytes, and offers no size query. The current readers guess that length instead of finding it:

TestLength=3000;
setdatatype(TextInfo, 'int8Ptr', TestLength)
TextInfoValue = TextInfo.Value';        % memcpy of TestLength bytes
while isempty(find(TextInfoValue == 0, 1))
    TestLength=TestLength*2;            % 3000 -> 6000 -> 12000 ...

setdatatype only records the declared extent; the .Value that follows memcpys all of it out of the raw address. Any declared length above the real allocation reads off the end of the block.

Evidence

I measured the true lengths by scanning one byte at a time (a scan that cannot over-read), on a 5-channel TIRF file:

SDK getter true bytes bytes read today over-read
Lim_FileGetAttributes 209 500 +290 on every call
Lim_FileGetTextinfo 6141 12000 +5858
Lim_FileGetMetadata 7097 12000 +4902
Lim_FileGetExperiment 407 3000 +2591

Three MATLAB R2026a crash dumps all show the same fault, and they pin it precisely:

  • the stack is at_rdot_list_mcos_no_check (dot-index of an MCOS object) -> omDirectGetProperty -> libmwcli2.dll (the loadlibrary/calllib layer) -> memcpy in VCRUNTIME140, i.e. a lib.pointer.Value read and nothing else;
  • R8 = 0x2ee0 = 12000 in all three, which is memcpy's byte count and exactly the third rung of the 3000 -> 6000 -> 12000 ladder above.

12000 - RCX gives the bytes copied before each fault: 7424, 7456, 7360. All are multiples of 32 and span only 96 bytes, consistent with an AVX copy loop running off the end of an ~7.4 KB accessible extent.

The mechanism reproduces on demand. Reading the textinfo string repeatedly at increasing declared lengths, 20 000 iterations each:

declared read true buffer result
6 142 B 6 142 B survived
12 000 B 6 142 B survived in a clean headless heap
65 536 B 6 142 B survived
1 048 576 B 6 142 B segmentation fault

Whether a given over-read faults depends only on where the allocation sits relative to the end of a committed region, which is why it is intermittent. It is much easier to hit in a desktop or Live Editor session, whose heap is more fragmented, than in a fresh -batch process.

What this changes

Adds ND2ReadString.m, which finds the terminator one byte at a time. Byte k is read only after bytes 0..k-1 are known to be non-NUL, which proves the allocation holds at least k+1 bytes, so no read is ever out of bounds. The payload is then taken in a single exact read. The four guessing loops become one call each, so the call sites get shorter.

Each call site passes the pointer type it used before, so text handling is unchanged byte for byte. ND2Info keeps 'int8Ptr', where char() folds bytes above 127 to NUL and CheckInfo's Description(Description == 0) = ' ' compensates. ND2Open and SeqInfo keep 'uint8Ptr'. I deliberately did not unify the encoding: that is a behaviour-changing decision and downstream parsing is tuned to the current bytes.

SeqInfo's existing free was an overrun of the same kind. It re-declared the string as 'voidPtr' with the same element count before calling Lim_FileFreeString, and calllib marshals the full declared extent of a lib.pointer argument, so MATLAB read eight bytes per element straight back through the overrun. ND2FreeString.m shrinks the extent first, and SeqInfo uses it.

Incidentally this removes a latent bug in SeqInfo's frame loop, which reused TestLength from the frame 0 probe: a frame with longer metadata produced an empty index and an error out of jsondecode. Finding the terminator per frame removes that.

Scope

This PR is limited to reads that can fault the process. It deliberately leaves alone:

  • the missing Lim_FileFreeString calls in ND2Info and ND2Open;
  • the missing Lim_DestroyPicture in ND2ReadSingle and ND2TIF;
  • the unchecked Lim_FileOpenForReadUtf8 return value.

Those are leaks and error-reporting issues rather than crashes, and they are ready as a separate follow-up if you want them.

Testing

Windows 11, MATLAB R2026a Update 4, five 131 MB 5-channel .nd2 files.

  • 200 consecutive ND2Info parses on this branch: no crash, ~433 ms/file.
  • ND2Info output unchanged: 512x512, 5 components, numImages 50, CoordSize 1, 4199-character description; all channel names, exposures, wavelengths, powers and temperatures identical to before.
  • Verified against unmodified CheckInfo.m, so the Description offset arithmetic still holds.

Cost

The byte-at-a-time scan runs at roughly 8 us/byte, about 110 ms for all four strings on these files, inside a ~433 ms total per file.

I considered declaring these returns as cstring in a prototype file so MATLAB does the strlen in C. It is faster and equally safe to read, but it discards the pointer and so makes Lim_FileFreeString impossible, trading the crash for a guaranteed leak. The scan seemed the better bargain; happy to switch if you prefer.

Notes

No API changes. ND2Info, ND2Open, ND2Read, ND2ReadSingle, SeqInfo and ND2TIF keep their signatures and return values.

Lim_FileGetAttributes, Lim_FileGetTextinfo, Lim_FileGetMetadata,
Lim_FileGetFrameMetadata and Lim_FileGetExperiment each return a LIMSTR
allocated to exactly strlen+1 bytes, and the SDK offers no size query.
The readers guessed that length instead of finding it: setdatatype(p,
type, N) only records the declared extent, but the p.Value that follows
memcpys all N bytes out of the raw address, so every read past the
terminator ran off the end of the heap block.

Measured on a 5-channel TIRF file, the previous code read 12000 bytes
from a 6142-byte textinfo buffer and from a 7098-byte metadata buffer,
500 bytes from a 210-byte attributes buffer, and 3000 bytes from a
408-byte experiment buffer. Such a read usually lands on a mapped page
and appears to work, which is why it went unnoticed, but when the block
sits near the end of a committed region it faults and kills the MATLAB
process with an access violation rather than raising a catchable error.

Add ND2ReadString, which finds the terminator one byte at a time. Byte k
is read only after bytes 0..k-1 are known to be non-NUL, which proves the
allocation holds at least k+1 bytes, so no read is ever out of bounds.
The payload is then taken in a single exact read.

Each call site passes the pointer type it used before, so text handling
is unchanged byte for byte. ND2Info keeps 'int8Ptr', where char() folds
bytes above 127 to NUL and CheckInfo's Description(Description == 0) =
' ' compensates; ND2Open and SeqInfo keep 'uint8Ptr'.

SeqInfo's existing free was itself an overrun of the same kind: it
re-declared the string as 'voidPtr' with the same element count before
calling Lim_FileFreeString, and calllib marshals the full declared extent
of a lib.pointer argument, so MATLAB read eight bytes per element. Add
ND2FreeString, which shrinks the extent first, and use it there.

Incidentally this also removes a latent bug in SeqInfo's frame loop,
which reused TestLength from the frame 0 probe: a frame whose metadata
was longer produced an empty index and an error out of jsondecode.

This change is limited to reads that can fault the process. The missing
Lim_FileFreeString calls in ND2Info and ND2Open, the missing
Lim_DestroyPicture in ND2ReadSingle, and the unchecked
Lim_FileOpenForReadUtf8 return value are left for a separate change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant