12 min read

White-Box Testing
Intermediate
A05 A08

C# and .NET Security

.NET has spent a decade removing its sharpest edges, which changes what a review is looking for. The deserialization finding that was critical in 2016 is now usually a .NET Framework project nobody migrated, and the injection finding is usually a report query written before Dapper arrived. Read the target framework first, then the code.

Two runtimes, two rule sets

.NET Framework (4.8) and modern .NET (8, 9, 10) differ in ways that matter for review: BinaryFormatter exists in one and throws in the other, path handling changed, and the default TLS and crypto primitives differ. .NET 10 is the current LTS; .NET 8 and 9 reach end of support on 10 November 2026, so a project on 8 is a finding in itself.

ADO.NET and EF Core injection

ADO.NET will execute whatever string you give it, and EF Core offers two raw-SQL methods whose names differ in safety by one word. FromSqlInterpolated and the interpolated overload of FromSql bind their arguments; FromSqlRaw does not, and passing it an interpolated string is the exact mistake it exists to allow.

UserRepository.cs
public User Find(string name)
{
    var cmd = new SqlCommand(
        "SELECT * FROM Users WHERE Name = '" + name + "'", connection);
    return Read(cmd);
}

// EF Core escape hatch, same defect
db.Users.FromSqlRaw("SELECT * FROM Users WHERE Name = '" + name + "'");
public User Find(string name)
{
    var cmd = new SqlCommand(
        "SELECT * FROM Users WHERE Name = '" + name + "'", connection);
    return Read(cmd);
}

// EF Core escape hatch, same defect
db.Users.FromSqlRaw("SELECT * FROM Users WHERE Name = '" + name + "'");

Two details worth checking beyond the obvious concatenation: string parameters bound without a size (which lets the server truncate or pad and defeats some query plans), and ORDER BY clauses assembled from a column name taken from the request, which cannot be parameterised and needs an allowlist.

Deserialization

BinaryFormatter was removed in .NET 9. In .NET 9 and later the type still exists so old code compiles, but every call throws PlatformNotSupportedException, and the compatibility switch that used to re-enable it is gone. That means the classic "BinaryFormatter deserialization" finding is now almost always one of two things: a .NET Framework 4.8 project, or a project referencing the System.Runtime.Serialization.Formatters compatibility package on an older runtime.

The live risk on modern .NET is Newtonsoft.Json with TypeNameHandling enabled. It reads type names from the payload and constructs them, which is the same primitive BinaryFormatter provided. System.Text.Json is safe by default and only becomes risky when a polymorphic converter is added without a type allowlist.

PayloadReader.cs
// types from the payload are honoured
var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.All
};
var obj = JsonConvert.DeserializeObject<Payload>(json, settings);
// types from the payload are honoured
var settings = new JsonSerializerSettings
{
    TypeNameHandling = TypeNameHandling.All
};
var obj = JsonConvert.DeserializeObject<Payload>(json, settings);

XML and path handling

XXE in .NET is a configuration question. XmlDocument and XmlReader honour DTDs when DtdProcessing is enabled and an XmlResolver is present; the safe configuration sets DtdProcessing.Prohibit and a null resolver. Since the framework defaults changed, a finding here usually means an explicit setting someone added to "make the legacy feed work".

Path traversal survives in .NET because Path.Combine has two behaviours that look alike: it returns the second argument untouched when that argument is rooted, and it does not normalise. A StartsWith check on the combined string therefore passes for a traversal, and fails for a legitimate sibling directory whose name shares a prefix — C:\data\public2 starts with C:\data\public.

FilesController.cs
// the user controls the whole path
var file = Path.Combine(root, Request.Query["name"]);
return PhysicalFile(file, "application/octet-stream");

// and a check that looks like containment but is not
if (!file.StartsWith(root)) throw new Exception("blocked");
// the user controls the whole path
var file = Path.Combine(root, Request.Query["name"]);
return PhysicalFile(file, "application/octet-stream");

// and a check that looks like containment but is not
if (!file.StartsWith(root)) throw new Exception("blocked");

Authorisation

ASP.NET Core authorises by default only if you configure a fallback policy. Without one, an action with no attribute is anonymous, and the defect is invisible in the controller — the file looks ordinary. The audit is two steps: find the fallback policy in Program.cs, then check every action that opted out.

DocumentsController.cs
[ApiController]
[Route("api/documents")]
public class DocumentsController : ControllerBase
{
    // no [Authorize]: the global fallback decides, and it is not configured
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
        => Ok(await db.Documents.FindAsync(id));
}
[ApiController]
[Route("api/documents")]
public class DocumentsController : ControllerBase
{
    // no [Authorize]: the global fallback decides, and it is not configured
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(int id)
        => Ok(await db.Documents.FindAsync(id));
}

