Post

Fuzzing an Android JNI Socket App with AFL++ Frida (Real Device)

Fuzzing an Android JNI Socket App with AFL++ Frida (Real Device)

I wanted to fuzz an Android app on-device (not emulator fantasy mode), and I wanted the target to be a JNI .so with a socket parser.

So I built one:

  • app listens on 4444
  • receives bytes
  • JNI/native side has a fuzzMe(...) function
  • crash trigger is FuzzMe@123

Then I fuzzed it the same way Quarkslab fuzzed qb.blogfuzz:

  • AFL++ Frida mode on rooted phone
  • external harness binary
  • afl.js persistent hook
  • corpus + crash triage

And yes, we got a crash file.


this is fine

“I’ll just test one input manually…”
five hours later: building harnesses and cursing forkserver handshakes


Target setup (what we fuzz)

Inside app native code (native-lib.cpp), we exposed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extern "C" void fuzzMe(const uint8_t *buffer, uint64_t length) {
    if (buffer == nullptr || length < 10) return;
    if (buffer[0]=='F')
      if (buffer[1]=='u')
        if (buffer[2]=='z')
          if (buffer[3]=='z')
            if (buffer[4]=='M')
              if (buffer[5]=='e')
                if (buffer[6]=='@')
                  if (buffer[7]=='1')
                    if (buffer[8]=='2')
                      if (buffer[9]=='3')
                        triggerCrash();   // intentional crash
}

This gives us:

  1. a direct native fuzz target (fuzzMe)
  2. deterministic crash condition
  3. realistic Android deployment (libfuzzme.so)

Environment

Host

Device

  • rooted Android phone
  • su available
  • writable /data/local/tmp

1) Build the app and get libfuzzme.so

From Android Studio project:

1
2
3
4
git clone https://github.com/nyxFault/FuzzMeApp.git
cd /path/to/FuzzMeApp
printf "sdk.dir=%s\n" "$HOME/Android/Sdk" > local.properties
./gradlew :app:assembleDebug

Decode APK and inspect bundled arm64 library:

1
apktool d app/build/outputs/apk/debug/app-debug.apk -o app-debug

Optional: if you do not want to build locally, download the release APK and decode it:

1
2
wget -O app-debug.apk https://github.com/nyxFault/FuzzMeApp/releases/download/v1.0.0/app-debug.apk
apktool d app-debug.apk -o app-debug

Check exported symbols from extracted .so:

1
objdump -T "app-debug/lib/arm64-v8a/libfuzzme.so" | rg 'fuzzMe|Java_com_example_fuzzmeapp_MainActivity'

You should see fuzzMe exported.


2) Build external harness (Quarkslab style)

I used this tiny harness project:

1
2
3
git clone https://github.com/nyxFault/AndroidJNIFuzzing.git
cd AndroidJNIFuzzing/harness/
ls
1
2
3
├── CMakeLists.txt
├── fuzz.c
└── afl.js

fuzz.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <errno.h>
#include <stdint.h>
#include <stdio.h>

#define BUFFER_SIZE 1024
extern void fuzzMe(const uint8_t *buffer, uint64_t length);

void fuzz_one_input(const uint8_t *buf, int len) { fuzzMe(buf, (uint64_t)len); }

int main(void) {
  uint8_t buffer[BUFFER_SIZE];
  ssize_t rlength = fread((void *)buffer, 1, BUFFER_SIZE, stdin);
  if (rlength == -1) return errno;
  fuzz_one_input(buffer, (int)rlength);
  return 0;
}

CMakeLists.txt

1
2
3
4
5
6
7
8
project(FuzzMeAppHarness)
cmake_minimum_required(VERSION 3.8)

link_directories(${CMAKE_SOURCE_DIR}/lib)

add_executable(fuzz "fuzz.c")
set_property(TARGET fuzz APPEND_STRING PROPERTY LINK_FLAGS " -Wl,-rpath=$ORIGIN")
target_link_libraries(fuzz fuzzme)

afl.js (persistent loop + include/exclude)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const pStartAddr = DebugSymbol.fromName("fuzz_one_input").address;

const MODULE_WHITELIST = [
  "fuzz",
  "libfuzzme.so",
];

