On .NET, parse untrusted XML with XmlReaderSettings.DtdProcessing = Prohibit and XmlResolver = null (both the modern defaults), so any DOCTYPE throws before an external entity is read (CWE-611) or entities expand (CWE-776). Legacy code that opted back in with DtdProcessing.Parse and an XmlUrlResolver is the bug. Do not blocklist '<!DOCTYPE' bytes; a UTF-16 copy evades it.
- Find every XmlReader, XmlReaderSettings, XmlDocument, XmlTextReader, XPathDocument, XmlSerializer, DataSet and LINQ-to-XML load that reads request data.
- Build an XmlReaderSettings with DtdProcessing = DtdProcessing.Prohibit and XmlResolver = null, and pass it to XmlReader.Create; load XmlDocument/XDocument from that reader, never from the raw bytes.
- On modern .NET these are already the defaults; set them explicitly anyway so intent is clear and a later edit cannot re-enable DTDs. Delete any DtdProcessing.Parse or new XmlUrlResolver() left over from a .NET Framework migration.
- If a DTD is genuinely needed, keep DtdProcessing.Ignore or a bounded MaxCharactersFromEntities/MaxCharactersInDocument and still use a null resolver; never attach a live XmlResolver to untrusted input.
- Catch XmlException and return a short 400; do not echo the parser message, and keep the developer exception page off in production.
- Add regression tests with this card's payloads (an external file entity and a small bounded expansion, each as UTF-8 and UTF-16) and cap the request body size.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
// Opt back in to the unsafe behaviour (as legacy code migrated from
// older frameworks often does): parse DTDs and hand over a URL resolver.
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Parse,
XmlResolver = new XmlUrlResolver(),
};With IMPL=vulnerable, 4 of 9 tests fail (4 exploit tests).
The fix: refuse the DTD
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // any DOCTYPE is an error
XmlResolver = null, // nothing to fetch with
MaxCharactersFromEntities = 1024, // bound expansion as well
};With IMPL=fixed, 9 of 9 tests pass.
The fix: parse untrusted bytes
try
{
using var stream = new MemoryStream(xml);
using var reader = XmlReader.Create(stream, settings);
var doc = new XmlDocument();
doc.Load(reader);
return Dom.Extract(doc);
}
catch (Exception e) when (e is XmlException or IOException)
{
// A DOCTYPE lands here ("DTD is prohibited"); do not echo the text.
throw new BadXmlException("XML document type definitions are not allowed", e);
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
private static bool LooksLikeDtd(byte[] xml)
{
// Latin1 keeps every byte as a character, so this is a byte-level search;
// OrdinalIgnoreCase only makes it feel thorough. UTF-16 bytes decode to
// interleaved NULs here, so the ASCII markers below never match.
var text = Encoding.Latin1.GetString(xml);
return text.Contains("<!DOCTYPE", StringComparison.OrdinalIgnoreCase)
|| text.Contains("<!ENTITY", StringComparison.OrdinalIgnoreCase);
}With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: each payload writes a marker file in the test's own temp
/// directory and points an external entity at it, or expands a small bounded
/// entity. The attack must leave no trace in the response and be refused.</summary>
public sealed class ExploitTests : ImportApiTests
{
public ExploitTests(ImportApiFactory factory) : base(factory) { }
private async Task AssertRefused(byte[] payload, string needle)
{
using var response = await PostImport(payload);
var body = await ReadBody(response);
Assert.False(body.Contains(needle, StringComparison.Ordinal),
"XXE leak: the attack output appeared in the response");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static async Task WithMarkerFile(Func<string, string, Task> body)
{
var path = Path.Combine(Path.GetTempPath(), $"xxe-marker-{Guid.NewGuid():N}.txt");
var text = "XXE-FILEMARKER-" + Guid.NewGuid().ToString("N");
await File.WriteAllTextAsync(path, text, TestContext.Current.CancellationToken);
try
{
await body(new Uri(path).AbsoluteUri, text);
}
finally
{
File.Delete(path);
}
}
[Fact]
public Task ExternalEntityFileUtf8() =>
WithMarkerFile((uri, text) => AssertRefused(Utf8(ExtFile(uri)), text));
[Fact]
public Task ExternalEntityFileUtf16() =>
WithMarkerFile((uri, text) => AssertRefused(Utf16(ExtFile(uri)), text));
[Fact]
public Task EntityExpansionUtf8() => AssertRefused(Utf8(Expansion), ExpansionMarker);
[Fact]
public Task EntityExpansionUtf16() => AssertRefused(Utf16(Expansion), ExpansionMarker);
}- Modern .NET (4.5.2+ and all of .NET 5-10) is safe by default: XmlReaderSettings.DtdProcessing is Prohibit and XmlResolver is null. The vulnerability is almost always legacy code that opted back in with DtdProcessing.Parse and a resolver.
- DtdProcessing has three values: Prohibit (throw on DOCTYPE, the safe choice), Ignore (skip it silently), and Parse (process it). Parse plus a non-null XmlResolver is the hole; Ignore is safe-ish but Prohibit is clearer.
- A byte or string blocklist for '<!DOCTYPE'/'<!ENTITY' is bypassed by sending the same document as UTF-16 (or UTF-7); the ASCII bytes are absent but XmlReader honours the BOM/encoding and reads the DTD. Reject at the reader.
- Older XmlTextReader and some XmlDocument/DataSet paths default to a live resolver on legacy frameworks; set XmlResolver = null on those objects too, not just on XmlReaderSettings.
- Entity expansion (CWE-776) is bounded by MaxCharactersFromEntities, but the default is 0 (no limit); Prohibit removes the need, otherwise set a cap.
- CA3075 and Semgrep CE did not flag this opt-in on .NET 10 (verified), so neither a clean analyzer run nor a clean Semgrep scan proves the parser is safe. Read the DtdProcessing/XmlResolver values and run the tests.
- XmlSerializer and DataContractSerializer read a DTD through their internal XmlReader; feed them a reader you configured, not a Stream or TextReader.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csharp-aspnetcore does this for you).
- Each exploit posts a DOCTYPE document (as UTF-8 and UTF-16) whose external entity reads a marker file the test wrote in its own temp dir, or expands a small bounded entity, and asserts the marker/expansion never appears and the request is refused with 400.
- Scanners are no help here: Semgrep CE p/csharp and the .NET CA3075 analyzer both reported nothing on the vulnerable code, so rely on reading DtdProcessing/XmlResolver and on the regression suite; CodeQL cs/xml/insecure-dtd-handling models the pattern if you run it.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/csharp | 0 findings on all three implementations. Semgrep CE has no C# rule that models XmlReaderSettings.DtdProcessing = Parse with a non-null XmlResolver, so the vulnerable opt-in is invisible to it (as with the deserialization C# card). The regression tests are the control.Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/csharp src/Import (Ran 27 rules on 8 files) |
| CodeQL | cs/xml/insecure-dtd-handling | Path-problem query (@id cs/xml/insecure-dtd-handling, tags CWE-611/827/776). Its model flags untrusted XML read with DtdProcessing.Parse and a non-null/insecure XmlResolver, which is exactly the vulnerable and wrong parsers here, and treats DtdProcessing.Prohibit + null resolver (the fixed parser) as safe. Expected to flag vulnerable and wrong and clear fixed.Checked: github/codeql main; not executed |
| .NET analyzer (Microsoft.CodeAnalysis.NetAnalyzers) | CA3075Insecure DTD processing in XML | CA3075 did NOT fire on the vulnerable parser (DtdProcessing.Parse + new XmlUrlResolver() flowing into XmlReader.Create and XmlDocument.Load), confirmed on an isolated probe too. On .NET 10 the built-in XXE analyzer does not match this XmlReaderSettings opt-in, so a clean build is not proof. The regression tests catch it.Checked: .NET SDK 10.0.107; dotnet build -p:EnableNETAnalyzers=true -p:AnalysisMode=All (24 CA warnings, none CA30xx) |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| System.Xml (XmlReader/XmlReaderSettings, in .NET 10) | 10.0.12 |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.2 (Microsoft Testing Platform v2) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xxe/csharp-aspnetcorefolder.
dotnet restore tests/Import.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Import.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Import.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Import.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xxe/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- aspnetcore 10.0.12 (Mvc.Testing)
- xunitV3 4.0.2
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 4 of 9 tests fail (4 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 9/9 pass; IMPL=vulnerable 4 exploit tests fail (both file-read markers leak via DtdProcessing.Parse + XmlUrlResolver, both expansions return 128 bytes); IMPL=wrong 2 exploit tests fail (the UTF-16 file-read and UTF-16 expansion slip past the Latin1 '<!DOCTYPE' blocklist; the UTF-8 pair is blocked).
- Scanners
- Semgrep 1.180.0 from a throwaway venv (../.tools/venv); CA3075 from the .NET 10 SDK analyzers.
- Notes
- Microsoft.AspNetCore.Mvc.Testing 10.0.12 and xunit.v3 4.0.2 are current and >14 days old. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch. Versions and results are true for this date and machine.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP XML External Entity Prevention Cheat Sheet (.NET section)
- .NET API: XmlReaderSettings.DtdProcessing and DtdProcessing enum
- .NET API: XmlReaderSettings.XmlResolver
- CA3075: Insecure DTD processing in XML
- CWE-611: Improper Restriction of XML External Entity Reference
- CWE-776: Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')
- CAPEC-221: Data Serialization External Entities Blowup
Attack patterns: