core-jmp core-jmpdeath of core jump

A 0-click Exploit Chain for the Pixel 9, Part 1: Decoding Dolby (CVE-2025-54957)

Project Zero’s Natalie Silvanovich turns CVE-2025-54957 in the Dolby Unified Decoder into mediacodec code execution on a Pixel 9 with no tap: Google Messages transcribes RCS audio, an evo-heap pad wraps, scudo secondary headers slide, and /proc/self/mem plus a 0x157 gadget write shellcode over __stack_chk_fail. Fixed 5 January 2026.

oxfemale September 15, 2026 56 min read 59 reads
Export PDF
A 0-click Exploit Chain for the Pixel 9, Part 1: Decoding Dolby (CVE-2025-54957)
Original text: "A 0-click exploit chain for the Pixel 9 Part 1: Decoding Dolby"Natalie Silvanovich, Google Project Zero (14 January 2026). Bug: CVE-2025-54957. Part 2: Big Wave. Part 3: lessons. Specification tables, pseudocode and figures below are reproduced verbatim with attribution captions. The working MP4 chain stays on the public Chromium issue; this draft does not re-host exploit media.
An unopened phone on a kitchen table with audio unspooling from the notification light
The message is still unopened. The decoder already went to work.
A cafeteria tray that only slides forward, stacked with envelopes
Kitchen: the evo heap is a tray that never goes backward. You only get a new tray at the next song.
Six-step diagram from RCS auto-decode to mediacodec code execution
Part 1 ends at mediacodec. Part 2 takes the same process to the kernel.

Executive Summary

On 14 January 2026 Natalie Silvanovich published Part 1 of a three-post Google Project Zero series: a fully remote, zero-click chain against a Google Pixel 9. The first link is CVE-2025-54957, found with Ivan Fratric in the Dolby Unified Decoder (UDC) that ships as a blob inside /vendor/lib64/libcodec2_soft_ddpdec.so. The decoder is not waiting for you to tap play. Google Messages transcribes incoming SMS and RCS audio automatically, so AC-3 / E-AC-3 (Dolby Digital / Dolby Digital Plus) is now 0-click attack surface on most Android phones. The pair of bugs in one CVE — a controllable heap overflow caused by a broken alignment add, plus a skip-buffer leak gated on emdf_container_length — is enough, after three MP4 attachments and a lot of allocator theater, to run shellcode in the mediacodec SELinux context.

This draft keeps every original specification table, every Project Zero figure, and every published snippet in source order. Around that spine we add the kitchen picture (mail opened in the basement), why a media decoder is a better 0-click door than a messaging parser, how scudo’s secondary headers became a slide rule, why Pixel 9’s missing mediacodec seccomp mattered next to a Samsung S24 that had one, and what defenders should actually hunt. The bugs were fixed as of 5 January 2026. The public exploit attachments remain on the Chromium issue tracker. We do not re-upload them.

Vendors as well as members of the security community have questioned whether such vulnerabilities are exploitable, as well as whether 0-click exploits are possible for all but the most well-resourced attackers in the modern Android Security environment.

Natalie Silvanovich, Project Zero, 14 January 2026
Kitchen table: A 0-click is not a phishing email you were dumb enough to open. It is the post office steaming your letters in the basement because an AI feature wants to transcribe voicemail before you come downstairs. You never tapped Play. The phone did, in a worker process named mediacodec, because that is how Messages now works.
For operators: Scope of Part 1: userland code execution in mediacodec on Pixel 9 via UDC. Not kernel (that is CVE-2025-36934 / Part 2, BigWave). Not Samsung’s Monkey’s Audio bug (CVE-2025-49415) — that was the appetizer that sent the team at Dolby. Not iOS/macOS UDC compiled with -fbounds-safety — P0 believes this CVE is not exploitable there. SPL: patched as of 5 January 2026. Unpatched devices with December 2025 SPL or earlier are the lab.

Read this in two voices

Green boxes are for a smart person who does not live in IDA. Blue boxes are for people who will grep a vendor blob or write a YARA rule tonight. The original post is a 11,000-word exploit diary. We keep that diary’s assets in order and add the maps a CISO and a hunter both need.

  • If you run a phone fleet: this is why media codecs on the incoming-message path are a patch-now class, not a “next quarterly.”
  • If you write Android: seccomp on mediacodec, checksums on scudo secondary, -fbounds-safety on vendor blobs, and do not leave /proc/self/mem writable in production.
  • If you hunt 0-click: three sequential audio attachments, a mediacodec crash-loop every ~3 seconds, then a process that lives. That is the reliability story (1/256).

Why a voicemail is a better door than a chat parser

For years, 0-click on mobile meant image codecs, font engines, and iMessage’s blast radius. Project Zero’s opening is that AI features changed the shape of the door. To search and summarize messages, the OS now decodes attachments before the user opens the thread. Audio transcription is one of those features. Incoming SMS and RCS voice notes in Google Messages are decoded with no tap. That moves every audio decoder the OEM shipped — including a Dolby blob with almost no symbols — onto the 0-click surface of most Android phones, not just the ones whose owners play movies.

Silvanovich had already filed CVE-2025-49415 in Monkey’s Audio on Samsung. That hunt is what sent Ivan Fratric and her through the Dolby Unified Decoder. Seth Jenkins, in parallel, looked at what a mediacodec process can talk to on a Pixel 9 and filed CVE-2025-36934 in BigWave — Part 2 of this series. The team’s public question was not “is there a bug.” It was “can a merely well-funded attacker, not a nation-state-only shop, turn this into a chain in 2026 Android.” The three posts are the answer: yes, in two exploits, if the decoder is this good and the sandbox is this chatty.

Part 1 is the decoder. Part 2 is the kernel driver reachable from that sandbox. Part 3 is the policy argument. This draft is Part 1 only. We will not steal Part 2’s thunder except to say the destination of this shellcode is not “a calculator app.” It is a process that can open a GPU/VPU-class driver.

Kitchen table: Think of Messages as a hotel front desk that photocopies every envelope “so the concierge AI can summarize your mail.” The photocopier is a closed-source Dolby engine sitting in the basement. CVE-2025-54957 is a tray in that photocopier that will keep sliding if you lie about how long the page is.

The Dolby Unified Decoder

UDC is the library that speaks Dolby Digital (AC-3, “DD”) and Dolby Digital Plus (E-AC-3, “DD+”). The bitstream is public: ETSI TS 102 366, available as a PDF. The implementation is not. Dolby ships OEMs a binary blob with limited symbols. The OEM statically links it into a shared object. On Pixel 9 that object is /vendor/lib64/libcodec2_soft_ddpdec.so. The same family of blob shows up on Android, iOS, Windows, and streaming boxes. A bug in the spec-adjacent parser is therefore not a Pixel-only story even if this exploit is.

Codec2 is Android’s current media-framework path; libcodec2_soft_* are software decoder plugins. UDC runs in its own process, separate from other codecs. That isolation is why the heap layout is stable enough to exploit: the static and dynamic buffers are weird sizes, so scudo hands back the same secondary chunks every time the decoder is created.

For operators: If you are inventorying: grep the vendor image for libcodec2_soft_ddpdec.so, ddp_udc, evo_malloc. The library is ~1.3 MB of code. Only about 5% of that is reachable as ROP if you only overwrite the low two bytes of two function pointers — that constraint dominates the second half of the post.

The Bug

DD+ is a stream of independently decodable syncframes. Each syncframe holds up to six audio blocks. One optional field in a block is a skip region: a 1-bit skiple, a 9-bit skipl, and skipl bytes of skipfld. Nine bits means at most 0x1FF bytes copied per block, so 0x1FF × 6 = 3066 (0xBFA) bytes of skip data per syncframe. Those bytes are not “ignored padding.” They carry Extensible Metadata Delivery Format — EMDF — which the UDC hunts for by scanning for the 16-bit syncword X8.

The specification tables from the original post, cell for cell:

SyntaxNumber of bits
skiple1
if(skiple)
skipl9
skipfld9 * 8
}
Audio-block skip fields. Maximum 0x1FF bytes per block, six blocks. Source: original article.

An EMDF container starts with a 16-bit syncword and a 16-bit length:

SyntaxNumber of bits
emdf_sync(){
syncword16
emdf_container_length16
}
emdf_sync: syncword + emdf_container_length. Syncword is X8. Source: original article.

The container itself is a nest of version, key id, payload ids, and a payload whose size is a variable_bits(8) integer with no stated upper bound:

