Don't allocate from a declared length before checking the source can supply it - #121
Open
ahkarl13 wants to merge 4 commits into
Open
Don't allocate from a declared length before checking the source can supply it#121ahkarl13 wants to merge 4 commits into
ahkarl13 wants to merge 4 commits into
Conversation
parse_element_body() allocated length + 1 bytes straight from the
element header and only then called dcm_require() to read the value, so
a truncated or hostile file could make libdicom attempt an allocation of
up to 4GB before discovering the data is not there.
Reachable on the very first parse step. A 177 byte input whose File Meta
Information declares an element of 0xFFFFFFFF bytes triggers a 4GB
calloc() inside dcm_filehandle_get_file_meta():
ERROR: libFuzzer: out-of-memory (malloc(4294967296))
ImagingDataCommons#8 dcm_calloc src/dicom.c:45
ImagingDataCommons#9 parse_element_body src/dicom-parse.c:606
ImagingDataCommons#10 dcm_parse_group src/dicom-parse.c:851
ImagingDataCommons#11 dcm_filehandle_read_file_meta src/dicom-file.c:575
ImagingDataCommons#12 dcm_filehandle_get_file_meta src/dicom-file.c:639
For a service that ingests DICOM from outside, a handful of small files
is enough to exhaust memory; on a 32-bit or memory-capped host a single
one aborts the process.
Add dcm_remaining(), which reports the bytes left in a seekable source
via the existing DcmIO seek method, and refuse an element that claims
more than the source holds. Sources that cannot report a size (a custom
DcmIO whose seek fails) return -1 and keep the previous behaviour, so
streaming clients are unaffected. PixelData is routed to
parse_pixeldata() before this point and is not affected either.
The upstream check_dicom suite passes, and dcm-dump output is byte
identical on all seven files in data/test_files.
Found with the libFuzzer target added in this branch.
dcm_parse_encapsulated_frame() reallocated the frame buffer to
frame_length + fragment_length using the fragment length straight from
the item header, checking only that the total stayed under 4GB. A 395
byte file declaring a fragment of 0xFF000008 therefore triggered:
ERROR: libFuzzer: out-of-memory (malloc(4278190088))
ImagingDataCommons#8 dcm_realloc src/dicom.c:58
ImagingDataCommons#9 dcm_parse_encapsulated_frame src/dicom-parse.c:1131
ImagingDataCommons#10 dcm_filehandle_read_frame src/dicom-file.c:1381
Same shape as the element-body allocation fixed earlier, in a path that
fix did not cover. Reuse dcm_remaining() to reject a fragment longer
than the source can supply. Sources that cannot report a size keep the
previous behaviour.
check_dicom passes; dcm-dump output is byte identical on all seven
files in data/test_files.
Third instance of the same shape: parse_pixeldata_item() allocated
item_length bytes from the item header before dcm_require() checked the
data was present.
ERROR: libFuzzer: out-of-memory (malloc(822083592))
ImagingDataCommons#8 dcm_calloc src/dicom.c:45
ImagingDataCommons#9 parse_pixeldata_item src/dicom-parse.c:473
ImagingDataCommons#10 parse_pixeldata src/dicom-parse.c:560
Apply the dcm_remaining() guard here too.
check_dicom passes; dcm-dump output is byte identical on all seven
files in data/test_files.
dcm_parse_frame() computed
*length = rows * columns * samples_per_pixel * (bits_allocated / 8);
with four uint16_t operands, which promote to int, so the product
overflows *signed* arithmetic -- undefined behaviour -- before it ever
reaches the uint32_t it is stored in:
dicom-parse.c:1078:41: runtime error: signed integer overflow:
1098696192 * 2 cannot be represented in type 'int'
and the wrapped value is then handed to DCM_MALLOC:
ERROR: libFuzzer: out-of-memory (malloc(2197392384))
Widening alone is not enough: 65535 x 65535 x 4 x 8 does not fit in 32
bits either, and *length is uint32_t in the API. Compute in uint64_t,
reject a size that cannot be represented or that the source cannot
supply, and only then allocate.
Worth noting for callers: consumers that recompute the frame size from
the rows/columns/samples/bits on DcmFrame would read past the buffer
when the product wrapped small.
check_dicom passes; dcm-dump output is byte identical on all seven
files in data/test_files.
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.
Four places take a length straight out of a file header and allocate it before
dcm_require()checks the bytes are actually there. Small crafted files then produce very large allocations:The last one is a bit different and worth reading on its own.
dcm_parse_frame()computedwith four
uint16_toperands. They promote toint, so the product overflows signed arithmetic — undefined behaviour — before it ever reaches theuint32_tit lands in:Widening isn't sufficient either — 65535 × 65535 × 4 × 8 doesn't fit in 32 bits — so it computes in
uint64_tand range-checks. Worth flagging for consumers: anything that recomputes the frame size from the rows/columns/samples/bits onDcmFramewould read past the buffer when that product wrapped small.The guard itself is a small helper,
dcm_remaining(), built on the seek methodDcmIOalready has. A customDcmIOwhose seek fails returns -1 and keeps today's behaviour, so streaming clients are unaffected.Three of these are the same shape in three places, which probably means the check wants to live somewhere shared rather than at each call site — happy to restructure it however you'd prefer.
check_dicompasses anddcm-dumpoutput is byte-identical on all seven files indata/test_filesafter each patch. Reproducers attached.