Let’s talk
Back to Blog

How to Audit a .NET Website for Injection Vulnerabilities

October 13, 202615 min read

An evidence-based guide to finding dangerous data flows, testing them safely, and fixing the underlying code.

Injection vulnerabilities happen when an application treats outside data as instructions.

A search term becomes part of a SQL statement. A customer's name becomes JavaScript. An uploaded filename becomes a server path. A value written to a log changes how an operator interprets the record.

Modern .NET provides useful protections, but those protections depend on how you use its APIs. A website can use Entity Framework, Razor and structured logging and still contain an injection vulnerability somewhere in the application.

A useful audit follows data through the entire system, including where it is stored and used later.

No tool or methodology can guarantee it catches everything. The practical goal is to account for every meaningful input, every dangerous destination and every connection between them—and document what you could not verify.

What can an injection vulnerability do?

The consequences depend on what interprets the input and which permissions it has.

VulnerabilityWhat interprets the input?Possible harm
SQL injectionDatabase engineUnauthorized reading, modification or deletion of records
Cross-site scripting, or XSSAnother person's browserAltered pages, stolen accessible data, actions performed through the victim's session
Command injectionShell or operating-system toolFile access, process execution or server compromise
Template injectionServer-side template engineUnintended expression evaluation, sometimes code execution
Log injectionLog formatter, collector or viewerMisleading records or manipulated operator displays
Header injectionHTTP or mail-processing componentsAltered response or message structure, where a parser permits it
Query-language injectionLDAP, XPath, search or reporting engineChanged filters, unauthorized results or unintended operations

A thorough review should also examine related boundaries such as path traversal, server-side request forgery, unsafe deserialization and spreadsheet-formula injection. They are not all the same vulnerability, but they share a useful audit question:

Can someone make this value do more than the application intended?

OWASP's Injection Prevention guidance covers this broader family of interpreters.

1. Establish exactly what you are auditing

Before searching for suspicious code, record the source revision and system boundaries.

From the repository root:

git rev-parse HEAD
git status --short
git ls-files
dotnet --info

These commands identify the commit, local modifications, tracked files and installed .NET toolchain. They do not establish which version is deployed.

Write down:

  • Application framework: Razor Pages, MVC, Blazor, Web API, legacy ASP.NET or a combination.
  • Database engines and data-access libraries.
  • Public, member, administrator and background-worker entry points.
  • Reverse proxies, CDNs and separately served static files.
  • Email, payment, identity and reporting integrations.
  • Upload, import, export and document-generation features.
  • Deployment scripts and scheduled jobs.
  • Code or infrastructure outside the repository.

For older applications, include .aspx, .ascx, .vb, web.config, HTTP handlers and modules. Do not assume an ASP.NET Core search covers an ASP.NET Framework application.

Also identify the people whose data crosses boundaries. A support message submitted by a visitor and displayed to an administrator is more security-sensitive than text displayed only to its author.

2. Prepare a test environment that cannot harm the real site

Source inspection can begin immediately. Runtime testing needs more preparation.

Use a disposable environment with synthetic accounts and data. Replace email, payments, webhooks and other external services with captured or mocked implementations.

Before starting the application, inspect its startup code. A background worker may begin sending messages, processing payments or cleaning up files as soon as the host starts.

The environment should have:

  • No production credentials or copied customer database.
  • Restricted networking and no unintended outbound services.
  • Dedicated writable directories and a disposable database.
  • Resource and execution-time limits.
  • A known cleanup procedure.

A local address does not make a test safe. An application listening on localhost can still connect to a production database.

Similarly, a container is not automatically isolated if it inherits secrets, mounts your home directory or exposes host services.

3. Build an input-to-destination map

Security reviewers often call an input a source and a dangerous destination a sink.

Sources include more than form fields:

  • Query strings, request bodies and headers.
  • Uploaded filenames and file contents.
  • Cookies and browser storage.
  • WebSocket messages.
  • Webhook payloads.
  • Third-party API responses.
  • Database records originally created by another person.
  • Imported spreadsheets.
  • Administrator-maintained text.
  • Queue messages processed later.