SyntaxNumber of bits
emdf_container() {
emdf_version2
if (emdf_version == 3) {
emdf_version += variable_bits(2)
}
key_id3
if (key_id == 7) {
key_id += variable_bits(3)
}
while (emdf_payload_id != 0x0) {5
if (emdf_payload_id == 0x1F) {
emdf_payload_id += variable_bits(5)
}
}
emdf_payload_config()
emdf_payload_sizevariable_bits(8)
for (i = 0; i < payload_size; i++) {
emdf_payload_byte8
}
emdf_protection()
}
emdf_container. The unbounded emdf_payload_size is the fuse. Source: original article.

variable_bits(n) is a classic “keep reading while the continue bit is set” encoding. The numeric result can be enormous:

SyntaxNumber of bits
variable_bits (n_bits) {
value = 0;
do {
value += readn_bits
read_more1
if (read_more) {
value <<= n_bits;
value += (1<<n_bits);
}
}
while (read_more);
return value
}
variable_bits. No cap in the spec fragment the decoder implemented. Source: original article.
Kitchen table: Skip bytes are the extra pages stuffed in an envelope after the letter. EMDF is a second letter hidden after a bookmark labeled X8. variable_bits is “how long is this second letter?” with no adult in the room. The decoder asks the heap for that many bytes, then copies until a second length field says stop.

Ivan Fratric’s reading of the Android blob is the allocation-then-copy into a private “evo” heap. Project Zero’s pseudocode, verbatim:

 result = read_variable_bits(this, 8, &payload_length);
  if ( !result )
  {
    if ( evo_heap )
    {
      buffer = ddp_udc_int_evo_malloc(evo_heap, payload_length, param.extra_len);
      outstruct.buf = buffer;
      if ( !buffer )
        return 2;
      if ( payload_length )
      {
        index = 0;
        while ( !ddp_udc_int_evo_brw_read(this, 8, &byte_read) )
        {
          outstruct.buf[index++] = byte_read;
          if ( index >= payload_length )
            goto ERROR;
        }
        return 10;
      }
    }

And the allocator itself:

void ddp_udc_int_evo_malloc(heap *h, size_t alloc_size, size_t extra)
{
  size_t total_size;
  unsigned __int8 *mem;

  total_size = alloc_size + extra;
  if ( alloc_size + extra < alloc_size )
    return 0;
  if ( total_size % 8 )
    total_size += (8 - total_size) % total_size;
  if ( total_size > heap->remaining )
    return 0;
  mem = heap->curr_mem;
  heap->remaining -= total_size;
  heap->curr_mem += total_size;
  return mem;
}

The evo heap is a single slab with a bump pointer. There is no free. It lives for one syncframe, then the whole slab is reset. evo_malloc does an integer-overflow check on alloc_size + extra, then a remainder check on the heap, then a padding step that is the actual bug:

 if ( total_size % 8 )
    total_size += (8 - total_size) % total_size;

If total_size on 64-bit is in 0xFFFFFFFFFFFFFFF9 .. 0xFFFFFFFFFFFFFFFF, that add wraps to a tiny allocation. The copy loop still uses the original payload_length. That would normally be an unusable multi-gigabyte write. It is not, because each byte is pulled through ddp_udc_int_evo_brw_read, which is bounded by emdf_container_length from the skip buffer. Fail the bounds check, and the loop stops. Overflow length and overflow bytes are both attacker-chosen, up to the skip budget (0x1FF × 6). Silvanovich calls this the buffer overrun capability.

The same bug has a leak. Skip data is written with length skipl. The bit reader that parses EMDF is sized from emdf_container_length. If the container length is larger than what was actually copied, EMDF parsing reads past initialized skip bytes. Verbatim:

 if ( skipflde && ... )
 {
 	int skip_copy_len = 0;
 	for ( int block_num = 0; block_num < total_blocks; ++block_num )
{
      	if ( skiple )
      	{
			...
        		for ( skip_copy_len; skip_copy_len < skipl; skip_copy_len++ )
        		{
          			b = read_byte_from_syncframe();
          			skip_buffer[skip_copy_len] = b;
        		}
      	}
}
int i = 0;
for (i = 0; i < skip_copy_len; i+=2 )
{
      	int16_t word = skip_buffer[i] | skip_buffer[i+1]);
      	if ( word == "X8" )
      	{
        		has_syncword = 1;
        		break;
      	}
}
if ( has_syncword )
{
		…
emdf_container_length = skip_buffer[i + 1] | ( skip_buffer[i] << 8);
      	bit_reader.size = emdf_container_length;
     		bit_reader.data = skip_buffer[i + 2];
	}
}

They did not file the leak as a second CVE. On a healthy decoder the skip buffer is zeroed at start and then only holds syncframe bytes the attacker already sent. The leak becomes interesting only after the overrun has planted new contents next door. Combined, you get a write and a read of a region you do not own. That pairing is why this is “exceptionally high quality” in the mitigations section.

For operators: CWE-190 on the pad, CWE-787 on the copy, CWE-125 on the leak. Primitive: relative overwrite of a bump-heap that sits inside a 692,855-byte static allocation, with a later write-what-where through the skip pointer. Max first-shot overflow: 3066 bytes. That is not enough to reach the function pointers at +0x8A410. The rest of the paper is how to make 3066 bytes do 0x28000 bytes of work.

Decoder Memory Layout

UDC makes four system-heap allocations when the decoder is created, before any syncframe. They are freed and re-allocated between media files — typical for decoders, because a malloc in the hot path would jitter playback. One is the static buffer, 692,855 bytes, a giant struct that is the decoder. The evo heap lives inside it. One is the dynamic buffer, 85,827 bytes, scratch space; the skip buffer lives here. The other two (input params, output samples) do not matter for this chain. The names are Android’s, and they are confusing: both buffers are dynamically allocated. In this post, those two sizes are the only meaning of the words.

Diagram of evo heap inside the static buffer and skip buffer inside the dynamic buffer
Evo heap at static+0x61d28; skip pointer immediately after, aiming 0x1000 below the skip buffer. Source: original article.

The skip pointer sits immediately after the 0x1F08-byte evo heap. It points 0x1000 below the skip buffer; the decoder adds 0x1000 when it writes skipfld. Overwrite that pointer and the next syncframe’s skip data becomes write-what-where. The catch: 3066 bytes of overflow cannot reach from a fresh evo allocation all the way across 0x1F08 to the pointer with attacker-controlled bytes. The original PoC on the tracker does exactly this naive overflow, copies zeros from past the skip buffer, nulls the skip pointer, and dies on the next write. Attachment 67118327 is that crash file.

The fix is to fill the evo heap first. An EMDF container may hold many payloads. The smallest payload eats 19 bits of skip. Each payload allocates 96 bytes on evo. About 99 payloads ≈ 235 bytes of skip fill the slab — well inside 3066. Then the overflowing payload paints the skip pointer with a chosen absolute value.

Kitchen table: The tray (evo) is only so long. If you dump one huge course on an empty tray, you never reach the stamp that sits after the rail (the skip pointer) with food you chose — you reach it with empty plate, zeros. If you first lay down 99 amuse-bouches, the overflowing roast lands on the stamp. Next envelope’s address is whatever you printed on that stamp.

Write what where?

An absolute skip-pointer write is a beautiful primitive that ASLR laughs at in 0-click. You do not know a destination. Partial overwrite is the usual answer: keep the high bytes, punch the low ones, and smear most of the dynamic buffer, because that is where the pointer already aimed. The dynamic buffer, unfortunately, is numeric scratch. No function pointers. The consolation prize is WRITE DYNAMIC: by walking the skip pointer backwards in overlapping 0x800-byte steps, you can paint up to 0xFFFF bytes of the dynamic buffer with attacker data, far past the 3066-byte skip cap.

  1. Syncframe sets skip pointer to 0x7XXXXX4000.
  2. Writes 3066 bytes of skip.
  3. Sets skip pointer to 0x7XXXXX3800 (only 0x800 back, because the pointer-setting frame itself clobbers the start of what you just wrote).
  4. Writes 0x800+ bytes to repair and extend.

You can also point the skip pointer at already-written data and send skipl=2 (just the X8 syncword) so the next parse consumes emdf_container_length without stomping the payload. WRITE DYNAMIC is still not a path to PC. It is a bigger skip buffer for later. Silvanovich then asked a different question: if I could overwrite anything in the static buffer, what would I want? Answer: two function pointers in DLB_CLqmf_analysisL at static offsets 0x8A410 and 0x8A438 — the only dynamically allocated function pointers in UDC, more than 0x20000 bytes past the evo heap. Even a 0xFFFF skip buffer cannot walk that far as a linear overflow.

For operators: ATT&CK-flavored: T1189/T1190 is the wrong row. This is T1203 (exploitation for client execution) on a background decoder, delivered as T1448-class incoming message (mobile). No user action. Reliability later becomes T1665-style crash-loop (process respawn ~3s) until ASLR hits.

