Android Fuzzing: AFL++, Frida, and Coverage-Guided Native Library Testing
Introduction
Android applications increasingly rely on native libraries (C/C++ via JNI) for performance-critical operations, making them susceptible to memory corruption vulnerabilities. Fuzzing these components requires specialized techniques for ARM architecture, cross-compilation, and Android runtime integration.
Android Fuzzing Landscape
Attack Surface Analysis
# Extract and analyze APK
apktool d app.apk -o app_decompiled
# Find native libraries
find app_decompiled/lib -name "*.so"
# Output:
# lib/arm64-v8a/libnative.so
# lib/armeabi-v7a/libnative.so
# Identify JNI functions
nm -D app_decompiled/lib/arm64-v8a/libnative.so | grep Java_
# Output:
# 0000000000001234 T Java_com_example_Native_processData
# 0000000000005678 T Java_com_example_Native_decryptBuffer
Interesting targets:
- Image/video codecs
- Cryptographic implementations
- Compression libraries
- Network protocol parsers
- File format parsers
AFL++ for Android
Cross-Compilation Setup
# Install Android NDK
export ANDROID_NDK=/path/to/android-ndk-r25c
# Clone AFL++
git clone https://github.com/AFLplusplus/AFLplusplus.git
cd AFLplusplus
make
# Build AFL++ for Android ARM64
export CC=$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android33-clang
export CXX=$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android33-clang++
make clean
make AFL_NO_X86=1 ANDROID=1
# Output: afl-fuzz, afl-clang-fast for ARM64
Instrumenting Native Libraries
// Target: native library with parsing function
// File: native.c
#include <jni.h>
#include <string.h>
JNIEXPORT jint JNICALL
Java_com_example_Native_parseData(JNIEnv* env, jobject thiz, jbyteArray data) {
jsize len = (*env)->GetArrayLength(env, data);
jbyte* buffer = (*env)->GetByteArrayElements(env, data, NULL);
// VULNERABLE: No length validation
char internal_buf[256];
memcpy(internal_buf, buffer, len); // Buffer overflow!
(*env)->ReleaseByteArrayElements(env, data, buffer, 0);
return process_buffer(internal_buf, len);
}
Compile with AFL++ instrumentation:
# CMakeLists.txt for fuzzing harness
cmake_minimum_required(VERSION 3.18)
project(FuzzNative)
set(CMAKE_C_COMPILER /path/to/AFLplusplus/afl-clang-fast)
set(CMAKE_C_FLAGS "-fsanitize=address -g")
add_executable(fuzz_harness
harness.c
native.c
)
Standalone Fuzzing Harness
// harness.c - Standalone fuzzer without Android runtime
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
// Mock JNI environment for fuzzing
extern int process_buffer(const char* buf, size_t len);
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_FUZZ_INIT();
#endif
int main(int argc, char** argv) {
#ifdef __AFL_HAVE_MANUAL_CONTROL
// Persistent mode
__AFL_INIT();
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(10000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
// Call target function
process_buffer((const char*)buf, len);
}
#else
// File-based mode
if (argc < 2) {
fprintf(stderr, "Usage: %s <input_file>\n", argv[0]);
return 1;
}
FILE* f = fopen(argv[1], "rb");
fseek(f, 0, SEEK_END);
long len = ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char* buf = malloc(len);
fread(buf, 1, len, f);
fclose(f);
process_buffer((const char*)buf, len);
free(buf);
#endif
return 0;
}
Build and run:
# Build with AFL++ compiler
afl-clang-fast -fsanitize=address -o fuzz_harness harness.c native.c
# Create corpus
mkdir input output
echo "VALID_DATA" > input/seed1.bin
# Run AFL++
afl-fuzz -i input -o output -m none -- ./fuzz_harness @@
libFuzzer Integration
libFuzzer Harness
// fuzz_target.cpp
#include <stdint.h>
#include <stddef.h>
extern "C" int process_buffer(const char* buf, size_t len);
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size == 0) return 0;
// Call target function
process_buffer((const char*)data, size);
return 0;
}
Build with Clang:
# Use Android NDK Clang with libFuzzer
$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android33-clang++ \
-fsanitize=fuzzer,address \
-o fuzz_target \
fuzz_target.cpp native.c
# Run on device
adb push fuzz_target /data/local/tmp/
adb shell /data/local/tmp/fuzz_target
# Or with corpus
adb push corpus /data/local/tmp/
adb shell /data/local/tmp/fuzz_target /data/local/tmp/corpus -max_total_time=3600
Frida-Based In-Memory Fuzzing
Instrumentation Script
// frida_fuzzer.js
'use strict';
// Attach to running app
const moduleName = "libnative.so";
const targetFunc = "Java_com_example_Native_parseData";
const baseAddr = Module.findBaseAddress(moduleName);
const funcAddr = Module.findExportByName(moduleName, targetFunc);
console.log(`[*] Module base: ${baseAddr}`);
console.log(`[*] Target function: ${funcAddr}`);
// Hook target function
Interceptor.attach(funcAddr, {
onEnter: function(args) {
// args[0] = JNIEnv*
// args[1] = jobject (this)
// args[2] = jbyteArray (data)
const env = args[0];
const dataArray = args[2];
// Get array length
const getArrayLength = new NativeFunction(
env.add(Process.pointerSize * 171).readPointer(),
'int', ['pointer', 'pointer']
);
const len = getArrayLength(env, dataArray);
console.log(`[*] parseData called with length: ${len}`);
// Save context for mutation
this.env = env;
this.dataArray = dataArray;
this.originalLen = len;
},
onLeave: function(retval) {
console.log(`[*] Return value: ${retval}`);
}
});
// Mutation function
function mutateInput(env, dataArray, originalLen) {
// Get byte array
const getByteArrayElements = new NativeFunction(
env.add(Process.pointerSize * 183).readPointer(),
'pointer', ['pointer', 'pointer', 'pointer']
);
const buffer = getByteArrayElements(env, dataArray, NULL);
// Apply mutations
const mutations = [
() => { /* Bit flip */ Memory.writeU8(buffer, Memory.readU8(buffer) ^ 0xFF); },
() => { /* Byte replace */ Memory.writeU8(buffer, 0x41); },
() => { /* Integer overflow */ Memory.writeU32(buffer, 0xFFFFFFFF); },
];
const mutation = mutations[Math.floor(Math.random() * mutations.length)];
mutation();
// Release array
const releaseByteArrayElements = new NativeFunction(
env.add(Process.pointerSize * 184).readPointer(),
'void', ['pointer', 'pointer', 'pointer', 'int']
);
releaseByteArrayElements(env, dataArray, buffer, 0);
}
console.log("[*] Fuzzing harness ready");
Run fuzzer:
# Attach to running app
frida -U -f com.example.app -l frida_fuzzer.js --no-pause
# Or spawn and attach
frida -U com.example.app -l frida_fuzzer.js
Crash Detection
// crash_detector.js
'use strict';
// Monitor for crashes
Process.setExceptionHandler(function(details) {
console.log("[!] CRASH DETECTED!");
console.log("Type:", details.type);
console.log("Address:", details.address);
console.log("Memory:", JSON.stringify(details.memory));
console.log("Context:", JSON.stringify(details.context));
// Log crash to file
const timestamp = Date.now();
const crashFile = `/sdcard/crash_${timestamp}.json`;
const file = new File(crashFile, 'w');
file.write(JSON.stringify(details, null, 2));
file.close();
console.log(`[+] Crash saved to ${crashFile}`);
return true; // Don't terminate
});
// Also hook abort/signal handlers
Interceptor.attach(Module.findExportByName(null, 'abort'), {
onEnter: function() {
console.log("[!] abort() called");
console.log(Thread.backtrace(this.context).map(DebugSymbol.fromAddress).join('\n'));
}
});
Coverage-Guided Fuzzing
SanitizerCoverage Integration
// Build with coverage instrumentation
// Add to CMakeLists.txt:
set(CMAKE_C_FLAGS "-fsanitize=address -fsanitize-coverage=trace-pc-guard")
// Coverage callback
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return;
// Log coverage to shared memory for AFL++
void *addr = __builtin_return_address(0);
// Update AFL++ bitmap
extern unsigned char __afl_area_ptr[65536];
uint32_t edge = ((uintptr_t)addr) ^ ((uintptr_t)guard);
__afl_area_ptr[edge % 65536]++;
}
Frida Coverage Tracking
// coverage_tracer.js
'use strict';
const moduleName = "libnative.so";
const module = Process.getModuleByName(moduleName);
const coverage = new Map();
// Instrument all basic blocks
const stalker = Stalker.follow({
events: {
call: false,
ret: false,
exec: true, // Basic block execution
block: false,
compile: false
},
onReceive: function(events) {
const parsedEvents = Stalker.parse(events, {
annotate: false,
stringify: false
});
for (const event of parsedEvents) {
if (event[0] === 'exec') {
const addr = event[1];
// Only track our module
if (addr >= module.base && addr < module.base.add(module.size)) {
const offset = addr.sub(module.base);
coverage.set(offset.toString(), (coverage.get(offset.toString()) || 0) + 1);
}
}
}
}
});
// Dump coverage after fuzzing
rpc.exports = {
dumpCoverage: function() {
const sorted = Array.from(coverage.entries()).sort((a, b) => b[1] - a[1]);
return sorted;
},
getCoverageCount: function() {
return coverage.size;
}
};
console.log("[*] Coverage tracing started");
On-Device Fuzzing
Android System Fuzzing
# Push fuzzer to device
adb push fuzz_harness /data/local/tmp/
adb push corpus /data/local/tmp/corpus
adb shell chmod +x /data/local/tmp/fuzz_harness
# Run on device
adb shell "cd /data/local/tmp && ./fuzz_harness -i corpus -o crashes @@"
# Monitor crashes
adb logcat -s DEBUG
Continuous Fuzzing
#!/usr/bin/env python3
"""
Continuous Android fuzzing automation
"""
import subprocess
import time
import os
def push_fuzzer(device_id, fuzzer_path, corpus_path):
"""Push fuzzer and corpus to device"""
subprocess.run(['adb', '-s', device_id, 'push', fuzzer_path, '/data/local/tmp/'])
subprocess.run(['adb', '-s', device_id, 'push', corpus_path, '/data/local/tmp/corpus/'])
subprocess.run(['adb', '-s', device_id, 'shell', 'chmod', '+x', '/data/local/tmp/fuzz_harness'])
def start_fuzzer(device_id):
"""Start fuzzer in background"""
cmd = [
'adb', '-s', device_id, 'shell',
'cd /data/local/tmp && '
'./fuzz_harness -i corpus -o crashes -m none -t 1000 @@'
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return proc
def monitor_crashes(device_id, output_dir):
"""Monitor and download crashes"""
while True:
time.sleep(60) # Check every minute
# List crash files
result = subprocess.run(
['adb', '-s', device_id, 'shell', 'ls', '/data/local/tmp/crashes/crashes/'],
capture_output=True, text=True
)
crash_files = result.stdout.strip().split('\n')
for crash_file in crash_files:
if crash_file.startswith('id:'):
local_path = os.path.join(output_dir, crash_file)
if not os.path.exists(local_path):
# Download crash
subprocess.run([
'adb', '-s', device_id, 'pull',
f'/data/local/tmp/crashes/crashes/{crash_file}',
local_path
])
print(f"[+] New crash: {crash_file}")
# Analyze crash
analyze_crash(local_path)
def analyze_crash(crash_file):
"""Analyze crash file"""
print(f"[*] Analyzing {crash_file}")
# Run with ASAN
result = subprocess.run(
['./fuzz_harness', crash_file],
capture_output=True, text=True,
env={'ASAN_OPTIONS': 'symbolize=1'}
)
if 'AddressSanitizer' in result.stderr:
print(f"[!] ASAN violation detected")
print(result.stderr)
if __name__ == '__main__':
device_id = subprocess.run(['adb', 'devices', '-l'], capture_output=True, text=True)
device_id = device_id.stdout.split('\n')[1].split()[0]
push_fuzzer(device_id, './fuzz_harness', './corpus')
start_fuzzer(device_id)
monitor_crashes(device_id, './crashes_local')
Crash Triage
ASAN Output Analysis
=================================================================
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xb4000000 at pc 0xb6f12340 bp 0xbefff000 sp 0xbeffeff0
WRITE of size 512 at 0xb4000100 thread T0
#0 0xb6f12340 in memcpy /build/glibc/string/memcpy.c:123
#1 0xb6f23456 in process_buffer /app/native.c:45
#2 0xb6f34567 in Java_com_example_Native_parseData /app/native.c:78
0xb4000100 is located 0 bytes to the right of 256-byte region [0xb4000000,0xb4000100)
allocated by thread T0 here:
#0 0xb6e12345 in malloc
#1 0xb6f23400 in process_buffer /app/native.c:42
SUMMARY: AddressSanitizer: heap-buffer-overflow /build/glibc/string/memcpy.c:123 in memcpy
Analysis:
- Type: Heap buffer overflow
- Location:
process_buffer()at native.c:45 - Root cause: Writes 512 bytes to 256-byte buffer
- Exploitability: Likely exploitable (controlled overflow size)
Automated Crash Deduplication
import hashlib
import os
import subprocess
def get_crash_signature(crash_file, harness):
"""Generate unique crash signature from ASAN output"""
result = subprocess.run(
[harness, crash_file],
capture_output=True,
text=True,
env={'ASAN_OPTIONS': 'symbolize=1'}
)
# Extract stack trace
lines = result.stderr.split('\n')
stack_trace = []
for line in lines:
if line.strip().startswith('#'):
# Parse frame
parts = line.split()
if len(parts) >= 3:
func_name = parts[3] if len(parts) > 3 else parts[2]
stack_trace.append(func_name)
# Hash first 5 frames
signature = '|'.join(stack_trace[:5])
return hashlib.md5(signature.encode()).hexdigest()
def deduplicate_crashes(crash_dir, harness):
"""Group crashes by unique signature"""
buckets = {}
for filename in os.listdir(crash_dir):
if filename.startswith('id:'):
path = os.path.join(crash_dir, filename)
sig = get_crash_signature(path, harness)
if sig not in buckets:
buckets[sig] = []
buckets[sig].append(filename)
# Print unique crashes
for i, (sig, files) in enumerate(buckets.items()):
print(f"\n[*] Crash bucket {i} ({len(files)} samples)")
print(f" Signature: {sig}")
print(f" Representative: {files[0]}")
return buckets
Case Studies
CVE-2020-0451: Android Media Framework
Target: libstagefright (media codec library)
Vulnerability: Heap buffer overflow in MPEG4 parser
Fuzzing approach:
// Harness for MPEG4 parser
#include <media/stagefright/MediaExtractor.h>
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
// Create data source from input
sp<DataSource> source = DataSourceFactory::CreateFromURI(
NULL, "file:///fuzzing_input.mp4", NULL
);
// Attempt to parse
sp<MediaExtractor> extractor = MediaExtractorFactory::Create(source);
if (extractor != NULL) {
size_t numTracks = extractor->countTracks();
for (size_t i = 0; i < numTracks; i++) {
sp<MediaSource> track = extractor->getTrack(i);
track->start();
// ... trigger parsing
}
}
return 0;
}
Results: Discovered heap overflow leading to RCE via MMS.
Conclusion
Android fuzzing requires tailored approaches for native code testing, cross-architecture compilation, and device-specific constraints. Combining AFL++, libFuzzer, and Frida enables comprehensive coverage of Android attack surfaces, from standalone libraries to integrated JNI components.
Modern Android vulnerabilities increasingly reside in native code, making systematic fuzzing essential for security research and proactive vulnerability discovery.
References
- Google (2023). “Android libFuzzer Integration”
- AFLplusplus (2023). “Fuzzing on Android”
- Quarkslab (2020). “Frida-based In-Memory Fuzzing”
- Google Project Zero (2020). “Android Media Framework Vulnerabilities”