Skip to content

ENH: code review of recent nifti_image_conflicted_names PR - #11

Closed
seanm wants to merge 15 commits into
InsightSoftwareConsortium:masterfrom
seanm:nifti_image_conflicted_names_followup
Closed

seanm wants to merge 15 commits into
InsightSoftwareConsortium:masterfrom
seanm:nifti_image_conflicted_names_followup

Conversation

@seanm

@seanm seanm commented Feb 4, 2025

Copy link
Copy Markdown
Collaborator

No description provided.

@seanm seanm mentioned this pull request Feb 4, 2025
@hjmjohnson
hjmjohnson force-pushed the nifti_image_conflicted_names_followup branch from cefe963 to a22bf88 Compare June 5, 2025 21:31
@hjmjohnson

Copy link
Copy Markdown
Member

@seanm I updated and fixed merge conflicts for this PR. Would you please review and update so that we could incorporate the removal of the "exit()" commands that could cause an application to crash from using this library?

@seanm

seanm commented Jun 5, 2025

Copy link
Copy Markdown
Collaborator Author

lgtm.

What do you want me to update? Not sure I understand that part of your comment...

@hjmjohnson

Copy link
Copy Markdown
Member

@seanm At a minimum, the commit message needs to be changed from WIP.

@hjmjohnson hjmjohnson changed the title WIP: code review of recent nifti_image_conflicted_names PR ENH: code review of recent nifti_image_conflicted_names PR Jun 5, 2025
@seanm

seanm commented Jun 6, 2025

Copy link
Copy Markdown
Collaborator Author

So you've already mutated my commits here, right? That means what's on my local disk is not the same. If I alter my commit message and push, I'll overwrite what you did I think. I'm not sure how to overcome that because my git-fu is weak...

@gdevenyi

Copy link
Copy Markdown

The exit() removal that @hjmjohnson asked for here is in #26, extended a bit.

This PR changes the two exit() calls in nifti2_io.c. nifti1_io.c has the same two and keeps them — its REJECT_COMPLEX branch still calls exit(13), only the message text changes. #26 does both libraries.

It also fixes the other defects in the same blocks, which are easy to miss because they sit on the lines either side of the exit():

free(basename);
char * gzname = (char *)calloc(strlen(hdrname) + 8, sizeof(char));
strcpy(gzname, hdrname);                        /* gzname unchecked */
...
    fprintf(stderr, "... basename (*.nii, *.nii.gz): %s\n",
            basename);                          /* freed above */
    exit(134);

basename is freed and then read by the fprintf that reports the error, the calloc result is used by strcpy/strcat without a NULL check, and hdrname leaks on that path. In nifti_image_read(), the REJECT_COMPLEX block tests nim->datatype before the if (nim == NULL) check immediately below it, so a header that failed to convert dereferences a null pointer instead of reporting the error; #26 moves the block after the check.

Behaviour change worth stating either way: callers that relied on the process dying now get NULL back, which is the point, but a caller that never checked will continue with a NULL pointer where it previously exited.

The doPigz/doPigz2 half of this PR is handled differently. Making them static removes two exported symbols from a PIGZ-enabled build, so #26 gives them file-local prototypes instead — same effect on -Wmissing-prototypes, no ABI change. Happy to be argued out of that if you would rather they were static.

@seanm — on your last comment about not knowing how to update the commit message after @hjmjohnson rebased your branch: git fetch origin pull/11/head && git checkout FETCH_HEAD && git commit --amend then force-push to your branch gets you there.

hjmjohnson and others added 10 commits September 21, 2026 19:42
Two of the five Build and Test jobs have never run to completion.

sanitize-clang-linux invokes scan-build, which lives in clang-tools and
was not installed, so the job exits 127 before configuring.

rel-clang-macos asks brew for "sed", which is not a formula; brew fails
the step and the job exits 1 before configuring. The GNU sed the
dashboard scripts expect is gnu-sed.

Both predate the workflow's first successful run, so neither has
regressed; the trigger named the wrong branch until recently and the
jobs never executed.
…onsortium/fix/buildyml-jobs

COMP: Install the tools the analysis jobs actually invoke
loc_strnlen measures a string that need not be terminated, but it
dereferences before testing the bound, so when no NUL appears in the
first maxlen bytes the last iteration reads str[maxlen].

Both callers pass an unterminated buffer: axml_read_buf takes the
caller's buffer and its length, and axml_read_file measures what
fread returned, which fills the buffer for any larger file.

AddressSanitizer on a buffer sized to its content reports a
heap-buffer-overflow read 0 bytes after a 324-byte region. The
returned length is unchanged wherever the old order was in bounds.
…l-strnlen-bound

BUG: Stop loc_strnlen reading one byte past the buffer it is given
Both header converters multiply the dimensions into nim->nvox without
checking the product:

    for( ii=1, nim->nvox=1; ii <= nhdr.dim[0]; ii++ )
       nim->nvox *= nhdr.dim[ii];

