Skip to content

CWE-91: XML Injection (aka Blind XPath Injection) - C#

Overview

XML Injection in C#/.NET applications occurs when untrusted user input is used to construct XML documents without validation or escaping. Injected special characters (<, >, &, ', ") then become part of the document's structure rather than its text content, letting an attacker add elements the receiving code was never written to handle.

Primary defense: Build XML with LINQ to XML (XDocument, XElement, XAttribute) instead of string concatenation or interpolation - these APIs treat values passed to element/attribute constructors as text content and escape them automatically. Where LINQ to XML isn't practical, escape every interpolated value with System.Security.SecurityElement.Escape(). For XPath, do not concatenate: .NET binds variables through XPathExpression.SetContext() with a custom XsltContext, and where that boilerplate isn't worth it, select a static node set and compare in C#. Parser hardening against external entities is a separate finding - CWE-611 - and does nothing about either sink here.

Common vulnerability scenarios: REST/SOAP endpoints that build XML responses or envelopes with string interpolation, XML configuration files written from user preferences, and XPath queries built by concatenating a search term into the expression.

C#/.NET XML APIs:

  • System.Xml.Linq (LINQ to XML) - modern, recommended API
  • System.Xml - legacy APIs (XmlDocument, XmlWriter)
  • System.Xml.Serialization - attribute-driven XML serialization
  • System.Xml.XPath - XPath queries
  • System.Security.SecurityElement.Escape - manual XML escaping utility

Common Vulnerable Patterns

String Interpolation into XML

// VULNERABLE - user input embedded directly in an XML string
public string CreateUserXml(string username, string email)
{
    string xml = $@"<?xml version=""1.0""?>
<user>
    <username>{username}</username>
    <email>{email}</email>
</user>";
    return xml;
}

// Attack: username = "</username><admin>true</admin><username>"
// Result: <username></username><admin>true</admin><username></username>
// Creates an unintended <admin> element

