Skip to content

Another NumberOfFrames error pile-up, plus the fuzz target that found it - #123

Open
ahkarl13 wants to merge 2 commits into
ImagingDataCommons:mainfrom
ahkarl13:pr/stale-error-harness
Open

Another NumberOfFrames error pile-up, plus the fuzz target that found it#123
ahkarl13 wants to merge 2 commits into
ImagingDataCommons:mainfrom
ahkarl13:pr/stale-error-harness

Conversation

@ahkarl13

Copy link
Copy Markdown

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

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

jcupitt commented Sep 1, 2026

Copy link
Copy Markdown
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.

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