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
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.
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 + "'");public User Find(string name)
{
var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Name = @name", connection);
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 100).Value = name;
return Read(cmd);
}
// EF Core: parameterised interpolation, not concatenation
db.Users.FromSql($"SELECT * FROM Users WHERE Name = {name}");public User Find(string name)
{
var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Name = @name", connection);
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 100).Value = name;
return Read(cmd);
}
// EF Core: parameterised interpolation, not concatenation
db.Users.FromSql($"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.
// 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);// types are resolved through an allowlist binder
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.None,
SerializationBinder = new AllowlistBinder(
typeof(Payload), typeof(Address))
};
var obj = JsonConvert.DeserializeObject<Payload>(json, settings);// types are resolved through an allowlist binder
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.None,
SerializationBinder = new AllowlistBinder(
typeof(Payload), typeof(Address))
};
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.
// 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");var requested = Request.Query["name"].ToString();
var full = Path.GetFullPath(Path.Combine(root, requested));
var rootFull = Path.GetFullPath(root) + Path.DirectorySeparatorChar;
// compare on the normalised path, with a separator boundary
if (!full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("outside root");
return PhysicalFile(full, "application/octet-stream");var requested = Request.Query["name"].ToString();
var full = Path.GetFullPath(Path.Combine(root, requested));
var rootFull = Path.GetFullPath(root) + Path.DirectorySeparatorChar;
// compare on the normalised path, with a separator boundary
if (!full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("outside root");
return PhysicalFile(full, "application/octet-stream");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.
[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));
}[ApiController]
[Authorize]
[Route("api/documents")]
public class DocumentsController : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var doc = await db.Documents.FindAsync(id);
if (doc is null) return NotFound();
// resource authorisation: the caller must own this document
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (doc.OwnerId != userId) return Forbid();
return Ok(doc);
}
}[ApiController]
[Authorize]
[Route("api/documents")]
public class DocumentsController : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var doc = await db.Documents.FindAsync(id);
if (doc is null) return NotFound();
// resource authorisation: the caller must own this document
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (doc.OwnerId != userId) return Forbid();
return Ok(doc);
}
}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.
Related Source Code Review Guides
Java and Spring Security
The same classes in a different runtime, with a filter the platform never removed.
Python Security
Pickle and YAML loaders, where the unsafe path is also the default.
Deserialization
Safe serializer configuration and the allowlist patterns that replace type handling.
Path Traversal
Containment checks that survive normalisation and symlinks.
C#/.NET labs
Local environments for running and testing .NET targets.