
Proof-of-concept and instrumented reproduction harness for CVE-2026-28609, an out-of-bounds write in Android's MatroskaExtractor reachable via a crafted WebM file with a big-endian PCM audio track.
Proof-of-concept, instrumented reproduction harness, and technical write-up for CVE-2026-28609, an out-of-bounds write in Android's MatroskaExtractor reachable via a crafted WebM file with a big-endian PCM audio track.
frameworks/av/media/module/extractors/mkv/MatroskaExtractor.cppMatroskaSource::read() in Android's MatroskaExtractor contains a
PCM big-endian byte-swap loop that casts a frame data pointer to
uint16_t * before adding frame->range_offset(), a byte offset.
Because C pointer arithmetic on a uint16_t * scales the offset by
sizeof(uint16_t) = 2, the resulting pointer is 2 * range_offset
bytes past the start of the buffer, not range_offset bytes.
When range_offset > 0, the loop reads and writes one or more bytes
past the end of the frame's MediaBuffer. On a device without ASan
the over-read succeeds silently and the over-write corrupts the byte
immediately following the buffer.
The vulnerable line:
// MatroskaExtractor.cpp:1105 (pre-fix)
uint16_t *dstData = (uint16_t *)frame->data() + frame->range_offset();
uint16_t *srcData = (uint16_t *)frame->data() + frame->range_offset();
for (size_t i = 0; i < frame->range_length() / 2; i++) {
dstData[i] = ntohs(srcData[i]);
}
The upstream fix casts the pointer to uint8_t * before applying
the offset:
uint16_t *data = (uint16_t *)((uint8_t *)frame->data() + frame->range_offset());
for (size_t i = 0; i < frame->range_length() / 2; i++) {
data[i] = ntohs(data[i]);
}
A working, reproducible trigger for CVE-2026-28609, verified under AddressSanitizer on a real Android 14 device. The repository includes:
range_offset.dlopen,
invokes GETEXTRACTORDEF, and reads frames from the file.The result, on a vulnerable device:
==14927==ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 2 at 0x003c61ab3960 thread T0
#0 ... MatroskaSource::read(...) MatroskaExtractor.cpp:1113
0x003c61ab3961 is located 0 bytes after 65-byte region
The vulnerable branch requires all of the following to be true:
| Condition | Source |
|---|---|
| Track is PCM | mType == PCM |
| Big-endian | AMEDIAFORMAT_KEY_PCM_BIG_ENDIAN == 1 |
| 16-bit samples | AMEDIAFORMAT_KEY_BITS_PER_SAMPLE == 16 |
Frame has non-zero range_offset | Set by set_range(offset, ...) |
The first three are satisfied by declaring the track with codec ID
A_PCM/INT/BIG and bit depth 16. The fourth is the interesting one.
range_offset is set to a non-zero value only in
MatroskaSource::setWebmBlockCryptoInfo(), which is called from
readBlock() under the condition:
if (err == OK && mExtractor->mIsWebm && trackInfo->mEncrypted) {
err = setWebmBlockCryptoInfo(mbuf);
}
Therefore the trigger file must satisfy:
mIsWebm — the EBML DocType element must be "webm", not
"matroska".mEncrypted — the track must declare ContentEncodings with
ContentEncodingType = 1 (Encryption) and a ContentEncKeyID.0x1) is 0, indicating that the frame
is unencrypted but content-encoded. This takes the else branch in
setWebmBlockCryptoInfo, which calls set_range(1, len - 1).After the strip, range_offset = 1 for every frame. The vulnerable
branch then computes (uint16_t *)data + 1, which advances 2 bytes,
and the loop reads/writes bytes [2, 2 + range_length) — one byte
past the end of the 65-byte allocation (64-byte frame + 1-byte
signal).
+---------+------------------------------------+
| 0x00 | 64 bytes of frame data (0xAA...) |
+---------+------------------------------------+
signal PCM payload
The signal byte 0x00 is stripped by set_range(1, 64), leaving a
64-byte frame inside a 65-byte allocation. The vulnerable pointer
arithmetic then writes at byte offset 2 through 65.
.
├── README.md
├── LICENSE
├── .gitignore
│
├── exploit/
│ └── generator.py WebM generator + verifier
│
├── harness/
│ └── harness_c_abi.cpp dlopen-based trigger harness
│
└── scripts/
├── build.conf API level, sanitizer, RTTI flags
├── include_dirs.conf.sample Include roots template
├── build_foundation.sh Build the foundation archive
├── build_plugin.sh Build the extractor plugin
├── build_harness.sh Build the harness
└── run.sh End-to-end build + push + run
Upstream dependencies, cloned by the user before building:
av/ frameworks/av (AOSP)
libwebm/ external/libwebm (mkvparser)
flac/ external/flac
aosp-includes/ system/core, system/logging, system/libbase,
frameworks/native — header trees only
aosp-includes/libs/ libstagefright_foundation.so, libmedia.so,
libutils.so, libbinder.so, libcutils.so,
libbase.so, libmediandk.so, libstagefright_flacdec.so
— pulled from the target device
bash, python3, make$ANDROID_NDK_HOMEadb on PATH (Linux, or adb.exe from WSL)mkdir -p deps && cd deps
# AOSP frameworks/av (contains the vulnerable extractor)
git clone --depth 1 -b android-14.0.0_r1 \
https://android.googlesource.com/platform/frameworks/av av
# libwebm (mkvparser)
git clone --depth 1 \
https://android.googlesource.com/platform/external/libwebm libwebm
# libFLAC
git clone --depth 1 \
https://android.googlesource.com/platform/external/flac flac
# AOSP header trees (no full checkout required)
mkdir -p aosp-includes
cd aosp-includes
for m in core libbase logging native; do
git clone --depth 1 \
"https://android.googlesource.com/platform/system/$m" "$m" 2>/dev/null || true
done
cd ../..
If your AOSP tree uses the modular extractor layout
(av/media/module/extractors/mkv/), no further adjustment is needed.
If it uses the older layout (av/media/libstagefright/matroska/),
see the build scripts for the MKV variable.
mkdir -p deps/aosp-includes/libs
for lib in libstagefright_foundation.so libstagefright_flacdec.so \
libmedia.so libutils.so libbinder.so libcutils.so \
libbase.so libmediandk.so; do
adb pull "/system/lib64/$lib" deps/aosp-includes/libs/
done