Heap Overflow in EXIF writer in IFD tags processing
Pwno found a heap-buffer-overflow write in FFmpeg's avcodec/exif for due to size calculation error of non‑contiguous extra IFDs (Image File Directory), this effects .png, .jpg, .webp, .avif... results in a four bytes heap overflow (that always overwrites next chunk header data, see house-of-einherjar).
// libavcodec/pngdec.c 763
static int decode_exif_chunk(AVCodecContext *avctx, PNGDecContext *s,
GetByteContext *gb)
{
if (!(s->hdr_state & PNG_IHDR)) {
av_log(avctx, AV_LOG_ERROR, "eXIf before IHDR\n");
return AVERROR_INVALIDDATA;
}
av_buffer_unref(&s->exif_data);
s->exif_data = av_buffer_alloc(bytestream2_get_bytes_left(gb));
if (!s->exif_data)
return AVERROR(ENOMEM);
bytestream2_get_buffer(gb, s->exif_data->data, s->exif_data->size);
return 0;
}Definition of the exif store (s->exif_data)
// libavcodec/pngdec.c:1758
if (s->exif_data) {
// we swap because ff_decode_exif_attach_buffer adds to p->metadata
FFSWAP(AVDictionary *, p->metadata, s->frame_metadata);
ret = ff_decode_exif_attach_buffer(avctx, p, &s->exif_data, AV_EXIF_TIFF_HEADER);
FFSWAP(AVDictionary *, p->metadata, s->frame_metadata);
if (ret < 0) {
av_log(avctx, AV_LOG_WARNING, "unable to attach EXIF buffer\n");
return ret;
}
}PNG decoder reads the exif chunk into PNGDecContext.exif_data in libavcodec/pngdec.c:763 That buffer becomes the EXIF payload associated with the frame in libavcodec/pngdec.c:1761
int ff_decode_exif_attach_buffer(AVCodecContext *avctx, AVFrame *frame, AVBufferRef **pbuf,
enum AVExifHeaderMode header_mode)
{
int ret;
AVBufferRef *data = *pbuf;
AVExifMetadata ifd = { 0 };
ret = av_exif_parse_buffer(avctx, data->data, data->size, &ifd, header_mode);
if (ret < 0)
goto end;
ret = exif_attach_ifd(avctx, frame, &ifd, pbuf);ff_decode_exif_attach_buffer() parses the EXIF payload via av_exif_parse_buffer() in libavcodec/decode.c:2436 -> libavcodec/exif.c:839. The TIFF IFD list is parsed in libavcodec/exif.c:455, and each tag becomes an AVExifEntry. Extra IFDs are represented as
AV_TIFF_IFD entries and, with this commit, can be stored under IDs 0xFFFC...0xFFED
/* IFD tags */
{"ExifIFD", 0x8769}, // <- An IFD pointing to standard Exif metadata
{"GPSInfo", 0x8825}, // <- An IFD pointing to GPS Exif Metadata
{"InteropIFD", 0xA005}, // <- Table 13 Interoperability IFD Attribute Information
{"GlobalParametersIFD", 0x0190},
{"ProfileIFD", 0xc6f5},
};(libavcodec/exif.c:192 and libavcodec/exif.c:912)
static int exif_attach_ifd(AVCodecContext *avctx, AVFrame *frame, const AVExifMetadata *ifd, AVBufferRef **pbuf)
{
const AVExifEntry *orient = NULL;
AVExifMetadata *cloned = NULL;
int ret;
for (size_t i = 0; i < ifd->count; i++) {
const AVExifEntry *entry = &ifd->entries[i];
if (entry->id == av_exif_get_tag_id("Orientation") &&
entry->count > 0 && entry->type == AV_TIFF_SHORT) {
orient = entry;
break;
}
}
if (orient) {
av_log(avctx, AV_LOG_DEBUG, "found EXIF orientation: %" PRIu64 "\n", orient->value.uint[0]);
ret = attach_displaymatrix(avctx, frame, orient->value.uint[0]);
if (ret < 0) {
av_log(avctx, AV_LOG_WARNING, "unable to attach displaymatrix from EXIF\n");
} else {
cloned = av_exif_clone_ifd(ifd);
if (!cloned) {
ret = AVERROR(ENOMEM);
goto end;
}
av_exif_remove_entry(avctx, cloned, orient->id, 0);
ifd = cloned;
//....exif_attach_ifd() looks for orientation, attaches displaymatrix, removes Orientation, and reserializes the IFD (libavcodec/decode.c:2375). That reserialization is what brings the write path into a decode flow.
static size_t exif_get_ifd_size(const AVExifMetadata *ifd)
{
/* 6 == 4 + 2; 2-byte entry-count at the beginning */
/* plus 4-byte next-IFD pointer at the end */
size_t total_size = IFD_EXTRA_SIZE;
for (size_t i = 0; i < ifd->count; i++) {
const AVExifEntry *entry = &ifd->entries[i];
if (entry->type == AV_TIFF_IFD) {
/* this is an extra IFD, not an entry, so we don't need to add base tag size */
size_t base_size = entry->id > 0xFFECu && entry->id <= 0xFFFCu ? 0 : BASE_TAG_SIZE;
total_size += base_size + exif_get_ifd_size(&entry->value.ifd) + entry->ifd_offset;
} else {
size_t payload_size = entry->count * exif_sizes[entry->type];
total_size += BASE_TAG_SIZE + (payload_size > 4 ? payload_size : 0);av_exif_write() allocates a buffer using exif_get_ifd_size() (libavcodec/exif.c:746 and libavcodec/exif.c:668). The new logic skips the 12‑byte base tag size for any AV_TIFF_IFD entry in 0xFFED...0xFFFC
Note that here av_exif_write() tries to peel off extra IFD tags from 0xFFFC downward (libavcodec/exif.c:801). It breaks on the first missing tag. If 0xFFFC is missing but 0xFFFB exists (non‑contiguous tag), the loop stops and leaves the extra tag inside the main IFD.
static int exif_write_ifd(void *logctx, PutByteContext *pb, int le, int depth, const AVExifMetadata *ifd)
{
int offset, ret, tell, tell2;
tell = bytestream2_tell_p(pb);
tput16(pb, le, ifd->count);
offset = tell + IFD_EXTRA_SIZE + BASE_TAG_SIZE * (uint32_t) ifd->count;
av_log(logctx, AV_LOG_DEBUG, "writing IFD with %u entries and initial offset %d\n", ifd->count, offset);
for (size_t i = 0; i < ifd->count; i++) {
const AVExifEntry *entry = &ifd->entries[i];
av_log(logctx, AV_LOG_DEBUG, "writing TIFF entry: id: 0x%04" PRIx16 ", type: %d, count: %"
PRIu32 ", offset: %d, offset value: %d\n",
entry->id, entry->type, entry->count,
bytestream2_tell_p(pb), offset);
tput16(pb, le, entry->id);
if (entry->id == MAKERNOTE_TAG && entry->type == AV_TIFF_IFD) {
size_t ifd_size = exif_get_ifd_size(&entry->value.ifd);
tput16(pb, le, AV_TIFF_UNDEFINED);
tput32(pb, le, ifd_size);
} else {
tput16(pb, le, entry->type);
tput32(pb, le, entry->count);
}
if (entry->type == AV_TIFF_IFD) {
tput32(pb, le, offset);
tell2 = bytestream2_tell_p(pb);
bytestream2_seek_p(pb, offset, SEEK_SET);
if (entry->ifd_offset)
bytestream2_put_buffer(pb, entry->ifd_lead, entry->ifd_offset);
ret = exif_write_ifd(logctx, pb, le, depth + 1, &entry->value.ifd);
if (ret < 0)
return ret;
offset += ret + entry->ifd_offset;
bytestream2_seek_p(pb, tell2, SEEK_SET);
} else {
size_t payload_size = entry->count * exif_sizes[entry->type];
if (payload_size > 4) {
tput32(pb, le, offset);
tell2 = bytestream2_tell_p(pb);
bytestream2_seek_p(pb, offset, SEEK_SET);
exif_write_values(pb, le, entry);
offset += payload_size;
bytestream2_seek_p(pb, tell2, SEEK_SET);
} else {
/* zero uninitialized excess payload values */
AV_WN32(pb->buffer, 0);
exif_write_values(pb, le, entry);
bytestream2_seek_p(pb, 4 - payload_size, SEEK_CUR);exif_write_ifd() always writes every entry in the IFD, including the still‑present 0xFFFB entry (libavcodec/exif.c:647). Since the buffer was sized as if that entry had no 12‑byte directory slot, the buffer is too small. When it hits a small payload entry (e.g., SHORT), it executes AV_WN32(pb->buffer, 0) to zero inline padding (libavcodec/exif.c:731). At that point pb->buffer is already at pb->buffer_end - 2, so the 4‑byte zero write spills past the buffer end into the next heap chunk’s metadata.
poc-exif/poc_exif.png: ASANheap-buffer-overflow (decoder thread av:png:df0)poc-exif/poc_exif.webp: ASANheap-buffer-overflow (T0)poc-exif/poc_exif.jpg: ASANheap-buffer-overflow (decoder thread dec0:0:mjpeg)poc-exif/poc_exif.avif: ASANheap-buffer-overflow (T0)poc-exif/poc_exif.jxl: ASANheap-buffer-overflow (decoder thread dec0:0:libjxl)

Note that for .tiff:
build-asan-jxl/ffmpeg -v debug -i poc-exif/poc_exif.tiff -f null -logs: -writing IFD with 17 entries and initial offset 218-writing TIFF entry: id: 0xfffb ... offset value: 329-EXIF metadata: (323 bytes)- The directory area is
IFD_EXTRA_SIZE + BASE_TAG_SIZE * count = 6 + 12*17 = 210bytes. With the TIFF header, the initial payload offset is 218 (matches the log). - The under‑allocation from
exif_get_ifd_size()is only 12 bytes (the skipped base tag size for the0xfffbIFD entry) inlibavcodec/exif.c. - So even under‑allocated, the buffer still comfortably covers the directory area, where the only unguarded write happens (
AV_WN32(pb->buffer, 0)for inline payloads inexif_write_ifd()). - The shortfall hits the payload area instead. Those writes use
bytestream2_put_*()/bytestream2_seek_p()which clamp and setp->eofrather than OOB.
- The directory area is
Timeline
- Dec, 2025: Discovery of potential issue, replications & validations.
- Dec, 2025: Disclosed to ffmpeg-security.
- Dec, 2025:
avcodec/exifmaintainer provided patch. - Dec, 2025: Patch merged upstream