Skip to content

BUG: Fix an out-of-bounds indirect call in axio_show_mim_summary - #40

Merged
hjmjohnson merged 3 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-oob-indirect-call
Sep 22, 2026
Merged

hjmjohnson merged 3 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-oob-indirect-call

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 15, 2026 •

Copy link
Copy Markdown

mind = get_map_index(xt->xchild[kid]);
if( kid >= 0 ) MIM_disp_funcs[mind](ofp, xt->xchild[kid], verb);

The guard tests kid, the loop counter, which is never negative, so it is
always true. The index actually used is mind, and get_map_index()
returns -1 for any element name not in MIM_kids[]. A CIFTI file
containing an unrecognised element under MatrixIndicesMap therefore reads
a function pointer from before the start of MIM_disp_funcs and calls it.

Reported by the clang static analyzer as security.ArrayBound, "Out of
bound access to memory preceding 'MIM_disp_funcs'".


Interface impact: none. On the union of all these changes, configured with USE_FSL_CODE=ON and USE_CIFTI_CODE=ON: all 448 exported symbols across libniftiio, libnifti2, libznz, libfslio, libnifticdf and libcifti are identical to master under nm -D --defined-only, and all ten installed headers are identical under gcc -E -P. Under gcc -dM -E one macro definition differs, intentionally and only in text: #61 makes FSL_RADIOLOGICAL read (-1) so it is safe inside an expression. Its value is still -1, checked by compiling against each installed fslio.h and printing it.

