[ Posts ] [ About ]
---------------------------------------------------------------------
| 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