Skip to content

BUG: Swap a nifti_1_header as a NIFTI-1 header, not as whatever its magic claims - #63

Merged
hjmjohnson merged 2 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:fix/hdr1-swap-width
Sep 22, 2026
Merged

hjmjohnson merged 2 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:fix/hdr1-swap-width

Conversation

@gdevenyi

Copy link
Copy Markdown

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
    #1 nifti_swap_as_nifti2 nifti2_io.c:3194
    #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.


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

Copy link
Copy Markdown
Author

Pushed a second commit. The same defect was still in nifti2/nifti_tool.c, which passes the magic-derived NIFTI_VERSION() to swap_nifti_header() at three sites.

act_mod_hdrs() and act_swap_hdrs() hold a 348-byte nifti_1_header, so a "n+2" magic makes the swap treat the allocation as a 540-byte nifti_2_header. Both refuse a header that is valid NIFTI-2, but one valid as neither version reaches the call. Reproduced with a 348-byte file whose sizeof_hdr is byte-swapped and whose magic is "n+2":

==2747364==ERROR: AddressSanitizer: heap-buffer-overflow
    #0 nifti_swap_4bytes      nifti2_io.c:3029
    #1 nifti_swap_as_nifti2   nifti2_io.c:3172
    #2 swap_nifti_header      nifti2_io.c:3131
    #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 — the two 348-byte layouts. act_mod_hdr2s() holds a nifti_2_header and gets an explicit 2, matching what act_swap_hdrs() already does for its NIFTI-2 display path. The old_swap_nifti_header() calls beside these take a nifti_1_header and a boolean, so they need no guard. ASan clean on both paths afterwards; test suite unchanged.

gdevenyi and others added 2 commits September 21, 2026 20:02
…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
        #1 nifti_swap_as_nifti2 nifti2_io.c:3194
        #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
      #1 nifti_swap_as_nifti2   nifti2_io.c:3172
      #2 swap_nifti_header      nifti2_io.c:3131
      #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
@hjmjohnson
hjmjohnson merged commit a7487f3 into InsightSoftwareConsortium:master Sep 22, 2026
21 checks passed
@hjmjohnson

Copy link
Copy Markdown
Member

The commit messages in this range were rewritten to remove trailers that do not belong in permanent history: Co-Authored-By: naming an AI tool, and Claude-Session: URLs that resolve for nobody. Only messages changed — the tree at the tip of master is byte-identical, and author, committer, and dates are preserved.

This PR's commits on the rewritten master:

  • 522e62432b BUG: Swap the tool's headers at their own width too
  • 16a6b67c02 BUG: Swap a nifti_1_header as a NIFTI-1 header, not as whatever its magic claims

The SHA recorded above by GitHub is from the pre-rewrite history and no longer resolves.

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