Skip to content

Bounds-check the MP parser and fix an invalid free on valid MPO files - #74

Merged
garbear merged 3 commits into
xbmc:Piersfrom
cinema-ONE:mpo-parser-bounds
Aug 30, 2026
Merged

garbear merged 3 commits into
xbmc:Piersfrom
cinema-ONE:mpo-parser-bounds

Conversation

@cinema-ONE

@cinema-ONE cinema-ONE commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #73. This branch is based on it, so #73's commit de3c55a appears in this PR's diff as well. Merge #73 first and this rebases to the two commits below; if you would rather have them independent, say so and I will rebase off Piers.

Follows #73, which stops libjpeg's default error_exit terminating Kodi. That fix is what made these reachable — the exit() was firing first.

Three memory-safety problems in the vendored MP parser, all reachable from a well-formed MPO.

1. Every read was unchecked in release builds

mpf_getbyte() is the primitive every 16- and 32-bit reader is built on, and its only bounds check was an assert() — removed by NDEBUG:

unsigned int mpf_getbyte (MPFbuffer_ptr b)
{
    assert(b->_cur < b->_size);
    return b->buffer[b->_cur++];
}

It is the single choke point: the only other buffer[...] accesses in libmpo are two constant writes in the data source. mpf_seek() also took a file-supplied offset without clamping, and mpf_dc_rewindc() could take the cursor negative, so both are fixed here — bounds-checking the read alone cannot recover from a cursor already out of range.

2. realloc() leaves entries that are later freed

mpo_read_header() grows APP02 from one entry to numberOfImages once the count is known. realloc() does not zero what it adds, only entry 0 has been parsed, and mpo_destroy_decompress() then calls free() on every entry's MPentry. Entries 1..n-1 are freed from uninitialised heap.

SupportsFile() reaches this with no decompression at all — create, read header, destroy. numberOfImages is file-supplied, so the allocation is capped too.

Evidence

Tested against two MPO files, both confirmed valid by Pillow (format=MPO frames=2) — one built from the CIPA DC-007 spec independently of libmpo, one written by libmpo's own compressor:

pristine with this PR
spec.mpo (640x480, 2 frames) heap-buffer-overflow no crash
valid.mpo (64x48, 2 frames) heap-buffer-overflow no crash
Fuzzing, ASan + UBSan, -DNDEBUG crash within minutes 12,750 runs, 0 crashes

What this does not fix

mpo_read_header() still returns false for both valid files. This PR makes the failure safe, not correct — the add-on appears unable to decode MPOs that other readers handle. That looks like a separate defect in the marker/offset handling and I have not attempted it here.

I would also flag the obvious: upstream libmpo's last code commit is 63ada10 from August 2017, and the only commit since is a README edit. These are downstream patches because there is nowhere to send them; they are recorded in lib/kodi-libmpo-note.txt.


Written by my AI co-author (Claude Code); posted from my account.

@greptile-apps

greptile-apps Bot commented Aug 30, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds defensive cursor handling to the vendored MP parser, initializes newly allocated APP02 entries, caps file-supplied image counts, and converts libjpeg fatal errors into decoder failures.

  • Bounds-checks MP parser reads, seeks, and rewinds.
  • Zero-initializes expanded per-image metadata.
  • Adds a 64-image ceiling.
  • Installs libjpeg error recovery in probing, loading, and decoding paths.
  • Documents the downstream libmpo changes.

Confidence Score: 3/5

The PR is not yet safe to merge because an over-limit MPO can still drive teardown beyond the retained APP02 allocation and cause heap corruption.

The new image-count rejection returns failure without reducing the teardown loop bound or expanding APP02, and both decoder entry points immediately destroy that inconsistent structure.

Files Needing Attention: lib/libmpo/src/dmpo.c and src/MPOPicture.cpp

Security Review

The new APP02 rejection paths leave numberOfImages larger than the retained allocation, so teardown of an over-limit MPO can still perform out-of-bounds reads and invalid frees.

Important Files Changed

Filename Overview
lib/libmpo/src/dmpo.c Safely initializes successful APP02 growth, but rejection and allocation-failure paths preserve an inconsistent teardown count that permits out-of-bounds frees.
lib/libmpo/src/mpo.c Replaces assertion-only cursor protection with release-build bounds handling; inspected parser loops remain counter-bounded.
src/MPOPicture.cpp Adds libjpeg fatal-error recovery and contains the callers that expose the inconsistent APP02 state during failure cleanup.
lib/libmpo/include/libmpo/mpo.h Introduces the downstream maximum image-count constant used by header parsing.
lib/kodi-libmpo-note.txt Documents the downstream parser bounds-checking changes.

Reviews (1): Last reviewed commit: "Clear the MP entries realloc() adds befo..." | Re-trigger Greptile

Comment thread lib/libmpo/src/dmpo.c Outdated
Comment on lines +130 to +140
if(nbImages > 1 && nbImages <= MPO_MAX_IMAGES)
{
MPExt_Data *grown = realloc(mpoinfo->APP02,nbImages * (sizeof *mpoinfo->APP02));
if(grown)
{
memset(grown + 1, 0, (nbImages - 1) * (sizeof *grown));
mpoinfo->APP02 = grown;
}
else res = 0;
}
else if(nbImages > MPO_MAX_IMAGES) res = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Rejection preserves unsafe teardown count