Sinks include SQL execution, HTML rendering, JavaScript execution, shell commands, file operations, outbound requests and log output.

Create a small coverage table:

InputIntermediate stepsFinal destinationWho could be affected?
Support messageDatabase → admin pageBrowser renderingAdministrator
Display nameDatabase → email templateTemplate rendererEmail recipient
Search termController → repositoryDatabase queryOther customers' records
Uploaded documentStorage → conversion workerExternal processApplication server

This catches second-order injection: data enters safely, is stored safely, and becomes dangerous only when another feature reuses it.

4. Search systematically—but treat matches as leads

The following examples use PowerShell and rg, the ripgrep search tool. Run them from the repository root.

Define a helper that excludes common generated directories:

function Find-Code {
    param([string]$Pattern)

    rg -n --hidden `
      -g '!**/.git/**' `
      -g '!**/bin/**' `
      -g '!**/obj/**' `
      -g '!**/node_modules/**' `
      -g '*.cs' -g '*.cshtml' -g '*.razor' `
      -g '*.vb' -g '*.aspx' -g '*.ascx' `
      -g '*.js' -g '*.ts' `
      -g '*.ps1' -g '*.py' -g '*.sh' -g '*.bat' `
      -- $Pattern .
}

Find likely entry points:

Find-Code 'Request\.(Query|Form|Headers|Body)|IFormFile|\[BindProperty'
Find-Code '\[From(Query|Body|Form|Header)\]|Map(Get|Post|Put|Patch|Delete)'
Find-Code 'Hub<|:\s*Hub|OnConnectedAsync|JsonDocument\.Parse'

Then search each sink category below.

Search results are not findings. Conversely, no results do not prove absence. Check whether:

  • Git ignore rules concealed a relevant file.
  • Your application uses another language or extension.
  • A wrapper hides the actual operation.
  • Generated code or a separate service owns the sink.
  • The output was truncated.
  • An API has a different name in your framework version.

Do not use an unrestricted secret-file search and paste its contents into the audit report.

5. Audit SQL and other database queries

Search for raw execution and string-based query APIs:

Find-Code 'FromSql|ExecuteSql|SqlQuery|CommandText|SqlCommand|NpgsqlCommand'
Find-Code 'QueryAsync|ExecuteAsync|DynamicExpressionParser'

An unsafe SQL Server example:

var sql =
    $"SELECT * FROM Customers WHERE Email = '{email}'";

var customers = await db.Customers
    .FromSqlRaw(sql)
    .ToListAsync();

The application has mixed a value with SQL syntax.

Prefer a normal LINQ query:

var customers = await db.Customers
    .Where(c => c.Email == email)
    .ToListAsync();

When SQL is necessary, preserve parameterization:

var customers = await db.Customers
    .FromSqlInterpolated(
        $"SELECT * FROM Customers WHERE Email = {email}")
    .ToListAsync();

The interpolation is passed directly to an API that creates parameters. It is not first converted into an ordinary SQL string.

FromSqlRaw is not automatically vulnerable: it can also receive explicit parameters correctly. Review its arguments, not just its name. Microsoft explains these distinctions in its EF Core SQL-query documentation.

Pay special attention to identifiers. Parameters represent values; they do not generally represent table names, column names or sort directions.

Use fixed branches:

query = sort switch
{
    "name" => query.OrderBy(c => c.Name),
    "newest" => query.OrderByDescending(c => c.CreatedAt),
    _ => query.OrderBy(c => c.Id)
};

Review stored procedures too. A parameterized call can reach a procedure that concatenates its input into dynamic SQL internally. Database permissions should also limit the consequences of a defect. See OWASP's SQL injection guidance.

Verification: capture commands and parameters in a synthetic test. Confirm the supplied value remains a parameter and cannot change the selected records. Use the production database engine for provider-specific behavior; SQLite is not a complete substitute for SQL Server or PostgreSQL.

6. Audit Razor, HTML and JavaScript separately

Search for rendering escape hatches and browser execution sinks:

Find-Code 'Html\.Raw|HtmlString|MvcHtmlString|MarkupString'
Find-Code 'innerHTML|outerHTML|insertAdjacentHTML|document\.write'
Find-Code '\.html\s*\(|eval\s*\(|new Function|javascript:'
Find-Code 'on(click|submit|change|error|load)\s*='

Ordinary Razor text rendering encodes values:

<p>@Model.Comment</p>

This bypasses that protection:

<p>@Html.Raw(Model.Comment)</p>

For plain text, remove the raw rendering. If the feature genuinely requires rich HTML, use a maintained HTML sanitizer with a narrowly defined policy.

Another dangerous pattern is inserting a value directly into an event handler:

<button onclick="showDetails('@Model.DisplayName')">
    Details
</button>

HTML encoding is not sufficient protection for this nested JavaScript context: the browser decodes character references while processing the attribute.

Keep the value as data:

<button type="button"
        class="details-button"
        data-name="@Model.DisplayName">
    Details
</button>
document.querySelectorAll(".details-button").forEach(button => {
    button.addEventListener("click", () => {
        document.getElementById("details").textContent =
            button.dataset.name;
    });
});

Microsoft's ASP.NET Core XSS guidance specifically addresses these context differences.

For live updates, follow the data after fetch() or a WebSocket event. JSON does not make this safe:

container.innerHTML = message.body;

For plain text:

container.textContent = message.body;

Also trace values into links and image URLs. Encoding an attribute does not establish that its URL scheme or destination is permitted. A script policy can provide additional protection, but it does not replace safe rendering. See OWASP's XSS prevention guidance.

Verification: test initial server rendering, live refresh, dialogs, administrator views and exports separately. Use one account to create content and another to view it.

7. Check JSON embedded in HTML

Search for customized encoders:

Find-Code 'JsonSerializer\.Serialize|UnsafeRelaxedJsonEscaping'

JSON returned by an API and JSON embedded inside an HTML <script> element have different surrounding parsers.

Do not manually concatenate JSON. Review whether serializer settings preserve protection against HTML-significant characters when output is embedded in a page.

Microsoft warns against placing output produced with UnsafeRelaxedJsonEscaping into HTML or a <script> element. See its System.Text.Json encoding documentation.

Test delimiter-containing values through the actual rendered page, not only by calling the serializer in isolation.

8. Audit command execution and template engines

Search:

Find-Code 'Process\.Start|ProcessStartInfo|UseShellExecute'
Find-Code 'cmd\.exe|powershell\.exe|pwsh|/bin/sh|/bin/bash'
Find-Code 'Invoke-Expression|shell=True|os\.system'
Find-Code 'RazorLight|Scriban|Liquid|CSharpScript|ScriptEngine'

The preferred fix for command injection is often to avoid an external command entirely. Use a .NET library for the operation where practical.

When a tool is required, use a fixed executable and separate arguments:

var start = new ProcessStartInfo
{
    FileName = "/opt/example/bin/image-worker",
    UseShellExecute = false,
    RedirectStandardError = true
};

start.ArgumentList.Add("--input");
start.ArgumentList.Add(validatedInputPath);

ArgumentList handles argument escaping, but it does not decide whether an argument is safe for the tool. An input might still select an unintended option, file or operation. Microsoft documents the API in ProcessStartInfo.ArgumentList.

Setting UseShellExecute = false does not solve the problem if you explicitly launch a shell with constructed command text. Review executable selection, argument semantics, environment, working directory and privileges. OWASP's command-injection guidance explains why avoiding shell interpretation is only part of the defense.

For templates, distinguish supplying data to a fixed template from allowing someone to supply template source. Search for compilation and evaluation paths, including email and document-generation workers.

9. Audit file paths, uploads and archive extraction

Search:

Find-Code 'Path\.Combine|GetFullPath|File\.|Directory\.'
Find-Code 'ExtractToDirectory|ExtractToFile|ExtractToFileSystem'
Find-Code 'FileName|ContentDisposition|ContentType'

Ask:

  • Does the browser choose the stored filename?
  • Can an absolute path override the intended directory?
  • Are .., alternate separators or repeated decoding relevant?
  • Can an archive entry escape its extraction directory?
  • Can a symbolic link redirect an otherwise valid path?
  • Is validation performed again when a cleanup worker uses a stored path?

Prefer application-generated identifiers and filenames. Check containment using path-component boundaries and the target filesystem's semantics; a naïve string-prefix check is insufficient.

For images, verify actual content rather than trusting extensions or MIME headers. Where appropriate, decode and re-encode accepted formats. Enforce byte and decoded-dimension limits. These controls reduce risk but do not establish that the image library itself has no vulnerabilities. See OWASP's file-upload guidance.

10. Review XML and deserialization

Search:

Find-Code 'XmlReader|XmlDocument|XDocument|XmlResolver|DtdProcessing'
Find-Code 'BinaryFormatter|NetDataContractSerializer|TypeNameHandling'
Find-Code 'Deserialize|SerializationBinder|Type\.GetType'

For XML that does not need DTDs or external resources, make that restriction explicit:

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
    MaxCharactersInDocument = 1_000_000
};

using var reader = XmlReader.Create(inputStream, settings);
var document = XDocument.Load(reader);

The size limit is illustrative; choose one appropriate to the feature. Review framework version and the actual parser configuration rather than assuming all XML APIs have identical defaults. See OWASP's XXE guidance.

For deserialization, prefer fixed expected types. Do not allow incoming data to select arbitrary runtime types.

Microsoft explicitly states that BinaryFormatter is insecure and cannot be made secure. Treat its presence as a migration concern, while verifying whether the path is reachable and which runtime actually executes it. See Microsoft's BinaryFormatter analysis guidance.

11. Trace outbound URLs, redirects and external queries

Search:

Find-Code 'HttpClient|GetAsync|PostAsync|SendAsync|WebRequest'
Find-Code 'Redirect\(|LocalRedirect|Location|Request\.Host'
Find-Code 'GraphQL|XPath|DirectorySearcher|Ldap|Filter\s*='

Separate three questions:

  1. Can input change the destination?
  2. Can it change the path or query structure?
  3. Can it change the meaning of a query sent to that destination?

For server-side requests, prefer fixed service origins and encoded values. If arbitrary destinations are a feature requirement, address resolved IP ranges, redirects and DNS behavior—not just the original hostname string. An initial allowlisted URL is insufficient if a followed redirect reaches a forbidden destination. See OWASP's SSRF prevention guidance.

For GraphQL, LDAP, XPath and search engines, use their parameter or variable mechanisms where available. Restrict selectable fields and operators to a defined set.

For password-reset and verification links, examine how the application chooses its public origin. An encoded token can still be delivered to the wrong host if the origin comes from an untrusted request header.

12. Inspect email, logs and spreadsheet exports

Search:

Find-Code 'MailMessage|MailAddress|Headers\.Add|ReplyToList'
Find-Code 'Log(Information|Warning|Error|Critical|Debug)'
Find-Code 'CsvWriter|text/csv|StringBuilder|WriteAllText'

For email, trace stored fields into later replies as well as immediate notifications. Validate single-mailbox fields and use typed mail APIs. Do not claim a header-injection vulnerability merely because a subject is user-controlled: establish whether the actual API accepts an input that changes message structure.

For logs, distinguish structured templates from presentation:

logger.LogWarning("Login failed for {Identity}", identity);

The identity does not become a new message template. However, control characters may still matter to a downstream formatter or viewer.

Inspect the entire logging pipeline. A harmless dummy marker containing a newline should remain clearly identifiable as part of one untrusted field—not appear as a separate trusted event. Preserve useful evidence without recording passwords, tokens or unnecessary personal data. See OWASP's logging guidance.

For CSV exports, ordinary CSV quoting addresses delimiters; it does not necessarily prevent spreadsheet formula interpretation. Test exported cells in the spreadsheet applications you support, and deliberately preserve untrusted values as text.

13. Test behavior, not just suspicious strings

Build a small regression corpus:

InputWhat it checks
O'ReillyApostrophes remain legitimate data
<b data-audit-marker="1">sample</b>Text does not become unintended markup
Quotes, ampersands and backslashesOutput-context handling
line one followed by a newline and DUMMY_EVENTLog and header handling
../outside.txtPath containment in disposable fixtures
An absolute pathRejection of caller-selected destinations
Maximum-length and over-limit valuesConsistent input boundaries
Encoded variantsAgreement between parsing layers

A markup test alone does not prove JavaScript execution. Where execution must be established, use a harmless local marker in a disposable browser fixture, without network requests or data access.

For a plain-text browser element, an illustrative Playwright assertion is:

var displayed = page.Locator("[data-test=comment-body]");

Assert.Equal(input, await displayed.TextContentAsync());
Assert.Equal(
    0,
    await displayed.Locator("[data-audit-marker]").CountAsync());

The application should have accepted the value through its real input path first. Changing the DOM directly does not test the application's handling.

ASP.NET Core's WebApplicationFactory and TestServer can exercise routing, binding, middleware and services with substituted dependencies. They are not security sandboxes by themselves. Microsoft documents the setup in Integration tests in ASP.NET Core.

An example command, after creating and restoring your own test project in the controlled environment:

dotnet test tests/Security.Tests/Security.Tests.csproj `
  --no-restore `
  --filter "FullyQualifiedName~Injection"

That path and filter are placeholders. --no-restore does not prevent application code from accessing the network or other files.

14. Use scanners and dependency reports as supporting evidence

For .NET 10:

dotnet package list --project YourApp.csproj `
  --include-transitive --vulnerable --no-restore

For .NET 9 and earlier:

dotnet list YourApp.csproj package `
  --include-transitive --vulnerable

These commands check reported dependency vulnerabilities, not your application's complete injection behavior. Vulnerability lookup can contact configured feeds; --no-restore is not an offline guarantee. See Microsoft's package-list documentation.

Use static analysis to find additional source paths. Use a web scanner against an authorized disposable deployment to inspect reachable responses.

Understand what the scanner actually does. For example, ZAP's baseline scan spiders the target and performs passive analysis; it does not perform active attacks. It still makes requests. A clean baseline report therefore cannot establish that injection attempts fail. See ZAP's baseline documentation.

Authenticated and second-order paths usually need deliberate workflows: submit as one role, process as another, then inspect the eventual output.

15. Require evidence before calling something a vulnerability

For every candidate, record:

  • The lower-trust person or system controlling the value.
  • The real entry point.
  • Every significant transformation and stored copy.
  • The final interpreter or sensitive operation.
  • Existing validation, encoding, authorization and containment.
  • The conditions required to reach the sink.
  • The smallest demonstrated harmful result.
  • Exact source locations.
  • The fix and regression test.
  • Anything still unknown.

Use separate outcomes:

OutcomeMeaning
ConfirmedEvidence establishes the boundary violation and claimed result
Needs validationA concrete source path exists, but a decisive fact remains unknown
RejectedA control or incorrect assumption defeats the proposed finding
Hardening recommendationAdditional protection without an established vulnerability

A database exception is not proof of SQL injection. A suspicious string in a response is not proof of XSS. A missing CSP is not proof that an attacker can execute JavaScript.

Ask a second reviewer to try to disprove important findings.

16. Fix the interpreter boundary—and keep the regression test

The strongest fixes address the point where data could become instructions:

  • Parameterize database values.
  • Choose dynamic identifiers from fixed mappings.
  • Preserve context-appropriate output encoding.
  • Use text rendering for plain text.
  • Avoid shell construction and restrict tool arguments.
  • Generate storage names and enforce filesystem containment.
  • Use fixed outbound destinations where possible.
  • Deserialize into expected types.
  • Preserve log event boundaries through the actual viewer.

After a fix, rerun the original reproduction and legitimate edge cases. A name containing an apostrophe should not become unusable because the application previously handled quotes incorrectly.

Maintain the coverage table as the site changes. New exports, background jobs, administrator tools and integrations create new places where old data may acquire a different meaning.

A credible audit ends with a specific statement:

“We reviewed these inputs and destinations, tested these behaviors on this revision, confirmed these findings, and still need this evidence.”

That statement is more useful—and more defensible—than “the scanner found nothing” or “we caught everything.”

Need help building this?

I turn ideas like these into production-ready software. Let's talk about your project.

Get a Free Quote