Skip to content

BUG: Do not use sscanf's output when sscanf matched nothing - #67

Merged
hjmjohnson merged 1 commit into
InsightSoftwareConsortium:masterfrom
gdevenyi:fix/ascii-scan-failure
Sep 22, 2026
Merged

hjmjohnson merged 1 commit into
InsightSoftwareConsortium:masterfrom
gdevenyi:fix/ascii-scan-failure

Conversation

@gdevenyi

Copy link
Copy Markdown

nifti_image_from_ascii() scans its input three times with

ii = sscanf( str+spos , "%1023s%n" , lhs , &nn ) ; spos += nn ;
if( ii == 0 || strcmp(lhs,"<nifti_image") != 0 ) return NULL ;

Two things are wrong with that pair of lines. sscanf() returns EOF, not
0, when the input ends before anything is matched, so ii == 0 does not
detect the failure; and nn is added to spos before ii is looked at, so
the position advances by an indeterminate amount whichever value comes
back.

The first of the three is reachable from any string that holds nothing
but whitespace: sscanf() returns EOF, the test passes, and strcmp()
walks an uninitialized 1024 byte stack buffer looking for a terminator
it is not guaranteed to find. MemorySanitizer:

WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 nifti_image_from_ascii nifti2_io.c:8836
  Uninitialized value was created by an allocation of 'lhs' in the
  stack frame ... nifti2_io.c:8826

All three now test for the one successful conversion and only then
advance spos, which is what the surrounding code assumed. The two
inside the loop cannot fail today, because whitespace is skipped and
end of string is checked before each, but they are the same pattern and
are corrected the same way.

The same function, with the same three lines, exists in nifti1_io.c.

Found by fuzzing nifti_image_from_ascii() with clang's libFuzzer under
MemorySanitizer.


How to reproduce, and the sanitizer builds used, are in the commit message. Found with clang libFuzzer harnesses over nifti_image_read(), the two header converters, nifti_image_from_ascii() and axml_read_buf(), run under AddressSanitizer, UndefinedBehaviorSanitizer and MemorySanitizer.

One commit on master, independent of my other open PRs. ctest passes on master with this branch alone (343/343, excluding the nifti_c22_copy_image failure that #31 fixes), and on the union of all my branches under ASan+UBSan, TSan and MSan.

@gdevenyi
gdevenyi marked this pull request as ready for review August 15, 2026 21:30
@hjmjohnson
hjmjohnson force-pushed the fix/ascii-scan-failure branch 2 times, most recently from b46aaf9 to dfec2f8 Compare September 22, 2026 02:10
@hjmjohnson
hjmjohnson force-pushed the fix/ascii-scan-failure branch from dfec2f8 to ead5873 Compare September 22, 2026 10:49
@hjmjohnson
hjmjohnson force-pushed the fix/ascii-scan-failure branch from ead5873 to f61f914 Compare September 22, 2026 13:50
@hjmjohnson

Copy link
Copy Markdown
Member

Rebased onto current master (b4876bf). Full suite: 362/362 pass.

No test added — NOT-DEMONSTRABLE on this host. The reasoning is below; if you would rather have a test that pins the NULL return for whitespace-only input even though it passes with and without the fix, say so and I will add it.

Rebase and commit-message cleanup

The ~29 shared-base commits are already on master under new SHAs, so the rebase replayed only the single commit this PR owns. No conflicts.

Message cleanup: removed the Co-Authored-By: trailer naming an AI tool and the Claude-Session: https://claude.ai/... URL, and condensed the body to the project's line limits. The body now also states the reachability finding below. Author, committer, dates and tree are unchanged.

Why there is no test

Three separate checks, all of which have to hold for a ctest to be worth adding:

  1. The failing scan is not reachable from a file. Every in-tree reader (nifti_image_read, nifti_read_n1_hdr, nifti_read_n2_hdr) gates the ASCII path on has_ascii_header(), which requires the first twelve bytes to be exactly <nifti_image. A whitespace-only .nia never reaches nifti_image_from_ascii(). The two scans inside the loop are guarded by the preceding whitespace skip and '\0' test and cannot fail, as the commit message already says. So the only caller that can trigger this is a direct call to the published nifti_image_from_ascii().

  2. The consequence is not behavioral. With the old code, ii == 0 fails to catch EOF, strcmp() reads one uninitialized byte of lhs, and the comparison almost certainly mismatches, so the function returns NULL — the same result as with the fix. There is no output, return value, or file state that differs.

  3. The right instrument is not available here. This is a use-of-uninitialized-value, which is MemorySanitizer's territory, and MSan does not exist on this platform:

clang: error: unsupported option '-fsanitize=memory' for target 'arm64-apple-darwin25.6.0'

I also tried -fsanitize=address -ftrivial-auto-var-init=pattern as a substitute, calling the function directly with " \t\n " against both the fixed and the unfixed source. Both print result=0x0 with no sanitizer report: the pattern byte 0xAA differs from '<' at index 0, so strcmp() returns after one byte and never runs off the buffer. That confirms the read is real but one byte wide, and only MSan can see it.

The fix is still right — EOF is not 0, and spos += nn before testing ii advances by an indeterminate amount — it just is not something a ctest on this machine can be made to fail on. A Linux MSan job in CI would cover it.

nifti_image_from_ascii() scans its input three times with

    ii = sscanf( str+spos , "%1023s%n" , lhs , &nn ) ; spos += nn ;
    if( ii == 0 || strcmp(lhs,"<nifti_image") != 0 ) return NULL ;

Two things are wrong with that pair of lines.  sscanf() returns EOF,
not 0, when the input ends before anything is matched, so ii == 0 does
not detect the failure; and nn is added to spos before ii is looked
at, so the position advances by an indeterminate amount.

For a string holding nothing but whitespace the first scan returns
EOF, the test passes, and strcmp() reads the uninitialized 1024 byte
stack buffer.  MemorySanitizer:

    WARNING: MemorySanitizer: use-of-uninitialized-value
        #0 nifti_image_from_ascii nifti2_io.c:8836

All three now test for the one successful conversion and only then
advance spos, which is what the surrounding code assumed.  The two
inside the loop cannot fail today, because whitespace is skipped and
end of string is checked before each, and the in-tree readers gate on
has_ascii_header(), so the first is reached only through a direct call
to this published function.  The same three lines exist in
nifti1_io.c and are corrected there too.

Found by fuzzing nifti_image_from_ascii() with libFuzzer under
MemorySanitizer.
@hjmjohnson
hjmjohnson force-pushed the fix/ascii-scan-failure branch from f61f914 to 599279c Compare September 22, 2026 15:54

@hjmjohnson hjmjohnson left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no comment.

@hjmjohnson
hjmjohnson merged commit 70fa3a6 into InsightSoftwareConsortium:master Sep 22, 2026
25 checks passed
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.

3 participants