Rediscovering a 0-Click MMS Vulnerability in Samsung s10 (CVE-2020-8899)

0-click vulnerabilities are million-dollar bugs. Here’s how we reproduced one using a black-box fuzzing harness for Samsung’s Qmage parser. In this article, we'll reproduce the crash associated with CVE-2020-8899 by fuzzing libhwui.so. This work builds on our previous article, where we extracted the Samsung Note 10+ stock firmware and obtained the libhwui.so library. Using the extracted firmware as a rootfs to emulate the target filesystem for fuzzing, we'll construct a custom fuzzing harness to rediscover the same vulnerability.
Aug 21

Fuzzing Qmage in libhwui.so from Samsung Galaxy S10 Firmware

If you haven't already, please follow our earlier article on extracting libhwui.so and preparing the root filesystem for fuzzing:
Accessing Samsung Firmware Files - Mobile Hacking Lab
From that process, we obtained the libhwui.so library, which serves as our fuzzing target. Using the extracted firmware as a root filesystem, we emulate the target environment and run fuzzing in QEMU mode with AFL++.

In this post, we'll cover:
  • Designing a black-box fuzzing harness.
  • Why a shim layer is needed to bridge Skia/Android codec APIs.
  • Integrating with AFL++ in persistent mode for performance.
  • Debugging and reproducing the crash tied to CVE-2020-8899.

By the end, you'll see how we rediscovered the vulnerability in libhwui.so using a custom fuzzing workflow.

Strategy for Building the Harness

Our approach makes use of the Skia API, particularly the SkData, SkAndroidcodec, Skcodec class. According to the Skia documentation, SkData represents an immutable data buffer and provides access through methods such as data() and bytes(), both of which return read-only pointers. This understanding is essential when designing a fuzzing harness.

Before writing the harness, we first inspect these symbols in library and we reverse it using Ghidra, ensuring that SkData and related codec entrypoints are present and callable in the Samsung firmware build.

While exploring the Skia API documentation, we came across an interesting function within SkData that allows reading the underlying data buffer:

Accessing Symbols with dlopen + dlsym

The first idea was to directly open the target library and resolve Skia's exported functions:

  • Use dlopen("/system/lib64/libhwui.so", RTLD_NOW | RTLD_GLOBAL)
  • Use dlsym to look up relevant codec functions (SkAndroidCodec, SkCodec, etc.)
  • Call those functions with fuzz input and observe behavior
At first glance, this seemed straightforward. By resolving symbols such as

  • _ZN6SkData14MakeFromMallocEPKvm (SkData::MakeFromMalloc)
  • _ZN6SkData12MakeWithCopyEPKvm (SkData::MakeWithCopy)

we could pass fuzzed input directly into Skia's constructors.

The Problem

In practice, this approach was brittle:
  • Unstable API surface: Samsung\u2019s builds of libhwui.so don't expose Skia symbols in a stable way.
  • Inlined or optimized symbols: Many functions are inlined, hidden, or optimized away entirely.
  • Unreliable behavior: Even when symbols resolved, they often returned nullptr or crashed immediately when fuzz input was provided.
Essentially, relying on direct symbol resolution wasn't robust enough across firmware versions.
This limitation pushed us toward a more reliable strategy: building a shim that wraps Skia's public C++ APIs (SkData, SkAndroidCodec, etc.) into a stable C ABI, which we could then safely call from the harness.

Building a Shim Layer Around Skia (SkData + SkAndroidCodec)

After realizing that directly resolving symbols from libhwui.so was brittle and inconsistent across Samsung firmware builds, the next step was to introduce a shim layer.

What is a Shim?

A shim is a thin wrapper library that sits between our harness and the target library (libhwui.so). Instead of trying to dlsym unstable C++ mangled symbols, the shim exposes a stable C ABI. Internally, it uses the official Skia API (SkData, SkAndroidCodec, etc.), which gives us a clean and consistent entrypoint for fuzzing.

This makes the harness portable, ABI-safe, and much easier to maintain. Even if Samsung changes internal symbol names, our harness will still work because it only talks to the shim.

Skia Entry Points 

SkData  wraps raw memory into a Skia-owned buffer.

  • SkAndroidCodec::MakeFromData(SkData*) - attempts to decode (QMG included on Samsung builds).
  • getInfo() - returns metadata like width, height, and color format.
  • getAndroidPixels() - actually decodes the image into a pixel buffer.

Together, these functions give us a safe path from raw fuzz data -> structured decode attempt.

Preparing Skia Headers

To compile the shim, we need Skia's headers:
The required headers live under skia/include/. These will let us call into SkData and SkAndroidCodec from our shim.

The Shim Interface (shim_skandroid.h)

This header defines a minimal C API for the harness. Notice how it avoids C++ types and templates entirely just plain structs and opaque handles:

The Shim Implementation (shim_skandroid.cpp)

Now let's walk through the implementation block by block.

Wrapping SkAndroidCodec

We wrap Skia's codec inside a small struct:

Creating a Codec

  • Fuzzer input is copied into a heap buffer
  • SkData::MakeFromMalloc() wraps that buffer into a Skia-managed object.
  • SkAndroidCodec::MakeFromData() tries to interpret it as an image (including QMG).
  • If successful, we return a handle to the codec.

Getting Image Info

This extracts metadata from the codec and puts it into a plain C struct. Perfect for safe fuzzing.

Decoding Pixels

This is the actual decode step. We always force RGBA_8888 output for consistency. The result tells us whether decoding succeeded.