Extending the evo heap

Near the end of the evo slab sits heap_len, the cap used each syncframe. Overwrite it large, and evo allocations walk out of the original 0x1F08 and into the rest of the static buffer. Then a 0x286E8-byte allocation from heap base 0x61D28 lands on 0x8A410 — the function pointer. Payloads cost 19 bits of skip per 96 bytes of evo, so theoretically ~0xA000 skip bytes, which WRITE DYNAMIC can supply.

Static-buffer fields immediately after the evo heap, including heap_len
heap_len is the allocation cap. Overwrite it and the bump pointer leaves the slab. Source: original article.

The practical problem: a write that reaches heap_len also hits the skip pointer. A garbage skip pointer crashes before the new length is used. You cannot plant a known-good absolute pointer without a leak of a writable address. The dynamic buffer’s payload has no pointers. Its scudo header does.

Scudo secondary header in front of the dynamic buffer
Secondary large-chunk header; 0x00–0x50 unused; guard page before that. Source: original article.
A warehouse pallet with a length tag and empty floor as a guard page
Kitchen: the pallet tag is the header. Slide the tag, and the next delivery parks further down the aisle.

Android scudo facts P0 used. If a secondary chunk is freed and a request up to 0x2000 smaller arrives, the chunk is reused and the header slides upward. Size on free is taken from the “curr chunk len” field. The static and dynamic buffers are such odd sizes that they are unique in this process; they always land in the same spots. UDC is a separate process from other codecs, which is why that uniqueness holds.

Recipe: aim the skip pointer at “curr chunk len,” change 0x15000 into 0x17000, reset the decoder (new file), and the reallocation comes back with 0x2000 extra writable bytes before the header. Transcription will decode multiple attachments in one message, so a multi-file exploit is free. One nibble of ASLR remains: the dynamic buffer looks like 0x000000XXXXXXY0A0. The write wants …Y065, and because of the 0x1000 skip-pointer bias you must also know Y. Measured on a Pixel, Y is fairly uniform:

Histogram of the ASLR nibble Y on the dynamic buffer
Guessing Y is 1/16. Crash and respawn in ~3 seconds; average 24 seconds for this step. Source: original article.

The exploit guesses Y=3. With the slid header, an EMDF container can sit before the header; the leak capability copies it over the skip pointer and on into heap_len. The length that lands is leftover bit-allocation-pointer data: 0x77007700770077.

Kitchen table: You are not breaking the warehouse lock. You are rewriting the number on the pallet so the forklift thinks this slot is bigger, parks the next crate two meters further on, and leaves you a corridor of empty floor to hide a second letter in. You have to guess which painted stripe on the floor the pallet started on (the nibble). Sixteen stripes. Wrong stripe, the forklift drops the crate and a new forklift appears three seconds later.

Controlling PC

With a huge heap length and ~2070 EMDF payloads, evo should walk ~0x28000 bytes and the last payload should paint 0x8A410. It did not. Fields after heap_len got in the way.

Fields after heap_len including array_extra and payload_extra
Integrator knobs that add extra bytes to evo allocations. Source: original article.

EMDF parse is two passes. Count payloads (no heap), allocate an array of pointers plus 88-byte structs, rewind, parse again into those structs. Verbatim:

int num_payloads = 0;

while(true){

	int error = evo_parse_payload_id(&reader, &payload_id);

	if(payload_id == 0 || error)
		break;

	num_payloads++;
error = evo_parse_payload(reader, payload_id, 0, 0, &payload, 0); //allocates no memory
	if(error)
		break;
}

void** payload_array = evo_malloc(evo_heap, 8 * num_payloads, 8 *  array_extra);

for (int i = 0; i < num_payload; i++){
	payload_array[i] = evo_alloc(88, 0);
}

reader.seek(0);

for (int i = 0; i < num_payload; i++){

	int error = evo_parse_payload_id(&reader, &payload_id);

	if(payload_id == 0 || error)
		break;

error = evo_parse_payload(reader, payload_id, evo_heap, 0, payload_array[i], 0); 

	if(error)
		break;
}

Inside the second parse, the overflowing allocation is:

void* payload_mem = evo_alloc(payload_size, payload_extra);

array_extra and payload_extra (bold in the original write-up) are integrator knobs that pad specific evo_alloc calls. A 2070-payload array is 0x40B0 bytes of pointers. It slides off the evo slab and smashes payload_extra with tagged pointer values. The next

void* payload_mem = evo_alloc(payload_size, payload_extra);

becomes something like:

total_size = payload_size + 0xB400007XXXXXXXXX;

which is larger than the already-overwritten heap length, so the allocation fails. Worse, payload_extra persists across syncframes, so the bug can never fire again. The skip pointer, also smashed by that array, now aims up into the static buffer. By writing through it, P0 still overwrote the function pointer — PC control, once. That one-shot is not enough for shellcode. They threw the strategy away and kept the lesson: payload_extra is a weapon if you can set it on purpose.

Non-contiguous Overwrites

Two payloads in one container allocate the second payload at num_payloads×96 + payload_1_size + payload_extra. If payload_extra is a chosen distance to the QMF state, and payload_1_size is a small 8-byte-aligned slider, you get a relative write anywhere in a neighborhood of the static buffer — including the function pointer when the slider is 14×8. That neighborhood write is the rest of the exploit. It needs payload_extra set once, cleanly.

Layout of allocations on the evo heap: pointer array, payload structs, payload bytes
Pointer array, then 88-byte structs, then payload bytes. Source: original article.

Overwriting payload_extra

The heap-length trick is not enough: that write was “whatever BAPs happen to be sitting after the header,” and BAPs are low 16-bit integers rewritten every frame between WRITE DYNAMIC and parse. You cannot park a precise payload_extra in live scratch. P0 instead created an inactive scudo header. If a secondary chunk is the first of its size in the process, its prev pointer points at itself. Partial-overwrite the last two bytes (0x3000 → 0x5000) and the next allocation returns at 0x5000 while the header at 0x3000 is left dirty. That only works because nothing else in mediacodec is near this size. Cost: a third MP4.

  • first.mp4 — WRITE DYNAMIC writes dynamic_base+0x3061 to 0x48, so the next init reallocates at +0x4800.
  • second.mp4 — writes dynamic_base+0x4861 to 0x50, next init at +0x5000.
  • third.mp4 — the rest of the exploit, with a stale header at +0x4800 that BAPs will not touch.

When ASLR is the guessed nibble, the live dynamic buffer is at dynamic_base+0x3000. Overlapping WRITE DYNAMIC still clobbers the start of a precise 0xC8 write, and 0x60 bytes is too small for a pointer-moving payload. So a new primitive: WRITE DYNAMIC FAST. After skip is copied, UDC searches for X8 and parses after it. Put data before X8; it is written through the skip pointer, then the container that moves the pointer lives in the same frame. One frame, no follow-up clobber. Downsides: less payload per frame, harder to eyeball in a hex dump. The exploit uses FAST only when it must.

Inactive scudo header showing prev chunk pointer and the 0xc8 write target
Need a precise write at 0xc8 without killing prev chunk ptr. Source: original article.

FAST plus the stale header yields a skip pointer that still aims at dynamic, a huge heap length, and a chosen payload_extra. That is WRITE STATIC: a write to any static-buffer offset greater than 0x63C30.

For operators: Three files is not a usability problem on RCS: one message, three audio attachments, all transcribed. Hunters: three back-to-back Dolby init/teardown cycles in one mediacodec PID, then a long third file, is a stronger story than a single crashing attachment.

Calling Controllable Functions

The two function pointers are invoked here. Verbatim from the post:

void* DLB_CLqmf_analysisL(void **static_buffer, __int64 *output_index, __int64 in_param)
{

//static_buffer is static buffer at offset 0x8a3c8
…

int loop_times = *(int*)static_buffer + 5);
int index = *(_DWORD *)static_buffer;
do
{
    index_val = *output_index++;
    param_X0 = static_buffer[12];
    param_val = param_X0 + 8 * index;
    (static_buffer[14])(
        param_X0,
        static_buffer[5],
        static_buffer[1],
        static_buffer[7],
        in_param);

    result = dlb_forwardModulationComplex(
        param_X0,
        index_val,
        param_val,
        *static_buffer,
        static_buffer[13],
        static_buffer[8],
        static_buffer[9]);

    index = *(unsigned int *)static_buffer;
    --loop_times;
    …
    }
    while ( loop_times );
    return result;
}

