Skip to content

BUG: Keep the XML skip depth at the element that started the skip - #65

Merged
hjmjohnson merged 3 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:fix/axml-skip-depth
Sep 22, 2026
Merged

hjmjohnson merged 3 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:fix/axml-skip-depth

Conversation

@gdevenyi

Copy link
Copy Markdown

afni_xml's parser holds sixteen levels of open elements in a fixed
array, and refuses to push a seventeenth by entering a "skip block":
epush() records the depth in xd->dskip and stops touching the stack
until the matching pop. Two details of that bookkeeping are wrong, and
together they walk off the end of the array.

epush() overwrites dskip on every push past the limit, so after
elements at depth 17 and 18 the skip is recorded as starting at 18.
epop() then clears dskip before the stack is handled, so the pop that
ends the skip -- depth 18 -- falls through to

xd->stack[xd->depth-1] = NULL;

and writes a pointer eight bytes past the sixteen-entry array, which
lives in the file-scope afni_xml_control. The pop of depth 17 that
follows finds dskip clear and calls process_popped_element(), which
reads xd->stack[16] and dereferences it.

Eighteen nested elements are enough. On '' repeated eighteen times
and closed, AddressSanitizer reports:

ERROR: AddressSanitizer: global-buffer-overflow
WRITE of size 8 ...
    #0 epop afni_xml.c:802
    #1 cb_stop_ele afni_xml.c:734
... is located 8 bytes after global variable 'gAXD'

The XML comes from the CIFTI extension of a NIfTI file, or from any
file read by axml_read_file(), so the nesting is chosen by the input.

Two changes: epush() keeps the outermost skip depth rather than the
innermost, since that is the depth whose pop ends the skip; and epop()
clears dskip only after the element has been skipped, which puts the
stack assignment in the branch that owns the stack. Depths within the
limit follow the same path as before.

Found by fuzzing axml_read_buf() 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
gdevenyi marked this pull request as ready for review August 15, 2026 21:30
@gdevenyi

Copy link
Copy Markdown
Author

Pushed a second commit, plus an ASan demonstration of the first one.

On master, 25 levels of nested XML against an AXML_MAX_DEPTH of 16:

==2798988==ERROR: AddressSanitizer: global-buffer-overflow
WRITE of size 8
    #0 epop         cifti/afni_xml.c:768
    #1 cb_stop_ele  cifti/afni_xml.c:700

That is the xd->stack[xd->depth-1] = NULL that ran once the skip had been cleared early — an out-of-bounds write past the stack in the global control struct, not just a bookkeeping slip. Clean with this branch.

The second commit guards process_popped_element(), which read xd->stack[xd->depth-1] and dereferenced it with no test. That read is only safe because of the first commit, so it seemed better not to depend on it.

@hjmjohnson

Copy link
Copy Markdown
Member

Rebased onto current master (b4876bf) and added a red-green regression test. Full suite: 363/363 pass.

Rebase and commit-message cleanup

The ~29 shared-base commits are already on master under new SHAs, so the rebase replayed only the two commits 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 both bodies to the project's line limits (the inlined sanitizer trace is reproduced in the test's red proof instead, where it stays checkable). Author, committer, dates and trees are unchanged per commit.

Test added, and the red proof

New fixture cifti/testdata/deep_nesting.xml: a CIFTI extension nesting twenty elements inside a sixteen-slot stack, with a BrainModel sibling at legal depth. Driven through cifti_tool in the same shape as the existing cifti_mim_summary_* tests.

Red, with only the afni_xml.c fix hunks reverted, under AddressSanitizer:

1/1 Test #363: cifti_deep_nesting ...Subprocess aborted***Exception
==86750==ERROR: AddressSanitizer: global-buffer-overflow
WRITE of size 8
    #0 epop afni_xml.c:768
    #1 cb_stop_ele afni_xml.c:700
    #8 axml_read_file afni_xml.c:214
    #9 axio_read_file afni_xml_io.c:97
    #10 process cifti_tool.c:185
0x000100e700f8 is located 40 bytes after global variable 'gAXD'

The same revert in a plain Release build fails too — SEGFAULT — so the test does not depend on a sanitizer build to catch it.

Green with the fix, in both builds. Per the "a fix that stops something happening needs a companion assertion", the test pins PASS_REGULAR_EXPRESSION "BrainModel", so a fix that simply refused the whole document would not pass.

