Fix out-of-bounds reads when marshalling SDK strings - #4
Open
kscrudders wants to merge 1 commit into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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,ND2OpenandSeqInforead 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.hdocumentsLim_FileGetAttributes,Lim_FileGetTextinfo,Lim_FileGetMetadata,Lim_FileGetFrameMetadataandLim_FileGetExperimentas returning aLIMSTRallocated to exactlystrlen+1bytes, and offers no size query. The current readers guess that length instead of finding it:setdatatypeonly records the declared extent; the.Valuethat 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:
Lim_FileGetAttributesLim_FileGetTextinfoLim_FileGetMetadataLim_FileGetExperimentThree MATLAB R2026a crash dumps all show the same fault, and they pin it precisely:
at_rdot_list_mcos_no_check(dot-index of an MCOS object) ->omDirectGetProperty->libmwcli2.dll(theloadlibrary/callliblayer) ->memcpyinVCRUNTIME140, i.e. alib.pointer.Valueread and nothing else;R8 = 0x2ee0 = 12000in all three, which is memcpy's byte count and exactly the third rung of the3000 -> 6000 -> 12000ladder above.12000 - RCXgives 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:
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
-batchprocess.What this changes
Adds
ND2ReadString.m, which finds the terminator one byte at a time. Bytekis read only after bytes0..k-1are known to be non-NUL, which proves the allocation holds at leastk+1bytes, 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.
ND2Infokeeps'int8Ptr', wherechar()folds bytes above 127 to NUL andCheckInfo'sDescription(Description == 0) = ' 'compensates.ND2OpenandSeqInfokeep'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 callingLim_FileFreeString, andcalllibmarshals the full declared extent of alib.pointerargument, so MATLAB read eight bytes per element straight back through the overrun.ND2FreeString.mshrinks the extent first, andSeqInfouses it.Incidentally this removes a latent bug in
SeqInfo's frame loop, which reusedTestLengthfrom the frame 0 probe: a frame with longer metadata produced an empty index and an error out ofjsondecode. Finding the terminator per frame removes that.Scope
This PR is limited to reads that can fault the process. It deliberately leaves alone:
Lim_FileFreeStringcalls inND2InfoandND2Open;Lim_DestroyPictureinND2ReadSingleandND2TIF;Lim_FileOpenForReadUtf8return 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
.nd2files.ND2Infoparses on this branch: no crash, ~433 ms/file.ND2Infooutput unchanged: 512x512, 5 components,numImages50,CoordSize1, 4199-character description; all channel names, exposures, wavelengths, powers and temperatures identical to before.CheckInfo.m, so theDescriptionoffset 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
cstringin a prototype file so MATLAB does thestrlenin C. It is faster and equally safe to read, but it discards the pointer and so makesLim_FileFreeStringimpossible, 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,SeqInfoandND2TIFkeep their signatures and return values.