And the optional indirect call inside dlb_forwardModulationComplex:

  if ( a7 )
  {
    result = (__int64 (__fastcall *)(__int64, __int64, _QWORD))(*a7)(a3, a1, a4);
  }

The function reads a function pointer and its arguments out of WRITE STATIC’s neighborhood, calls it, optionally makes a second indirect call if you have a pointer-to-pointer, and repeats loop_times times. Partial-overwrite those pointers and you have ROP with controlled X0–X3 and a repeat count. That is the entire userland endgame, squeezed through two bytes of ASLR.

What’s the plan, (Seth and) Jann?

Jann Horn kept asking how ROP becomes instructions in mediacodec. Classic answers die. SELinux: the context cannot open-and-write the same file, so dlopen of a dropped .so is gone; no execmem, so mprotect to RX is gone. libcodec2_soft_ddpdec.so barely talks to libc — fopen/fread yes, fwrite/fseek no. The three of them landed on /proc/self/mem, the procfs file that exists so debuggers can plant software breakpoints. Write any address in your own process. Overwrite a function, call it.

A debug hatch on a phone backplane, standing in for /proc/self/mem
Kitchen: the engine-room inspection plate. It is supposed to be for mechanics with a signed work order.
  1. Map shellcode into the dynamic buffer with WRITE DYNAMIC (early, before those primitives break).
  2. Call fopen("/proc/self/mem", "wb") many times so the fd is guessable (UDC opens almost nothing).
  3. Call pwrite (not imported by the Dolby lib) to copy shellcode over a later-called function.

Partial overwrites of the two pointers only give you the first byte for free. The upper nibble of the second byte of the load address is ASLR’d, fairly uniformly:

Histogram of the ASLR nibble on libcodec2_soft_ddpdec.so
Second 1/16 guess. Combined with the heap nibble: 1/256, ~six minutes at 3s respawn. Source: original article.

Guessing that nibble opens a 0xFFFF-byte ROP window — about 5% of the 1.3 MB library. The indirect call’s window covers most of the export table and GOT (~40 libc exports). Inside the window: memcpy (dst in dynamic, src in static if you leave parameters alone) and the increment gadget:

0x000000000026ae38 : 

ldr w8, [x1]
add w8, w8, #0x157 
str w8, [x1] 
ret	

Plan, as written at the time:

  1. Indirect call → GOT of fopen, spray /proc/self/mem.
  2. Indirect call → memcpy, copy that GOT entry into the dynamic buffer.
  3. memcpy again with dst = the copied pointer slot, so a live libc address of fopen sits in dynamic.
  4. WRITE DYNAMIC the last byte so the distance to pwrite is a multiple of 0x157.
  5. Run the increment gadget until the pointer is pwrite.
  6. Call pwrite.

The arithmetic on the library they used:

0x157 × 890 + 0x92E4A = 0xDD6C0

fopen at 0x92E90, pwrite at 0xDD6C0. One-byte punch to 0x92E4A, then 890 × 0x157. On other libc builds there are four near-hits (pwrite, its PLT, pwrite64, its PLT) plus seek+write combinations and other GOT bases. P0’s claim is that some pair works on every compilation they care about.

The Exploit

Third file, syncframe by syncframe. Follow along in attachment 72535031. Numbered names like 10_write_x0 are the frames; make_10_write_x0.py generated many of them (often with Gemini); _special means hand-edited after generation; combine_frames.py glues checksum-correct MP4s. We paraphrase the diary. We do not ship the MP4s.

WRITE STATIC memory layout: entries feeding DLB_CLqmf_analysisL
Each 64-bit slot is one WRITE STATIC entry; the QMF function runs once per syncframe. Source: original article.

longmem

Thirty-six frames dump shellcode into the dynamic buffer at descending addresses via WRITE DYNAMIC, while that primitive still works. Later frames will break it.

1_adjust_write_heap

Skip pointer → dynamic_base + 0xF000.

2_adjust_write_heap_special

WRITE DYNAMIC FAST plants wb and /proc/self/mem at that address for a future fopen, then moves the pointer to +0xD000 so the strings survive.

3_adjust_write_heap

Skip pointer → dynamic_base + 0x48C8, the slot that will become evo heap length and payload_extra after the copy. (Could have been folded into frame 2.)

4_adjust_write_heap_special

FAST writes heap length 0xFFFFFFFFFFFFFFFF and payload_extra = 0x28530, then aims the skip pointer at +0x473A.

5_do_heap_write

Writes the start of an EMDF container so frames 3–5 become one valid container. Parse triggers the bug. WRITE STATIC is live. WRITE DYNAMIC / FAST die, because evo allocations no longer have the old size.

6_write_pc

WRITE STATIC can punch one 64-bit entry per syncframe, but DLB_CLqmf_analysisL also runs once per syncframe. While you are still setting arguments, the old function pointer would crash you. Frame 6 partial-overwrites direct_call_fptr (entry 14) to a ret gadget.

7_garbage

Any two EMDF frames in a row now crash in an OOB memset that used to zero the evo heap and now uses the huge length. A garbage frame with invalid data resets that. Every subsequent even frame is real; every odd frame is garbage. The diary stops naming them.

8_write_str_str

Indirect pointer (entry 9) must be a pointer-to-pointer, not a gadget. Partial overwrite to the GOT of strstr. X0/X1 will be pointers for a while; strstr does not mutate memory, so the default four-iteration loop is survivable.

10_write_x0

Indirect fopen wants X0 = path. The formula is:

indirect_call_x0 = direct_call_X0 + 8 *  index;

Frame 1 already parked the path in dynamic. Index=1 aims X0 at /proc/self/mem, eight bytes from wb.

12_write_x1

Partial-overwrite entry 10 to dynamic_base+0xF000 = wb.

14_write_fopen

Entry 9 → fopen. It fires four times immediately (default loop_count).

16_garbage to 23_garbage

More garbage frames that still call fopen, spraying fds. UDC opens almost no files, so the number is stable enough to guess (they later use 40).

24_write_str_str

Entry 9 back to strstr so fopen stops.

26_write_x2

direct_call_X2 (entry 1) = 0xB8, memcpy length.

28_write_x0

direct_call_X0 (entry 12) → dynamic_base+0xEC00, dummy memcpy dst away from skip.

30_loop_count

Entry 2 loop_count = 1. From here, one call per frame.

32_memcpy

Entry 14 → memcpy gadget at 0x26CC2C. Static buffer (including the strstr GOT pointer in entry 9) copies into dynamic every frame until 14 is changed again.

34_write_x0

Real memcpy destination: dynamic_base+0x5F83.

36_zero_page and 38_copy_x1_special

Use the leak to lift the copied GOT pointer into direct_call_X1 (memcpy src). 36 writes zeros plus the tail of a container; memcpy drops the GOT pointer into the middle; 38 writes the head; parse sets entry 5.

40_write_x0 and 42_write_x0

40: dst dynamic_base+0xEF00, memcpy a live strstr pointer there. 42: move dst to +0x6043 so it is not clobbered, and stage the next copy.

44_write_x2, 46_write_scf, 48_zero_page and 50_write_x3_special

Original idea: put strstr in direct_call_X3 as pwrite’s offset (the function to overwrite). Bad idea. getpid, munlock, and friends sit after strstr in libc and run first, jumping into the middle of shellcode. Replacement: __stack_chk_fail, never called in normal UDC, neighbors unused. One leak-round instead of two, because they could retarget the existing GOT pointer in X1 from strstr to __stack_chk_fail.

52_set_pc_back

Direct call back to ret. memcpy stops.

54_write_skip, 56_write_x1_end_special and 58_write_x1_start_special

Silvanovich had hoped WRITE STATIC would retire WRITE DYNAMIC. It did not: direct_call_X1 still held a GOT pointer and needed a dynamic-buffer pointer. Dynamic already contained nice pointers from the earlier static copy. Rather than a fiddly skip walk to those addresses, they rebuilt WRITE DYNAMIC under the new payload_extra. You no longer need the integer-overflow bug: heap length is already a lie, so evo will not refuse an OOB write. Allocate exactly enough payloads that the pointer array plus structs end on the skip pointer, and the first payload’s bytes paint it.

Evo heap layout again, used to rebuild WRITE DYNAMIC
Same layout figure as earlier in the post, now for the rebuilt WRITE DYNAMIC. Source: original article.

60_write_skip, 62_write_single_byte and 64_move_skip

WRITE DYNAMIC punches the last byte of the strstr libc pointer so the 0x157 gap to pwrite is exact. 64 moves the skip pointer so the punch is not applied twice.

66_write_index

Zero entry 0 (index). The increment gadget will also bump this; leaving it dirty would OOB later reads.

68_loop_count

