---------------------------------------------------------------------
| Fuzzing Android Native Libraries with AFL++ QEMU Persistent Mode |
---------------------------------------------------------------------
date: 2026-03-23
author: feelqah
# Introduction
This post walks you through how to fuzz a closed-source Android native library
using AFL++ in QEMU persistent mode, on a regular x86_64 Linux machine.
The target is libmigalleryffmpeg.so, a 17MB native library shipped with
Xiaomi's MIUI Gallery app. It has the entire FFmpeg library statically
linked inside it (along with OpenSSL, x264, libmodplug, and zlib),
all rolled into a single .so file. 391 decoders, 273 demuxers, 47 parsers.
That's a lot of attack surface for a preinstalled app that processes
every image and video on your phone.
In my previous posts I wrote a dumb GIF fuzzer that just throws garbage
files at the Gallery app over ADB. It was slow (2 seconds per input) and
relied on luck. This time we're doing it properly: coverage-guided fuzzing
with persistent mode, which can crank out hundreds of executions per second
for heavy targets like this one (or thousands for lighter targets).
The approach:
1. Pull the .so from the APK
2. Write a C harness that calls the FFmpeg API directly (no JNI needed)
3. Cross-compile it with the Android NDK
4. Run it under AFL++ QEMU mode on our x86_64 host
5. Enable persistent mode for speed
6. Let it rip
This method works for many closed-source Android native libraries, not just
this one. If you can write a harness that calls the target function and
feeds it your input, you can fuzz it. Libraries that use JNI callbacks
or depend on Android framework services require extra work (stubbing
JNI_OnLoad, providing a fake JNIEnv, or even spinning up a minimal JVM),
but it's doable.
Fair warning: this is not a "click three buttons and go" setup. Building
AFL++ with QEMU support for aarch64, getting the right sysroot, and
configuring persistent mode takes some work. But once it's running,
it's worth it.
If you need more background on AFL++ or QEMU, check the internet.
I'll explain just enough to keep things moving.
Disclaimer: This tutorial is intended for educational purposes and legitimate
security research only. Only fuzz software on hardware you own.
If you discover vulnerabilities, disclose them responsibly to the vendor.
The author assumes no liability for misuse.
# What is AFL++ QEMU Mode?
AFL++ is a coverage-guided fuzzer. Normally it needs source code to
inject instrumentation at compile time. QEMU mode is the alternative:
AFL++ ships a patched QEMU (qemuafl) that runs in user-mode emulation,
translating the target's aarch64 instructions to x86_64 on the fly.
It inserts coverage tracking into every basic block, giving AFL++
the same edge-coverage feedback it would get from compile-time
instrumentation, but for a binary we can't recompile.
It's roughly 2-5x slower than compile-time instrumentation. For a
binary-only cross-architecture target, it's the best option available.
For details on how QEMU mode works internally see:
* AFL++ QEMU mode README: https://github.com/AFLplusplus/AFLplusplus/blob/stable/qemu_mode/README.md
* How it works under the hood: https://galtashma.com/posts/how-fuzzing-with-qemu-and-afl-work
* AFL++ approach (edge coverage algorithm): https://aflplus.plus/docs/afl-fuzz_approach/
# What is Persistent Mode?
In standard QEMU mode, AFL++ forks a new process for every test case.
Persistent mode eliminates that overhead: you pick a function, tell
AFL++ its address, and QEMU loops that function with a new input each
time. No fork, no process teardown. After N iterations (default 1000)
it forks once to reset state, then loops again.
The result: 2-5x faster than standard QEMU mode.
We'll configure the specific environment variables
(AFL_QEMU_PERSISTENT_ADDR, AFL_QEMU_PERSISTENT_GPR, etc.) later in
the "Configuring Persistent Mode" section when we have the actual
addresses.
For details on how Persistent Mode mode works see:
* AFL++ persistent mode README: https://github.com/AFLplusplus/AFLplusplus/blob/stable/qemu_mode/README.persistent.md
* AFL++ env variables: https://aflplus.plus/docs/env_variables/
* Airbus SecLab walkthrough: https://airbus-seclab.github.io/AFLplusplus-blogpost/
# The Target: libmigalleryffmpeg.so
MIUI Gallery is the default gallery app on Xiaomi phones. It handles
photos, videos, thumbnails, editing, etc. Like most Android apps
that need to do heavy media processing, it offloads the work to native
C/C++ libraries via JNI.
One of those libraries is libmigalleryffmpeg.so. It's a 17MB aarch64
shared library that Xiaomi ships inside the Gallery APK.
Let's look at what's inside.
Pull the APK off your phone:
adb shell pm path com.miui.gallery
adb pull /data/app/.../base.apk .
Get the lib:
unzip base.apk lib/arm64-v8a/libmigalleryffmpeg.so
Move it to just lib/ and remove the arm64-v8a dir:
mv lib/arm64-v8a/libmigalleryffmpeg.so lib
rm -rf lib/arm64-v8a
Check what it is:
file lib/libmigalleryffmpeg.so
libmigalleryffmpeg.so: ELF 64-bit LSB shared object, ARM aarch64,
version 1 (SYSV), dynamically linked, stripped
Check its dependencies:
readelf -d lib/libmigalleryffmpeg.so | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libm.so]
0x0000000000000001 (NEEDED) Shared library: [libz.so]
0x0000000000000001 (NEEDED) Shared library: [liblog.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so]
0x0000000000000001 (NEEDED) Shared library: [libdl.so]
Only five dependencies, all standard Android system libraries. That
means almost everything is statically linked into this single .so.
This is great for fuzzing because we don't have to chase down a bunch
of external libraries.
Now let's see what's actually compiled in. List the exported symbols:
readelf --dyn-syms lib/libmigalleryffmpeg.so | grep "FUNC.*GLOBAL" | wc -l
8421
8421 exported functions. Let's break that down by prefix:
readelf --dyn-syms lib/libmigalleryffmpeg.so | grep "FUNC.*GLOBAL" | \
awk '{print $8}' | grep -oP '^[a-z]+_' | sort | uniq -c | sort -rn | head -15
2471 ff_
747 av_
80 ec_
73 ssl_
62 avcodec_
57 avpriv_
57 avio_
42 avfilter_
31 sws_
29 cms_
25 avformat_
24 bn_
22 sk_
21 avresample_
20 ffio_
That's FFmpeg alright. The ff_ and av_ prefixes are internal and public
FFmpeg functions. But notice the ssl_, ec_, bn_, cms_ prefixes. That's
OpenSSL. The whole thing is baked in.
Count the registered demuxers, decoders, and parsers:
readelf --dyn-syms lib/libmigalleryffmpeg.so| grep "OBJECT.*GLOBAL" | \
awk '{print $8}' | grep "_demuxer$" | wc -l
273
readelf --dyn-syms lib/libmigalleryffmpeg.so | grep "OBJECT.*GLOBAL" | \
awk '{print $8}' | grep "_decoder$" | wc -l
391
readelf --dyn-syms lib/libmigalleryffmpeg.so | grep "OBJECT.*GLOBAL" | \
awk '{print $8}' | grep "_parser$" | wc -l
47
readelf --dyn-syms lib/libmigalleryffmpeg.so | grep "OBJECT.*GLOBAL" | \
awk '{print $8}' | grep "_encoder$" | wc -l
144
So we've got:
273 demuxers
391 decoders
47 parsers
144 encoders
The FFmpeg version bundled in this .so is 4.3.1,
based on the version strings in the binary:
strings lib/libmigalleryffmpeg.so | grep "FFmpeg version"
FFmpeg version 4.3.1
This is important. Before you fire up the fuzzer, try the low-hanging
fruit. Go through the CVE list for FFmpeg 4.3.1 and everything above it.
Some PoCs are public and you can just throw them at the real app. For
the ones without public PoCs, look at the upstream git commit history.
Security fixes have commit messages and diffs. Read the diff, understand
what was broken, and write a PoC yourself from the fix.
You might ask: why fuzz this .so when Google already fuzzes upstream
FFmpeg 24/7 through OSS-Fuzz? Because what ships to users is not
upstream. It's a frozen snapshot, built with specific flags, and never touched
again until the next app update (if ever). Bugs that got fixed upstream
months/years ago might still be live in this binary. The point isn't to find
new FFmpeg bugs. It's to test what actually runs on millions of phones.
The FFmpeg public API is fully exported. A quick check confirms the
key functions are accessible:
readelf --dyn-syms lib/libmigalleryffmpeg.so | grep "FUNC.*GLOBAL" | \
grep -E "avformat_open|avcodec_send|av_read_frame"
322: 00000000002db7f4 184 FUNC GLOBAL DEFAULT 9 avcodec_send_frame
408: 00000000002aa600 264 FUNC GLOBAL DEFAULT 9 avcodec_send_packet
4285: 00000000008cd370 1320 FUNC GLOBAL DEFAULT 9 avformat_open_input
9270: 00000000008ce72c 612 FUNC GLOBAL DEFAULT 9 av_read_frame
We'll figure out exactly which functions matter in the next chapter.
Our harness will call these directly. No JNI, no Java VM, no Android framework.
Just C calling C.
# Picking the Right Function to Fuzz
We have 8421 exported functions. We can't fuzz all of them. We need to
pick one entry point that pulls in as much code as possible with a
single call. The goal is maximum code coverage from minimum harness
complexity.
But before we guess which functions matter, let's find out which ones
the app actually calls. Theory is nice. Frida traces are better.
## Tracing the real app with Frida
We need a rooted device with frida-server running. Install frida on
your PC:
pip install frida-tools
Let's check which libraries get loaded at startup:
frida -U -f com.miui.gallery
Process.enumerateModules().forEach(function(m) {
console.log(m.name);
});
libmigalleryffmpeg.so is there. We already confirmed from readelf
that it exports the standard FFmpeg public API (avformat_open_input,
av_read_frame, avcodec_send_packet, etc.). These are the functions
that make up the standard FFmpeg decode pipeline. We hook all of them to
see which ones the app actually calls and in what order.
Save this as trace.js:
var timer = setInterval(function() {
var mod = null;
Process.enumerateModules().forEach(function(m) {
if (m.name.includes("ffmpeg")) mod = m;
});
if (mod) {
clearInterval(timer);
console.log("Found: " + mod.name + " at " + mod.base);
var targets = ["avformat_open_input", "avformat_find_stream_info",
"av_read_frame", "avcodec_send_packet", "avcodec_receive_frame",
"avcodec_open2", "avcodec_find_decoder", "av_find_best_stream",
"avformat_close_input"];
var hooked = 0;
mod.enumerateExports().forEach(function(exp) {
if (exp.type === "function" && targets.indexOf(exp.name) !== -1) {
Interceptor.attach(exp.address, {
onEnter: function() { console.log("CALLED: " + exp.name); }
});
console.log("HOOKED: " + exp.name);
hooked++;
}
});
console.log("Hooked " + hooked + "/" + targets.length +
" --- play a video now ---");
}
}, 500);
console.log("Waiting for FFmpeg...");
Run it:
frida -U -f com.miui.gallery -l trace.js
All 9 functions hooked successfully:
[M2007J17G::com.miui.gallery ]-> Found: libmigalleryffmpeg.so at 0x72f0ece000
HOOKED: avcodec_send_packet
HOOKED: avcodec_open2
HOOKED: avformat_find_stream_info
HOOKED: avcodec_receive_frame
HOOKED: avcodec_find_decoder
HOOKED: avformat_open_input
HOOKED: avformat_close_input
HOOKED: av_find_best_stream
HOOKED: av_read_frame
Hooked 9/9 --- play a video now ---
Now we interact with the app. Three distinct user actions produce three
distinct phases of FFmpeg calls:
When we OPEN the video (tap on it in the gallery):
CALLED: avformat_open_input
CALLED: avformat_find_stream_info
CALLED: avcodec_open2
CALLED: avcodec_send_packet
CALLED: avcodec_receive_frame
CALLED: avformat_open_input
CALLED: avformat_find_stream_info
CALLED: avcodec_open2
CALLED: avcodec_send_packet
CALLED: avcodec_receive_frame
CALLED: avcodec_find_decoder
CALLED: avcodec_open2
CALLED: av_read_frame (11 times)
Just tapping the video triggers a lot of work before anything plays.
When we PLAY the video (press the play button):
CALLED: avcodec_send_packet
CALLED: avcodec_receive_frame
CALLED: avcodec_receive_frame
CALLED: avcodec_send_packet
CALLED: avcodec_receive_frame
CALLED: avcodec_receive_frame
... (repeating pattern)
CALLED: avcodec_receive_frame
CALLED: avcodec_receive_frame
CALLED: avcodec_receive_frame
CALLED: avcodec_receive_frame
CALLED: avcodec_receive_frame
CALLED: avcodec_receive_frame
This is the actual decode loop.
When we GO BACK (exit the video player):
CALLED: avformat_close_input
The file gets closed and all resources are freed.
## What we learned
The Frida trace gives us the real pipeline. The app calls these FFmpeg
functions in this order for a single video playback:
avformat_open_input open file, probe container format
avformat_find_stream_info identify streams, trial-decode
avcodec_find_decoder pick the right decoder
avcodec_open2 initialize the decoder
av_read_frame read packets from the container
avcodec_send_packet feed packet to decoder
avcodec_receive_frame get decoded frame back
avformat_close_input close file, free resources
This is the standard FFmpeg decode pipeline. The app doesn't do
anything exotic. It opens the file, probes it, sets up a decoder,
reads packets, decodes them, and cleans up.
This tells us exactly what to fuzz.
## Choosing the entry point
We fuzz the full pipeline: avformat_open_input through
avcodec_receive_frame. Maximum code coverage from one entry point.
We use the public API, not internal functions. The public API
handles all initialization and is designed to accept untrusted
input.
For reference, Google's OSS-Fuzz targets individual FFmpeg decoders
and demuxers separately for depth. Our approach trades that depth
for breadth, one input exercises the entire stack.
https://github.com/google/oss-fuzz/blob/master/projects/ffmpeg/build.sh
## Why we can fuzz FFmpeg directly
The Frida trace shows the app calls standard FFmpeg public API
functions. No private symbols, no internal hooks, no custom I/O
callbacks. Our harness will call the same functions in the same
order.
## The plan
1. Take fuzzed input as a file
2. Open it with avformat_open_input
3. Call avformat_find_stream_info
4. Find the best video/audio stream and its decoder
5. Open the decoder
6. Loop av_read_frame + avcodec_send_packet + avcodec_receive_frame
7. Clean up and return
Later when we configure persistent mode, this entire sequence will be
the body of our persistent loop. Each iteration gets a new fuzzed file,
and the whole pipeline runs again.
# Setting Up the Environment
We need to get the toolchain in place.
This means: AFL++ with QEMU aarch64 support, the Android NDK for
cross-compiling our harness, and an aarch64 sysroot so QEMU can
find the dynamic linker and system libraries.
I'm doing all of this on an x86_64 Arch Linux machine.
## Install dependencies
sudo pacman -S base-devel automake flex bison glib2 pixman \
python ninja aarch64-linux-gnu-gcc aarch64-linux-gnu-binutils \
lib32-glibc ffmpeg
## Build AFL++ with QEMU aarch64 support
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus
make distrib
When fuzzing Android targets, AFL++ needs QEMU to ignore signal
handlers registered by the target. Otherwise, AFL++ won't detect
crashes. You can set AFL_QEMU_FORCE_DFL=1 at runtime, or apply a
patch to permanently disable them. We'll do both.
The AFL_QEMU_FORCE_DFL tip comes from Andrea Fioraldi (author of
qemuafl). The full procedure is documented in Conviso AppSec's
Android fuzzing tutorial:
https://blog.convisoappsec.com/introduction-to-fuzzing-android-native-components/
Apply the patch before building QEMU:
1. Comment out signal_init() in qemu_mode/qemuafl/linux-user/main.c:
[...]
target_set_brk(info->brk);
syscall_init();
// signal_init();
[...]
Note: if you need to debug with GDB later, uncomment signal_init() and rebuild.
2. Comment out the git commands in qemu_mode/build_qemu_support.sh so the
build script doesn't overwrite your patch:
[...]
cd "qemuafl" || exit 1
if [ -n "$NO_CHECKOUT" ]; then
echo "[*] Skipping checkout to $QEMUAFL_VERSION"
else
echo "[*] Checking out $QEMUAFL_VERSION"
# sh -c 'git stash' 1>/dev/null 2>/dev/null
# git pull
# git checkout "$QEMUAFL_VERSION" || {
# echo "[-] Failed to checkout to commit $QEMUAFL_VERSION"
# exit 1
#}
fi
[...]
3. Build:
cd qemu_mode
CPU_TARGET=aarch64 ./build_qemu_support.sh
cd ..
sudo make install
4. Verify:
which afl-qemu-trace
If that doesn't return anything, something went wrong during the
build. Check the output of build_qemu_support.sh for errors.
## Install the Android NDK
Download it from:
https://developer.android.com/ndk/downloads
Or grab it directly:
wget https://dl.google.com/android/repository/android-ndk-r27d-linux.zip
unzip android-ndk-r27d-linux.zip
Add the compiler to your PATH:
export NDK_BIN=path_to/android-ndk-r27d/toolchains/llvm/prebuilt/linux-x86_64/bin
export PATH=$NDK_BIN:$PATH
Add those lines to your ~/.zshrc (.bashrc, or whatever) so they persist.
Source it, and verify:
which aarch64-linux-android35-clang
## Setting up the aarch64 sysroot
When QEMU runs our aarch64 harness, it needs two things: the Android
dynamic linker (linker64) and the aarch64 system libraries. The linker
doesn't exist on your x86_64 host, and neither do aarch64 versions of
libc, libm, etc.
We already have a rooted phone. So we pull the sysroot files from the same
device. This gives us the exact linker and libraries the target was actually
running against.
First, create the sysroot dir with Android's filesystem layout:
mkdir -p ~/fuzzing/xiaomi/sysroot/system/bin
mkdir -p ~/fuzzing/xiaomi/sysroot/system/lib64
Pull the dynamic linker:
adb pull /system/bin/linker64 ~/fuzzing/xiaomi/sysroot/system/bin/
Pull the system libraries our target needs. We know the dependencies
from the readelf output earlier (libm.so, libz.so, liblog.so, libc.so,
libdl.so):
adb pull /system/lib64/libc.so ~/fuzzing/xiaomi/sysroot/system/lib64/
adb pull /system/lib64/libm.so ~/fuzzing/xiaomi/sysroot/system/lib64/
adb pull /system/lib64/libz.so ~/fuzzing/xiaomi/sysroot/system/lib64/
adb pull /system/lib64/liblog.so ~/fuzzing/xiaomi/sysroot/system/lib64/
adb pull /system/lib64/libdl.so ~/fuzzing/xiaomi/sysroot/system/lib64/
adb pull /system/lib64/libc++.so ~/fuzzing/xiaomi/sysroot/system/lib64/
We tell QEMU to use this sysroot with the QEMU_LD_PREFIX environment
variable.
The target .so is already in the lib/ directory, and we point
LD_LIBRARY_PATH at it. This keeps it separate from the system libs.
## Set up the working directory
mkdir -p ~/fuzzing/xiaomi/harness
mkdir -p ~/fuzzing/xiaomi/corpus
mkdir -p ~/fuzzing/xiaomi/output
Your working directory should now look like this:
~/fuzzing/xiaomi/
├── sysroot/
│ └── system/
│ ├── bin/
│ │ └── linker64
│ └── lib64/
│ ├── libc.so
│ ├── libdl.so
│ ├── libm.so
│ ├── libz.so
│ ├── liblog.so
| └── libc++.so
├── lib/
│ └── libmigalleryffmpeg.so
├── harness/
├── corpus/
└── output/
## Verify QEMU works
Let's do a quick sanity check. Compile a trivial aarch64 binary and
run it under QEMU:
printf '#include \nint main() { printf("hello from aarch64\\n"); return 0; }\n' > hello.c
aarch64-linux-android35-clang -o hello_arm64 hello.c
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot afl-qemu-trace ./hello_arm64
You'll see:
[...]
hello from aarch64
Ignore the warnings.
The important part is "hello from aarch64". QEMU is translating
ARM instructions on our x86_64 machine. It works.
Now let's quickly verify afl-fuzz itself works in QEMU mode. Create
a dummy corpus (AFL++ needs at least one seed file to start):
mkdir -p /tmp/test_corpus
echo "AAAA" > /tmp/test_corpus/seed1
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
afl-fuzz -Q -i /tmp/test_corpus -o /tmp/test_output -- ./hello_arm64 @@
Note:
follow what AFL++ tells you to do (echo core | sudo tee /proc/sys/kernel/core_pattern etc.)
If you see the AFL++ status screen, everything is working. Kill it
with Ctrl+C, this was just a sanity check. The hello binary doesn't
read input so there's nothing useful to fuzz here.
Clean up the test files:
rm -rf /tmp/test_corpus /tmp/test_output hello.c hello_arm64
# Writing the Harness
The harness is a small C program that does one thing: takes a file,
feeds it through the FFmpeg decode pipeline, and exits.
AFL++ will run this program thousands of times with different mutated
files. If any of them cause a crash, AFL++ catches it.
## Getting the FFmpeg headers
We need FFmpeg headers to compile our harness. The binary reports
FFmpeg 4.3.1 (confirmed by calling av_version_info() and
avformat_configuration() on the .so), but it's built from Xiaomi's
internal fork of bilibili's ijkplayer with custom struct layouts.
Stock FFmpeg 4.3.1 headers segfault when accessing struct fields.
Bilibili's public ff4.0 fork also segfaults. The latest FFmpeg
headers work. The struct offsets happen to match this binary.
cd ~/fuzzing/xiaomi
git clone --depth 1 https://github.com/FFmpeg/FFmpeg
cd FFmpeg
./configure --disable-everything --disable-programs --disable-doc --disable-x86asm
cd ..
The configure step generates config headers (avconfig.h, config.h)
that other headers depend on. We're not building FFmpeg, just
generating what our harness needs to compile.
## The harness code
Create ~/fuzzing/xiaomi/harness/harness.c:
#include
#include
#include
int decode_file(const char *filename) {
AVFormatContext *fmt_ctx = NULL;
AVCodecContext *codec_ctx = NULL;
AVPacket *pkt = NULL;
AVFrame *frame = NULL;
int ret, stream_idx, frames_decoded = 0;
ret = avformat_open_input(&fmt_ctx, filename, NULL, NULL);
if (ret < 0) goto cleanup;
ret = avformat_find_stream_info(fmt_ctx, NULL);
if (ret < 0) goto cleanup;
const AVCodec *decoder = NULL;
stream_idx = av_find_best_stream(
fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0
);
if (stream_idx < 0 || !decoder) {
decoder = NULL;
stream_idx = av_find_best_stream(
fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0
);
}
if (stream_idx < 0 || !decoder) goto cleanup;
codec_ctx = avcodec_alloc_context3(decoder);
if (!codec_ctx) goto cleanup;
// NO avcodec_parameters_to_context
// NO pkt->stream_index filtering
AVDictionary *opts = NULL;
av_dict_set(&opts, "threads", "1", 0);
ret = avcodec_open2(codec_ctx, decoder, &opts);
av_dict_free(&opts);
if (ret < 0) goto cleanup;
pkt = av_packet_alloc();
frame = av_frame_alloc();
if (!pkt || !frame) goto cleanup;
while (av_read_frame(fmt_ctx, pkt) >= 0) {
ret = avcodec_send_packet(codec_ctx, pkt);
av_packet_unref(pkt);
if (ret < 0) continue;
while (avcodec_receive_frame(codec_ctx, frame) >= 0) {
av_frame_unref(frame);
if (++frames_decoded >= 3) goto cleanup;
}
}
cleanup:
if (frame) av_frame_free(&frame);
if (pkt) av_packet_free(&pkt);
if (codec_ctx) avcodec_free_context(&codec_ctx);
if (fmt_ctx) avformat_close_input(&fmt_ctx);
return 0;
}
int main(int argc, char *argv[]) {
if (argc < 2) return 1;
return decode_file(argv[1]);
}
Notes:
threads=1 via AVDictionary prevents avcodec_open2 from spawning a
thread pool that corrupts persistent mode.
Maximum 3 frames decoded. Video first, audio fallback.
Cleanup frees everything. In persistent mode the function loops,
so leaks accumulate.
## Why avcodec_parameters_to_context and stream filtering are skipped
Both were in the first version. The harness segfaulted on every input.
libmigalleryffmpeg.so is built from Xiaomi's internal ijkplayer fork with
modified struct layouts. Accessing stream->codecpar or pkt->stream_index
through upstream header offsets reads the wrong bytes and the pointer
dereference crashes. The binary's internal headers aren't public, so we
skip both and live with the reduced decode coverage.
## Compiling the harness
cd ~/fuzzing/xiaomi
aarch64-linux-android35-clang \
-o harness_bin harness/harness.c \
-I ./FFmpeg -L ./lib -lmigalleryffmpeg -lm -lz -ldl
## Testing under QEMU
ffmpeg -f lavfi -i testsrc=duration=1:size=64x64:rate=10 \
-c:v libx264 -pix_fmt yuv420p -t 1 test.mp4
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
QEMU_SET_ENV=LD_LIBRARY_PATH=./lib \
afl-qemu-trace ./harness_bin test.mp4; echo "exit code: $?"
If it says "exit code: 0", the harness works. If it segfaults,
check your sysroot and library paths.
# Configuring Persistent Mode
The harness works under QEMU. Now we make it fast.
## Finding the function address
Our harness is a PIE binary. Find the offset of decode_file:
aarch64-linux-gnu-nm harness_bin | grep decode_file
0000000000001cc8 T decode_file
QEMU loads PIE aarch64 binaries at 0x5500000000 (verify with
AFL_QEMU_DEBUG_MAPS=1 if needed). The runtime address is:
0x5500000000 + 0x1cc8 = 0x5500001cc8
## The environment variables
AFL_QEMU_PERSISTENT_ADDR=0x5500001cc8
AFL_QEMU_PERSISTENT_GPR=1
AFL_QEMU_PERSISTENT_CNT=1000
AFL_QEMU_PERSISTENT_ADDR points to decode_file. GPR=1 restores
registers each iteration. CNT=1000 forks every 1000 iterations
to reset state.
## Testing persistent mode
cd ~/fuzzing/xiaomi
mkdir -p corpus_test
cp test.mp4 corpus_test/
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
QEMU_SET_ENV=LD_LIBRARY_PATH=./lib \
AFL_QEMU_PERSISTENT_ADDR=0x5500001cc8 \
AFL_QEMU_PERSISTENT_GPR=1 \
AFL_QEMU_PERSISTENT_CNT=1000 \
AFL_QEMU_FORCE_DFL=1 \
AFL_INST_LIBS=1 \
afl-fuzz -Q -i corpus_test -o /tmp/test_fuzz_output \
-t 2000 -- ./harness_bin @@
If you see the AFL++ status screen with exec speed ticking up,
everything works. Ctrl+C, this was just a smoke test.
rm -rf /tmp/test_fuzz_output corpus_test
# Building the Corpus
AFL++ needs seed files to start with. It mutates these seeds to
generate new inputs. The better your seeds, the faster the fuzzer
finds interesting paths. The choice of corpus matters more than
most people think, so let's go through the options.
## Option 1: generate a multi-format corpus (what we'll use)
The trick is to generate the smallest possible files across as
many formats and codecs as our target supports. Small files mean
fast iterations. Many formats mean broad coverage. We use ffmpeg
to generate tiny valid files for each:
cd ~/fuzzing/xiaomi/corpus
# video formats — different containers and codecs
ffmpeg -f lavfi -i "testsrc=d=0.1:s=8x8:r=1" -c:v libx264 -t 0.1 tiny_h264.mp4 -y
ffmpeg -f lavfi -i "testsrc=d=0.1:s=8x8:r=1" -c:v mpeg4 -t 0.1 tiny_mpeg4.avi -y
ffmpeg -f lavfi -i "testsrc=d=0.1:s=8x8:r=1" -c:v libx264 -t 0.1 -f matroska tiny.mkv -y
ffmpeg -f lavfi -i "testsrc=d=0.1:s=8x8:r=1" -c:v flv1 -t 0.1 -f flv tiny.flv -y
ffmpeg -f lavfi -i "testsrc=d=0.1:s=8x8:r=1" -c:v libx264 -t 0.1 -f mpegts tiny.ts -y
ffmpeg -f lavfi -i "testsrc=d=0.1:s=8x8:r=1" -c:v wmv2 -t 0.1 tiny.wmv -y
ffmpeg -f lavfi -i "testsrc=d=0.1:s=8x8:r=1" -c:v libvpx -t 0.1 tiny.webm -y
# image formats — single frame
ffmpeg -f lavfi -i "color=black:s=8x8:d=0.1" -frames:v 1 tiny.gif -y
ffmpeg -f lavfi -i "color=black:s=8x8:d=0.1" -frames:v 1 tiny.png -y
ffmpeg -f lavfi -i "color=black:s=8x8:d=0.1" -frames:v 1 tiny.bmp -y
ffmpeg -f lavfi -i "color=black:s=8x8:d=0.1" -frames:v 1 -c:v mjpeg tiny.jpg -y
ffmpeg -f lavfi -i "color=black:s=8x8:d=0.1" -frames:v 1 -c:v libwebp tiny.webp -y
# audio formats
ffmpeg -f lavfi -i "sine=f=440:d=0.1" -c:a aac -b:a 32k tiny.aac -y
ffmpeg -f lavfi -i "sine=f=440:d=0.1" -c:a libmp3lame -b:a 32k tiny.mp3 -y
ffmpeg -f lavfi -i "sine=f=440:d=0.1" -c:a flac tiny.flac -y
ffmpeg -f lavfi -i "sine=f=440:d=0.1" -c:a libopus tiny.opus -y
ffmpeg -f lavfi -i "sine=f=440:d=0.1" -c:a libvorbis tiny.ogg -y
ffmpeg -f lavfi -i "sine=f=440:d=0.1" -c:a pcm_s16le tiny.wav -y
19 files, all under 10KB, covering 7 container formats, 5 image
formats, and 7 audio formats. Each one hits a different demuxer
and decoder inside the target. The fuzzer takes these as starting
points and mutates them into millions of variations.
## Option 2: OSS-Fuzz corpus (more coverage)
Google fuzzes FFmpeg continuously through OSS-Fuzz. Their evolved
corpus is publicly available as a daily backup zip. The URL follows
this pattern (verify the exact fuzzer name — it may change):
curl -o /tmp/demuxer_corpus.zip \
"https://storage.googleapis.com/ffmpeg-backup.clusterfuzz-external.appspot.com/corpus/libFuzzer/ffmpeg_DEMUXER_fuzzer/public.zip"
Note: the exact fuzzer target name (ffmpeg_DEMUXER_fuzzer) should be
verified against the OSS-Fuzz project page for FFmpeg at
https://github.com/google/oss-fuzz/tree/master/projects/ffmpeg.
OSS-Fuzz corpus access is documented at
https://google.github.io/oss-fuzz/advanced-topics/corpora/.
The corpus can be several hundred MB and contain tens of thousands of
files. These are highly optimized edge-case inputs that took millions
of CPU hours to evolve.
If you want to use it, filter out crashing files first, then minimize.
This is a multi-hour process for 40000 files.
For this tutorial we'll stick with the generated corpus and let
AFL++ evolve its own interesting inputs from there.
## Option 3: real-world files (okay as a supplement)
Grab a handful of legitimate media files from the internet or your
own collection. A small mp4, a gif, a webm, an mp3, a flac.
The problem: real-world files hit the "happy path" through the code.
Normal headers, normal data, everything well-formed. Ten different
mp4 files from your phone probably exercise the exact same code
paths. They're also usually way too big (several MB each), which
slows down every iteration.
If you go this route, keep them small. Under 100KB ideally:
ffmpeg -i big_video.mp4 -t 1 -s 64x64 -c:v libx264 \
-preset ultrafast small.mp4
## Minimizing the corpus
cd ~/fuzzing/xiaomi
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
QEMU_SET_ENV=LD_LIBRARY_PATH=./lib \
afl-cmin -Q -m none -i corpus -o corpus_min \
-t 5000 -- ./harness_bin @@
This runs every seed through the harness under QEMU and keeps only
the ones that contribute unique coverage.
## Verify the seeds
cd ~/fuzzing/xiaomi
for f in corpus_min/*; do
echo -n "$(basename $f): "
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
QEMU_SET_ENV=LD_LIBRARY_PATH=./lib \
timeout 10 afl-qemu-trace ./harness_bin "$f" 2>/dev/null
echo "exit: $?"
done
All should exit 0. If anything crashes, drop it.
# Running the Fuzzer
## The full command
cd ~/fuzzing/xiaomi
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
QEMU_SET_ENV=LD_LIBRARY_PATH=./lib \
AFL_QEMU_PERSISTENT_ADDR=0x5500001cc8 \
AFL_QEMU_PERSISTENT_GPR=1 \
AFL_QEMU_PERSISTENT_CNT=1000 \
AFL_QEMU_FORCE_DFL=1 \
AFL_QEMU_INST_RANGES=libmigalleryffmpeg.so \
afl-fuzz -Q -i corpus_min -o output \
-t 2000 -- ./harness_bin @@
Environment variables:
QEMU_LD_PREFIX sysroot where QEMU finds linker64 and
system libraries
QEMU_SET_ENV passes LD_LIBRARY_PATH into the guest
so QEMU finds our target .so
AFL_QEMU_PERSISTENT_ADDR start of the persistent loop: the
address of decode_file (PIE base
0x5500000000 + offset 0x1cc8)
AFL_QEMU_PERSISTENT_GPR restores registers each iteration so
function arguments stay valid
AFL_QEMU_PERSISTENT_CNT fork every 1000 iterations to reset
state and prevent FD/memory leaks
AFL_QEMU_FORCE_DFL forces QEMU to ignore guest signal
handlers so AFL++ catches crashes
AFL_QEMU_INST_RANGES only instrument this module(accepts
module names, no need to hardcode
address ranges that can shift)
afl-fuzz flags:
-Q QEMU mode
-i corpus_min seed corpus directory
-o output results directory (crashes, queue)
-t 2000 2 second timeout per execution
-- ./harness_bin @@ target binary, @@ is replaced with
the input file path
## Parallelizing with multiple instances
A single AFL++ instance under QEMU mode uses one core. On a heavy
target like this one, that might get you 30-200 exec/s (depending on your CPU).
Spreading across cores multiplies that linearly. It's the easiest performance
win available and takes two minutes to set up.
AFL++ parallelization uses one main instance (-M) and N-1 secondary
instances (-S). The main instance drives the queue and syncs state.
Secondaries run independently and sync back. All instances share the
same output directory.
Open as many terminals as you have cores to spare, or use tmux.
main instance:
cd ~/fuzzing/xiaomi
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
QEMU_SET_ENV=LD_LIBRARY_PATH=./lib \
AFL_QEMU_PERSISTENT_ADDR=0x5500001cc8 \
AFL_QEMU_PERSISTENT_GPR=1 \
AFL_QEMU_PERSISTENT_CNT=1000 \
AFL_QEMU_FORCE_DFL=1 \
AFL_QEMU_INST_RANGES=libmigalleryffmpeg.so \
afl-fuzz -Q -M main -i corpus_min -o output \
-t 2000 -- ./harness_bin @@
slave1 instance:
cd ~/fuzzing/xiaomi
QEMU_LD_PREFIX=~/fuzzing/xiaomi/sysroot \
QEMU_SET_ENV=LD_LIBRARY_PATH=./lib \
AFL_QEMU_PERSISTENT_ADDR=0x5500001cc8 \
AFL_QEMU_PERSISTENT_GPR=1 \
AFL_QEMU_PERSISTENT_CNT=1000 \
AFL_QEMU_FORCE_DFL=1 \
AFL_QEMU_INST_RANGES=libmigalleryffmpeg.so \
afl-fuzz -Q -S slave1 -i corpus_min -o output \
-t 2000 -- ./harness_bin @@
Keep going up to the number of cores you're willing to dedicate.
To see aggregate status across all instances:
afl-whatsup output/
This prints a combined view: total execs, total paths, crashes
found, and per-instance exec speed. Run it any time to see how
the campaign is doing without switching between terminals.
# Conclusion
You now have a working fuzzing setup for a closed-source Android
native library running on an x86_64 machine with no device attached
and no source code.
The target here is libmigalleryffmpeg.so but the method works on any
Android native library.
Pull the .so, trace the real app with Frida to find out which functions get called,
write a harness, cross-compile it, and run under QEMU. The steps are the same.
The one part that changes per target is the harness. Libraries that
register JNI_OnLoad and make JNI callbacks need a stub JNIEnv. Libraries
that pull Android framework services need those services stubbed out.
That's additional work. Stub what the library asks for and nothing else.
The other variable is speed. QEMU mode costs you a factor of 2-5x
compared to native instrumented binaries. Persistent mode recovers most
of that back. Parallelization does the rest. For a binary-only
cross-architecture target, this is as fast as it gets without
source code.
Let it run. Wait. Maybe it finds some crashes.
Happy hunting.
← back