Verification. This branch: builds with gcc 16.1.1, ctest unchanged from master (2 of 345 fail on master itself in this environment; #31 and #29 each fix one). The union of all the PRs: 0 errors under both gcc 16.1.1 and clang 22.1.8, ctest 345/345 under each, and the whole suite under valgrind memcheck with --trace-children=yes gives 484 traced processes with no invalid access, no uninitialised value and no leak in any nifti binary.

Coordination. Every line of every branch was compared, whitespace-normalised, against the diffs of the open PRs (#11, #21, #22, #23, #24). Where one of those already changes a line, the line was left alone, and the few deliberate overlaps are named in the text above. What survives is 17 compiler warnings, all of them on those lines: 9 -Wsign-conversion (5 in fslio.c for #22, 2 in nifti2_io.c and 2 in nifti_tester001.c for #24) and 8 -Wcalloc-transposed-args in nifti_findhdrname and nifti_findimgname, which #11 rewrites. No formatting changes appear anywhere, to stay clear of #10 and #12.

One of a set of independent, single-purpose PRs. Each bases on master and can be merged on its own, in any order.

The full set of PRs (35)

The union of all of them is on the fork as all-changes, if you want to build and test the lot at once.

CI and build

Configuration and documentation

Defects

Warning and check classes

This was referenced Aug 15, 2026
gdevenyi and others added 2 commits September 22, 2026 06:27
    mind = get_map_index(xt->xchild[kid]);
    if( kid >= 0 ) MIM_disp_funcs[mind](ofp, xt->xchild[kid], verb);

The guard tests kid, the loop counter, which is never negative, so it is
always true.  The index actually used is mind, and get_map_index()
returns -1 for any element name not in MIM_kids[].  A CIFTI file
containing an unrecognised element under MatrixIndicesMap therefore reads
a function pointer from before the start of MIM_disp_funcs and calls it.

Reported by the clang static analyzer as security.ArrayBound, "Out of
bound access to memory preceding 'MIM_disp_funcs'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSPnbwpDjVcAYqDdVqLkMU
Drives cifti_tool with -eval_type show_summary over two XML fixtures.
The first holds a MatrixIndicesMap child whose name get_map_index()
does not resolve, and fails without the preceding commit: the sanitizer
legs report a global-buffer-overflow, an 8-byte read 8 bytes before
MIM_disp_funcs.

The second holds a BrainModel and asserts it is still displayed, so
that skipping the unresolved name cannot pass by skipping every name.

These are the first tests for the cifti library. The fixtures are small
enough to keep in tree, so neither needs the external testing data.
@hjmjohnson
hjmjohnson force-pushed the pr/fix-oob-indirect-call branch from 98389a4 to e2f30ed Compare September 22, 2026 11:30
@hjmjohnson

Copy link
Copy Markdown
Member

Rebased onto current master and added regression coverage. The fix is unchanged; I confirmed the bug is real before touching it, rather than reading it off the diff.

AddressSanitizer on unmodified master, driven through the shipped cifti_tool:

ERROR: AddressSanitizer: global-buffer-overflow
READ of size 8 at 0x0001005d0118
    #0 axio_show_mim_summary afni_xml_io.c:266
    #1 show_cifti_summary    cifti_tool.c:303
    #2 eval_cifti_extension  cifti_tool.c:259
    #3 process               cifti_tool.c:194
    #4 main                  cifti_tool.c:100

0x0001005d0118 is located 8 bytes before global variable
'MIM_disp_funcs' defined in 'cifti/afni_xml_io.c' of size 40

Eight bytes before a 64-bit function-pointer array is MIM_disp_funcs[-1] exactly, and the chain starts at main. The trigger is the XML's own content: any MatrixIndicesMap child whose name is outside {NamedMap, Surface, Parcel, Volume, BrainModel} makes get_map_index() return -1.

The two tests, and why there are two

Both drive cifti_tool -as_cext -eval_cext -eval_type show_summary, matching how every other test in the project exercises a shipped binary. The fixtures are a few lines each and live in tree, so they need nothing from the external testing data.

cifti_mim_summary_unknown_child holds an unrecognized child and is the regression test.

cifti_mim_summary_known_child holds a BrainModel and asserts it is still displayed. Without it the suite would pass equally well if the guard skipped every child, so the first test alone cannot tell a correct fix from an over-broad one.

To confirm the first test is actually tied to the fix, I reverted the one-line change while keeping both tests:

2/2 Test #346: cifti_mim_summary_known_child ..... Passed
50% tests passed, 1 tests failed out of 2

The regression test fails on its own and passes once the guard is restored.

Verification
Build Result
ASan Debug, USE_CIFTI_CODE=ON, apps on 346/346, no sanitizer reports
Release, USE_CIFTI_CODE=ON, apps on 346/346, 0 warnings
Fix reverted, tests kept 1 failed, as intended
Exported symbols vs master identical, no ABI change

The change is inside a function body, so no symbol appears or disappears; the comparison above confirms it.

Two notes on the change itself

I tightened the added comment to one line describing what the code does. The original mentioned that the test "used to be on kid", and history of that kind reads better from git log than from the source; it also used a British spelling.

Worth recording separately: these are the first tests the cifti library has. USE_CIFTI_CODE=ON builds in CI today with no test coverage behind it, which is why a dead guard on an exported entry point survived this long.

process() returned without releasing either the afni_xml_t it parsed or
the nifti_image the non-cext path fills in, so every run leaked the whole
tree. Both free routines already accept NULL, so neither path needs a
guard.

LeakSanitizer is on by default under AddressSanitizer on Linux but not on
Apple, so the leak ended the process with a non-zero status on the
sanitizer job alone. That also discarded the buffered standard output,
which is why the summary text went missing there rather than merely
being followed by a leak report.
@hjmjohnson

Copy link
Copy Markdown
Member

The sanitizer job failed on the previous push, and the cause was a second real bug rather than a problem with the tests. Pushed a third commit that fixes it; the same configuration now passes.

cifti_tool's process() returned without releasing the afni_xml_t it parsed or the nifti_image the non-cext path fills in, so every run leaked the whole tree. These are the first tests to run cifti_tool under a sanitizer, which is why it surfaced now.

Why it failed on Linux only, and why the output vanished

LeakSanitizer runs by default under AddressSanitizer on Linux, and does not on Apple. So the same binary that exits 0 locally on macOS exits 1 on the sanitize-clang-linux job:

==3513200==ERROR: LeakSanitizer: detected memory leaks
SUMMARY: AddressSanitizer: 626 byte(s) leaked in 21 allocation(s).
  ...
  #1 new_afni_xml   cifti/afni_xml.c:402
  #11 axml_read_file cifti/afni_xml.c:214
  #12 axio_read_file cifti/afni_xml_io.c:97
  #13 process        cifti/cifti_tool.c:185

That also explains the second, stranger symptom. cifti_mim_summary_known_child failed with Required regular expression not found. Regex=[BrainModel, which looked like the tool had produced no output at all. It had: stdout is a pipe under ctest and therefore fully buffered, and LeakSanitizer ends the process without flushing it, so the summary text was still sitting in the buffer when the process died.

Both symptoms are the one leak.

The fix
   axml_free_xml_t(ax);
   nifti_image_free(nim);

Both routines already return early on NULL, so neither path needs a guard, and the -as_cext path that leaves nim NULL is safe.

Verification, on Linux under the failing configuration

Ubuntu, clang, Debug, -fsanitize=address with LeakSanitizer active, USE_CIFTI_CODE=ON:

before after
cifti_tool on either fixture exit 1, leak report, stdout lost exit 0, summary printed
full suite 2 failed of 346 346/346

The BrainModel line is present in the output again, so the companion test is doing its job rather than passing vacuously.

@hjmjohnson
hjmjohnson merged commit 6484a6d 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:

  • b8d5246f2f BUG: Fix an out-of-bounds indirect call in axio_show_mim_summary
  • 06cb5a64b9 ENH: Cover the MatrixIndicesMap dispatch in axio_show_mim_summary
  • e96d1e91f1 BUG: Free the XML tree and image that cifti_tool parses

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