loop_count = 0x7B. The QMF function runs twice per real frame, so the gadget fires 0xF6 times.

70_write_x1

direct_call_X1 currently somewhere in dynamic; this frame points it at the modified strstr pointer.

72_inc_157

Direct fptr → increment gadget, 0xF6 times. The dynamic pointer now is pwrite.

74_set_pc_back

Back to ret. Incrementing stops.

76_set_malloc

Indirect is still strstr. Preparing pwrite’s X0 as an integer fd would crash strstr. Switch indirect to malloc (GOT in range); one integer argument is legal.

78_write_x0

direct_call_X0 = 40, the guessed /proc/self/mem fd.

80_write_x1

Partial-overwrite X1 to the shellcode in dynamic.

82_write_x2

X2 = shellcode length.

84_write_end_special and 86_write_start_special

Copy the pwrite pointer onto entry 14 the same way other dynamic-to-entry copies work. pwrite fires and overwrites __stack_chk_fail with shellcode.

88_write_scf

Partial-overwrite the indirect register to the GOT of __stack_chk_fail. It runs. Shellcode runs. Part 1 is done.

Kitchen table: The third cassette is not one song. It is a DJ set: park the lyrics (shellcode), rewrite the stamp, gag the speakers so they only say “return,” spray keys to the debug hatch, photocopy the address book (GOT), walk a pointer from fopen to pwrite by taking 0x157-sized steps, then ask the hatch to tattoo a function you will call on the next beat. Every other track is noise so the photocopier’s “erase the tray” habit does not paint the hallway.
For operators: If you are reproducing in a lab you own, the public tree is the numbered frames plus combine_frames.py. If you are detecting: a mediacodec process that fopens /proc/self/mem in a tight loop, then pwrites, then dies-or-lives after a burst of Dolby inits, is not transcription.

How reliable is this exploit?

ASLR guessing: about 1 in 255. A second failure mode: binder sometimes makes a secondary allocation mid-exploit, scudo header checks fail, crash. With a debugger attached this is common; in the wild Silvanovich saw it under 10%. Respawn is ~3 seconds, so the 1/256 path is on the order of six minutes. That is long for a demo and short for a patient 0-click.

Two ideas for raising reliability, both “months more work.” First, force the dynamic buffer to a known offset from dynamic_base by overwriting only the low byte of prev — the only byte you can hit without ASLR bits — via BAPs or evod_process’s post-parse write. You still need the destination mapped: sliding +0x3000 to +0xF000 is fine; the reverse writes a header into unmapped memory and dies. Pinning at +0xF000 would always map, but then WRITE DYNAMIC only covers +0xF000..+0xFFFF, so the exploit would have to shrink its dynamic footprint.

Second, leak a library pointer into dynamic, use its nibble as emdf_container_length, and ship 16 flavors of trigger of which only the matching length runs (too-short never reaches the bug bytes; too-long fails the second evo_parse_payload before commit). The blocker is copying static→dynamic without already knowing the library nibble, because both callable pointers are so constrained. P0’s close: reliability can be improved a lot; it would not be a weekend.

Reflections on Mitigations

Table of which Android mitigations actually cost Project Zero weeks
ASLR low bits and SELinux were real taxes. Pixel 9’s missing seccomp and writable /proc/self/mem were gifts.

ASLR was the expensive one. Partial pointer overwrites are standard; randomizing the low bits is what turned this into a two-nibble grind. High-bit entropy still matters so you cannot brute the whole pointer. P0’s takeaway: low-bit randomization buys more exploit-dev time than another high bit. They also went looking for Android ASLR holes and did not find a region under-randomized enough to skip the grind — a change from older Android.

SELinux blocked the textbook shellcode paths. That cost is probably one-time per attacker team: once you know /proc/self/mem plus fd spray, you reuse it. mediacodec usually has seccomp. AOSP even has a policy file. Silvanovich checked a Samsung S24: enforced. Pixel 9: somehow not. A Samsung-like policy would have blocked pwrite. It would not have blocked the chain — every syscall BigWave needs must remain available for decode to work — but it would have forced a ROP-only Part 1 and “at least a few more weeks.”

/proc/self/mem is a debug hatch. P0 asks whether it can be unreachable when the device is not being debugged. scudo secondary headers have no checksums; primary does. Guard pages make secondary-header bugs rare, so the performance argument for checksums is weak and the exploit-dev argument is strong. The memory layout (huge structs, overflow between members) is not unique to Dolby. Silvanovich’s 2022 H.264 report had a similar shape.

On Mac and iOS, UDC was built with -fbounds-safety, injecting bounds checks including on arrays inside structs. P0 believes CVE-2025-54957 is not exploitable on those binaries. The performance cost is real; compiling every media library with the flag would delete a class of “exceptionally exploitable” bugs. Even if production will not take the hit, fuzzing with the flag on would make these bugs louder in the lab.

Kitchen table: The lock on the basement door (ASLR’s low bits) was the part that made the locksmith sweat. The union rules (SELinux) banned the usual crowbars. The Pixel had left the seccomp chain off the photocopier, unlike Samsung’s. And someone left the engine-room hatch (/proc/self/mem) on a latch because “we might need to debug.”

The Next Step

Userland in mediacodec is not the end. Part 2 is “Cracking the Sandbox with a Big Wave” — CVE-2025-36934, a driver reachable from this context. Part 3, “Where do we go from here?”, is the ecosystem argument: vendor blobs, 0-click media, and what platforms should tax. Both shipped the same day as Part 1. If you only remember one sentence from this draft: audio transcription moved a Dolby blob onto the 0-click surface, and the blob was good enough that two bugs became a chain.

CVE cards and a timeline

FieldValue
CVECVE-2025-54957
ComponentDolby Unified Decoder in libcodec2_soft_ddpdec.so
CWECWE-190 pad / CWE-787 write / CWE-125 leak (one report)
DiscoverersIvan Fratric and Natalie Silvanovich, Project Zero
Surface0-click via Google Messages SMS/RCS audio transcription
Fix5 January 2026
Public write-up14 January 2026
This part’s impactarbitrary code execution in mediacodec
Chain mateCVE-2025-36934 BigWave (Part 2)
CousinCVE-2025-49415 Monkey’s Audio on Samsung
Compiled from the Project Zero post and issue tracker. Source: original article / Chromium issue.
WhenWhat
PriorCVE-2025-49415 Monkey’s Audio (Samsung) — the hunt that led to Dolby.
In parallelSeth Jenkins files CVE-2025-36934 on a Pixel 9 driver visible from mediacodec.
5 Jan 2026Both vulnerabilities in this series fixed on devices that took the update.
14 Jan 2026Parts 1–3 published together, with public exploit attachments.
May 2026Seth Jenkins’s Pixel 10 follow-up: Dolby still, BigWave gone, VPU instead.
Dates from Project Zero. Pixel 10 is a later post, not this one.

ATT&CK and what a hunter can actually see

StageTechnique (approximate)What it looks like
DeliveryIncoming RCS/SMS audio, no tapGoogle Messages pulls three audio attachments
ExploitT1203 on UDC / Codec2mediacodec crash-loop ~3s or a long Dolby decode
Heap gamesAllocator metadata tamperRepeated UDC init; unusual scudo secondary reuse
ROPT1620 / T1055-adjacent in-processfopen(/proc/self/mem) spray, memcpy of GOT, pwrite
PayloadIn-process shellcode over __stack_chk_failThen BigWave ioctls — Part 2
Mapped from techniques P0 actually used. Not a claim that every row is a numbered ATT&CK mobile technique.

Defensive hunts that do not require the MP4s:

  • Alert on mediacodec opening /proc/self/mem. Transcription does not need a debugger hatch.
  • Alert on pwrite from that context. Pixel 9 lacked seccomp; your build might still allow it — measure.
  • Crash telemetry: mediacodec dying on ddpdec, respawning, dying again, on a device that is idle at the lock screen.
  • Vendor image audit: is libcodec2_soft_ddpdec.so the January 2026+ build? Is mediacodec seccomp actually loaded (Samsung yes, this Pixel no)?
  • Do not treat “user did not open the message” as “not exploited.” That is the point of the feature.

A glossary so the rest of the diary stays readable

Project Zero’s Part 1 is written for people who already live in decoder internals. This glossary is the translation layer. Every term below is used the way Silvanovich used it, not the way a DSP textbook uses it.