Resource-based authorisation belongs inside the handler or in an IAuthorizationHandler that receives the resource. A policy that checks a role proves the caller may use the endpoint; it never proves this row is theirs.

Crypto and secrets

The recurring defects are SHA256 with a hand-rolled salt used for passwords, AES in CBC mode with a static IV, and secrets in appsettings.json or in a user secret that shipped to production. Configuration should come from Azure Key Vault, AWS Secrets Manager or environment variables injected at deploy; a connection string with a password in appsettings.json is a finding regardless of whether the repository is public.

Check Expected Where to look
The target framework of every project is known net10.0, net8.0, net48 or netstandard2.0 recorded per project — the deserialization and path findings depend on it The csproj files, or dotnet list package
No SQL string is built from a variable SqlCommand uses parameters, Dapper uses anonymous parameter objects, EF Core uses LINQ or interpolated SQL with parameters grep for CommandText and ExecuteSqlRaw
BinaryFormatter is absent, or the reason it survives is recorded Not present in net9.0+ projects at all; in net48 projects, an inventory of what still deserializes untrusted input The package and project references
Every controller action has an explicit authorisation decision Fallback policy or [Authorize] at the controller level, [AllowAnonymous] only where intended, and a resource check inside the handler Program.cs and the controller

Evidence Collection

For .NET, the framework version is part of the finding. Record it before anything else:

Target framework per project: net48 and net10.0 have different defaults for serialization, path handling and TLS. A finding that is true for one may be closed by the runtime in the other.

The serializer in use, and its settings: Newtonsoft with TypeNameHandling, System.Text.Json with a polymorphic converter, BinaryFormatter via a compatibility package — they fail differently.

The decoded value reaching the sink: Path.GetFullPath output, the resolved connection string, the deserialized type name. Show the value, not the intention.

False Positive Identification

  • The parameter is bound as a typed value: Dapper anonymous objects and SqlParameter assignments are bound. A concatenation of constant fragments around them is not injection.
  • The serializer never sees untrusted data: Cache or session payloads written by the same process with a signed envelope are not an input path. The finding is only real where bytes cross a trust boundary.
  • The path is resolved against a single allowed file: Path.Combine with a name that is used only for logging, or a fixed file chosen by an integer id, is not traversal.
  • The authorisation is in a filter: ASP.NET Core resource-based handlers and endpoint filters can enforce authorisation without an attribute on the action. Read Program.cs before filing.

C# and .NET Review Checklist

Data access

  • ☐ Every SqlCommand uses parameters, with explicit types and sizes for strings.
  • ☐ FromSqlRaw and ExecuteSqlRaw are reached only with constants or parameter objects.
  • ☐ Connection strings come from configuration with least-privilege accounts.

Deserialization

  • ☐ No BinaryFormatter in net9.0+ projects; net48 projects have an inventory of untrusted deserialization.
  • ☐ Newtonsoft TypeNameHandling is None or SerializationBinder-bound.
  • ☐ System.Text.Json polymorphism (JsonDerivedType) is restricted to a known set.

Web and authorisation

  • ☐ A global fallback policy exists, so a new controller is protected by default.
  • ☐ Resource-based checks run inside the handler for every object read or written.
  • ☐ Anti-forgery tokens are validated on state-changing browser requests.
  • ☐ Developer exception pages and detailed errors are off in production.

Crypto and files

  • ☐ Password hashing uses ASP.NET Core Identity or a current KDF, not SHA256 with a salt.
  • ☐ File paths are normalised and compared with a separator boundary.
  • ☐ XML parsing uses a hardened XmlReaderSettings (DtdProcessing.Prohibit, XmlResolver null).

Fixing What You Found

Most .NET fixes replace a legacy API with the current one, which is also the shorter call:

Why the BinaryFormatter finding keeps arriving late

Because the scanner rule was written when the class was dangerous and has not been retired. A modern .NET 10 service that references the type to keep an old interface compiling will still trigger it, and the ticket will quote a CVE from 2012 as though it applied. Read the target framework, then decide whether the call can even execute. The honest finding for a .NET Framework 4.8 project is a migration decision, not a patch.

.NET Review Practice Targets

Applications with documented .NET defects where the source is available, so each finding can be confirmed by reading it.

OWASP Juice Shop (.NET port) Custom Lab medium
Compare the Node original to a .NET portTrace a challenge to its handlerSPA plus API boundary
WebGoat.NET Custom Lab medium
ASP.NET defects with sourceInjection and access control lessonsLegacy Framework patterns
OWASP Benchmark Custom Lab medium
Scored cases to compare rule qualityFalse positive measurementRule tuning