Skip to content

[io] Validate collection sizes read from corrupt data - #23831

Open
chrisburr wants to merge 5 commits into
root-project:masterfrom
chrisburr:io-corrupt-collection-size
Open

chrisburr wants to merge 5 commits into
root-project:masterfrom
chrisburr:io-corrupt-collection-size

Conversation

@chrisburr

Copy link
Copy Markdown
Contributor

In LHCb I often see jobs failing with out-of-memory errors due to corrupted inputs or bad reads. These can be quite painful to debug, especially at some grid sites. For the example I studied to produce this MR a corrupt file stored the size of a std::vector as 0xa2c35db6. ROOT treated this as 2,730,712,502 elements and allocated 43.7 GB inside TBranch::GetEntry.

This PR aims to make reading corrupt data fail gracefully instead of allocating huge amounts of memory or reading past the end of the buffer. This appears to be similar to #21814.

The case I was studying is fixed by the first commit (5defd6a) which now fails gracefully with:

Error in <TStreamerInfoActions::ReadSTLMemberWiseSameClass>: Corrupt data: the collection of type vector<LHCb::RawEvent::Bank,...> read from 00383420_00136290_1.bandqlow.dst has a negative number of elements (-1564254794); reading it as empty.

I have an additional check a1aaa66 that further ensures that the remaining size of the buffer could plausibly contain the requested data.

Both of these main commits rely on a new private header file core/meta/res/ROOT/CheckCollectionSize.hxx. This check assumes each element takes at least one byte and its only applied where that is guaranteed.

When manually reviewing the changes suggested by Claude I kept finding more issues which are along the same lines. This led to 8cc6bfd, 3bec77b and f9dfd35. I could keep going through finding more cases but I thought it was better to open the PR to get feedback.

Feel free to close this PR if you'd rather solve this a different way or convert this into a bug report instead.

Changes or fixes:

  • Avoid allocating arbitrary sizes of memory when reading corrupt files.

Checklist:

  • tested changes locally
  • updated the docs (if necessary)

This PR was prepared with the assistance of Claude Code (Opus 5.5).

The element count of an STL collection is stored as an Int_t but passed
on to TVirtualCollectionProxy::Allocate(UInt_t) or std::vector::resize.
When the count comes from corrupt data and is negative, it wraps to
billions of elements: in an LHCb DST, a count of 0xa2c35db6 made ROOT
allocate and value-initialise 43.7 GB before the reading code could
notice anything, getting grid jobs OOM-killed, or throw std::bad_alloc
or std::length_error out of TBranch::GetEntry.

TBufferFile already rejects such counts for arrays and TBranchElement
for split collections, but the collection streaming actions and the
collection proxies, including the emulated ones used when reading
without a dictionary, did not. Report an error and read the collection
as empty instead. The rest of the object is then read from a wrong
position until the next byte count check, if any, resynchronises the
buffer; it is no worse than reading corrupt data was before. The
generic collection streamers used to skip negative sizes silently,
either reading the collection as empty or leaving its previous content;
they now report them and read the collection as empty too.

TConvertClonesArrayToProxy takes the absolute value of the count for
backward compatibility, which is undefined for INT_MIN; keep that one
negative so that it is rejected too.

The check lives in a private header of core/meta, so that the
streamers of libCore containers can share it.

Assisted-by: claude-code:claude-opus-5-5
A corrupt collection size that is positive but larger than the data
can hold is not caught by the negative size check: the collection is
resized to up to 2^31 elements, and when reading member-wise the
streaming actions then run past the end of the buffer.

When each element is known to occupy at least one byte on file, a size
larger than the number of bytes left in the buffer is impossible, so
report it and read the collection as empty. This holds for collections
of basic types, strings and pointers written with an object tag, and
for member-wise collections whose value class has, in its layout on
file, at least one data member of such a type. It does not hold for
objects of class type, which can be written in zero bytes by a custom
streamer, including those pointed to by a `//->` member, nor for
data members with a custom member streamer, nor for pointers to
fundamental types, which collection proxies do not stream at all.
Those are left unchecked. The check is restricted to
TBufferFile, and its cost is only paid for sizes that exceed the
remaining bytes.

Bounding by one byte per element rather than by the actual size of an
element keeps the check simple; it still lets through sizes up to the
size of the buffer, but no longer arbitrary ones.

Assisted-by: claude-code:claude-opus-5-5
Strings of more than 254 characters store their length as an Int_t,
which ReadStdString and ReadTString passed to std::string::resize and
TString::Clobber unchecked. A corrupt length can thus throw
std::length_error, or allocate up to 2 GB, before ReadFastArray finds
that the buffer does not hold that many characters. ReadCharStar
similarly allocated any positive length.

Reject negative lengths, and lengths larger than the bytes left in the
buffer, and read the string as empty. ReadCharStar keeps ignoring
negative lengths silently, as before.

Skipping a char* data member that was removed now goes through
ReadCharStar too, rather than allocating the buffer itself. This reads
the same bytes from a TBufferFile; TBufferJSON and TBufferXML now skip
the member the way they wrote it.

Assisted-by: claude-code:claude-opus-5-5
TObjArray, TRefArray and TClonesArray read their number of objects as
an Int_t and expand their storage to that size before reading the
objects. A corrupt count can thus allocate up to 2^31 slots, and for
TClonesArray as many objects, before reading them past the end of the
buffer. TClonesArray also takes the absolute value of negative counts,
which is undefined for INT_MIN and left it negative.

Reject negative counts, as for STL collections. Each object of a
TObjArray and TRefArray occupies at least 4 bytes on file, and each
object of a TClonesArray one byte unless the streamer is bypassed, in
which case it depends on the layout of the class. When that holds, a
count larger than the bytes left in the buffer can only come from
corrupt data, so report it and read the collection as empty too.

Assisted-by: claude-code:claude-opus-5-5
The TArray streamers read their size as an Int_t and resize the array
to it before ReadFastArray checks that the buffer holds that many
elements, so a corrupt size can allocate up to 16 GB for a TArrayD.
These arrays hold e.g. the bin contents of histograms.

Each element occupies at least one byte on file, so reject sizes that
are negative or larger than the bytes left in the buffer, and read the
array as empty, as for the other collections.

Assisted-by: claude-code:claude-opus-5-5

This branch has not been deployed

No deployments
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.

2 participants