Compiling the Shim

We cross-compile the shim as a shared object for Android (AArch64):
This produces libshim_skandroid.so, which we'll load inside our harness.
With this shim in place, we now have a stable bridge to fuzz Skia/QMG decoding logic through AFL++ QEMU.

Building the Harness Around the Shim

Once the shim was ready, the next step was to build the harness. The harness is the actual fuzzing entrypoint: it takes the mutated input data from AFL++, feeds it into the shim, and observes how the library behaves.

Instead of calling into C++ Skia APIs directly, we first declare function pointer types that match the shim's exported functions:
These types mirror the shim's API:

  • shim_make_codec_from_data - create a codec from raw fuzz data.
  • shim_get_info - extract basic image metadata (width, height, color type).
  • shim_get_android_pixels - attempt decoding into a pixel buffer.
  • shim_destroy_codec - free everything safely.

We then resolve these symbols at runtime. First, the harness loads libhwui.so (the target library) and our compiled libshim_skandroid.so. Then it looks up each shim function using dlsym:
If any of these symbols fail to resolve, the harness exits immediately. Otherwise, we're ready to start fuzzing.
The heart of the harness lies in the helper function decode_once(). This function is responsible for taking a single fuzzing input, passing it through the shim, and handling all possible edge cases in a safe and controlled way. The first step inside decode_once() is to validate the input. Any input that is empty, oversized, or exceeds the predefined AFL shared buffer is immediately rejected to prevent unnecessary crashes or wasted cycles. In addition, there's an optional filter to skip certain formats like TIFF, which are already well-tested. By doing this, the fuzzing effort can be biased toward exploring less common or less-tested formats, increasing the chances of uncovering new issues.

Harness:

Compiling the Harness

To validate our setup, we first tested the harness with a known valid QMG file.

Before launching the fuzzer, we need to set up the runtime environment for QEMU:

  • QEMU_LD_PREFIX points to the extracted root filesystem from the Samsung firmware.
  • QEMU_SET_ENV is used to define environment variables, including the LD_LIBRARY_PATH so QEMU can resolve shared libraries correctly.
As we can see, the harness successfully initialized the codec, extracted the sk_imageinfo_pod structure (with width, height, color type, and alpha type), and attempted to decode pixels from the QMG file. This confirms that our shim and harness are working as expected.

The sk_imageinfo_pod structure used to carry this information looks like this:
With the harness validated, the next step is fuzzing. We'll use AFL++ in QEMU mode to emulate the aarch64 environment and run our harness on mutated QMG inputs.

Running the Fuzzer

To run AFL++ in QEMU mode, use the -Q option and enable library instrumentation with AFL_INST_LIBS=1
The fuzzer was running quite slowly. This is expected since every fuzzing iteration in QEMU mode forks the process, which adds significant overhead. To speed things up, we enabled QEMU persistent mode.
https://github.com/AFLplusplus/AFLplusplus/blob/stable/qemu_mode/README.persistent.md
In AFL++ QEMU mode, persistent mode allows fuzzing to continue within a single process between two defined addresses  avoiding repeated forks for every test case. This can massively improve execution speed.

Implementing AFL++ Qemu persistent mode

To support this, we expose a dedicated fuzzing entry function inside the harness:
This function acts as the persistent target AFL++ will repeatedly call fuzz_entry() with new mutated inputs.

In-Memory Input Hook

We can further boost performance by skipping file I/O entirely and placing fuzzing input directly into memory. AFL++ provides a persistent mode hook for this purpose. Below is our hook implementation:
In this setup:
  • Register x0 points to the target buffer in memory.
  • Register x1 holds the size of the fuzzing input.
  • The hook copies AFL's input directly into the guest buffer (g_afl_buf) before each call to fuzz_entry.

Compile the hook with your system compiler:

Finally, we update the harness so it no longer attempts to read input from a file  fuzzing input is now injected directly into memory each iteration by AFL++.
This setup eliminates the repeated process forking and file I/O overhead, allowing the fuzzing campaign to run much faster and more efficiently.

Updated Harness

Compiling Harness

Configuring Persistent Mode in AFL++Qemu

Next, we configure the fuzzer to locate our entry point and hook:
We also disable Scudo randomness and reduce its logging overhead for stability:

Running the Fuzzer

After running for several hours, the fuzzer will typically discover crashes.

Conclusion

In this journey, we moved from extracting the Samsung Note 10+ firmware to building a fully functional fuzzing harness around libhwui.so. Starting with failed attempts at resolving brittle Skia symbols, we pivoted to designing a stable shim layer leveraging Skia's clean APIs (SkData, SkAndroidCodec). From there, we constructed a harness, integrated persistent mode with AFL++ QEMU, and injected fuzzing inputs directly into memory for speed and reliability.

This approach not only allowed us to reproduce the crash associated with CVE-2020-8899, but also established a reusable framework for fuzzing other closed-source Android libraries shipped in vendor firmware. By carefully layering shims, persistence hooks, and harness design, we've turned a black-box binary into a fuzzing-ready target.

Our work here demonstrates how firmware extraction, thoughtful harness engineering, and fuzzing infrastructure can come together to rediscover and validate real-world vulnerabilities. And this is just the start future explorations may extend these techniques to other system components, from graphics to baseband, opening up broader research opportunities in Android device security.

Want to learn advanced fuzzing and exploitation? Check out our courses, “Android Userland Fuzzing and Exploitation” and “Android Kernel Fuzzing and Exploitation” or do some of our free labs.

References