CVE-2025-65018 AI-generated analysis
platform/external/libpng, simplified read API (pngread.c: png_image_finish_read, png_image_read_direct)
Fixes a heap buffer overflow in libpng's simplified read API (png_image_finish_read / png_image_read_direct) when a 16-bit PNG is read into an 8-bit output buffer: first by rejecting IHDR/output bit-depth mismatches, then (rearchitected) by routing interlaced 16-to-8 conversions through an intermediate local_row buffer in a new png_image_read_direct_scaled routine.
Root cause
In pngread.c png_image_read_direct, when the PNG's IHDR bit depth is 16 and the caller requests a non-LINEAR (8-bit) output format, png_set_scale_16 is applied and rows are read into the caller's buffer, which was sized with PNG_IMAGE_SIZE(image) for 8-bit components. Per the fix commits, for interlaced images png_combine_row writes into that buffer using the IHDR (16-bit) depth before the 16-to-8 transformation completes, so writes exceed the 8-bit-sized destination (the commit gives 6144 vs 4096 bytes for a 32x32 16-bit RGB to 8-bit RGBA read).
How the fix works
- Commit 566e14540832 (Change-Id I1aa2538e), pngread.c:png_image_finish_read: before building the png_image_read_control, reads image->opaque->png_ptr->bit_depth and image->format & PNG_FORMAT_FLAG_LINEAR; returns png_image_error(...) with '16-bit PNG must use 16-bit output format' if bit_depth == 16 and LINEAR is not requested, and '8-bit PNG must not use 16-bit output format' if bit_depth < 16 and LINEAR is requested. This blocks the overflow by refusing every cross-depth conversion in the simplified API.
- Commit 5e46c0292d5c (Change-Id I6fe5ac89), pngread.c:png_image_finish_read: removes the rejection block above (the commit states it was unnecessarily limiting because only interlaced 16-to-8 reads are affected).
- Same commit, pngread.c:png_image_read_direct: adds int do_local_scale; in the 8-bit output branch after png_set_scale_16(png_ptr), sets do_local_scale = 1 when png_ptr->interlaced != 0. In the end-routine selection adds 'else if (do_local_scale != 0)': allocates row = png_malloc(png_ptr, png_get_rowbytes(png_ptr, info_ptr)), sets display->local_row = row, runs png_safe_execute(image, png_image_read_direct_scaled, display), clears local_row and frees it, returns the result. Per the commit, non-interlaced 16-to-8 reads continue to use the unchanged fast path.
- Same commit, pngread.c: new static png_image_read_direct_scaled: computes passes from png_ptr->interlaced (1 for NONE, PNG_INTERLACE_ADAM7_PASSES for ADAM7, png_error otherwise); for each pass and each of image->height rows calls png_read_row(png_ptr, local_row, NULL) then memcpy(output_row, local_row, row_bytes) with row_bytes = display->row_bytes, advancing output_row by row_bytes. The user buffer thus only receives row_bytes bytes per row from the intermediate buffer.
Completeness
- Final (rearchitected) fix covers only the png_image_read_direct path (non-colormap output) and only when png_set_scale_16 is selected (IHDR 16-bit, non-LINEAR output) and png_ptr->interlaced != 0.
- The new branch is added as an 'else if' after a preceding branch in the end-routine selection, so it applies only when that earlier branch was not taken; the commit says the design follows the pattern involving do_local_compose.
- The colormap path (png_image_read_colormap / png_image_read_colormapped) is not modified by either commit; the material does not show whether it has an analogous issue.
- The reverse mismatch (sub-16-bit PNG read with a LINEAR 16-bit output format, png_set_expand_16) was rejected by the first commit and is no longer rejected after the second; the second commit asserts only 16-to-8 interlaced reads are affected.
- On Android 15 and 16 only the first (rejection) commit is shown, which is broader: it blocks all cross-depth conversions, interlaced or not, for both colormap and direct paths, because the check sits in png_image_finish_read just before the colormap/direct routine selection.
Behavior changes
- With the rejection commit alone (Android 15/16 state as shown): png_image_finish_read fails via png_image_error for any 16-bit PNG read with a non-LINEAR format and for any <16-bit PNG read with a LINEAR format; callers relying on the simplified API's automatic 16-to-8 or 8-to-16 conversion now get an error.
- With the rearchitected fix (Android 14/17 state): no new API rejection; interlaced 16-bit PNGs read to 8-bit formats allocate one extra row buffer (png_get_rowbytes) and perform a per-row memcpy for every row of every pass, described by the commit as slow but necessary. Non-interlaced reads are unchanged.
Per major
- Android 14: Both commits applied in android-security-14.0.0_r28: 566e14540832 (rejection check) then 5e46c0292d5c (rearchitected local_row fix, which removes the check). Final state is the rearchitected fix; at android-security-14.0.0_r29 the context shows no rejection block and 8/8 patched lines present.
- Android 15: Only the rejection commit shown (6a5cceb169b3 in android-security-15.0.0_r16, same diff as A14's first commit, offset +6 lines); 4/4 lines present at android-security-15.0.0_r17. The rearchitected commit is not shown for this branch, so 15 carries the stricter bit-depth rejection.
- Android 16: Only the rejection commit shown (960064fdb5f4 in android-security-16.0.0_r7, identical diff to A15); 4/4 lines present at android-security-16.0.0_r8. Rearchitected commit not shown for this branch.
- Android 17: Rearchitected commit 0e9750be87cb in android-17.0.0_r1 (diff base d804c90e3, i.e. on top of the rejection commit, same content as A14's second commit); 8/8 lines present at android-security-17.0.0_r1.
Unknowns
- The material does not show the png_combine_row code or the full else-if chain in png_image_read_direct, so the exact mechanism by which the IHDR depth governs the write size, and the conditions of the preceding branch, are taken from the commit messages.
- Whether Android 15/16 later received the rearchitected commit is not shown; only the rejection commit appears for those branches.
- Whether first_row is a pre-existing png_image_read_control field on a fork's base is not shown.
- png_image_read_direct_scaled copies local_row into every output row of every pass from a single reused buffer; the material does not show whether the resulting interlaced image content is pixel-correct (the commit reports regression tests passing). This is a correctness question, not a memory-safety one.
- Which Android components actually call png_image_finish_read is not shown.