new ModuleMap().values().forEach(m => {
  if (!MODULE_WHITELIST.includes(m.name)) {
    Afl.addExcludedRange(m.base, m.size);
  }
});

const cm = new CModule(`
  #include <string.h>
  #include <gum/gumdefs.h>
  #define BUF_LEN 1024
  void afl_persistent_hook(GumCpuContext *regs, uint8_t *input_buf, uint32_t input_buf_len) {
    uint32_t length = (input_buf_len > BUF_LEN) ? BUF_LEN : input_buf_len;
    memcpy((void *)regs->x[0], input_buf, length);
    regs->x[1] = length;
  }
`, { memcpy: Module.getExportByName(null, "memcpy") });

Afl.setEntryPoint(pStartAddr);
Afl.setPersistentAddress(pStartAddr);
Afl.setPersistentHook(cm.afl_persistent_hook);
Afl.setPersistentCount(10000);
Afl.setInstrumentLibraries();
Afl.done();

Build harness for Android arm64

1
2
3
4
5
6
7
8
9
10
11
cd /path/to/AndroidJNIFuzzing
mkdir -p harness/lib
cp "/path/to/FuzzMeApp/app-debug/lib/arm64-v8a/libfuzzme.so" harness/lib/

cmake -S harness \
      -B harness/build \
      -DANDROID_PLATFORM=31 \
      -DCMAKE_TOOLCHAIN_FILE=/path/to/android-ndk-r25c/build/cmake/android.toolchain.cmake \
      -DANDROID_ABI=arm64-v8a

cmake --build harness/build -j4

This step builds only the harness binary (harness/build/fuzz). It does not build AFL++ binaries.


3) Build AFL++ binaries (afl-fuzz + afl-frida-trace.so)

1
2
3
4
5
6
7
8
9
10
11
12
cd /path/to/AndroidJNIFuzzing
git submodule update --init --recursive

cd AFLplusplus
curl -L https://raw.githubusercontent.com/quarkslab/android-fuzzing/main/AFLplusplus/CMakeLists.txt -o CMakeLists.txt

cmake -G "Unix Makefiles" \
      -DANDROID_PLATFORM=31 \
      -DCMAKE_TOOLCHAIN_FILE=/path/to/android-ndk-r25c/build/cmake/android.toolchain.cmake \
      -DANDROID_ABI=arm64-v8a \
      .
cmake --build . -- -j"$(nproc)"

After this, you should have:

1
2
/path/to/AndroidJNIFuzzing/AFLplusplus/afl-fuzz
/path/to/AndroidJNIFuzzing/AFLplusplus/afl-frida-trace.so

4) Push artifacts to phone

1
2
3
4
5
6
adb shell "mkdir -p /data/local/tmp/fuzzme"
adb push /path/to/AndroidJNIFuzzing/AFLplusplus/afl-fuzz /data/local/tmp/fuzzme/
adb push /path/to/AndroidJNIFuzzing/AFLplusplus/afl-frida-trace.so /data/local/tmp/fuzzme/
adb push /path/to/AndroidJNIFuzzing/harness/build/fuzz /data/local/tmp/fuzzme/
adb push /path/to/AndroidJNIFuzzing/harness/afl.js /data/local/tmp/fuzzme/
adb push /path/to/AndroidJNIFuzzing/harness/lib/libfuzzme.so /data/local/tmp/fuzzme/

Prepare CPU governor + corpus:

Then prepare the environment on the device for our first fuzzing campaign (in root):

1
2
3
4
5
6
adb shell
# su
# cd /sys/devices/system/cpu
# echo performance | tee cpu*/cpufreq/scaling_governor

adb shell "sh -c \"cd /data/local/tmp/fuzzme && rm -rf in_fuzzme out_fuzzme && mkdir in_fuzzme out_fuzzme && dd if=/dev/urandom of=in_fuzzme/sample.bin bs=1 count=16\""

5) Run AFL++ Frida mode

Start fuzzing:

1
2
3
4
5
adb shell
# su
# cd /data/local/tmp/fuzzme