TermKitchenOperator
0-clickThe letter is opened in the basement before you come downstairs.No user gesture. Messages transcribes RCS/SMS audio on receipt.
UDC / Dolby blobA closed photocopier OEM bolted under the desk.libcodec2_soft_ddpdec.so, statically linked, few symbols.
syncframeOne independently wrapped page of the letter.Independently decodable DD+ frame, up to 6 audio blocks.
skip bufferThe extra pages after the letter, scanned for a bookmark.Up to 0xBFA bytes/frame copied from skipfld.
EMDF / X8A second letter after a bookmark printed X8.Extensible Metadata Delivery Format; 16-bit syncword 0x5838 as text X8.
variable_bits“How long is this?” with no adult in the room.Continue-bit integer; emdf_payload_size is variable_bits(8).
evo heapA cafeteria tray that only slides forward, reset each song.Bump allocator, no free, one syncframe lifetime, 0x1F08 bytes.
static bufferThe photocopier’s whole chassis, 692,855 mm³.692855-byte UDC state; evo lives at +0x61d28.
dynamic bufferScratch paper, 85,827 mm², where skip is written.85827-byte scratch; skip pointer aims 0x1000 below skip.
WRITE DYNAMICWalking the stamp backwards to fill a whole notebook.Overlapping skip-pointer walks; up to 0xFFFF attacker bytes in dynamic.
WRITE DYNAMIC FASTStamp and letter in the same envelope so nobody overwrites the start.Data before X8 plus mover container in one frame.
WRITE STATICWriting anywhere on the chassis past the tray rail.Relative write at static offsets > 0x63c30 via payload_extra.
scudo secondaryA warehouse pallet whose length tag you can rewrite.Large-chunk allocator; no checksums on the header P0 rewrote.
/proc/self/memThe engine-room hatch labeled “for mechanics.”ProcFS self-write used to plant shellcode because execmem is denied.
increment gadgetTaking 0x157-sized steps from fopen to pwrite.ldr w8,[x1]; add w8,#0x157; str w8,[x1]; ret at 0x26ae38.
Dual-audience glossary for Part 1. Source: terms from the original article; kitchen column is ours.
Kitchen table: If you remember only five cards: unopened mail, a tray that never goes backward, a stamp after the tray, a pallet tag you can lie about, and a hatch that was left on the latch for debugging.

Why this is one CVE with two teeth, not two CVEs

Silvanovich is explicit: the leak was not filed separately because, by itself, it has no security impact. The skip buffer starts zero and then holds only bytes the attacker already put in the syncframe. Reading “out of bounds” of skipl but inside a zero page is reading your own homework. The overrun is the tooth that matters in isolation — except a naive overrun nulls the skip pointer and dies, which is what the first public PoC does. The leak becomes a tooth only after the overrun has painted neighboring memory with something worth reading, or after you have used WRITE DYNAMIC to put a container where the leak will copy it onto the skip pointer and heap_len.

That pairing is why she calls the bug “exceptionally high quality” in the mitigations section. Plenty of decoder bugs are either a huge unstoppable overflow (unexploitable in 0-click because you smash everything) or a read of zeros. This one lets you choose the overflow length (because the bit reader stops at emdf_container_length) and then reuse the same skip buffer as a staging area for the next lie. If you are triaging vendor blobs, the question is not “is there an integer overflow.” It is “does the copy stop on a second length field the attacker also controls, and is there a pointer immediately after the bump heap.”

For operators: When you write a similar bug: one tracker item is correct if the leak has no independent impact. When you write a detection: hunt the pair — a Dolby decode that both overflows evo and then writes skip through a non-null, non-original skip pointer. The original PoC is the crash. The exploit is the second file that does not crash.

The spec is doing the attacker a favor. ETSI documents skip fields, EMDF containers, and variable_bits without a payload-size cap the Android blob enforced. Fratric did not need a fuzzer to see “unbounded size into a bump heap.” He needed the blob, the spec, and the question “what is evo.” That is the unglamorous 0-click finding method: read the public bitstream, then read the private allocator. Fuzzing still helps for the padding wrap — the (8 - total_size) % total_size line is not something a spec review shouts — but the unbounded payload is a paper finding.

The three MP4s as one RCS message

A common pushback on multi-stage decoder exploits is “the user would notice three files.” Not here. Google Messages transcribes incoming audio attachments automatically. One RCS (or MMS-class) message can carry multiple audio parts. Each part is a “new file” to UDC: allocations freed and re-created, which is exactly the scudo slide.

first.mp4’s only job is to lie about chunk length so the next init parks the dynamic buffer 0x1800 further on. second.mp4 lies again, parking at +0x5000 and leaving a dirty header at +0x4800. third.mp4 is the novel. From the user’s point of view this is one voicemail thread. From mediacodec’s point of view it is three decoder lifetimes. From IR’s point of view it is three Dolby inits in one PID without a tap on the notification.

Kitchen table: You did not get three envelopes. You got one envelope with three folded sheets. The photocopier in the basement copies each sheet as a new job, which is when the forklift re-parks the pallet. The exploit is a clerk who knows the forklift does that.
FileDecoder lifetimeJob
first.mp4Init A → freeWRITE DYNAMIC: curr chunk len 0x15000 → 0x17000-class lie; realloc at +0x4800
second.mp4Init B → freeHeader slide again to +0x5000; stale header remains at +0x4800
third.mp4Init C, longlongmem shellcode, heap_len/payload_extra, WRITE STATIC, ROP, pwrite, shellcode
Three attachments, one message, three scudo lifetimes. Source: original article; table is ours.

Syncframe index for the third file

The diary names dozens of frames. Here they are in one table so you can keep your place. Even-numbered frames after 6 are “real”; odd frames are garbage to stop the OOB memset. Names are P0’s.

FramesPrimitiveOne-line job
longmem ×36WRITE DYNAMICPark shellcode in dynamic, descending addresses
1skip pointerAim at +0xF000
2_specialFASTPlant wb and /proc/self/mem; move to +0xD000
3skip pointerAim at +0x48C8 (future heap_len / payload_extra)
4_specialFASTheap_len = all-ones, payload_extra = 0x28530
5bugGlue 3–5 into one EMDF; WRITE STATIC unlocks; DYNAMIC dies
6WRITE STATICdirect_call_fptr → ret gadget
7, then all oddsgarbageBreak consecutive-EMDF memset crash
8WRITE STATICindirect → strstr GOT
10–14WRITE STATICX0 path, X1 wb, fopen ×4
16–23garbage+fopenfd spray; later guess 40
24–32WRITE STATICstop fopen; prep memcpy; loop_count=1; memcpy gadget
34–42memcpy+leakGOT into dynamic, then live strstr pointer
44–50leak retargetX3 = __stack_chk_fail not strstr (neighbors too hot)
52retStop memcpy
54–58new WRITE DYNAMICX1 back to dynamic under new payload_extra math
60–64byte punchLast byte of fopen-class pointer; 0x157-aligned to pwrite
66–74incrementindex=0, loop 0x7B×2=0xF6, gadget, then ret
76–82argsindirect malloc; X0=40; X1=shellcode; X2=len
84–86copy pwriteEntry 14 = pwrite; pwrite fires onto __stack_chk_fail
88callindirect GOT of __stack_chk_fail → shellcode
Index of third.mp4. Source: original article frame names.
For operators: If you are labeling a lab capture: look for a long third attachment whose syncframes alternate valid EMDF and garbage, with fopen of /proc/self/mem in the first third of the valid frames and a burst of 0xF6-class repeated calls before pwrite. That shape is more stable than hashes of the MP4, which P0 generated with scripts.

The 0x157 walk, done on paper

This is the piece non-ROP people bounce off, so we do it slowly. You cannot call pwrite: the Dolby library does not import it, and you can only partial-overwrite two bytes of two pointers. You can call whatever sits in a 0xFFFF window around those pointers, including memcpy and a gadget that adds 0x157 to *X1.

So you materialize a libc function pointer you can reach (fopen, via GOT), copy it into dynamic memory you can write a single byte of, punch that byte so the remaining distance to pwrite is a multiple of 0x157, then run the gadget hundreds of times. On P0’s libc:

  • fopen = 0x92E90
  • pwrite = 0xDD6C0
  • Punch low byte 0x90 → 0x4A, getting 0x92E4A
  • 0xDD6C0 − 0x92E4A = 0x4A876
  • 0x4A876 / 0x157 = 890 exactly
  • loop_count 0x7B, QMF runs twice → 0xF6 (246) calls per real frame; they structured surrounding frames so the total adds to 890

If 890 does not land on your libc, P0’s fallback list is the point: pwrite, pwrite PLT, pwrite64, pwrite64 PLT, or start from a different GOT (not fopen) so a different one-byte punch works. The invariant is “some exported or PLT address in libc is a 0x157 lattice point away from a one-byte-mutated neighbor of a GOT you can already call.” That is a weaker requirement than “pwrite is imported.”

