Skip to content

BMP: read pixels after the palette and bitmasks when data_offset points into the headers - #3132

Open
KenM76 wants to merge 2 commits into
image-rs:mainfrom
KenM76:bmp-zero-data-offset
Open

KenM76 wants to merge 2 commits into
image-rs:mainfrom
KenM76:bmp-zero-data-offset

Conversation

@KenM76

@KenM76 KenM76 commented Oct 27, 2026

Copy link
Copy Markdown

Fixes #3131.

Some writers leave bfOffBits at 0. The lenient clamp from #2919 only covers
14 <= data_offset < bmp_header_end, so 0 to 13 still seek into the file header and the
picture decodes wrong with no error. Its target, bmp_header_end, is also before any bitmasks
and palette, so a paletted or BI_BITFIELDS image clamped there read those bytes as pixels.

In lenient mode, any data_offset below bmp_header_end now becomes the reader position after
read_palette(), the position the no_file_header path already takes. These cases are
unchanged:

  • offsets at or past bmp_header_end, so a file whose biClrUsed overstates its palette but
    has a correct offset still decodes as before;
  • strict mode.

Strict mode could reject these offsets instead, as the issue suggests. I left that out to keep
this to the lenient fix, but I can add it if you'd like.

One limit: like the no_file_header path, this doesn't skip a colour table in a 16/24/32-bit
file (biClrUsed > 0 with no palette use). Such a file with bfOffBits = 0 would still read
its table as pixels.

Tests

test_data_offset_inside_the_headers_starts_after_the_palette_and_bitmasks builds 2×1
red/blue BMPs and checks each decodes to [255, 0, 0, 0, 0, 255]:

  • 24-bit with offsets 0, 1, 13, 14, 53;
  • 8-bit paletted with offsets 0, 13, 53, and the correct 62;
  • 16-bit BI_BITFIELDS 5-6-5 with offsets 0, 13, 53, and the correct 66.

With the old clamp restored, the test fails:

  • 24-bit at offset 0 gives [62, 77, 66, 0, 0, 0], the output the issue predicted;
  • with the 24-bit cases skipped, 8-bit at offset 53 gives [255, 0, 0, 255, 0, 0];
  • with the 8-bit cases also skipped, bitfields at offset 53 give [255, 0, 0, 0, 0, 0].

test_invalid_data_offset_into_dib_header (#2919) still passes.

These pass locally on stable:

  • cargo fmt -- --check;
  • cargo clippy --all-targets --features bmp,ico -- -D warnings;
  • cargo test --lib (333 tests);
  • cargo test --test reference_images.

I found this while fixing the same file in a PDF editor (storytold/pdfcraft#4943), which works
around it before decoding.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9

…ts into the headers

Some writers leave bfOffBits at 0. The lenient clamp from image-rs#2919 only
covered offsets from 14 up to the end of the DIB header, so 0..13 still
seeked into the file header and decoded the wrong picture silently. Its
target, the end of the DIB header, also lies before any bitmasks and
palette, which were then read as pixels.

In lenient mode, any data_offset below the end of the DIB header now
becomes the reader position after the bitmasks and palette, the same
position the no-file-header (ICO) path already uses. Offsets at or past
the end of the DIB header, and strict mode, are unchanged.

Fixes image-rs#3131

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
@KenM76

KenM76 commented Oct 27, 2026

Copy link
Copy Markdown
Author

A note on one limit, so it isn't a surprise in review.

An offset exactly at bmp_header_end is left alone on purpose. I tried repairing it too (<=), and it broke round_trip_single_pixel_rgba and six reference images (the V4/V5 bitfield ones, e.g. V4_R5_G6_B5, V5_A1_R5_G5_B5). Those files put their pixels right at the header end, but palette_offset adds 12/16 mask bytes for V4/V5 headers as well as for 40-byte ones, so the reader position after the palette is past their data.

The same accounting means a V4/V5 BI_BITFIELDS file with an offset of 0 still lands 12–16 bytes late with this change. That was already wrong before it, and none of the current tests covers it. Fixing it means counting the masks only for the 40-byte header, which changes palette_offset for every V4/V5 file, so I've left it out of this PR. Happy to do it here or as a follow-up if you'd like.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9

In lenient mode the last row's length check subtracted the reader position
from the file length. With the pixel data offset past the end of the file
(declared that way, or reached by a V4/V5 bitfields file whose offset is
inside its headers) that underflowed: a panic in debug builds and a wrapped
length in release. It now falls through to the normal read, which reports
the short file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
@KenM76

KenM76 commented Oct 27, 2026

Copy link
Copy Markdown
Author

Correction to my note above, after checking it with a synthetic V4 file (108-byte header, 16-bit BI_BITFIELDS, 2×1) rather than reasoning about it.

With an offset of 0, the reader position after the palette is 16 bytes past the real data. For this small file that is past the end of the file, and that turned up a separate bug that is already on main. The lenient last-row check computes end_pos - current_pos, which underflows whenever the pixel offset is past the end of the file. Any BMP that declares such an offset (e.g. 1000 on a 62-byte file) panics in a debug build on main today ("attempt to subtract with overflow"), and the length wraps in release.

This PR would have made that easier to reach, so I've added 33e7bd9: the subtraction is checked, and such a file now returns the short-read error. There is a regression test (test_data_offset_past_the_end_of_the_file_is_an_error), and I confirmed it panics with the fix reverted. cargo test --lib (334) and reference_images (189) pass, and clippy is clean.

The V4/V5 mask accounting itself is unchanged, as described above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9

This branch has not been deployed

No deployments
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.

BMP: bfOffBits = 0 makes the decoder read pixel rows from the start of the file

1 participant