Skip to content

Don't allocate from a declared length before checking the source can supply it - #121

Open
ahkarl13 wants to merge 4 commits into
ImagingDataCommons:mainfrom
ahkarl13:pr/alloc-guards
Open

Don't allocate from a declared length before checking the source can supply it#121
ahkarl13 wants to merge 4 commits into
ImagingDataCommons:mainfrom
ahkarl13:pr/alloc-guards

Conversation

@ahkarl13

Copy link
Copy Markdown

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:

Where | Input | Requested -- | -- | -- parse_element_body() — reached from dcm_filehandle_get_file_meta(), the first parse step | 177 B | 4 GiB dcm_parse_encapsulated_frame() — from dcm_filehandle_read_frame() | 395 B | 4.27 GiB parse_pixeldata_item() — from parse_pixeldata() | small | 822 MB dcm_parse_frame() — frame size arithmetic (below) | small | 2.2 GB
ERROR: libFuzzer: out-of-memory (malloc(4294967296))
    #8  dcm_calloc                     src/dicom.c:45
    #9  parse_element_body             src/dicom-parse.c:606
    #10 dcm_parse_group                src/dicom-parse.c:851
    #11 dcm_filehandle_read_file_meta  src/dicom-file.c:575
    #12 dcm_filehandle_get_file_meta   src/dicom-file.c:639

The last one is a bit different and worth reading on its own. dcm_parse_frame() computed

c
*length = rows * columns * samples_per_pixel * (bits_allocated / 8);

with four uint16_t operands. They promote to int, so the product overflows signed arithmetic — undefined behaviour — before it ever reaches the uint32_t it lands in:

dicom-parse.c:1078:41: runtime error: signed integer overflow:
                       1098696192 * 2 cannot be represented in type 'int'

Widening isn't sufficient either — 65535 × 65535 × 4 × 8 doesn't fit in 32 bits — so it computes in uint64_t and range-checks. Worth flagging for consumers: anything that recomputes the frame size from the rows/columns/samples/bits on DcmFrame would read past the buffer when that product wrapped small.

The guard itself is a small helper, dcm_remaining(), built on the seek method DcmIO already has. A custom DcmIO whose 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_dicom passes and dcm-dump output is byte-identical on all seven files in data/test_files after each patch. Reproducers attached.

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