Kitchen table: You cannot ask the photocopier to call the hatch-writer by name. You can ask it to call the file-opener, photocopy that opener’s address onto scratch paper, change the last digit with a stamp, then walk the address 343 (0x157) steps at a time until it is the hatch-writer. Then you ask the photocopier to call whatever is written on the scratch paper.
fopen      = 0x00092E90
pwrite     = 0x000DD6C0
punched    = (fopen & ~0xFF) | 0x4A   # 0x92E4A
delta      = pwrite - punched         # 0x4A876
steps      = delta / 0x157            # 890
# gadget: ldr w8,[x1]; add w8,#0x157; str w8,[x1]; ret
# X1 points at the punched pointer in the dynamic buffer

scudo: why the pallet tag had no checksum

Android’s scudo splits allocations into primary (small, size-classed, checksummed headers) and secondary (large, mmap-backed). The static (692855) and dynamic (85827) buffers are secondary. Secondary chunks sit after a guard page. That guard is why “just overflow a neighbor into the header” is usually science fiction. P0 never overflowed into the header from below. They used a decoder write-what-where (the skip pointer) to poke fields inside a header they could already aim at once they guessed a nibble.

Primary headers being checksummed is why they say this would have died in the primary partition. Secondary headers being unchecksummed is why rewriting “curr chunk len” and “prev chunk ptr” was a data-only trick, not a crypto trick. The uniqueness of the sizes is the third gift: once those two chunks exist, they are the only secondary slots that satisfy the next request of that size, so the process hands you the same addresses every init. UDC running as its own process, not co-tenancy with other codecs, keeps that uniqueness from being polluted.

The inactive-header trick (prev pointer partially overwritten so the next alloc returns further along while the old header stays dirty) is the sort of behavior you only find by playing with the allocator, not by reading the Dolby spec. It is also the reason first.mp4 and second.mp4 exist. P0’s recommendation is cheap: checksum secondary headers. Guard pages do not authenticate the length field. Rare bug class, small performance tax, large exploit-dev tax.

For operators: If you ship scudo: treat secondary metadata as hostile whenever a decoder has a relative write. If you hunt: a mediacodec process whose secondary chunk headers change length without a matching mmap is not a decoder jitter optimization.

Cousin bugs, so this is not a Dolby-only sermon

BugWhereShape0-click?
CVE-2025-49415Monkey’s Audio, SamsungAudio decoder on incoming path — the hunt that led hereSilvanovich’s prior filing
CVE-2025-54957Dolby UDC, most AndroidUnbounded EMDF + evo pad wrap + skip leakYes, Messages transcription
CVE-2025-36934BigWave, Pixel 9Driver from mediacodec (Part 2)After Part 1
2022 H.264PZ-42451420Huge structs, overflow between members — same layout classDifferent surface
Pixel 10 chain (May 2026)Dolby still; BigWave gonePart 1 ports; LPE becomes VPUSame 0-click decoder
Family resemblance. Pixel 10 is a later P0 post, cited so Part 1 is not read as a one-device curiosity.

The H.264 comparison in the mitigations section is the design lesson. Media decoders love giant state structs allocated once at init. A bump heap inside that struct, a scratch buffer next to a pointer, integrator “extra” fields that persist across frames — none of that is Dolby-specific. What is Dolby-specific is EMDF’s unbounded variable_bits and a skip pointer sitting immediately after evo. What is Android-specific is scudo secondary plus a transcription feature that feeds the blob without a tap. The chain is the product of all three.

Seth Jenkins’s May 2026 Pixel 10 post is the epilogue you should read after Part 2: CVE-2025-54957 still worked (offset churn, not a new bug), BigWave was gone from the device, and the LPE moved to a VPU driver. Patching one OEM driver does not retire the decoder. Patching the decoder does not retire the next driver mediacodec can open.

What Pixel 9 forgot to ship

AOSP has a mediacodec seccomp policy. Samsung S24 enforced it. Pixel 9 did not. That sentence is the most expensive omission in the post. seccomp would not have saved the decoder from CVE-2025-54957 and would not have saved the kernel from a driver the decoder is allowed to talk to. It would have taken pwrite off the table and forced Part 1 to stay in ROP, which Silvanovich estimates as weeks. Weeks are the unit 0-click economics care about.

The same paragraph applies to /proc/self/mem. It exists so a debugger can plant software breakpoints. mediacodec in production is not a debugger. SELinux already forbade execmem and same-file open-write. The hatch reopened a path those two policies had closed. If you are writing an Android hardening review, “is /proc/self/mem writable in this context when the device is not being debugged?” is a one-line question that would have changed this paper.

A seccomp policy similar to Samsung’s would have prevented the call to pwrite used by the exploit. This wouldn’t have prevented exploitation … but it likely would have forced the exploit to be written entirely in ROP.

Natalie Silvanovich, Reflections on Mitigations

Reliability as an IR timeline, not a percentage

1/256 sounds like a coin-flip circus. Translated into wall-clock: crash, ~3 second respawn, try again. Average about six minutes of a mediacodec process being born, eating three audio attachments, dying, repeating. Binder’s extra secondary allocation adds a <10% additional die-roll when you are not attached to a debugger.

For a demo, six minutes is awkward. For a 0-click against a phone that is in a pocket, six minutes of background decode crashes is a telemetry story if you collect them, and invisible if you do not. P0’s reliability-improvement sketches (pin the dynamic buffer, length-oracle the library nibble) are months of work. A real attacker might just send the message more than once. RCS will transcribe again.

Kitchen table: The locksmith has a keyring of 256 keys and tries one every three seconds. You are asleep. The photocopier in the basement keeps jamming and restarting. Unless someone logs photocopier jams, nobody wakes you.
For operators: EDR/MDM hunts: burst of mediacodec SIGSEGV/SIGABRT on ddpdec, 3s spacing, same incoming message id, device idle. That is the 1/256 brute, not a flaky codec. After success, crashes stop and the process lives — then Part 2 ioctls.

A defensive lab you can run without the MP4s

We are not attaching P0’s exploit media. You can still exercise the defensive questions on a device you own, patched or not.

  1. Does mediacodec’s SELinux policy allow open of /proc/self/mem? sesearch / audit2allow on a dump of the running policy.
  2. Is seccomp actually attached? /proc/<pid>/status Seccomp field on a live mediacodec; compare Pixel-class vs Samsung-class builds.
  3. What is the SHA-256 of /vendor/lib64/libcodec2_soft_ddpdec.so vs the January 2026 image?
  4. Does Messages still auto-transcribe RCS audio? If you turned the feature off, you shrank surface; you did not patch the blob.
  5. Can mediacodec open BigWave (or the Pixel 10 VPU node)? That is Part 2’s question, but inventory it now.
# on a lab device / extracted ramdisk — not a recipe to attack anyone
# 1. blob identity
sha256sum /vendor/lib64/libcodec2_soft_ddpdec.so
strings /vendor/lib64/libcodec2_soft_ddpdec.so | egrep -i 'evo_malloc|heap_len|EMDF|X8'

# 2. seccomp on a running mediacodec (lab)
pid=$(pidof mediacodec | awk '{print $1}')
grep -i seccomp /proc/$pid/status
cat /proc/$pid/status | egrep 'Seccomp|NoNewPrivs'

# 3. policy smell
sesearch -A -s mediacodec -c file -p write 2>/dev/null | head
# look specifically for proc_pid_mem / procfs self mem

YARA-shaped (unofficial) strings for a vendor-image crawl — these are public symbol/path names from the post, not a detector for the MP4s:

rule P0_Dolby_UDC_mediacodec_hatch
{
    meta:
        description = "Hunt aid from public P0 Part 1 — not a detector for the exploit MP4s"
        reference = "https://projectzero.google/2026/01/pixel-0-click-part-1.html"
    strings:
        $lib = "libcodec2_soft_ddpdec.so" ascii
        $evo = "ddp_udc_int_evo_malloc" ascii
        $proc = "/proc/self/mem" ascii
        $qmf = "DLB_CLqmf_analysisL" ascii
    condition:
        2 of them
}

What this draft refuses to host

The Chromium issue has the crashing PoC (attachment 67118327) and the exploit tree (attachment 72535031) with numbered frames, Python generators, and combine_frames.py. Those are public because Project Zero published them. This WordPress draft links them and reproduces the pseudocode and figures that were in the HTML. It does not re-upload MP4s, does not add a more reliable variant, and does not fill in the “months more development” sketches as working code. If you need the bits, take them from the issue tracker with your own account and your own lab. The point of this page is that a defender can read the chain without us becoming a second distribution mirror.

