Skip to content

BUG: Bound the formatted write in znzprintf, and end the va_list on failure - #41

Merged
hjmjohnson merged 4 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-vsprintf-bounded
Sep 22, 2026
Merged

hjmjohnson merged 4 commits into
InsightSoftwareConsortium:masterfrom
gdevenyi:pr/fix-vsprintf-bounded

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 15, 2026 •

Copy link
Copy Markdown

znzprintf() built its output with vsprintf(), which has no bound:

size = strlen(format) + 1000000;  /* overkill I hope */
tmpstr = (char *)calloc(1, size);
...
vsprintf(tmpstr,format,va);

The comment is honest about what was protecting the buffer. A single %s
argument longer than a megabyte writes past the end of the allocation.
vsnprintf() with the size already being computed turns that from an
overflow into a truncation, and the truncation is now reported.

The same block also returned on calloc failure without calling va_end(),
leaving the va_list unterminated.

This is the only unbounded formatted write in the tree; there is no
sprintf() and no gets() anywhere, and 138 snprintf() calls, largely from
seanm's earlier work in upstream PRs #5 and #19.


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

Copy link
Copy Markdown
Author

Pushed a second commit. The first one bounds the write but still returned the gzprintf count after a truncation, so a caller could not detect it; it also returned 0 for the allocation failure, and the function already returned 0 for a NULL stream. printf reports failure with a negative value and 0 is a successful empty write, so all three now return -1, and a truncated result is not written at all.

Before, printing 2 MB through the 1000001-byte buffer:

==2807002==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 2000001
    #0 vsprintf
    #1 znzprintf  znzlib/znzlib.c:311
  allocated at znzlib.c:306

After: NULL -> -1, normal -> 9, 2MB -> -1, and the file holds exactly the 9 good bytes.

One correction so this is not ranked higher than it deserves: znzprintf sits inside COMPILE_NIFTIUNUSED_CODE in both znzlib.c and znzlib.h, and nm shows it absent from libznz.a in a default build. This is a latent defect in code no released configuration compiles. The harness above needs -DCOMPILE_NIFTIUNUSED_CODE to link.

@hjmjohnson

Copy link
Copy Markdown
Member

Rebased onto current master (b4876bf) and added red-green coverage for the truncation path. Full suite: 363/363 pass.

Maintainer decision needed: this changes znzprintf(NULL, ...) from returning 0 to returning -1. znzprintf is exported in znzlib.h, so that is downstream-visible even though nothing in this tree calls it.

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; replaced the bare cross-references #5 and #19 with prose (this is a fork, so bare numbers name different PRs upstream); fixed the British spelling "Behaviour"; condensed both bodies to the project's line limits. The #0/#1 frame numbers in the quoted sanitizer trace were left alone. Author, committer, dates and trees are unchanged per commit.

The first subject was shortened to BUG: Bound the formatted write in znzprintf, and end the va_list to fit 78 characters.

Test added, and the red proof

znzprintf has no caller anywhere in this tree and is compiled only under COMPILE_NIFTIUNUSED_CODE, which no build system defines. The existing suite therefore could not reach it at all. znzlib/znzprintf_test.c builds its own copy of znzlib.c with that definition and drives a 2 MB %s argument through the 1000001-byte buffer.

Red, with only the znzlib.c fix hunk reverted (Release):

1/363 Testing: znzprintf_truncation
Output:
** FAIL: truncating write returned 0, expected a negative value
Test Failed.

Same revert under AddressSanitizer, showing the underlying overflow:

==68597==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 2000000
    #4 vsprintf
    #5 znzprintf znzlib.c:311
    #6 main znzprintf_test.c:43

Green with the fix, in both builds:

** ERROR: znzprintf output truncated at 1000001 bytes
znzprintf test passed

Per the "a fix that makes something stop happening needs a companion assertion", the test also pins that an ordinary write still returns its byte count and that the file afterwards holds exactly those bytes, so a fix that simply refused every write would not pass.

The test deliberately does not assert the NULL-stream return value, since that is the semantic change flagged above and should be your call, not the test's.

@hjmjohnson
hjmjohnson force-pushed the pr/fix-vsprintf-bounded branch 2 times, most recently from 57814cf to 962decd Compare September 22, 2026 14:59
gdevenyi and others added 4 commits September 22, 2026 10:09
znzprintf() built its output with vsprintf(), which has no bound; the
buffer was sized strlen(format) + 1000000 under a comment reading
"overkill I hope".  A single %s argument longer than a megabyte writes
past the end of the allocation.  vsnprintf() with the size already
being computed turns that overflow into a reported truncation.

The same block also returned on calloc failure without calling
va_end(), leaving the va_list unterminated.

This is the only unbounded formatted write in the tree; there is no
sprintf() and no gets() anywhere.
The preceding commit bounds the write but still returned the gzprintf
count after a truncation, so a caller could not tell.  It also
returned 0 for an allocation failure, and the function returns 0 for a
NULL stream.  The printf family reports failure with a negative value;
0 is a successful empty write, so it cannot carry either meaning.

All three now return -1, and a truncated result is not written at all:
a partial record reported as a whole one is worse than nothing.

znzprintf sits inside COMPILE_NIFTIUNUSED_CODE in both znzlib.c and
znzlib.h and is absent from libznz.a in a default build, so this is a
latent defect in code no released configuration compiles.
znzprintf has no caller in this tree and is compiled only under
COMPILE_NIFTIUNUSED_CODE, so nothing reached it.  The test builds its
own copy of znzlib.c with that definition and drives a 2 MB argument
through the 1000001 byte buffer.

Without the bound the write overflows the allocation, and the call
reports success; the test pins the negative return, and pins the
ordinary write and the resulting file content so an over-broad fix
that refuses everything does not pass.
znzflush() and znzeof() are defined inside COMPILE_NIFTIUNUSED_CODE and
declared nowhere, unlike znzgets(), znzputc() and znzgetc() beside them.
Nothing compiled that block until a test in this branch did, so the
omission was invisible.

They are declared where the rest of the block is declared, so the
warning set the project already requires stays clean when the block is
built.
@hjmjohnson
hjmjohnson force-pushed the pr/fix-vsprintf-bounded branch from 962decd to bb54abc Compare September 22, 2026 15:09
@hjmjohnson
hjmjohnson merged commit 8449a1f 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