Android Native Debugging with LLDB: From JNI Analysis to Exploit Development
Introduction
Android native code debugging requires specialized tools and techniques for ARM/ARM64 architectures. LLDB (LLVM Debugger) provides powerful capabilities for analyzing JNI functions, inspecting memory, and developing exploits for Android native vulnerabilities.
LLDB Setup for Android
Installing LLDB
# LLDB comes with Android NDK
export ANDROID_NDK=/path/to/android-ndk-r25c
export PATH=$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
# Verify installation
lldb --version
# lldb version 14.0.6
# Android-specific lldb-server
ls $ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/lib64/clang/14.0.6/lib/linux/
# lldb-server-arm64.so
# lldb-server-armv7.so
Remote Debugging Setup
# Push lldb-server to device
adb push $ANDROID_NDK/prebuilt/android-arm64/lldb-server/lldb-server /data/local/tmp/
# Start lldb-server on device
adb shell "chmod +x /data/local/tmp/lldb-server"
adb shell "/data/local/tmp/lldb-server platform --listen '*:1234' --server"
# Forward port to host
adb forward tcp:1234 tcp:1234
# Connect from host
lldb
(lldb) platform select remote-android
(lldb) platform connect connect://localhost:1234
Platform: remote-android
Connected: yes
Attaching to Process
# Method 1: Attach to running process
(lldb) process attach --name com.example.app
# Method 2: Attach by PID
adb shell ps | grep com.example.app
# Get PID: 12345
(lldb) process attach --pid 12345
# Method 3: Launch and attach
(lldb) process launch --stop-at-entry -- /data/app/com.example.app/lib/arm64/libnative.so
JNI Function Analysis
Locating JNI Functions
# List loaded modules
(lldb) image list
[ 0] /system/bin/app_process64
[ 1] /system/lib64/libc.so
[ 2] /data/app/com.example.app/lib/arm64/libnative.so
# Find JNI exports
(lldb) image lookup --regex --name "Java_" libnative.so
2 matches found in /data/app/com.example.app/lib/arm64/libnative.so:
Address: libnative.so[0x0000000000001234] (libnative.so.PT_LOAD[0]..text + 4660)
Summary: libnative.so`Java_com_example_Native_processData
Address: libnative.so[0x0000000000005678] (libnative.so.PT_LOAD[0]..text + 22136)
Summary: libnative.so`Java_com_example_Native_decrypt
# Set breakpoint on JNI function
(lldb) b Java_com_example_Native_processData
Breakpoint 1: where = libnative.so`Java_com_example_Native_processData, address = 0x00007a12345678
Inspecting JNI Parameters
// JNI function signature:
// JNIEXPORT jint JNICALL Java_com_example_Native_processData
// (JNIEnv *env, jobject thiz, jbyteArray data, jint len);
//
// ARM64 calling convention:
// x0 = JNIEnv* env
// x1 = jobject thiz
// x2 = jbyteArray data
// x3 = jint len
LLDB inspection:
(lldb) b Java_com_example_Native_processData
Breakpoint hit
# Examine registers
(lldb) register read x0 x1 x2 x3
x0 = 0x00007a00000100 # JNIEnv*
x1 = 0x00007a00000200 # jobject
x2 = 0x00007a00000300 # jbyteArray
x3 = 0x0000000000000040 # len = 64
# Dereference JNIEnv to access JNI function table
(lldb) memory read --size 8 --count 1 $x0
0x7a00000100: 0x00007a12340000 # JNINativeInterface*
# Access GetByteArrayElements (offset 183)
(lldb) memory read --size 8 --format x 0x00007a12340000+183*8
0x7a123405b8: 0x00007a99887766
# Call JNI function to get array data
(lldb) expr void* (*GetByteArrayElements)(void*, void*, void*) = (void*(*)(void*,void*,void*))0x00007a99887766
(lldb) expr void* buf = GetByteArrayElements((void*)$x0, (void*)$x2, NULL)
(lldb) memory read --size 1 --count 64 buf
0x7b00001000: 41 42 43 44 45 46 47 48 ABCDEFGH
0x7b00001008: 49 4a 4b 4c 4d 4e 4f 50 IJKLMNOP
JNI Function Hooking
# Set breakpoint and modify parameters
(lldb) b Java_com_example_Native_processData
(lldb) breakpoint command add 1
> expr $x3 = 0x1000 # Modify len parameter
> continue
> DONE
# Now function receives modified length
Memory Inspection
Reading Memory Regions
# Read as hex
(lldb) memory read --size 4 --format x --count 16 0x7a00000000
0x7a00000000: 0x464c457f 0x00010101 0x00000000 0x00000000
0x7a00000010: 0x00030003 0x00000001 0x00001234 0x00000000
# Read as instructions (disassemble)
(lldb) disassemble --start-address 0x7a12345678 --count 10
libnative.so`Java_com_example_Native_processData:
0x7a12345678: stp x29, x30, [sp, #-0x30]!
0x7a1234567c: mov x29, sp
0x7a12345680: stp x20, x19, [sp, #0x10]
0x7a12345684: stp x22, x21, [sp, #0x20]
0x7a12345688: mov x19, x0
0x7a1234568c: mov x20, x1
0x7a12345690: mov x21, x2
0x7a12345694: mov w22, w3
# Read as string
(lldb) memory read --format s 0x7a00010000
0x7a00010000: "Hello from native code"
# Search memory
(lldb) memory find --string "password" 0x7a00000000 0x7a10000000
data found at location: 0x7a00005678
Writing Memory
# Write integer
(lldb) memory write 0x7a00001000 0x41424344
(lldb) memory read --size 4 --format x 0x7a00001000
0x7a00001000: 0x41424344
# Write bytes
(lldb) memory write 0x7a00001000 --infile /tmp/shellcode.bin
# Modify instruction (NOP out call)
(lldb) disassemble --start-address $pc --count 1
0x7a12345690: bl 0x7a12340000 ; dangerous_function
(lldb) memory write $pc 0x1f2003d5 # NOP instruction (ARM64)
Breakpoint Strategies
Conditional Breakpoints
# Break only when specific condition is met
(lldb) b Java_com_example_Native_processData
(lldb) breakpoint modify 1 --condition "$x3 > 0x100"
# Break when accessing specific memory
(lldb) watchpoint set expression -- 0x7a00001000
(lldb) watchpoint modify 1 --condition "*(int*)0x7a00001000 == 0x41424344"
Hardware Breakpoints
# Set hardware breakpoint (limited to 4 on ARM)
(lldb) breakpoint set --hardware --name vulnerable_function
# Hardware watchpoint (memory access)
(lldb) watchpoint set expression -- 0x7a00001000
(lldb) watchpoint modify 1 --watch read # Break on read
(lldb) watchpoint modify 1 --watch write # Break on write
(lldb) watchpoint modify 1 --watch read_write # Break on both
Function Entry/Exit Hooks
# LLDB Python script: log_calls.py
import lldb
def log_function_calls(debugger, command, result, internal_dict):
target = debugger.GetSelectedTarget()
process = target.GetProcess()
# Set breakpoint on function
bp = target.BreakpointCreateByName("Java_com_example_Native_processData")
def breakpoint_callback(frame, bp_loc, dict):
thread = frame.GetThread()
process = thread.GetProcess()
# Log parameters
x0 = frame.FindRegister("x0").GetValueAsUnsigned()
x1 = frame.FindRegister("x1").GetValueAsUnsigned()
x2 = frame.FindRegister("x2").GetValueAsUnsigned()
x3 = frame.FindRegister("x3").GetValueAsUnsigned()
print(f"[*] processData called: env={hex(x0)}, thiz={hex(x1)}, data={hex(x2)}, len={x3}")
return False # Continue execution
bp.SetScriptCallbackFunction("log_calls.breakpoint_callback")
# Load script in LLDB
# (lldb) command script import /path/to/log_calls.py
# (lldb) command script add -f log_calls.log_function_calls log_calls
Stack and Heap Analysis
Stack Trace
# Print backtrace
(lldb) bt
* thread #1, name = 'com.example.app', stop reason = breakpoint 1.1
* frame #0: 0x00007a12345678 libnative.so`Java_com_example_Native_processData
frame #1: 0x00007a99887766 libart.so`art_quick_generic_jni_trampoline
frame #2: 0x00007a99889000 libart.so`art::Method::Invoke
frame #3: 0x00007a9988a000 libart.so`art::JNI::CallIntMethodV
frame #4: 0x00007a00001234 base.odex`com.example.Native.processData
# Select frame
(lldb) frame select 0
# Print local variables
(lldb) frame variable
(JNIEnv *) env = 0x00007a00000100
(jobject) thiz = 0x00007a00000200
(jbyteArray) data = 0x00007a00000300
(jint) len = 64
# Disassemble current function
(lldb) disassemble --frame
Heap Inspection
# Find heap allocations
(lldb) image lookup --type "malloc"
(lldb) b malloc
(lldb) breakpoint command add 1
> bt
> register read x0 # Allocation size
> continue
> DONE
# Track allocation
(lldb) expr void* ptr = malloc(256)
(lldb) memory read $ptr
# Detect heap corruption
(lldb) watchpoint set expression -- $ptr
# If watchpoint triggers unexpectedly → heap corruption
Exploit Development Workflow
Vulnerability Analysis
// Vulnerable function (simplified)
JNIEXPORT jint JNICALL
Java_com_example_Native_processData(JNIEnv* env, jobject thiz, jbyteArray data, jint len) {
char buffer[256];
jbyte* input = (*env)->GetByteArrayElements(env, data, NULL);
// VULNERABLE: No length check
memcpy(buffer, input, len); // Stack overflow if len > 256
(*env)->ReleaseByteArrayElements(env, data, input, 0);
return process(buffer);
}
LLDB analysis:
# Set breakpoint before memcpy
(lldb) b 0x7a12345690 # Address of memcpy call
# Examine stack layout
(lldb) register read sp x29
sp = 0x007ffff000
x29 = 0x007ffff030 # Frame pointer
# Calculate buffer offset
(lldb) p/d (long)$x29 - (long)$sp
32 # Buffer starts at sp + 32
# Trigger overflow
(lldb) expr $x3 = 0x300 # Set len = 768 (overflow)
(lldb) continue
# Check for crash
Thread 1 stopped
* thread #1, name = 'com.example.app', stop reason = EXC_BAD_ACCESS (code=1, address=0x4141414141414141)
frame #0: 0x4141414141414141
(lldb) bt
* thread #1, stop reason = EXC_BAD_ACCESS
* frame #0: 0x4141414141414141
# RIP control achieved!
ROP Gadget Hunting
# Find ROP gadgets in loaded libraries
import lldb
def find_gadgets(debugger, command, result, internal_dict):
target = debugger.GetSelectedTarget()
module = target.FindModule(lldb.SBFileSpec("libnative.so"))
text_section = module.FindSection(".text")
start = text_section.GetLoadAddress(target)
size = text_section.GetByteSize()
# Search for gadgets
gadgets = {
"pop {r0, pc}": b"\x00\x80\xbd\xe8", # ARM32
"pop {x0, x30}; ret": b"\xfd\x7b\xc1\xa8\xc0\x03\x5f\xd6", # ARM64
}
process = target.GetProcess()
error = lldb.SBError()
for name, pattern in gadgets.items():
for offset in range(0, size - len(pattern)):
addr = start + offset
data = process.ReadMemory(addr, len(pattern), error)
if data == pattern:
print(f"[+] Found gadget '{name}' at {hex(addr)}")
# (lldb) command script import /path/to/find_gadgets.py
# (lldb) find_gadgets
Shellcode Testing
# Allocate executable memory
(lldb) expr void* (*mmap_ptr)(void*, size_t, int, int, int, off_t) = (void*(*)(void*,size_t,int,int,int,off_t))dlsym((void*)-2, "mmap")
(lldb) expr void* shellcode_mem = mmap_ptr(NULL, 0x1000, 7, 0x22, -1, 0) # PROT_READ|WRITE|EXEC
# Write shellcode
(lldb) memory write shellcode_mem --infile /tmp/shellcode.bin
# Execute shellcode
(lldb) expr ((void(*)())shellcode_mem)()
# Or set PC to shellcode
(lldb) register write pc shellcode_mem
(lldb) continue
Frida + LLDB Integration
Combining Tools
// frida_lldb_bridge.js
'use strict';
// Frida script to help LLDB
const moduleName = "libnative.so";
const funcName = "Java_com_example_Native_processData";
const baseAddr = Module.findBaseAddress(moduleName);
const funcAddr = Module.findExportByName(moduleName, funcName);
console.log(`[*] Module base: ${baseAddr}`);
console.log(`[*] Function address: ${funcAddr}`);
console.log(`[*] LLDB commands:`);
console.log(` (lldb) process attach --pid ${Process.id}`);
console.log(` (lldb) b ${funcAddr}`);
// Hook to pause for LLDB attachment
Interceptor.attach(funcAddr, {
onEnter: function(args) {
console.log(`[*] Function called. Attach LLDB now!`);
// Wait for debugger
while (!Debug.isDebuggerPresent()) {
Thread.sleep(0.1);
}
console.log(`[+] LLDB attached. Continuing...`);
}
});
Workflow:
# Terminal 1: Start Frida
frida -U -f com.example.app -l frida_lldb_bridge.js --no-pause
# Terminal 2: Attach LLDB when prompted
lldb
(lldb) platform select remote-android
(lldb) platform connect connect://localhost:1234
(lldb) process attach --pid 12345
(lldb) b 0x7a12345678
(lldb) continue
Debugging Anti-Debug Techniques
Detecting ptrace
// Anti-debug check
bool is_debugged() {
int status = ptrace(PTRACE_TRACEME, 0, NULL, NULL);
if (status < 0) {
return true; // Already being traced
}
ptrace(PTRACE_DETACH, 0, NULL, NULL);
return false;
}
Bypass with LLDB:
# Hook ptrace
(lldb) b ptrace
(lldb) breakpoint command add 1
> expr $x0 = 0 # Change return value to 0 (success)
> expr $x0 = -1 # Or force error
> continue
> DONE
# Alternative: Patch check
(lldb) disassemble --name is_debugged
(lldb) memory write 0x7a12345690 0x52800000 # mov w0, #0 (return false)
TracerPid Check
# Anti-debug: Read /proc/self/status
if (grep_file("/proc/self/status", "TracerPid:\\t0")) {
// Not debugged
} else {
// Debugged → exit
}
Bypass:
# Hook open/read syscalls
(lldb) b open
(lldb) breakpoint command add 1
> # Check if opening /proc/self/status
> expr char* path = (char*)$x0
> expr if (strcmp(path, "/proc/self/status") == 0) { $x0 = (long)"/dev/null"; }
> continue
> DONE
LLDB Python Scripting
Custom Commands
# custom_commands.py
import lldb
def print_jni_string(debugger, command, result, internal_dict):
"""
Print Java string from JNIEnv
Usage: print_jni_string <jstring_addr>
"""
target = debugger.GetSelectedTarget()
process = target.GetProcess()
frame = process.GetSelectedThread().GetSelectedFrame()
# Get JNIEnv*
env_ptr = frame.FindVariable("env").GetValueAsUnsigned()
# Get GetStringUTFChars function (offset 169)
jni_funcs = process.ReadPointerFromMemory(env_ptr, lldb.SBError())
get_string_utf = process.ReadPointerFromMemory(jni_funcs + 169*8, lldb.SBError())
# Call GetStringUTFChars
jstring_addr = int(command, 16)
# ... implementation ...
print(f"String: {result}")
def __lldb_init_module(debugger, internal_dict):
debugger.HandleCommand('command script add -f custom_commands.print_jni_string print_jni_string')
print("Custom commands loaded")
Conclusion
LLDB provides comprehensive capabilities for Android native debugging, from basic memory inspection to advanced exploit development. Combining LLDB with Frida, understanding ARM calling conventions, and leveraging Python scripting enables deep analysis of Android native code and systematic vulnerability research.
Modern Android security increasingly depends on native code protections, making LLDB proficiency essential for security researchers and exploit developers.
References
- LLVM Project (2023). “LLDB Documentation”
- Google (2023). “Android NDK Debugging”
- ARM (2022). “ARM64 Procedure Call Standard”
- Google Project Zero (2021). “Android Native Exploitation”