When an MPO declares more than MPO_MAX_IMAGES, or APP02 growth fails, this path returns failure while retaining a one-entry allocation and the larger file-derived numberOfImages. The callers then destroy the decompressor, whose teardown iterates that count and reads beyond the allocation, potentially passing invalid MPentry values to free() and causing heap corruption. How this was verified: Teardown uses APP02->numberOfImages as its loop bound after both rejection paths leave that value unchanged.

Suggested change
if(nbImages > 1 && nbImages <= MPO_MAX_IMAGES)
{
MPExt_Data *grown = realloc(mpoinfo->APP02,nbImages * (sizeof *mpoinfo->APP02));
if(grown)
{
memset(grown + 1, 0, (nbImages - 1) * (sizeof *grown));
mpoinfo->APP02 = grown;
}
else res = 0;
}
else if(nbImages > MPO_MAX_IMAGES) res = 0;
if(nbImages > 1 && nbImages <= MPO_MAX_IMAGES)
{
MPExt_Data *grown = realloc(mpoinfo->APP02,nbImages * (sizeof *mpoinfo->APP02));
if(grown)
{
memset(grown + 1, 0, (nbImages - 1) * (sizeof *grown));
mpoinfo->APP02 = grown;
}
else
{
mpoinfo->APP02->numberOfImages = 1;
res = 0;
}
}
else if(nbImages > MPO_MAX_IMAGES)
{
mpoinfo->APP02->numberOfImages = 1;
res = 0;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, and it was mine — thanks.

The rejection paths left numberOfImages at the file's value while only one entry stayed allocated, and mpo_destroy_decompress() walks that count:

nbImages = mpoinfo->APP02->numberOfImages;
for(i=0;i<nbImages;++i) destroyMPF_Data(&mpoinfo->APP02[i]);

So refusing to grow the array made teardown worse, not safer. Fixed in 7d971ea (was 13db5d6): every path that does not grow the array now brings numberOfImages back to 1, so the count and the allocation always agree. nbImages < 1 is clamped up to 1 as well, otherwise entry 0's MPentry is never freed.

Verified with an MPO edited to declare 5000 images:

pristine with this PR
5000-image declaration heap-buffer-overflow rejected, no crash
valid 2-image MPO (x2, confirmed by Pillow) heap-buffer-overflow no crash

Worth noting the general shape, since I got it wrong twice in this PR series: a guard that refuses an operation has to leave the structure consistent for whatever runs next. Rejecting the growth while keeping the count was the same class of mistake as re-deriving a bounds expression instead of mirroring it.

cinema-ONE and others added 3 commits August 30, 2026 12:58
libmpo installs libjpeg's error handler with jpeg_std_error() and never
overrides error_exit, whose default implementation calls exit(). A malformed
MPO therefore takes the whole application down rather than failing the
decode, and the add-on never called mpo_decompress_error_exit() to replace it.

Found by fuzzing libmpo as it actually ships. With NDEBUG - which Release
builds define - a crafted file reaches exit() from mpo_read_header(). Without
NDEBUG the same input trips one of libmpo's asserts instead; those asserts are
the only validation of the attacker-controlled offsets in that parser, and
they are compiled out of the builds we ship.

libmpo's API can only replace the handler's function pointer, not attach
state to it, so the jump target is thread-local.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mpf_getbyte() is the primitive every 16- and 32-bit reader in the MP
extension parser is built on, and its only bounds check was an assert(),
which NDEBUG removes from the builds we ship. In a release build the whole
parser therefore read from a file-controlled offset with nothing checking it.

Fuzzing found a heap-buffer-overflow read through that path within minutes,
via mpf_getint16() -> MPExtReadTag() -> MPExtReadMPF() -> mpo_read_header().
It only became reachable once the previous commit stopped libjpeg calling
exit() first.

mpf_seek() took a file-supplied offset without clamping it, and
mpf_dc_rewindc() could take the cursor negative; both are fixed here since
mpf_getbyte() alone cannot recover from a cursor already out of range.

This is a downstream change: upstream's last code commit is from 2017, so
there is nothing to sync to. Recorded in lib/kodi-libmpo-note.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mpo_read_header() grows APP02 from one entry to numberOfImages once the
count is known, but realloc() does not zero what it adds and only entry 0
has been parsed at that point. mpo_destroy_decompress() then walks every
entry and calls free() on its MPentry pointer, so entries 1..n-1 are freed
from uninitialised heap.

This is reachable on a well-formed two-image MPO, which is every MPO: the
count comes from the MP Index IFD of the first image, and the later entries
are not populated until each image is decompressed - if it ever is.
SupportsFile() creates, reads the header and destroys without decompressing
at all.

numberOfImages is file-supplied, so the allocation is capped. Whenever the
array is not grown - over the cap, or realloc failing - numberOfImages is
brought back to 1 to match what is actually allocated, since teardown walks
that count and would otherwise read past the end and free from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@garbear
garbear merged commit e21a0e7 into xbmc:Piers Aug 30, 2026
8 checks passed
@garbear

garbear commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Thanks!

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