Skip to content

fix(apng): Resume the push-mode chunk skip after a partial buffer - #906

Open
Nexory wants to merge 2 commits into
pnggroup:libpng18from
Nexory:fix/apng-push-chunk-skip-resume
Open

fix(apng): Resume the push-mode chunk skip after a partial buffer#906
Nexory wants to merge 2 commits into
pnggroup:libpng18from
Nexory:fix/apng-push-chunk-skip-resume

Conversation

@Nexory

@Nexory Nexory commented Aug 7, 2026

Copy link
Copy Markdown

What

png_push_read_IDAT() skips a chunk that turns up in the middle of APNG frame
data by warning and consuming the chunk body, which requires the whole chunk to
be buffered. That check sits inside the "chunk header not yet read" block,
so on a short buffer the function saves the buffer and returns with
PNG_HAVE_CHUNK_HEADER already set and the body unconsumed. The next call skips
that block, reaches the idat_size == 0 tail and calls
png_crc_finish(png_ptr, 0), consuming the first four bytes of the chunk
body as its CRC; the rest of the body is then parsed as a chunk stream.

This is the same defect faf069246 removed from png_push_read_chunk, whose
message notes that "an early return from insufficient buffer data cannot sit
between the sequence read and the body consumption". png_push_read_chunk is
safe because its APNG dispatch sits after the header-parse block, so a
re-entry re-evaluates it. In png_push_read_IDAT the dispatch is nested
inside that block, so the re-entry never comes back to it.

Whether this is reached depends only on how the caller happens to split its
png_process_data() calls, which for a streaming decoder is network or pipe
arrival, not something the file author needs to control.

Fix

Move the skip out of the header-parse block, so it is reached on every call
while the chunk header is held. The condition selecting the skip is unchanged,
so no chunk changes category, and a reader that receives whole chunks at once
behaves exactly as before. Only the resumption after a partial buffer differs.

Verification

A harness builds a 2-frame APNG with libpng's own writer, splices an unknown
ancillary chunk between two fdAT chunks of the second frame, and feeds the
result at 21 different png_process_data() granularities, comparing each
against the whole-file feed. A plain PNG runs through the same sweep first as a
control.

Diff of the harness output, before against after, is exactly this and nothing
else:

-  apng-aware  21 steps : diverged=10  crc-error-runs=10  -> DESYNC
+  apng-aware  21 steps : diverged=0   crc-error-runs=0   -> granularity-neutral

Every other line is identical, including the whole-file results
(rows=256 frames=2 warn=1, warning "Ignoring unexpected chunk in APNG
sequence") and the plain-PNG control (diverged=0 both before and after, so the
harness itself is granularity-neutral). The "CRC error" warnings only appear
when body bytes are consumed as a CRC, which is the desynchronisation being
reported.

ctest passes 37/37 with the change (including pngvalid, pngstest,
pngunknown and pngimage-full), pngpread.c compiles clean under
-Wall -Wextra, and it also compiles with PNG_READ_APNG_SUPPORTED off.

I can attach the harness if that is useful. I did not add a test file because
the two neighbouring APNG push-mode fixes, faf069246 and 9ec49c2d5, are
code-only, but I am happy to add one in whatever form you prefer.

Relation to #903

#903 adds a PNG_CHUNK_CRITICAL() rejection to this same branch, but inserts
it after the push_length + 4 > buffer_size early return, so on a short buffer
that check is skipped along with the rest of the branch. The two changes are
complementary rather than alternatives, and they will conflict textually. Happy
to rebase on top of #903 if it goes in first.

png_push_read_IDAT() skips a chunk that appears in the middle of APNG frame
data by warning and consuming the chunk body, which needs the whole chunk to
be buffered. The check for that sat inside the "chunk header not yet read"
block, so when the buffer was short the function saved the buffer and returned
with the chunk header flag already set and the body unconsumed. The next call
skipped that block entirely, reached the idat_size == 0 tail and consumed the
first four bytes of the chunk body as if they were its CRC, after which the
rest of the body was parsed as a chunk stream. Attacker-chosen bytes inside an
ignored chunk were therefore smuggled into the frame-data path, which is what
faf0692 removed from png_push_read_chunk; png_push_read_IDAT kept it because
its dispatch is nested inside the header-parse block instead of sitting after
it.