Why this is vulnerable: No XML special characters are escaped, so a value containing </username> closes the current element early and any markup that follows becomes part of the document structure instead of text content. The same flaw shows up wherever interpolated values land: SOAP envelopes (<UserId>{userId}</UserId> lets an attacker inject <Role>admin</Role>), XML config files, and attribute values (custom="{attrValue}" - a value containing an unescaped " closes the attribute early and lets the attacker append new attributes). XmlDocument.LoadXml() and similar parsers accept whatever string they're given; they don't retroactively escape it.

XPath Query Injection

// VULNERABLE - user input concatenated into an XPath expression
public XmlNodeList FindUserByName(XmlDocument doc, string username)
{
    string xpathExpr = $"//user[name='{username}']";
    return doc.SelectNodes(xpathExpr);
}

// Attack: username = "' or '1'='1"
// XPath becomes: //user[name='' or '1'='1']  -> returns all users

Why this is vulnerable: This is a different sink from markup injection - the attacker isn't adding XML elements, they're changing the boolean logic of the query itself. Escaping XML entities does nothing here because the injection point is inside an XPath string literal, not XML markup.

Secure Patterns

LINQ to XML (Primary Defense)

// SECURE - LINQ to XML escapes element content automatically
using System.Xml.Linq;
using System.Text.RegularExpressions;

// \A and \z, not ^ and $: .NET's $ also matches immediately before a trailing
// newline, so "alice\n" satisfies ^[a-zA-Z0-9._-]{1,100}$
private static readonly Regex UsernamePattern = new(@"\A[a-zA-Z0-9._-]{1,100}\z");
private static readonly Regex EmailPattern = new(@"\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z");

public string CreateUserXml(string username, string email)
{
    if (!UsernamePattern.IsMatch(username))
        throw new ArgumentException("Invalid username");
    if (!EmailPattern.IsMatch(email))
        throw new ArgumentException("Invalid email");

    var doc = new XDocument(
        new XDeclaration("1.0", "utf-8", null),
        new XElement("user",
            new XElement("username", username),  // escaped automatically
            new XElement("email", email)
        )
    );
    // XDocument.ToString() serializes the tree only - it drops the XDeclaration
    // silently. Concatenate it back if the consumer needs one.
    return doc.Declaration + Environment.NewLine + doc;
}

// CreateUserXml("<script>alert('xss')</script>", "test@example.com")
// -> ArgumentException: Invalid username. UsernamePattern rejects the value
//    before LINQ to XML sees it, so nothing is emitted at all.
//
// The escaping is the control that matters for a field with no such format
// constraint. Given a free-text bio element:
//    new XElement("bio", "</bio><admin>true</admin>").ToString()
// -> <bio>&lt;/bio&gt;&lt;admin&gt;true&lt;/admin&gt;</bio>

Why this works: XElement's constructor treats its second argument as text content, not markup, so it escapes for the context the value lands in - &, < and > in element content, and additionally " in an attribute, which is what would otherwise close the attribute early. A value containing </username><admin>true</admin> becomes inert text rather than new elements. LINQ to XML builds an in-memory tree and serializes it, so there is never a raw string for an attacker to break out of. The regex validation adds defense-in-depth by rejecting XML metacharacters before they reach the API and bounding input length. Use XDocument/XElement for documents that fit comfortably in memory; for very large or streamed output, use XmlWriter.

Two details the code above is careful about, both measured on .NET 10. XDocument.ToString() returns only the serialized tree: an XDeclaration passed to the constructor never appears in its output, so replacing a concatenated template with this one silently drops the <?xml ... ?> prolog the template had. doc.Save(TextWriter) does emit a declaration, but takes the encoding from the writer - a StringWriter gives you encoding="utf-16" regardless of what the XDeclaration said. And $ in a .NET regex matches immediately before a final newline, so ^[a-zA-Z0-9._-]{1,100}$ accepts "alice\n"; \A ... \z is the anchor pair that does not.

XmlWriter for Large or Streamed XML

// SECURE - XmlWriter streams output and escapes automatically
using System.Xml;

using System.Text.RegularExpressions;

private static readonly Regex XmlName = new(@"\A[A-Za-z_][A-Za-z0-9._-]*\z");

using var writer = XmlWriter.Create(output, new XmlWriterSettings { Indent = true });
writer.WriteStartDocument();
writer.WriteStartElement("response");
foreach (var (key, value) in data)
{
    // Check the name before writing it. WriteElementString does reject an
    // invalid one, but it does so by putting the writer into an error state
    // from which nothing more can be written
    if (!XmlName.IsMatch(key))
        throw new ArgumentException($"Invalid XML element name: {key}");

    writer.WriteElementString(key, value);  // value escaped automatically
}
writer.WriteEndElement();
writer.WriteEndDocument();

Why this works: WriteElementString/WriteAttributeString escape content the same way LINQ to XML does, but write incrementally instead of building a full DOM tree - use this when exporting XML too large to hold comfortably in memory. Names are a separate matter: only the value is escaped, and the key here is a dictionary key that may have come from a request or a database.

.NET rejects an invalid name itself, which is why the check above is about failure mode rather than injection. WriteElementString("evil><admin>true</admin><x", "v") throws ArgumentException - "Invalid name character" - so a name cannot carry markup into the document. But the writer is then in an error state, and the next call, including WriteEndElement, throws InvalidOperationException: The Writer is closed or in error state. One bad key therefore loses the whole document rather than one element, and it cannot be caught and skipped. Validating first turns that into a rejected key with the rest of the export intact.

The check is also what rejects names that are perfectly valid XML and still wrong, such as a key of admin in a document where that element means something. XElement behaves the same way, throwing XmlException for a malformed name.

Note the anchors: written as ^[A-Za-z_][A-Za-z0-9._-]*$ this check passes a key of "total\n", because .NET's $ matches before a final newline - and a newline is not a valid XML name character, so WriteElementString then throws and poisons the writer, which is the exact failure the check exists to prevent. \A ... \z closes it.

SecurityElement.Escape (Fallback for Manual Construction)

// SECURE - explicit escaping when LINQ to XML isn't practical
using System.Security;

string safeUsername = SecurityElement.Escape(username);
string safeEmail = SecurityElement.Escape(email);
string xml = $"<user><username>{safeUsername}</username><email>{safeEmail}</email></user>";

Why this works: SecurityElement.Escape() escapes all five XML special characters, making it safe to interpolate the result into a string. It is more error-prone than LINQ to XML because every interpolated value needs its own explicit call - miss one attribute a few lines down and that value stays unescaped. Reach for this only when integrating with code that requires an XML string rather than a tree API.

XML Serialization for Object Graphs

// SECURE - XmlSerializer maps typed objects to XML
[XmlRoot("user")]
public class User
{
    [XmlElement("username")] public string Username { get; set; }
    [XmlElement("email")] public string Email { get; set; }
}

var serializer = new XmlSerializer(typeof(User));
using var writer = new StringWriter();
serializer.Serialize(writer, new User { Username = username, Email = email });

Why this works: XmlSerializer escapes property values during serialization, and because only declared properties are ever emitted, an attacker cannot inject an arbitrary <admin> element - there's no code path that writes a field the User class doesn't declare. This is the natural fit for WCF/SOAP clients generated from a WSDL (svcutil), which use XmlSerializer internally and never build envelopes by hand.

XPath: Bind a Variable

// SECURE - the value is bound to $username, not interpolated into the expression
using System.Collections.Generic;
using System.Xml.XPath;
using System.Xml.Xsl;

public XPathNodeIterator FindUserByName(XmlDocument doc, string username)
{
    var nav = doc.CreateNavigator()!;
    var expr = nav.Compile("//user[name=$username]");   // fixed expression
    expr.SetContext(new XPathVariableContext(
        new Dictionary<string, object> { ["username"] = username }));
    return nav.Select(expr);
}

// .NET ships no concrete XsltContext, so the binding needs this adapter once.
sealed class XPathVariableContext : XsltContext
{
    private readonly IReadOnlyDictionary<string, object> _vars;
    public XPathVariableContext(IReadOnlyDictionary<string, object> vars) => _vars = vars;

    public override IXsltContextVariable ResolveVariable(string prefix, string name)
        => _vars.TryGetValue(name, out var v)
            ? new StringVariable(v)
            : throw new ArgumentException($"Unbound XPath variable: ${name}");

    // No extension functions: an expression calling one is rejected, not resolved.
    public override IXsltContextFunction ResolveFunction(
        string prefix, string name, XPathResultType[] argTypes)
        => throw new NotSupportedException($"XPath extension function not available: {name}()");

    public override bool Whitespace => true;
    public override bool PreserveWhitespace(XPathNavigator node) => true;
    public override int CompareDocument(string baseUri, string nextbaseUri) => 0;

    private sealed class StringVariable : IXsltContextVariable
    {
        private readonly object _value;
        public StringVariable(object value) => _value = value;
        public bool IsLocal => false;
        public bool IsParam => false;
        public XPathResultType VariableType => XPathResultType.String;
        public object Evaluate(XsltContext xsltContext) => _value;
    }
}

Why this works: The string handed to Compile() is fixed and holds no attacker-controlled characters. $username resolves to a single value at evaluation time, so quotes, or and function calls inside it are compared as text rather than parsed as XPath syntax. Measured on .NET 10 against a document holding alice, bob and O'Brien: 1 node for alice, 0 for ' or '1'='1', where the interpolated version returns all three. It also accepts values the interpolated form cannot express - XPath 1.0 string literals have no escape sequence for their own delimiter, so O'Brien has no safe spelling inside a '...' literal.

Both overrides throw rather than return null, and that is the difference between a control that fails loudly and one that fails quietly. A $typo in the expression raises ArgumentException: Unbound XPath variable: $typo instead of comparing against nothing and returning an empty result that reads like "no such user". ResolveFunction is only consulted for extension functions, not built-ins - substring(), contains() and the rest of XPath 1.0 still evaluate normally - so throwing there costs nothing and refuses to expose managed code to an expression. Verified on .NET 10; both signatures also compile without nullable-reference warnings under the default <Nullable>enable</Nullable>, which returning null does not.

Note that SetContext mutates the compiled XPathExpression rather than producing a new one. Re-calling it with a fresh context does rebind correctly, but a compiled expression cached in a static field and shared across concurrent requests would have callers overwriting each other's values. Compile per call as above, or Clone() before setting the context.

XPath: Iterate and Compare

// SECURE - no user input in the XPath expression itself
public XmlNode FindUserByNameSecure(XmlDocument doc, string username)
{
    foreach (XmlNode user in doc.SelectNodes("//user"))
    {
        var nameNode = user.SelectSingleNode("name");
        if (nameNode?.InnerText == username)
            return user;
    }
    return null;
}

Why this works: The XPath expression ("//user") is static and contains no user input, eliminating the injection surface entirely; filtering happens afterward with an exact string comparison in C#. This is the pragmatic choice for a one-off lookup: both versions scan the document, but this one materializes every <user> node and walks it in C# instead of filtering inside the evaluator - and it needs none of the XsltContext boilerplate above. Prefer the bound version once more than one call site needs it, or where the predicate is more than a single equality.

Framework-Specific Guidance

ASP.NET Core

Validate query/route parameters before building the response, return a generic XDocument error body on failure (not the raw exception), and set the content type explicitly:

[HttpGet, Produces("application/xml")]
public IActionResult GetUser([FromQuery] string username)
{
    if (!UsernamePattern.IsMatch(username ?? ""))
    {
        // Set the status on the ContentResult. BadRequest(Content(...)) would wrap the
        // ContentResult as the *value* of a BadRequestObjectResult, and the client would
        // get that object serialized as JSON instead of the XML error body.
        var error = Content(new XDocument(new XElement("error", "Invalid username")).ToString(), "application/xml");
        error.StatusCode = 400;
        return error;
    }

    var doc = new XDocument(new XElement("user", new XElement("name", username)));
    return Content(doc.ToString(), "application/xml");
}

Why this works: Model binding gives you the raw parameter before it reaches any XML API, so validating here is the earliest point in the pipeline. Content(..., "application/xml") sets Content-Type explicitly, preventing clients from misinterpreting the response, and the generic error body avoids leaking parser or validator internals. The status goes on the ContentResult rather than through BadRequest(...): that overload takes the value to serialize, so handing it a ContentResult returns a JSON rendering of that object - {"content":"<error>...","contentType":"application/xml",...} - instead of the XML body, measured on .NET 10.

Testing

  • Submit </username><admin>true</admin> to a field with no format constraint (a bio, a comment, a description). Assert on the serialized output, not the HTTP status: the payload must come back as &lt;/username&gt;... text and re-parsing must yield one element, not two. A field guarded by UsernamePattern is rejected before the escaping is exercised, so testing only that field proves nothing about the escaping.
  • Round-trip a value containing all five metacharacters (<script>&"') through build, serialize and re-parse, and assert the text comes back identical. That single assertion catches both an escaper that misses a character and a "fix" that double-escapes.
  • Submit ' or '1'='1 to the XPath lookup and assert the result count matches that of a nonexistent user (zero), not the whole document. The payload only bypasses a single-clause predicate: against a concatenated two-clause //user[name='X' and password='Y'] it has to appear in both fields, because and binds tighter than or.
  • Test normal inputs (valid usernames/emails) to confirm the secure pattern doesn't reject legitimate data, and include O'Brien - a bound query matches it where an interpolated one cannot express it at all.
  • Feed every ^...$ allowlist on the page its own permitted value with "\n" appended and assert it is rejected. .NET's $ matches before a final newline, so ^[a-zA-Z0-9._-]{1,100}$ accepts "alice\n" and \A...\z does not.
  • Test boundary cases: empty strings, maximum-length values, Unicode characters.
  • Confirm attribute values and element names are both covered, not just element text content.
  • Re-scan with the security scanner that produced the original finding to confirm it no longer triggers.

Common Pitfalls

  • Calling SecurityElement.Escape() on the element content built by string interpolation but not on every attribute value in the same template - each interpolated slot needs its own escape call, and it's easy to miss an attribute a few lines down.
  • Fixing the XPath lookup in one method with the iterate-and-compare pattern while a nearby overload or helper still builds a SelectNodes($"...{value}...") expression with the same untrusted value - the injection is closed for the code path that was reviewed, not for the sink itself.

Dependencies and Installation

No external package is required: LINQ to XML (System.Xml.Linq), XmlWriter/XmlDocument (System.Xml), XmlSerializer (System.Xml.Serialization), and SecurityElement.Escape (System.Security) all ship in the .NET base class library.

Additional Resources