Another NumberOfFrames error pile-up, plus the fuzz target that found it - #123
Open
ahkarl13 wants to merge 2 commits into
Open
Another NumberOfFrames error pile-up, plus the fuzz target that found it#123ahkarl13 wants to merge 2 commits into
ahkarl13 wants to merge 2 commits into
Conversation
get_num_frames() treats a missing NumberOfFrames (0028,0008) as a
non-error and defaults to a single frame, but it passed the caller's
DcmError** down into get_tag_str(). dcm_dataset_get() therefore sets an
error object that is never returned and never freed.
Two consequences, both reachable from a valid file that simply has no
NumberOfFrames element (for example a single-frame CT):
* the DcmError leaks -- LeakSanitizer reports 105 bytes in 3
allocations per call;
* dcm_error_set() refuses to overwrite an error that is already set
(it logs "DcmError set twice" and returns), so every later failure
in the same call chain is silently discarded and the caller is
handed the earlier, unrelated "could not find data element"
message instead of the real reason.
get_frame_offset() already passes NULL when probing an optional tag.
Do the same here.
Before, on data/test_files/ct_brain_single.dcm:
after get_metadata_subset: error=getting data element '00280008' ...
read_frame(99999) -> NULL
reported error: could not find data element | getting data element '00280008' ...
After:
after get_metadata_subset: error=(none)
read_frame(99999) -> NULL
reported error: reading frame item failed | frame number must be less than 1
Found with a libFuzzer target built against the public filehandle API.
Adds a libFuzzer target that drives dcm_filehandle_get_file_meta(), dcm_filehandle_get_metadata_subset() and dcm_filehandle_read_frame() over untrusted input, behind -Dfuzzers=true so ordinary builds are untouched. -Dfuzzing_engine= takes an external engine flag (OSS-Fuzz's $LIB_FUZZING_ENGINE). The seven files in data/test_files work as a seed corpus and a small DICOM dictionary is included for the mutator. The target clears the error after every call, not only on failure branches: libdicom can return success with an error still set, so a target that clears only on failure reports those as leaks of its own.
Collaborator
|
Hi @ahkarl13, thanks for these PRs. I'm trapped in another project right now, but I should be free in a week or so and I'll look over these then. |
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.
This is the same shape as the pile-ups you fixed in #70 — one more instance, in get_num_frames().
It treats a missing NumberOfFrames as a non-error and defaults to a single frame, which is right, but passes the caller's DcmError** into the lookup and then swallows the failure. The error object leaks, and since dcm_error_set() won't overwrite an error that's already set, every later failure in that chain is discarded:
=== BEFORE, on data/test_files/ct_brain_single.dcm ===
after get_metadata_subset: error=getting data element '00280008' from data set failed
read_frame(99999) -> NULL
reported error: could not find data element | getting data element '00280008' ...
=== AFTER ===
after get_metadata_subset: error=(none)
read_frame(99999) -> NULL
reported error: reading frame item failed | frame number must be less than 1
Note the first line before the fix: a successful call leaves a non-NULL error behind, so a caller written as if (error) { ... } treats a good CT scan as a failure. get_frame_offset() thirty lines down already passes NULL for exactly this case, so the fix is to match it.
The second patch adds the libFuzzer target that found all of this, behind -Dfuzzers=true so ordinary builds are untouched. -Dfuzzing_engine= takes an external engine flag, which is what OSS-Fuzz's $LIB_FUZZING_ENGINE provides. The seven files in data/test_files work as a seed corpus and there's a small DICOM dictionary for the mutator.
One thing about the target worth knowing: it clears the error after every call, not just on failure branches. Because libdicom can return success with an error still set — the case above — a target that only clears on failure reports those as leaks of its own. That cost me a false positive before I spotted it.
After all of these, a 7.5 million execution run with ASan, UBSan and LeakSanitizer came back clean. The grown corpus is attached if it's useful.
While fuzzing I also noticed "DcmError set twice" still fires from parse_element_header:315, parse_element_sequence:429, parse_pixeldata_item:473 and parse_element_body:705 on malformed files — same class, wider than this one function. Nothing crashes; the caller just gets whichever error happened first rather than the specific one. Didn't want to guess at a fix for that one — is a dcm_error_replace() in the error layer the direction you'd want, or per-site clears?
libdicom-grown-corpus.zip