Seven NIFTI-1 dimensions of 32767 are enough to overflow int64_t, and a
NIFTI-2 header needs only two dimensions to do it:

    nifti2_io.c:4838: runtime error: signed integer overflow:
        1152780773560811521 * 32767 cannot be represented in type 'int64_t'

Signed overflow is undefined, and what the compiler does produce is a
voxel count that no longer describes the file.  nvox then goes on to
nifti_get_volsize(), which multiplies it by nbyper for another
unchecked product, and that result is used as an allocation size and a
read length.

Both products are now checked before they are made, in the way the
surrounding code already reports a bad header.  A header that overflows
is rejected with a message instead of producing a silently wrong image.
No valid image is affected: an int64_t voxel count is more than any
file can hold.

Found by fuzzing the header converters with clang's libFuzzer under
UndefinedBehaviorSanitizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014m231RPDZPDbYDVxawxpjG
The first commit guards both converters in nifti2/nifti2_io.c.
niftilib/nifti1_io.c has the same loop in nifti_convert_nhdr2nim(), on
the same untrusted path, and was left unguarded.

  $ nifti1_tool -disp_nim -infiles huge.nii   # dim[0]=7, dim[1..7]=32767
    dim     32  8  7 32767 32767 32767 32767 32767 32767 32767
    nvox    64  1  -1073512449

The wrapped count then becomes the data allocation size and the result
of nifti_get_volsize().  With the guard the header is refused:

  ** ERROR: nifti_convert_nhdr2nim: dim[] overflows the voxel count

nifti_image.nvox is a size_t in this library and an int64_t in the
NIFTI-2 one, so the bound here is SIZE_MAX rather than INT64_MAX.
nifti_get_volsize() is size_t * size_t to match.  dim[0] is already
bounded by need_nhdr_swap(), and the loop that raises every dim to at
least 1 still runs first, so the dim[ii] > 0 test never has to reject.

Two more nvox loops remain in each library, in
nifti_update_dims_from_array() and update_nifti_image_for_brick_list().
Both take dims that a caller has already set rather than dims read from
a file, and reach them from nifti_tool's command line, so they are left
for a separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176ZLorDY784us6dFAbE1ZJ
…rter

nifti_convert_n2hdr2nim() uses nhdr.dim[0], the number of dimensions,
as a loop bound over the eight-element dim[] array:

    for( ii=2 ; ii <= nhdr.dim[0] ; ii++ )        ...
    for( ii=nhdr.dim[0]+1 ; ii <= 7 ; ii++ )      ...
    for( ii=1 ; ii <= nhdr.dim[0] ; ii++ )        ...

It never checks that dim[0] is in [0,7].  The NIFTI-1 converter gets
that check for free, because need_nhdr_swap() rejects a dim[0] outside
[1,7] in either byte order, but the NIFTI-2 path decides swapping from
sizeof_hdr and reaches the loops with whatever the file said.

A NIFTI-2 header with a large negative dim[0] therefore starts the
second loop at a wild negative index, reads far outside the header and
segfaults.  This is not confined to the header API: nifti_image_read()
gets there for any file with a valid 540 byte NIFTI-2 header, so a
604 byte file crashes nifti_tool:

    $ nifti_tool -disp_nim -infiles bad_n2_dim0.nii
    nifti2_io.c:5079: runtime error: index -6727636073941130588 out of
        bounds for type 'int64_t[8]'
    AddressSanitizer: SEGV ... in nifti_convert_n2hdr2nim

dim[0] is now range checked in the same place, and in the same style,
as the dim[1] check just below it.  Zero stays acceptable, as it is on
the NIFTI-1 side.  Valid headers are unaffected: dim[0] outside [0,7]
has no meaning in either format.

Found by fuzzing nifti_convert_n2hdr2nim() with clang's libFuzzer under
AddressSanitizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014m231RPDZPDbYDVxawxpjG
…agic claims

swap_nifti_header() takes a void pointer and a version number, and picks
the struct to swap from the version:

    if     ( ni_ver == 0 ) nifti_swap_as_analyze((nifti_analyze75 *)hdr);
    else if( ni_ver == 1 ) nifti_swap_as_nifti1((nifti_1_header *)hdr);
    else if( ni_ver == 2 ) nifti_swap_as_nifti2((nifti_2_header *)hdr);

Two callers pass it a nifti_1_header, 348 bytes, together with the
version taken from that header's own magic string by NIFTI_VERSION().
A file whose magic is "n+2" therefore has 540 bytes swapped in place:
192 bytes of read and write past the end of the caller's stack object.

nifti_read_n1_hdr() reads the header straight from a file, so the bytes
that decide this come from the file being read.  A 348 byte file with

    sizeof_hdr = 348, dim[0] byte-swapped, magic = "n+2"

