DEV Community

Cover image for Why your media parsers are a 0-click attack surface (and how to harden them)
Alan West
Alan West

Posted on

Why your media parsers are a 0-click attack surface (and how to harden them)

I spent last Tuesday staring at a crash dump from a fuzzing run, watching a perfectly innocent-looking image decoder eat a malformed header and write 16 bytes past the end of a heap buffer. No user interaction. No clicks. Just a file that arrived over the network and got parsed.

That's the whole game with 0-click exploits. The news cycle this week is full of writeups about a new Pixel 10 exploit chain reportedly disclosed by Project Zero, and while I haven't read every line of the analysis yet, the pattern is depressingly familiar: a parser somewhere in the message-handling pipeline mishandles attacker-controlled bytes, and you're off to the races.

If you ship anything that auto-processes untrusted input — image previews, document thumbnails, push notification payloads, media transcoding — this is your problem too. Let's talk about why it keeps happening and what actually moves the needle.

The root cause: untrusted bytes meet unsafe code

Most 0-click chains start the same way. Something on the device receives data without the user lifting a finger:

  • A messaging app pre-renders an image thumbnail
  • A notification handler parses a custom payload format
  • A media library extracts metadata for indexing
  • A protocol stack reassembles a fragmented packet

That code path almost always ends up in C or C++, because that's where the legacy parsers live. And C/C++ parsers written before 2018 or so tend to share three deadly habits:

  1. Trusting length fields from the input itself ("the header says 64 bytes, so I'll read 64 bytes")
  2. Reusing scratch buffers across parser states without zeroing them
  3. Returning early on error without unwinding partial state

Each one is a footgun on its own. Combined, they let an attacker chain a heap overflow into a type confusion into a controlled write. From there it's gadget hunting and you're in the kernel.

The Project Zero writeups I have read over the years almost always trace back to one of these three patterns. The specifics of the Pixel 10 chain may differ, but the class of bug is older than I am as a developer.

Step 1: Find your unattended parsers

Before you fix anything, you need to know where untrusted bytes get parsed without a human in the loop. I do this with a quick audit script — nothing fancy, just ripgrep and a list of suspect function names:

# Find native parser entry points reachable from background services
rg -t cpp -t c -n \
  -e 'parse_[a-z_]+\(' \
  -e 'decode_[a-z_]+\(' \
  -e 'demux_' \
  -e 'memcpy\s*\([^,]+,\s*[a-z_]+->' \
  src/ \
  | grep -v test/
Enter fullscreen mode Exit fullscreen mode

The last pattern is the interesting one. Any memcpy where the source is a field on a parsed struct is a candidate for a length-field-from-input bug. You'll get false positives, but it's a starting point.

For the list of entry points, I cross-reference with anything registered as a content handler, intent receiver, or background service in the manifest. Those are the paths an attacker can reach without you clicking anything.

Step 2: Validate lengths against the container, not the header

This is the single highest-leverage fix. When a parser reads a length field, it should be checked against the size of the containing buffer, not just used directly. Here's the pattern I've been migrating projects to:

// BAD: trusts the length field from the input
static int parse_chunk_bad(const uint8_t *buf, size_t buf_len) {
    uint32_t chunk_len = read_u32_be(buf);     // attacker-controlled
    memcpy(out, buf + 4, chunk_len);            // boom
    return 0;
}

// GOOD: validates against the actual buffer we have
static int parse_chunk_safe(const uint8_t *buf, size_t buf_len) {
    if (buf_len < 4) return -1;                 // header itself fits?
    uint32_t chunk_len = read_u32_be(buf);

    // Check for both overflow and overrun in one expression.
    // chunk_len + 4 could wrap on 32-bit size_t, so subtract instead.
    if (chunk_len > buf_len - 4) return -1;

    memcpy(out, buf + 4, chunk_len);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

The subtraction trick on line 11 of the safe version matters. I've seen real code that does if (chunk_len + 4 > buf_len) and gets bypassed by passing chunk_len = 0xFFFFFFFC, which wraps to zero on 32-bit systems. Always express the check so the known-safe value is the one you subtract from.

Step 3: Sandbox the parser, not just the process

Even a perfectly written parser can have a bug you haven't found yet. So assume it will be exploited and limit the blast radius. The cheap, effective version of this is running the parser in a separate process with seccomp-bpf filtering syscalls down to read, write, exit, and maybe mmap:

#include <seccomp.h>

static int lockdown_parser_process(void) {
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
    if (!ctx) return -1;

    // Only allow the syscalls a pure parser needs.
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0);

    int rc = seccomp_load(ctx);
    seccomp_release(ctx);
    return rc;
}
Enter fullscreen mode Exit fullscreen mode

If an attacker pops the parser, they're inside a process that can't open files, can't network, can't fork. That turns a remote code execution into a denial of service — still bad, but no longer catastrophic. Android's mediaserver has been split this way for years, and the seccomp-bpf docs walk through the model in detail.

Step 4: Rewrite the hot parsers in a memory-safe language

This is the long-term fix. After migrating three projects' image and font parsers to Rust over the last two years, I'm pretty convinced this is where the industry is going. The Android team has been shipping new components in Rust since around 2021, and the published bug-density data shows the obvious: memory-safety bugs essentially vanish in new Rust code.

You don't have to rewrite everything. Pick the parser that sees the most untrusted bytes and start there. A typical FFI wrapper looks like this:

// Safe wrapper exposed to C callers via cbindgen
#[no_mangle]
pub extern "C" fn parse_header(
    buf: *const u8,
    buf_len: usize,
    out: *mut Header,
) -> i32 {
    // Convert raw pointer to a bounded slice before any parsing happens.
    let slice = unsafe {
        if buf.is_null() || out.is_null() { return -1; }
        std::slice::from_raw_parts(buf, buf_len)
    };

    match Header::parse(slice) {
        Ok(h) => { unsafe { *out = h; } 0 }
        Err(_) => -1,
    }
}
Enter fullscreen mode Exit fullscreen mode

The unsafe block does exactly one thing: turn a raw pointer into a slice with a known length. After that, the entire parser runs in safe Rust, and bounds checks are the compiler's problem.

Prevention: fuzz on every commit

Last thing. None of the above matters if you're not continuously fuzzing the parsers. I use libFuzzer or cargo-fuzz and run it as a CI job on every PR that touches a parser file. A 30-second smoke fuzz catches the obvious stuff before it ships, and a nightly hour-long run catches the deeper bugs.

The corpus matters more than the runtime. Seed it with every real-world sample you can find — broken files from bug reports, generated edge cases, the test vectors from the spec. A weak fuzzer with a great corpus beats a clever fuzzer running on empty inputs every time.

0-click bugs are not going away. But they're not magic either — they're parser bugs in code that runs without consent. Audit, validate, sandbox, rewrite, fuzz. In that order.

Top comments (0)