gdevenyi and others added 3 commits September 22, 2026 10:52
afni_xml's parser holds sixteen levels of open elements in a fixed
array and refuses to push a seventeenth by entering a skip block:
epush() records the depth in xd->dskip and stops touching the stack
until the matching pop.  Two details of that bookkeeping are wrong.

epush() overwrote dskip on every push past the limit, so the skip was
recorded as starting at the innermost depth.  epop() then cleared
dskip before the stack was handled, so the pop that ends the skip fell
through to "xd->stack[xd->depth-1] = NULL" and wrote a pointer eight
bytes past the sixteen-entry array, which lives in the file-scope
afni_xml_control.  The next pop found dskip clear and dereferenced
xd->stack[16].

epush() now keeps the outermost skip depth, since that is the depth
whose pop ends the skip, and epop() clears dskip only after the
element has been skipped.  Depths within the limit are unaffected.

The XML comes from the CIFTI extension of a NIfTI file, or from any
file read by axml_read_file(), so the nesting is chosen by the input.
Found by fuzzing axml_read_buf() with libFuzzer under
AddressSanitizer.
process_popped_element() read xd->stack[xd->depth-1] and dereferenced
it without a test.  A slot is filled by the matching epush(), and an
element that was skipped never fills one, so the read is only safe
because the preceding commit keeps the skip running to the element
that started it.

Guard it rather than rely on that: check the depth is inside the
stack, and that the slot holds a struct with a name, before the
strcmp().
deep_nesting.xml nests twenty elements inside a CIFTI extension whose
stack holds sixteen, which is the shape that walked off the end of
xd->stack.  The test drives it through cifti_tool and pins that the
BrainModel sibling is still summarized, so a fix that refused the
whole document would not pass.
@hjmjohnson

Copy link
Copy Markdown
Member

Rebased onto current master (9b31abf) and tested. This is the strongest of the remaining open fixes: it is the only one whose defect fails in an ordinary Release build, so its test guards on every CI leg rather than only the two sanitizer ones.

One thing a reviewer needs to know up front: the two fix commits are a matched pair and neither is sufficient alone. Taking one without the other produces a tree that passes the new test while remaining broken.

Red proof, and why both commits are required

I reverted each hunk independently before reverting both, in a plain Release build with no sanitizer:

Reverted Result
epush skip-depth guard only 366/366 — no failure
epop reordering only 366/366 — no failure
both 1 failed of 366 — cifti_deep_nesting (SEGFAULT)

That is worth stating because "each commit stands alone" is normally the property you want, and here it does not hold: the guard stops a nested skip from resetting dskip to an inner depth, and the reordering stops epop writing to a stack slot that was never pushed. Either one alone leaves the other's protection sufficient for this fixture.

Under AddressSanitizer with both reverted, the diagnostic is precise:

ERROR: AddressSanitizer: global-buffer-overflow
    #0 epop           afni_xml.c:780
    #1 cb_stop_ele    afni_xml.c:712
    #2 doContent      (libexpat)
The fixture, and its companion assertion

cifti/testdata/deep_nesting.xml is 8 lines: 20 levels of <deep> against the 16-slot AXML_MAX_DEPTH stack, wrapped in the CIFTI/Matrix/MatrixIndicesMap structure the reader expects.

It also carries a legal-depth <BrainModel> sibling. That is the companion assertion — without it, a fix that simply refused to descend at all would pass just as well as the correct one.

The input is the XML's own nesting depth, so this is reachable from a malformed or hostile CIFTI file rather than only from a programming error.

Verification
Configuration With the fix
Release, cifti + FSL + apps 366/366
Debug + AddressSanitizer 366/366

Rebased cleanly onto current master; 3 files changed, 38 insertions.

CI on this push is still running at the time of writing; the local results above are what I can state now. Both Windows legs are among the queued checks, which this PR has not previously been built against.

One correction to the commit text

The original commit inlined a stack trace whose line numbers had drifted from the code — it cited :802 and :734 where the actual frames are :768 and :700. A trace that no longer matches the source is worse than none, since a reader will go to the wrong function. It has been dropped rather than renumbered, because the trace adds nothing the prose does not already say.

@hjmjohnson
hjmjohnson merged commit 0e0033d 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.

2 participants