One file path. No form, no captured traffic, no automated finding anywhere in the pipeline. Here's the
full story of a real CWE-611 (XML External Entity)
vulnerability — the first bug in this series where a static rule, a business-logic rule, and a wired
Active Test probe all come back empty, and every step from proof to report has to be done by hand — all
running locally, with no cloud AI anywhere in the pipeline.
The vulnerable code
The target is a deliberately vulnerable local training app: a hand-rolled XML "entity resolver" — not a
real XML parser, on purpose, so the demo has no native dependencies — that reproduces the classic XXE
pattern any real parser falls into when DTD/external-entity processing isn't explicitly disabled.
function resolveEntities(xml) {
const entities = {};
const entityRe = /<!ENTITY\s+(\w+)\s+SYSTEM\s+"([^"]+)"\s*>/g;
let m;
while ((m = entityRe.exec(xml))) {
const [, name, systemId] = m;
const filePath = systemId.replace(/^file:\/\//, ''); // BUG: no restriction on which file
try {
entities[name] = fs.readFileSync(filePath, 'utf8');
} catch (err) {
entities[name] = `[[failed to resolve entity ${name}: ${err.message}]]`;
}
}
let resolved = xml;
for (const [name, value] of Object.entries(entities)) {
resolved = resolved.split(`&${name};`).join(value);
}
return resolved;
}
app.post('/import', (req, res) => {
const xml = req.body || '';
const resolved = resolveEntities(xml);
const noteMatch = resolved.match(/<note>([\s\S]*?)<\/note>/);
res.json({ note: noteMatch ? noteMatch[1] : null, resolvedXml: resolved });
});
Every declared SYSTEM entity gets read off disk with no allowlist at all, and the whole resolved
document — not just whatever lands inside <note> — comes back in the response. GET / renders
instructions and the exact curl command to try; there's no <form> anywhere on this app, which turns
out to matter for how you have to prove this one.
Step 1 — Why the static rule engine stays silent (and that's not a bug)
Before assuming "no finding" means "broken," it's worth tracing exactly why. The platform's XXE rule is
real and precise — it just targets a different library's exact call shape than this app uses:
parseXml(String)?(…, { noent: true, … }) ← the rule's actual target: libxmljs's opt-in
This app never calls parseXml or parseXmlString at all — it's a regex-based resolver, deliberately, so
the demo needs no native XML dependency. A code-scan pass produces zero CWE-611 findings, exactly as
the rule's own logic predicts. It isn't a clean zero, though: a generic missing-authorization rule flags
both routes (this app, like the rest of the series, has no auth anywhere), landing a real High finding
on POST /import — a second, entirely separate bug on the same route, worth its own line in a write-up
rather than being lost in the noise.
Step 2 — The dynamic/business-logic path comes back empty too
The path that rescued CSRF and SSRF with at least one automated finding — capture traffic through the
built-in proxy, then check it against the business-logic rules — doesn't help here either. Neither the
live HTTP-interceptor rules nor the business-logic analyzer (both run inside the same proxy command)
have ever had an entity/DOCTYPE-aware check written for them. A captured request carrying a full XXE
payload produces the same generic header findings as every other request and nothing else.
Step 3 — Proving it live, with nothing but curl
There's no form to click through — GET / is instructional only — so proof starts at the terminal:
curl -X POST http://localhost:3008/import -H "Content-Type: application/xml" --data-binary @payload.xml
{"note":"FLAG{xxe-demo-secret}","resolvedXml":"...<note>FLAG{xxe-demo-secret}</note>"}
That's the app's own intended file. The next payload proves it isn't confined to it — pointing the
SYSTEM identifier at file:///etc/passwd instead returns that file's contents just as readily. The bug
isn't "leaks one demo secret" — it's unrestricted file disclosure, any file the server process can read.
Step 4 — Escalating from scratch, because there's nothing to seed from
Every prior bug in this series had a captured request to right-click and send into the built-in Repeater.
Not this one — with no form and no proxy-captured traffic, the request has to be typed in by hand from a
blank editor: method, URL, headers, body, all composed manually. A third payload variant makes a point
worth keeping for a write-up:
<?xml version="1.0"?>
<!DOCTYPE root [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> <!ENTITY host SYSTEM "file:///etc/hostname"> ]>
<note>&xxe;</note>
<!-- &host; -->
The substitution is a literal string replace over the whole submitted document, and the response's
resolvedXml field echoes the entire resolved document — not just whatever ends up inside <note>. The
response shows note still contains only /etc/passwd (that field's own regex is scoped tightly), while
resolvedXml contains both files' contents. An attacker doesn't need to route a read through <note>
at all — anywhere in the document is enough, and several files can leak in a single request.
Step 5 — Attempting the platform's own Active Test probe — and finding there isn't one
The platform has two dozen wired Active Test probes: SSTI, GraphQL introspection, JWT verification, mass
assignment, CSRF confirmation, and more. I checked the full checklist and the CLI's --probes alias list
on camera rather than assert it from memory — neither has an XXE entry. Not a blind or limited probe like
SSRF's — genuinely absent. That's the whole phase: confirming an absence is itself the useful result here,
not a consolation prize for a failed run.
Step 6 — Filing the bug's only finding, by hand
With nothing in the pipeline ever creating a Finding object for this bug, the only way it exists in the
platform at all is filing it manually through the Assets hub's "Create finding" tool. Worth knowing before
you hit it yourself: that dialog collects a title, a risk level, and free-text evidence — no CWE or
reference field at all — so the CWE-611 citation has to go into the title text by hand. It's also worth
knowing the resulting finding will always show a "no proof of concept" submission blocker, regardless of
how much evidence you paste in — manual findings never populate the platform's structured proof field,
only free text. Cosmetic, not a real block on a normal report export.
Step 7 — An unexpected second corroboration
Running the platform's generic "Analyze response" tool over the /etc/passwd variant's captured
transaction surfaced something worth keeping: alongside the usual generic header findings, a completely
unrelated rule — a generic filesystem-path-disclosure check — independently flagged the same response,
because it literally contains an absolute path string. Different rule, different reasoning, same
underlying bug — a nice bit of corroborating evidence that cost nothing extra to collect.
Step 8 — A fully offline explanation
With the manually-filed finding's evidence in place, a local LLM generates a plain-language narrative from
it: something like "A hand-rolled XML entity resolver reads an attacker-specified file path from a SYSTEM
entity with no restriction, allowing disclosure of arbitrary files readable by the server process." No
network call is made — and this works identically whether a finding was auto-created by a rule or filed by
hand, since the narrator only ever reads the finding's own fields, never its provenance.
The fix
The demo's own bug is a hand-rolled resolver that never should have resolved external entities in the
first place — the fix isn't a configuration flag here, it's refusing to read SYSTEM identifiers at all:
function resolveEntities(xml) {
if (/<!DOCTYPE/i.test(xml)) {
throw new Error('DOCTYPE declarations are not permitted in submitted XML');
}
return xml; // no external entities are ever resolved
}
For a real XML parser, the equivalent is a configuration setting rather than a rewrite: disable DTD
processing and external-entity resolution outright (most real parsers have an explicit
disallow-doctype-decl / "external entities off" setting — e.g. libxmljs's parseXml(xml, { noent:, or Node's
false, dtdload: false, dtdvalid: false })fast-xml-parser with processEntities: false), or
use a parser that doesn't support DTDs at all for untrusted input — verbatim the fix the demo app's own
README.md suggests.
What's honestly not covered
This is the starkest version of "not covered" in the whole series: no static match, no dynamic/business-
logic match, no Active Test probe at all. That's not three separate failures — it's one honest gap,
demonstrated on camera at every layer rather than glossed over. The static rule's precision (it targets a
real library's real API, just not this app's) is actually the most reassuring part: it isn't guessing,
it's correctly scoped, and this app simply falls outside that scope by design.
Try it yourself
The target app and the full step-by-step playbook (every click, every payload, every panel, plus the exact
reasoning behind each "no finding" result) are linked below if you want to reproduce this end-to-end
against your own local copy.
This is an intentionally vulnerable local training app. Never run these techniques against a
system you don't own or don't have explicit written authorization to test.
Try it yourself → https://github.com/sendwavehub/scan-target-demo-apps
Windows Store https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US
Web Site https://Sendwavehub.tech/en/apps/ai-security-studio-4
Top comments (0)