Move the skip out of that block so it is reached on every call while the chunk
header is held, the way png_push_read_chunk does it. The condition selecting
the skip is unchanged, so no chunk changes category and a reader that receives
whole chunks at once behaves exactly as before; only the resumption after a
partial buffer differs.
@jbowler

jbowler commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The browsers are the guys who demanded the push reader and this is their code. Does anyone else use it? I suggest upstreaming it to them. I'll take a look; this sounds credible. You may hear me laughing.

@jbowler jbowler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At lines 536,537 you add a variable "chunk_read_header" to fix a bug when PNG_READ_APNG_SUPPORTED.

This variable declaration must also be protected by PNG_READ_APNG_SUPPORTED

The chunk_header_read flag and the idat_size assignment pulled out of the
header-parse block are only needed for the APNG resume path, so confine them
to PNG_READ_APNG_SUPPORTED. Non-APNG builds set idat_size in the header block
as before, leaving that path unchanged from pnggroup#906. Builds clean with
-Wall -Wextra -Werror both with and without APNG.
@Nexory

Nexory commented Aug 10, 2026

Copy link
Copy Markdown
Author

Done. I've confined chunk_header_read and the pulled-out idat_size assignment to PNG_READ_APNG_SUPPORTED, and set idat_size in the header block for non-APNG builds as before, so the non-APNG path is unchanged from #906. Verified pngpread.c compiles clean under -Wall -Wextra -Werror both with and without APNG.

It's getting late here, so I'll check back on this tomorrow.

@jbowler jbowler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Nexory: I asked that you make no changes to the code if !PNG_READ_APNG_SUPPORTED. Please assert that when !PNG_READ_APNG_SUPPORTED the code is completely unchanged.

@Nexory

Nexory commented Aug 10, 2026

Copy link
Copy Markdown
Author

Sorry, my earlier "unchanged from #906" wording was imprecise. Stated explicitly:

Confirmed: with PNG_READ_APNG_SUPPORTED off, pngpread.c is completely unchanged from before this PR. pngpread.c is the only file this PR touches, and chunk_header_read (its declaration, assignment, and the idat_size branch) compiles out entirely when !PNG_READ_APNG_SUPPORTED, with idat_size set in the header block exactly as before.

Verified two ways: preprocessing pngpread.c (gcc -E, APNG disabled) is byte-identical between the pre-#906 base of libpng18 and this PR's head; and the test suite passes 37/37 both with APNG and with it disabled, with no warnings in either build.

@jbowler

jbowler commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Verified two ways: preprocessing pngpread.c (gcc -E, APNG disabled) is byte-identical between the pre-#906 base of libpng18 and this PR's head; and the test suite passes 37/37 both with APNG and with it disabled, with no warnings in either build.

That sounds good. I haven't verified this but this is the way it should go unless someone wants to redo the full work that unified the two readers in libpng 1.7 and then change all the individual chunk readers to be state driven rather than a mess of nested C {}.

ATM fdAT looks just like IDAT and IDAT is the only chunk in the base code which does an incremental read in the push reader. The original (Stepin?) APNG code tried to do the same for fdAT but I couldn't convince myself it was correct. The original push reader (Greg's implementation IRC) did seem to be set up to do incremental read of other chunks (e.g. IRC the text chunks) but the implementation seemed to me to be incomplete and, maybe, not even reachable.

I don't think there is any reasonable way forward from the current code so it seems more realistic to fix the bugs one-by-one. For that reason I think this is a good approach, however I have not code reviewed the resultant code so someone familiar with the APNG stuff needs to do it.

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