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:
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:
https://api.skia.org/classSkData.html#a2fe5442c203a06a737bff32fa1e75250

https://api.skia.org/classSkData.html#af71e089032bfaf30ac9c81b1fc1dfeb5
The first idea was to directly open the target library and resolve Skia's exported functions:
At first glance, this seemed straightforward. By resolving symbols such as
In practice, this approach was brittle:
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.
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.
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.
SkData wraps raw memory into a Skia-owned buffer.
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.
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:
Now let's walk through the implementation block by block.
We wrap Skia's codec inside a small struct:
This extracts metadata from the codec and puts it into a plain C struct. Perfect for safe fuzzing.
This is the actual decode step. We always force RGBA_8888 output for consistency. The result tells us whether decoding succeeded.
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.
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:
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.
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:

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.
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.
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.
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:
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.
Next, we configure the fuzzer to locate our entry point and hook:
We also disable Scudo randomness and reduce its logging overhead for stability:

After running for several hours, the fuzzer will typically discover crashes.
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.
Check out our training:
https://www.mobilehackinglab.com/courses
MMS Exploit Part 2: Effective Fuzzing of the Qmage Codec (Mateusz Jurczyk) - https://googleprojectzero.blogspot.com/2020/07/mms-exploit-part-2-effective-fuzzing-qmage.html
Fuzzing Science (ant4g0nist) - https://fuzzing.science/blog/fuzzing-android-native-libraries-with-libfuzzer-qemu
AFL Qemu Documentation - https://github.com/AFLplusplus/AFLplusplus/blob/stable/qemu_mode/README.persistent.md