Do not skip this: Patched as of 5 January 2026. Unpatched December-2025-SPL devices are the only ethical lab. Sending these attachments to a phone you do not own is not research.

For the CISO who will not read the syncframe diary

Your messaging app decodes voicemail for an on-device model. That decode runs in a privileged-enough sandbox that, on this Pixel, it could open a kernel driver (Part 2) after a userland bug in a vendor blob. The blob was not written by Google. The transcription feature was. The missing seccomp was a Pixel-shaped hole Samsung had already closed. The debug hatch is a Linux default. None of those sentences is a reason to wait for a “real” 0-click in the wild before you patch media codecs.

  • Treat incoming-media decoders as 0-click surface in the threat model, not as gallery code.
  • Demand seccomp on mediacodec in every OEM you buy, with a test, not a slide.
  • Ask vendors whether /proc/self/mem is reachable from media contexts in production.
  • Ask whether media blobs are built with bounds-safety or equivalent, and whether they are fuzzed that way.
  • Budget the January 2026 SPL as a security update, not an optional radio.

The community asked P0 whether only well-resourced attackers could do this. The honest answer in the paper is: the first time, you need a Silvanovich, a Fratric, a Horn, and a Jenkins. The second time, you need the attachment and a nibble brute. That is the curve every 0-click walks once the write-up exists. Patching is how you refuse to be on the curve.

How a “not enough skip bytes” bug becomes a 0x28000-byte write

If you only remember the CVSS-shaped summary — “integer overflow in a decoder” — you will underestimate this paper. The first overflow is at most 3066 bytes. The function pointers live more than 0x20000 past the evo heap. Linear overflow cannot get there. The art is leverage: each EMDF payload spends 19 bits of skip to buy 96 bytes of evo, so a painted skip buffer of 0xA000-class size buys a 0x286E8-class bump, which is how 0x61D28 meets 0x8A410.

WRITE DYNAMIC exists because 3066 is too small to be that painted buffer. By walking the skip pointer backwards in 0x800-byte overlapping steps you turn the dynamic buffer into a 0xFFFF-byte skip. FAST exists because a mover frame would otherwise eat the start of a precise write. WRITE STATIC exists because payload_extra, once set, lets you skip over ranges you do not want to paint. None of those primitives is a second vulnerability. They are the first vulnerability aimed at integrator fields and allocator metadata that happened to sit in the blast radius. That is why Silvanovich spent pages on heap_len and payload_extra instead of hunting a second code bug for PC control. The second code bug (BigWave) is for the kernel, not for userland.

The 96-byte-per-payload figure is the conversion rate of the entire first half. 99 payloads ≈ 235 skip bytes to fill 0x1F08 so a short overflow can reach the skip pointer. 2070 payloads ≈ 0x40B0 of pointer array, which is also the size that smashed payload_extra by accident the first time they tried to walk to the QMF pointers. When they set payload_extra on purpose to 0x28530, they were using the same conversion rate as a slide rule: extra bytes of evo that are allocated but not overwritten, so the next payload’s bytes land on X0 of DLB_CLqmf_analysisL. payload_1_size then becomes a fine knob in units of 8. 14×8 is the function pointer. That is not poetry. That is arithmetic you can re-derive from the figures.

Kitchen table: You cannot throw a 3066-page dictionary at a shelf 130,000 pages away. You can fill the tray with 99 crackers so the next roast hits the stamp, use the stamp to fill a notebook, use the notebook to lie about how long the tray is, then use the long tray to park a single roast on the far shelf without touching the books in between. The crackers, the stamp, the notebook, and the lie are all the same bug aimed at different stickers on the machine.
For operators: Re-derive these constants from the post when you audit a fork of UDC: evo size 0x1F08, payload struct 88, pointer 8, min payload 19 bits, skip cap 0xBFA, static 692855, dynamic 85827, QMF fptrs 0x8A410/0x8A438, skip-pointer bias 0x1000, gadget 0x26AE38, memcpy 0x26CC2C. If a new OEM build moves them, Part 1’s offsets die and the bug class does not.

One more conversion rate that hid in the FAST section: because skip-pointer writes clobber the start of the previous write, a long DYNAMIC fill must overlap. FAST avoids overlap by putting the clobber and the payload in one frame, at the cost of capacity. P0’s rule of thumb — DYNAMIC wherever possible, FAST only for writes that cannot survive a clobber — is the same rule you should use if you ever have to read someone else’s EMDF exploit. Numbered frames that are “special” are almost always FAST or a hand-stitched container head/tail. Numbered frames that are not special are usually a skip-pointer move or a single WRITE STATIC entry.

Garbage frames are the last conversion rate: two valid EMDF frames in a row, after heap_len is a lie, call memset with the lie and paint off the static buffer. Inserting invalid data between valids is not obfuscation. It is keeping the photocopier’s “wipe the tray” habit from wiping the hallway. If you see alternating valid/invalid DD+ frames in a third attachment, that is the memset workaround, not a broken encoder.

Put the rates in one place and the 11,000-word diary becomes a one-page cheat sheet: 19 bits → 96 bytes; 3066 → skip cap; 0xFFFF → DYNAMIC cap; 0x1F08 → evo; 0x28530 → chosen extra; 0x157 → lattice; 1/16 × 1/16 → 1/256; 3 seconds → respawn. Everything else is choreography so those numbers land on the right stickers without waking memset, binder, or a neighbor in libc.

Reading order if you are not going to finish the diary tonight

  • Skim the glossary and the three-MP4 table. You now know why transcription plus scudo uniqueness matters.
  • Read “The Bug” through the two capabilities (overrun + leak) and look at the four spec tables. You now know why it is one CVE.
  • Jump to “What’s the plan, (Seth and) Jann?” and the 0x157 walk. You now know why SELinux plus a missing seccomp produced /proc/self/mem.
  • Read “Reflections on Mitigations” and “What Pixel 9 forgot to ship.” You now have the patch-and-harden list.
  • Leave the numbered frames for the week you are actually auditing a UDC build. They will still be here. So will the Chromium attachments.

Part 2 will still be waiting when you are done: the process this shellcode runs in was chosen because it can talk to a kernel driver. Part 1 is not a trophy. It is a door.

Key Takeaways

  • AI transcription put Dolby UDC on the 0-click path. The user never taps Play.
  • CVE-2025-54957 is one report with two teeth: a wrapped evo pad (overrun) and emdf_container_length > skipl (leak).
  • WRITE DYNAMIC, WRITE DYNAMIC FAST, and WRITE STATIC are skip-pointer choreography plus a scudo secondary slide across three MP4s.
  • PC control without a second bug is real; shellcode needed /proc/self/mem because SELinux killed dlopen and execmem.
  • Reliability is ~1/256 from two ASLR nibbles, plus rare binder collisions. Minutes, not milliseconds.
  • Pixel 9 omitted mediacodec seccomp that AOSP documents and Samsung shipped. That omitted line is why pwrite lived.
  • Apple’s UDC with -fbounds-safety is, per P0, not exploitable this way. Fuzz vendor blobs with that flag even if you will not ship it.

Defensive Recommendations

  1. Patch. January 5, 2026 SPL or later. Confirm libcodec2_soft_ddpdec.so is not the pre-fix blob.
  2. Seccomp. Load the AOSP mediacodec policy (or Samsung’s) on Pixel-class builds. Block pwrite, mmap-exec, and surprise fds. Measure with a unit test, not a comment.
  3. /proc/self/mem. Make it unreachable for production contexts that are not being debugged. This hatch turned ROP into shellcode.
  4. scudo. Checksum secondary headers. Guard pages are not a substitute for “the length field is authentic.”
  5. Compiler. -fbounds-safety (or equivalent) on media blobs. If production will not take it, CI fuzzing should.
  6. Surface. Anything you decode to feed an on-device model is 0-click surface. Budget it like a network parser, not like a gallery app.
  7. Detect. mediacodec + /proc/self/mem, crash-loops on ddpdec, three sequential Dolby inits from Messages.
  8. Read Part 2. This shellcode’s job is a kernel driver. Closing Part 1 without BigWave is half a ticket.

Conclusion

Project Zero did not publish this chain to stock a cache of 0-clicks. They published it because vendors asked whether a modern Android media bug was “really” exploitable by anyone but a superpower. The answer in Part 1 is a cafeteria tray, a warehouse pallet, a debug hatch, and a 0x157 gadget. The decoder was a blob. The message was unopened. The process that transcribed a voicemail learned to write to itself. Patch the blob. Put seccomp back on the photocopier. Then go read what that process is allowed to open next.

Original text: “A 0-click exploit chain for the Pixel 9 Part 1: Decoding Dolby” by Natalie Silvanovich at Google Project Zero.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion