abusing-android-media-pipelines-image-parsers.md (14926B)
1 --- 2 title: "Abusing Android Media Pipelines & Image Parsers" 3 section: "Mobile" 4 sectionSlug: "mobile-pentesting" 5 sourcePath: "src/mobile-pentesting/android-app-pentesting/abusing-android-media-pipelines-image-parsers.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/mobile-pentesting/android-app-pentesting/abusing-android-media-pipelines-image-parsers.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # Abusing Android Media Pipelines & Image Parsers 14 15 ## Delivery: Messaging Apps ➜ MediaStore ➜ Privileged Parsers 16 17 Modern OEM builds regularly run privileged media indexers that rescan `MediaStore` for "AI" or sharing features. On Samsung firmware prior to the April 2025 patch, `com.samsung.ipservice` loads Quram (`/system/lib64/libimagecodec.quram.so`) and automatically parses any file WhatsApp (or other apps) drops into `MediaStore`. In practice an attacker can send a DNG disguised as `IMG-*.jpg`, wait for the victim to tap "download" (1-click), and the privileged service will parse the payload even if the user never opens the gallery.<sup>[[1]](#references)</sup> 18 19 ```bash 20 $ file IMG-2025-02-10.jpeg 21 TIFF image data ... 22 $ exiftool IMG-2025-02-10.jpeg | grep "Opcode List" 23 Opcode List 1 : [opcode 23], [opcode 23], ... 24 ``` 25 26 **Key takeaways** 27 28 - Delivery relies on system media re-parsing (not the chat client) and thus inherits that process' permissions (full read/write access to the gallery, ability to drop new media, etc.). 29 - Any image parser reachable through `MediaStore` (vision widgets, wallpapers, AI résumé features, etc.) becomes remotely reachable if the attacker can convince a target to save media. 30 31 ## 0-click DD+/EAC-3 decoding path (Google Messages ➜ mediacodec sandbox) 32 33 Modern messaging stacks also auto-decode *audio* for transcription/search. On Pixel 9, **Google Messages** will hand incoming RCS/SMS audio to the **Dolby Unified Decoder (UDC)** inside `/vendor/lib64/libcodec2_soft_ddpdec.so` **before** the user opens the message, expanding the 0-click surface to media codecs.<sup>[[2]](#references)</sup> 34 35 **Key parse constraints** 36 - Each DD+ syncframe has up to 6 blocks; each block can copy up to `0x1FF` bytes of attacker-controlled *skip data* into a skip buffer (≈ `0x1FF * 6` bytes per frame). 37 - The skip buffer is scanned for **EMDF**: `syncword (0xX8)` + `emdf_container_length` (16b) + variable-length fields. `emdf_payload_size` is parsed with an unbounded `variable_bits(8)` loop. 38 - EMDF payload bytes are allocated inside a custom per-frame **“evo heap”** bump allocator and then copied byte-by-byte from a bit-reader bounded by `emdf_container_length`. 39 40 **Integer-overflow → heap-overflow primitive (CVE-2025-54957)** 41 - `ddp_udc_int_evo_malloc` aligns `alloc_size+extra` to 8 bytes via `total_size += (8 - total_size) % total_size` **without wrap detection**. Values near `0xFFFFFFFFFFFFFFF9..FF` shrink to tiny `total_size` on AArch64. 42 - The copy loop still uses the *logical* `payload_length` from `emdf_payload_size`, so attacker bytes overwrite evo-heap data past the undersized chunk. 43 - Overflow length is precisely capped by attacker-chosen `emdf_container_length`; overflow bytes are attacker-controlled EMDF payload data. The slab allocator is reset every syncframe, giving predictable adjacency. 44 45 **Secondary read primitive** 46 If `emdf_container_length > skipl`, EMDF parsing reads past initialized skip bytes (OOB read). Alone it leaks zeros/known media, but after corrupting adjacent heap metadata it can read back the corrupted region to validate the exploit. 47 48 **Exploitation recipe** 49 1. Craft EMDF with huge `emdf_payload_size` (via `variable_bits(8)`) so allocator padding wraps into a small chunk. 50 2. Set `emdf_container_length` to the desired overflow length (≤ total skip data budget); place overflow bytes in the EMDF payload. 51 3. Shape the per-frame evo heap so the small allocation sits before target structures inside the decoder’s static buffer (≈693 KB) or dynamic buffer (≈86 KB) allocated once per decoder instance. 52 4. Optionally choose `emdf_container_length > skipl` to read back overwritten data from the skip buffer after corruption. 53 54 ## AOSP DNG `MapTable`: wrapping plane intervals 55 56 A separate bug pattern appeared in AOSP's DNG SDK. `dng_opcode_MapTable::ProcessArea` bounded its plane loop with `plane < Plane() + Planes()`. Because both DNG fields are attacker-controlled `uint32` values, the computed exclusive end can wrap below the starting plane. The public fix avoids ever materializing that end and compares the distance travelled from the start instead.<sup>[[3]](#references)</sup> 57 58 ```cpp 59 const uint32 planeStart = fAreaSpec.Plane(); 60 const uint32 planeCount = fAreaSpec.Planes(); 61 const uint32 bufferPlanes = buffer.Planes(); 62 for (uint32 plane = planeStart; 63 plane < bufferPlanes && plane - planeStart < planeCount; 64 ++plane) { 65 // map this plane 66 } 67 ``` 68 69 When auditing other opcodes, search for the same `start + count`, `offset + length`, and `firstPlane + planes` idioms before clipping against image/tile bounds. A comparison against the destination size does **not** repair an already-wrapped end. See the generic [integer overflow/underflow patterns](https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/binary-exploitation/integer-overflow-and-underflow.md) for safe checked-arithmetic alternatives.<sup>[[3]](#references)</sup> 70 71 ## Quram's DNG Opcode Interpreter Bugs 72 73 DNG files embed three opcode lists applied at different decode stages. Quram copies Adobe's API, but its Stage-3 handler for `DeltaPerColumn` (opcode ID 11) trusts attacker-supplied plane bounds.<sup>[[1]](#references)</sup> 74 75 ### Failing plane bounds in `DeltaPerColumn` 76 - Attackers set `plane=5125` and `planes=5123` even though Stage-3 images only expose planes 0–2 (RGB). 77 - Quram computes `opcode_last_plane = image_planes + opcode_planes` instead of `plane + count`, and never checks whether the resulting plane range fits inside the image. 78 - The loop therefore writes a delta to `raw_pixel_buffer[plane_index]` with a fully controlled offset (e.g., plane 5125 ⇒ offset `5125 * 2 bytes/pixel = 0x2800`). Each opcode adds a 16-bit float value (0x6666) to the targeted location, yielding a precise heap OOB add primitive. 79 80 ### Turning increments into arbitrary writes 81 - The exploit first corrupts Stage-3 `QuramDngImage.bottom/right` using 480 malformed `DeltaPerColumn` operations so future opcodes treat enormous coordinates as in-bounds. 82 - `MapTable` opcodes (opcode 7) are then aimed at those fake bounds. Using a substitution table of all zeros or a `DeltaPerColumn` with `-Inf` deltas, the attacker zeroes any region, then applies additional deltas to write exact values. 83 - Because the opcode parameters live inside the DNG metadata, the payload can encode hundreds of thousands of writes without touching process memory directly. 84 85 ## Heap Shaping Under Scudo 86 87 Scudo buckets allocations by size. Quram happens to allocate the following objects with identical 0x30-byte chunk sizes, so they land in the same region (0x40-byte spacing on the heap):<sup>[[1]](#references)</sup> 88 - `QuramDngImage` descriptors for Stage 1/2/3 89 - `QuramDngOpcodeTrimBounds` and vendor `Unknown` opcodes (ID ≥14, including ID 23) 90 91 The exploit sequences allocations to deterministically place chunks: 92 1. Stage-1 `Unknown(23)` opcodes (20,000 entries) spray 0x30 chunks that later get freed. 93 2. Stage-2 frees those opcodes and places a new `QuramDngImage` inside the freed region. 94 3. 240 Stage-2 `Unknown(23)` entries are freed, and Stage-3 immediately allocates its `QuramDngImage` plus a new raw pixel buffer of the same size, reusing those spots. 95 4. A crafted `TrimBounds` opcode runs first in list 3 and allocates yet another raw pixel buffer before freeing Stage-2 state, guaranteeing "raw pixel buffer ➜ QuramDngImage" adjacency. 96 5. 640 additional `TrimBounds` entries are marked `minVersion=1.4.0.1` so the dispatcher skips them, but their backing objects stay allocated and later become primitive targets. 97 98 This choreography puts the Stage-3 raw buffer immediately before the Stage-3 `QuramDngImage`, so the plane-based overflow flips fields inside the descriptor rather than crashing random state. 99 100 ## Reusing Vendor "Unknown" Opcodes as Data Blobs 101 102 Samsung leaves the high bit set in vendor-specific opcode IDs (e.g., ID 23), which instructs the interpreter to *allocate* the structure but skip execution. The exploit abuses those dormant objects as attacker-controlled heaps:<sup>[[1]](#references)</sup> 103 - Opcode list 1 and 2 `Unknown(23)` entries serve as contiguous scratchpads for storing payload bytes (JOP chain at offset 0xf000 and a shell command at 0x10000 relative to the raw buffer). 104 - Because the interpreter still treats each object as an opcode when list 3 is processed, commandeering one object's vtable later is enough to start executing attacker data. 105 106 ## Crafting Bogus `MapTable` Objects & Bypassing ASLR 107 108 `MapTable` objects are larger than `TrimBounds`, but once the layout corruption lands, the parser happily reads extra parameters out-of-bounds:<sup>[[1]](#references)</sup> 109 1. Use the linear write primitive to partially overwrite a `TrimBounds` vtable pointer with a crafted `MapTable` substitution table that maps lower 2 bytes from a neighbouring `TrimBounds` vtable to the `MapTable` vtable. Only the low bytes differ between supported Quram builds, so a single 64K lookup table can handle seven firmware versions and every 4 KB ASLR slide. 110 2. Patch the rest of the `TrimBounds` fields (top/left/width/planes) so the object behaves like a valid `MapTable` when executed later. 111 3. Execute the fake opcode over zeroed memory. Because the substitution table pointer actually references another opcode's vtable, the output bytes become *leaked* low-order addresses from `libimagecodec.quram.so` or its GOT. 112 4. Apply additional `MapTable` passes to convert those two-byte leaks into offsets toward gadgets such as `__ink_jpeg_enc_process_image+64`, `QURAMWINK_Read_IO2+124`, `qpng_check_IHDR+624`, and libc's `__system_property_get` entry. The attackers effectively rebuild full addresses inside their sprayed opcode region without native memory disclosure APIs. 113 114 ## Triggering the JOP ➜ `system()` Transition 115 116 Once the gadget pointers and shell command are staged inside the opcode spray:<sup>[[1]](#references)</sup> 117 1. A final wave of `DeltaPerColumn` writes adds `0x0100` to offset 0x22 of the Stage-3 `QuramDngImage`, shifting its raw buffer pointer by 0x10000 so it now references the attacker command string. 118 2. The interpreter starts executing the tail of 1040 `Unknown(23)` opcodes. The first corrupted entry has its vtable replaced with the forged table at offset 0xf000, so `QuramDngOpcode::aboutToApply` resolves `qpng_read_data` (the 4th entry) out of the fake table. 119 3. The chained gadgets perform: load the `QuramDngImage` pointer, add 0x20 to point at the raw buffer pointer, dereference it, copy the result into `x19/x0`, then jump through GOT slots rewritten to `system`. Because the raw buffer pointer now equals the attacker string, the final gadget executes `system(<shell command>)` inside `com.samsung.ipservice`. 120 121 ## Notes on Allocator Variants 122 123 Two payload families exist: one tuned for jemalloc, another for scudo. They differ in how opcode blocks are ordered to achieve adjacency but share the same logical primitives (DeltaPerColumn bug ➜ MapTable zero/write ➜ bogus vtable ➜ JOP). Scudo's disabled quarantine makes 0x30-byte freelist reuse deterministic, while jemalloc relies on size-class control via tile/subIFD sizing.<sup>[[1]](#references)</sup> 124 125 ## Lab Workflow: Prove Reachability Before Fuzzing 126 127 Do not infer the decoder from the filename, advertised MIME type, or the app that received the attachment. Replay the **real ingress** (message auto-download, notification preview, gallery rescan, share sheet, etc.), then attribute the decode to a process, SELinux domain, and mapped library. The DNG sample above kept a JPEG-looking name, while the audio path executed in the `mediacodec` sandbox; this distinction decides both exploit impact and the next sandbox boundary.<sup>[[1]](#references)[[2]](#references)</sup> 128 129 ```bash 130 adb logcat -c 131 # Deliver/save the seed through the feature being tested, then: 132 adb logcat -b crash -d -v threadtime > crash.txt 133 adb shell 'ps -AZ | grep -E "ipservice|media\.codec|mediacodec"' 134 PID=$(adb shell pidof com.samsung.ipservice | tr -d '\r') 135 adb shell su -c "grep -E 'quram|codec|skia|hwui' /proc/$PID/maps" # rooted lab device 136 ``` 137 138 Test the same bytes with the original extension, a misleading common extension, and the MIME type used by the delivery app. Compare **save-only**, thumbnail/listing, notification rendering, and explicit-open paths. A crash that only appears on explicit open is not a zero-click primitive; a background crash in a privileged OEM indexer is not equivalent to a crash in the sender app.<sup>[[1]](#references)[[2]](#references)</sup> 139 140 ### Exercising proprietary Skia codecs 141 142 [`SkCodecFuzzer`](https://github.com/googleprojectzero/SkCodecFuzzer) links against a firmware's `libhwui.so` (or older `libskia.so`) and reaches the same `SkCodec`/`SkAndroidCodec` path used by `BitmapFactory`. Build against the exact firmware libraries: OEM codec availability and ABI are build-specific. Its guard-page allocator (`libdislocator`) makes short heap overreads/writes easier to detect and `-l` records heap operations; always reproduce interesting cases with `-d` as well because the default Android allocator can produce a different layout.<sup>[[4]](#references)</sup> 143 144 ```bash 145 adb push loader seed.img /data/local/tmp/ 146 adb shell 'LIBC_HOOKS_ENABLE=1 /data/local/tmp/loader -l -i /data/local/tmp/seed.img' 147 adb shell '/data/local/tmp/loader -d -i /data/local/tmp/seed.img' 148 ``` 149 150 For coverage fuzzing, keep separate corpora per firmware/format and preserve parse-valid structure (container directories, tile offsets, opcode-list lengths, checksums) while mutating semantic dimensions and ranges. Deduplicate first with the guarded allocator, then confirm on the target build with its production allocator before treating a crash as reachable or exploitable.<sup>[[4]](#references)</sup> 151 152 153 ## References 154 155 - [1] [Project Zero – A look at an Android ITW DNG exploit](https://projectzero.google/2025/12/android-itw-dng.html) 156 - [2] [Project Zero – Pixel 0-click: CVE-2025-54957 in Dolby UDC](https://projectzero.google/2026/01/pixel-0-click-part-1.html) 157 - [3] [AOSP DNG SDK – Handle underflows in dng_opcode_MapTable](https://android.googlesource.com/platform/external/dng_sdk/+/80a267ed1ac714acff455e85ae28c1732777d5b6) 158 - [4] [Project Zero – Android Skia Image Fuzzing Harness](https://github.com/googleprojectzero/SkCodecFuzzer)