Skip to content

fix(exif): harden the shared Exif decoder against malformed input - #5399

Open
lgritz wants to merge 1 commit into
AcademySoftwareFoundation:mainfrom
lgritz:lg-exif
Open

fix(exif): harden the shared Exif decoder against malformed input#5399
lgritz wants to merge 1 commit into
AcademySoftwareFoundation:mainfrom
lgritz:lg-exif

Conversation

@lgritz

@lgritz lgritz commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Audit of the shared Exif decoder, used by JPEG, PNG, Raw, HEIF, PSD, and WebP. Turned up several issues that could be trouble for corrupt, malicious, or unusual Exif blocks:

  • Recursive IFD directory-count overread: the recursive ExifIFD/GPSIFD and Interoperability-IFD branches in read_exif_tag only checked that the target offset was < buffer size before reading a 2-byte directory count there, so an offset of buffer_size - 1 would run one byte past the end of the blob.

  • Unknown-type size sentinel: tiff_data_size() reports an unrecognized TIFF data type as size_t(-1). Then dataspan(), dataptr() and tiff_dir_data() multiplied that by the entry's count and fed the result to their bounds checks. Now tiff_data_size knows the type (which also makes Exif 3.0 UTF-8 tags decode correctly rather than being dropped or crashing), and a new pvt::dirdata_size() rejects the sentinel so that unknown types don't wrap the arithmetic. Also switch to 64 bit math for computing those offsets.

  • IFD depth: ifd_offsets_seen stops an IFD chain from looping, but nothing stopped it from being arbitrarily deep and overflowing the stack. Cap the depth at at 32 levels, more than enough for any real file.

  • Change some OIIO_ALLOCA to OIIO_ALLOCATE_STACK_OR_HEAP, to guarantee not allocating things too big to fit on the stack.

  • The directory-entry bounds test used >= where it wanted >, quietly discarding a legitimate final entry that ended exactly at the blob end, and the TIFFHeader was read through a cast that assumed alignment.

  • Add jpeg-corrupt and png-damaged tests (and scripts that generate those odd test cases).

Assisted-by: Claude Code / Claude Opus 4.8

Audit of the shared Exif decoder, which every container that embeds Exif
reaches with file-controlled bytes: jpeg APP1, png eXIf and the "Raw
profile type exif" text chunk, heif, psd, webp, and the raw plugin's
LibRaw callback. The short-blob TIFFHeader overread from the external
advisory draft was already closed; the following were not, two of them
crashes on an ordinary Release build with no sanitizer.

Recursive IFD directory-count overread: the recursive ExifIFD/GPSIFD and
Interoperability-IFD branches in read_exif_tag only checked that the
target offset was < buffer size before reading a 2-byte directory count
there, so an offset of buffer_size - 1 passed but the 2-byte read then ran
one byte past the end of the blob (heap out-of-bounds read). Require room
for the full 2-byte count before reading, matching decode_ifd, and read
the count via memcpy to avoid an unaligned load (UBSan).

Unknown-type size sentinel: tiff_data_size() reports an unrecognized TIFF
data type as size_t(-1). dataspan(), dataptr() and tiff_dir_data()
multiplied that sentinel by the entry's count and fed the result to their
bounds checks, where size_t(offset) + size_t(-1) wraps to offset - 1 and
passes any "> buffer size" test, yielding a span of length size_t(-1) into
the blob. The trigger is not exotic: EXIF_UTF8_TYPE = 129 is the Exif 3.0
UTF-8 string type added in AcademySoftwareFoundation#4961, which add_exif_item_to_spec explicitly
accepts but tiff_data_size was never taught about, so every Exif 3.0 UTF-8
tag took this path. A 26-byte blob -- one tag, type 129, count 1, offset 1
-- is a heap out-of-bounds read under ASan and an uncaught
std::length_error that aborts the process in Release. tiff_data_size now
knows the type (which also makes Exif 3.0 UTF-8 tags decode correctly
rather than being dropped or crashing), and a new pvt::dirdata_size()
rejects the sentinel so no other unknown type can wrap the arithmetic. The
offset math in those helpers is promoted to 64-bit while here.

IFD depth: ifd_offsets_seen stops an IFD chain from looping, but nothing
stopped it from being deep -- a nested IFD only has to sit at an offset not
yet seen, and 18 bytes buys one level, so a blob can chain as many levels
as its own length allows and a 180 KB blob exhausts the stack. A jpeg APP1
marker caps a blob at 64 KB, but a png eXIf chunk's length field is 31 bits
and heif and psd are unbounded. Now capped at 32 levels, with the depth
passed down the call stack as an ordinary parameter of read_exif_tag and
decode_ifd. The one wrinkle is the MakerNote, the only tag whose decoding
re-enters the walk: TagInfo::HandlerFunc is public and fixed, so it cannot
carry the depth. Its body moves to decode_makernote(), which takes the
depth, and read_exif_tag calls that directly instead of going through the
handler pointer; makernote_handler stays in the tag table as a thin
wrapper so the table and tag_table() are unchanged.

alloca'd rational arrays: the RATIONAL and SRATIONAL branches of
add_exif_item_to_spec sized an OIIO_ALLOCA from the file's element count.
OIIO_ALLOCA's own bound is a plain assert, compiled out under NDEBUG, so a
Release build has no check: a 32 MB blob holding one RATIONAL tag of count
4,000,000 alloca's 16 MB and segfaults. Now OIIO_ALLOCATE_STACK_OR_HEAP,
which is what the rest of the codebase uses for exactly this.

Also: the directory-entry bounds test used >= where it wanted >, quietly
discarding a legitimate final entry that ended exactly at the blob end,
and the TIFFHeader was read through a cast that assumed alignment.

Adds jpeg-corrupt and png-damaged fixtures across two container routes,
built by committed generators that embed their own 1x1 carrier so they
depend on no writer and carry no version-dependent metadata.

Assisted-by: Claude Code / Claude Opus 4.8
Signed-off-by: Larry Gritz <lg@larrygritz.com>
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.

1 participant