AFL_FRIDA_INST_NO_OPTIMIZE=1 AFL_FRIDA_INST_NO_PREFETCH=1 AFL_FRIDA_INST_NO_PREFETCH_BACKPATCH=1 ./afl-fuzz -O -G 1024 -i in_fuzzme -o out_fuzzme ./fuzz

Why these env vars?
On newer devices we often hit Frida patching edge cases. These settings trade a bit of speed for stability.

Optional (if you face startup/handshake errors): add debug logs with AFL_DEBUG=1.

1
2
AFL_DEBUG=1 AFL_FRIDA_INST_NO_OPTIMIZE=1 AFL_FRIDA_INST_NO_PREFETCH=1 AFL_FRIDA_INST_NO_PREFETCH_BACKPATCH=1 \
./afl-fuzz -O -G 1024 -i in_fuzzme -o out_fuzzme ./fuzz

If Ctrl+C does not stop AFL, kill it from host:

1
adb shell "su -c 'pkill -INT afl-fuzz || pkill -TERM afl-fuzz || pkill -9 afl-fuzz'"

PID fallback:

1
2
adb shell "su -c 'ps -A | grep afl-fuzz'"
adb shell "su -c 'kill -INT <pid> || kill -TERM <pid> || kill -9 <pid>'"

6) Wait for crash, then triage

Monitor crash count:

1
adb shell "su -c 'sh -c \"cd /data/local/tmp/fuzzme && ls out_fuzzme/default/crashes/id:* 2>/dev/null | wc -l\"'"

Dump crash input:

1
adb shell "sh -c \"cd /data/local/tmp/fuzzme && xxd out_fuzzme/default/crashes/id:*\""

In my run:

1
2
00000000: 4675 7a7a 4d65 4031 3233 e0e0 e0e0 e0e0  FuzzMe@123......
00000010: e0e0 3275                                ..2u

So AFL found an input beginning with FuzzMe@123 and triggered SIGSEGV as expected.


Common pain points (aka why your campaign dies in 3 seconds)

1) Fork server handshake failed

Usually one of:

  • bad afl.js (forgot Afl.done())
  • too much instrumentation scope
  • unstable Frida options on your ROM/device

2) No new coverage forever

This was interesting in my setup too.
When fuzzMe was a simple std::string::find, coverage was boring.
When switched to branch ladder checks, AFL got a much better gradient and improved path discovery.

3) “Works manually, not in AFL”

Remember AFL assumptions:

  • target must behave deterministic enough
  • harness must avoid external side effects
  • all needed shared libs should be loaded before execution path enters target

fuzzing speed

When exec/s goes up and crashes are still 0, but you pretend this is fine.


Repro one-liner (compact)

If your files are already in /data/local/tmp/fuzzme:

1
2
3
4
adb shell "su -c 'sh -c \"cd /data/local/tmp/fuzzme && \
rm -rf out_fuzzme && mkdir out_fuzzme && \
AFL_FRIDA_INST_NO_OPTIMIZE=1 AFL_FRIDA_INST_NO_PREFETCH=1 AFL_FRIDA_INST_NO_PREFETCH_BACKPATCH=1 \
./afl-fuzz -O -G 1024 -i in_fuzzme -o out_fuzzme ./fuzz\"'"

Optional troubleshooting variant:

1
2
3
4
adb shell "su -c 'sh -c \"cd /data/local/tmp/fuzzme && \
rm -rf out_fuzzme && mkdir out_fuzzme && \
AFL_DEBUG=1 AFL_FRIDA_INST_NO_OPTIMIZE=1 AFL_FRIDA_INST_NO_PREFETCH=1 AFL_FRIDA_INST_NO_PREFETCH_BACKPATCH=1 \
./afl-fuzz -O -G 1024 -i in_fuzzme -o out_fuzzme ./fuzz\"'"

Final thoughts

This workflow is very close to the Quarkslab qb.blogfuzz style, but adapted to a custom JNI-backed Android app:

  • build target .so
  • export a deterministic fuzz function
  • external harness + Frida persistent hook
  • run AFL on rooted phone
  • triage crash corpus

Happy crashing 🍻


Credits

Big shoutout to Quarkslab for the original inspiration and methodology:

This post is licensed under CC BY 4.0 by the author.