seal.zone/challenge: instructions for agents ============================================ What this is ------------ Seal's "Steal the key" challenge. You get your own Linux machine (Ubuntu 24.04, root) for ten minutes. An AI chatbot runs on it and uses a real API key through Seal, which puts the key into each request from outside the machine. The first person to submit the real key wins $5,000, and the challenge stays open after that. Rules (the same as on the page) ------------------------------- - Your machine and the Seal enclave beside it are yours to break. Other visitors' machines and enclaves, the rest of Seal's servers, Azure itself and anyone else are not. - One machine at a time per person. - If you are an agent acting for someone, tell them what you are about to do, and keep to these rules. The short version ----------------- All of it is plain HTTPS. There is no login and nothing to solve; your ticket is your place. 1. POST https://seal.zone/api/challenge/join -> {"ticket":"", ...} 2. GET https://seal.zone/api/challenge/status header x-ticket: poll every ~2.5 s until "phase":"active" -> {"phase":"active","secondsLeft":600,"instance":"https://box.seal.zone//"} (a machine takes about 1 to 3 minutes) 3. The machine: chat page root shell wss://box.seal.zone//shell/ws (WebSocket subprotocol "tty") (the same address as the instance, with https -> wss and shell/ws on the end) 4. Submit a key: POST https://seal.zone/api/challenge/claim header x-ticket, body {"key":"..."} 5. Give the machine back: POST https://seal.zone/api/challenge/leave header x-ticket The ticket goes in the x-ticket header, never in a URL. Keep it: it is your session. Endpoints in detail ------------------- join 200 {"ticket", "status":{...}} 429 {"error":"duplicate"} you already hold the 2 places an address may have status {"phase":"waiting","position":N,"etaSeconds":N} in line; at most 20 machines run at once {"phase":"starting"} a machine is being made for you {"phase":"active","secondsLeft":N,"instance":URL} ready; the ten minutes started {"phase":"over","reason":"expired|left|failed|unknown"} A waiting visitor who stops asking is dropped after 90 s, so keep polling. If a machine fails to start ("failed"), join again. claim 200 {"result":"won","code":"<8 chars>","first":true|false} first=true took the prize; quote the code to harvey@seal.zone 200 {"result":"no"} not the key 429 {"result":"slow"} one try every 1.5 s 429 {"result":"too-many"} 30 tries per machine 403 {"result":"no-session"} that machine's time is up (a key is still accepted for 2 minutes after) 400 {"error":"key"} empty, or over 512 characters Only the first correct key takes the prize; later ones are still recognised (first=false). leave frees your place or ends your machine early. Otherwise it ends by itself after 10 minutes. The shell --------- The shell is a ttyd terminal, a login shell as root. Its WebSocket protocol, with the "tty" subprotocol: first message, text: {"AuthToken":"","columns":200,"rows":50} you send: "0" + keystrokes (end a command with "\r") "1" + {"columns":N,"rows":N} resize you receive: "0" + terminal output (with escape codes, as a terminal gives them) "1" + window title "2" + preferences (JSON) Frames may be text or binary; decode binary as UTF-8. A working script (Node 22 or newer; it uses the built-in fetch and WebSocket). It takes a machine, runs each argument as a shell command and prints the output and exit code, then gives the machine back: node seal-challenge-shell.mjs "id" "ls /opt/challenge/chatbot" ----8<---- seal-challenge-shell.mjs ----8<---- const API = "https://seal.zone/api/challenge" const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) // Terminal output without its escape codes (colours, title-setting) or carriage returns. export const clean = (t) => t.replace(/\x1b\][^\x07]*\x07/g, "").replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, "").replace(/\r/g, "") // What command number `i` printed, and its exit code, once its marker has come back; else null. // (The shell also echoes the command line, which holds "__done_N__$?" and not the digits, so only // the real marker matches.) export function extract(buffer, i) { const out = clean(buffer) const m = out.match(new RegExp(`__done_${i}__(\\d+)`)) return m && { output: out.slice(out.indexOf("\n") + 1, m.index).trim(), exit: m[1] } } async function main(commands) { // 1. Take a place in line. The ticket is your place and your session. const joined = await (await fetch(`${API}/join`, { method: "POST" })).json() if (!joined.ticket) throw new Error(`could not join: ${JSON.stringify(joined)}`) const H = { "x-ticket": joined.ticket } const leave = () => fetch(`${API}/leave`, { method: "POST", headers: H }).catch(() => {}) try { // 2. Ask until the machine is ready. Asking is also how the line knows you are still there. let st for (;;) { st = await (await fetch(`${API}/status`, { headers: H })).json() if (st.phase === "active") break if (st.phase === "over") throw new Error(`no machine: ${st.reason}`) await sleep(2500) } console.error(`machine ready, ${st.secondsLeft}s left: ${st.instance}`) // 3. Open the shell. const ws = new WebSocket(st.instance.replace(/^http/, "ws") + "shell/ws", ["tty"]) ws.binaryType = "arraybuffer" let buffer = "" ws.onmessage = (e) => { const text = typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data) if (text[0] === "0") buffer += text.slice(1) } await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = () => no(new Error("shell websocket failed")) }) ws.send(JSON.stringify({ AuthToken: "", columns: 200, rows: 50 })) // 4. Run each command; read until its marker comes back. await sleep(1500) for (const [i, cmd] of commands.entries()) { buffer = "" ws.send("0" + `${cmd}; echo __done_${i}__$?\r`) let r = null for (let t = 0; t < 240 && !r; t++) { await sleep(250); r = extract(buffer, i) } console.log(`$ ${cmd}\n${r ? `${r.output}\n[exit ${r.exit}]` : "(no answer in 60 s)"}\n`) } ws.close() } finally { await leave() } } if (process.argv[1]?.endsWith("seal-challenge-shell.mjs")) await main(process.argv.slice(2)) ----8<---- end ----8<---- What the machine is ------------------- Ubuntu 24.04, you are root, and the internet is open. The chat page and the shell are the only things served to the outside. The chatbot is a small Node program in /opt/challenge/chatbot (its source and its system prompt are there to read). The machine is on its own network. Next to it, not on it, runs your own Seal enclave (a confidential container on AMD SEV-SNP); its address is SEAL_ENDPOINT in /etc/challenge/seal.env, port 8443 only. The key is used on the machine, but the enclave puts it into each request from outside it, so it is never there to read. That is the whole point; getting it out anyway is the challenge. Be kind to the line: when you are done, POST /leave so the next visitor gets the machine.