is enough; need_nhdr_swap() then reports that swapping is needed and the
overflow happens before any validity check.  AddressSanitizer:

    ERROR: AddressSanitizer: stack-buffer-overflow
    READ of size 1 ...
        #0 nifti_swap_4bytes nifti2_io.c:3051
        InsightSoftwareConsortium#1 nifti_swap_as_nifti2 nifti2_io.c:3194
        InsightSoftwareConsortium#2 nifti_read_n1_hdr nifti2_io.c:5394
    Address ... is located in stack of thread T0 at offset 412 in frame
        [64, 412) 'nhdr' (line 5339) <== Memory access ... overflows

nifti_convert_n1hdr2nim() has the same line and takes its header from
the caller, so any application that fills a nifti_1_header itself can
reach it too.

Both call sites know the struct they hold, so both now ask for the swap
that struct supports: analyze when the magic is absent, NIFTI-1
otherwise.  Headers claiming versions 3 to 9, which swap_nifti_header()
previously refused with a message and left unswapped, are now swapped as
NIFTI-1 as well, which is the only interpretation 348 bytes allow.

nifti1_io.c is not affected: its swap_nifti_header() takes a
nifti_1_header * and a flag, so it cannot choose a wider struct.

Found by fuzzing nifti_convert_n1hdr2nim() with clang's libFuzzer under
AddressSanitizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014m231RPDZPDbYDVxawxpjG
nifti_tool.c has the same defect this branch fixes in the library: it
passes NIFTI_VERSION(*nhdr), read from the magic string, to
swap_nifti_header(), which selects the struct to swap by that number.

act_mod_hdrs() and act_swap_hdrs() hold a nifti_1_header of 348 bytes.
A magic of "n+2" makes NIFTI_VERSION() return 2, so swap_nifti_header()
treats the allocation as a 540 byte nifti_2_header and reads and writes
past its end.  Both functions refuse a header that is valid NIFTI-2, but
a header that is valid as neither version reaches the call.

  $ printf ... > evil.nii      # 348 bytes, sizeof_hdr byte swapped,
                               # magic "n+2"
  $ nifti_tool -mod_hdr -mod_field descrip hello -overwrite \
               -infiles evil.nii
  ==2747364==ERROR: AddressSanitizer: heap-buffer-overflow
      #0 nifti_swap_4bytes      nifti2_io.c:3029
      InsightSoftwareConsortium#1 nifti_swap_as_nifti2   nifti2_io.c:3172
      InsightSoftwareConsortium#2 swap_nifti_header      nifti2_io.c:3131
      InsightSoftwareConsortium#3 act_mod_hdrs           nifti_tool.c:3389
    0 bytes after 348-byte region allocated in nifti_read_n1_hdr()

Both now pass 1, or 0 for ANALYZE, which are the two 348 byte layouts.
act_mod_hdr2s() holds a nifti_2_header and gets the explicit 2, as
act_swap_hdrs() already does for its own NIFTI-2 display path.

old_swap_nifti_header() takes a nifti_1_header and a boolean, so the
calls beside these need no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176ZLorDY784us6dFAbE1ZJ
The only file in the tree without a trailing newline, and the only
-Wnewline-eof warning.  C11 5.1.1.2p1 requires a non-empty source file to
end in a newline not immediately preceded by a backslash, so this is
undefined behaviour rather than only a diff annoyance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
@hjmjohnson
hjmjohnson force-pushed the nifti_image_conflicted_names_followup branch from fd54dd7 to d786e5f Compare September 22, 2026 01:04
hjmjohnson and others added 5 commits September 21, 2026 20:08
…newline-eof

STYLE: Add the missing newline at end of nifti1_tool.h
…dim0-range

BUG: Check dim[0] before using it to index dim[] in the NIFTI-2 converter
…x-overflow

BUG: Refuse a header whose dimensions overflow the voxel count
…1-swap-width

BUG: Swap a nifti_1_header as a NIFTI-1 header, not as whatever its magic claims
In C++ library code, it’s generally considered bad practice to use
assert for input validation or error handling.

--Terminates the program
exit causes dependant applications to terminate - not ideal for libraries meant to be
reused safely by others.

--Not user-friendly
Gives no chance to recover or handle the failure gracefully.

--Violates encapsulation
Forces application behavior based on internal library assumptions.
@hjmjohnson
hjmjohnson force-pushed the nifti_image_conflicted_names_followup branch from d786e5f to e584a0e Compare September 22, 2026 02:11
@hjmjohnson

Copy link
Copy Markdown
Member

Superseded by #54, which merged as part of the same cleanup and makes the identical change in both copies of nifti_findhdrname.

Rebasing this branch onto current master leaves zero commits and an empty diff — the content is already in.

Why #54 was taken instead

Both replace exit() with return NULL on the ambiguous-filename path. #54 additionally releases everything that path still holds:

free(gzname); free(basename); free(hdrname);
return NULL;

where this branch frees only gzname, leaving basename and hdrname leaked on the way out. #54 also moved the free(basename) to after the fprintf that reads it, fixing a use-after-free that GCC reports as -Wuse-after-free under FSLSTYLE, and added a null check on the gzname allocation.

The exit() removal this PR proposed was the right call and is what prompted the wider pass; thank you for it.

@hjmjohnson hjmjohnson closed this Sep 22, 2026
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