C# Static Analysis: .NET Common Vulnerability Patterns and How to Catch Them
On August 8, 2023, Microsoft published an advisory for CVE-2023-36794, a remote code execution vulnerability in Visual Studio's project-load path that an attacker could trigger by convincing a developer to open a crafted project file. The bug class was familiar to anyone who has read a decade of .NET CVEs — untrusted input reaching a deserializer, an XML parser, or a code-generation step that the runtime treated as trusted because the file extension said it should be. Microsoft shipped the patch the same day on Patch Tuesday, but advisories like this keep appearing because the underlying patterns ship continuously across the .NET ecosystem: BinaryFormatter calls in legacy WCF endpoints, raw SqlCommand string concatenation in MVC controllers, Process.Start on a path assembled from a query parameter. The CVE itself was a textbook taint flow: a file-format field reached a parser without validation. Modern C# SAST catches that pattern in seconds.
C# has been the workhorse language of the Microsoft stack for more than two decades, and the .NET ecosystem has accumulated a deep catalog of vulnerability patterns specific to its frameworks, idioms, and runtime behaviors. This guide walks through the C# vulnerability landscape static analysis is built to surface, shows vulnerable-to-fixed code transformations developers actually ship, and explains where C# static analysis earns its keep. If you are evaluating a .NET SAST engine, the patterns below are the floor — any serious csharp static analysis tool should catch them on the diff that introduces them.
The C# Vulnerability Landscape
A handful of bug classes account for the majority of high-severity findings on .NET codebases. Insecure deserialization sits at the top because BinaryFormatter and NetDataContractSerializer reconstruct arbitrary type graphs from untrusted byte streams, and the gadget chains in popular .NET libraries turn that primitive into remote code execution. Microsoft has marked BinaryFormatter as obsolete since .NET 5 and removed it from .NET 9, but it survives in legacy WCF, Remoting, and ASP.NET ViewState code paths nobody has revisited. CWE-502 has been the formal classifier for years, and every Sitecore, SharePoint, and Telerik UI for ASP.NET RCE in the recent CVE catalog traces to the same root cause. SQL injection through SqlCommand remains common because cmd.CommandText = "..." with string concatenation still appears in tutorials a decade after parameterized queries became the obvious right answer. XSS in Razor views shows up wherever a developer used @Html.Raw on a value that originated from a request, or rendered a model field into an HTML attribute without the right encoder context.
Past the top three, .NET's surface area is wide. Command injection through Process.Start(string) reaches the shell because the single-string overload routes through cmd.exe argument parsing on Windows. Path traversal in File.ReadAllText, File.OpenRead, and Path.Combine appears in any feature that builds a filesystem path from a request value — Path.Combine happily returns the second argument when it is rooted, which is exactly the attacker primitive. Hardcoded connection strings in appsettings.json committed to source control remain a perennial finding. Weak cryptography — MD5.Create(), SHA1.Create(), DESCryptoServiceProvider, AES in ECB mode, hardcoded IVs — survives in legacy assemblies. SSRF through HttpClient.GetAsync on a URL assembled from a request parameter shows up in every webhook, image-proxy, and metadata-fetch feature. ASP.NET MVC mass assignment without an explicit [Bind] attribute lets an attacker set properties the controller never intended to expose, and XXE in XmlDocument and the older XmlTextReader defaults still resolves external entities on .NET Framework targets.
SQL Injection: The Pattern Every .NET Codebase Still Ships
The vulnerable shape is short enough to memorize, which is part of the reason it survives. A request value flows into a SqlCommand through string concatenation, and the database driver executes whatever the attacker supplied:
// VULNERABLE
using System.Data.SqlClient;
public SqlDataReader FindUser(SqlConnection conn, string username)
{
var sql = "SELECT id, email FROM users WHERE username = '" + username + "'";
var cmd = new SqlCommand(sql, conn);
return cmd.ExecuteReader();
} Pass username as ' OR '1'='1 and the query returns every row in the table; pass it as '; DROP TABLE users; -- on a driver configured with MultipleActiveResultSets and the table is gone. The fix is to bind the value as a parameter, which keeps the query plan and the data on opposite sides of the parser:
// FIXED
using System.Data;
using System.Data.SqlClient;
public SqlDataReader FindUser(SqlConnection conn, string username)
{
var sql = "SELECT id, email FROM users WHERE username = @username";
var cmd = new SqlCommand(sql, conn);
cmd.Parameters.Add("@username", SqlDbType.NVarChar, 64).Value = username;
return cmd.ExecuteReader();
}SqlParameter sends the SQL template to the database first, then binds the parameter as a typed value the parser never re-interprets as syntax. The same discipline applies to Entity Framework's FromSqlRaw with positional parameters versus the dangerous interpolated overload, to Dapper's anonymous-object parameter form versus string interpolation, and to DbCommand instances assembled by hand through ADO.NET. SAST data flow analysis traces the request value from the controller action parameter through any number of intermediate services into the ExecuteReader sink, flagging the path the moment string concatenation breaks the parameterization. See the A03 Injection guide for the broader taxonomy that covers SQL, command, LDAP, and expression-language variants.
Insecure Deserialization: The RCE Primitive Hiding in BinaryFormatter
.NET binary serialization was designed in the early 2000s as a transparent way to move object graphs across AppDomain and process boundaries. The design assumed both endpoints were trusted. When one endpoint became an HTTP request body, a session cookie, a ViewState payload, or a message-queue message, the trust assumption silently inverted, and the result was a decade of remote code execution CVEs across enterprise .NET middleware. The vulnerable shape is anything that hands attacker bytes to BinaryFormatter.Deserialize or NetDataContractSerializer.ReadObject:
// VULNERABLE
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
public object LoadSession(Stream sessionBytes)
{
var formatter = new BinaryFormatter();
return formatter.Deserialize(sessionBytes);
} If a vulnerable gadget type — anything from System.Workflow, ObjectDataProvider, WindowsIdentity, or one of the dozens of types ysoserial.net catalogues — is reachable in the loaded assemblies, the deserializer triggers a chain of property setters and method invocations during reconstruction that ends in Process.Start or arbitrary code execution. The fix is not to validate the input or strip dangerous bytes; the fix is to replace the entire mechanism. Microsoft made this official by marking BinaryFormatter as obsolete in .NET 5 and removing it from the framework in .NET 9:
// FIXED
using System.IO;
using System.Text.Json;
public SessionState LoadSession(Stream sessionBytes)
{
return JsonSerializer.Deserialize<SessionState>(sessionBytes,
new JsonSerializerOptions { AllowTrailingCommas = false })
?? throw new InvalidDataException("Invalid session payload");
}System.Text.Json binds to a known target type, performs no constructor-based gadget invocation, and refuses to instantiate arbitrary types from the wire. The same discipline applies to Newtonsoft.Json with TypeNameHandling.None (the default), to Protocol Buffers, and to MessagePack with the typeless variant disabled. Treat any code path that calls BinaryFormatter.Deserialize, NetDataContractSerializer.ReadObject, SoapFormatter.Deserialize, or ObjectStateFormatter.Deserialize on untrusted bytes as a finding regardless of the configuration. SAST recognizes the deserialization sink and the absence of a typed schema on the same stream, which is the inter-procedural pattern a regex grep cannot reproduce. The A08 Software and Data Integrity Failures guide covers the broader class.
Command Injection: The Process.Start Overload That Reaches the Shell
.NET exposes two shapes of Process.Start, and exactly one of them parses its first argument as a shell-style command line. The vulnerable construction is the single-string overload assembled from a request value:
// VULNERABLE
using System.Diagnostics;
public void ExportReport(string filename)
{
Process.Start("pdftk " + filename + " output report.pdf");
} Pass filename as doc.pdf & whoami and the second command runs as the application user. The fix is the explicit ProcessStartInfo form with the argument list passed as a strongly typed collection rather than a single string the runtime tokenizes:
// FIXED
using System.Diagnostics;
public void ExportReport(string filename)
{
var psi = new ProcessStartInfo("pdftk")
{
UseShellExecute = false,
RedirectStandardOutput = true
};
psi.ArgumentList.Add(filename);
psi.ArgumentList.Add("output");
psi.ArgumentList.Add("report.pdf");
Process.Start(psi);
}ArgumentList (introduced in .NET Core 2.1) hands each token to the OS as a discrete argv entry, bypassing the shell-style parser entirely. UseShellExecute = false disables the path that would otherwise route through cmd.exe or the Windows shell on Windows targets. Wrap the configuration in a small helper, and let SAST gate any direct call to the single-string Process.Start overload anywhere user input can reach.
Detection: Where C# SAST Earns Its Keep
C# is, in many ways, an ideal substrate for static analysis. Strong static typing, nullable reference annotations, explicit method signatures, and the Roslyn compiler platform's open syntax and semantic APIs give analyzers a remarkably clean foundation for inter-procedural data flow. Microsoft ships the Roslyn analyzers directly in the .NET SDK — the Microsoft.CodeAnalysis.NetAnalyzers package surfaces dozens of security rules (the CA5xxx series) covering weak crypto, deserialization, and XML parser configuration, and they run on every dotnet build. Security Code Scan is the open-source community baseline that adds taint-tracking rules for SQL injection, XSS, and path traversal on top of the Roslyn platform. SonarQube's C# plugin extends the rule set further, and dedicated commercial engines — Checkmarx, Fortify, Veracode, GraphNode — add deeper data flow tracking and broader rule coverage across ASP.NET, ASP.NET Core, WCF, and Blazor surfaces.
The C# SAST market has settled into a small set of mature engines. A modern .NET static analysis tool builds a call graph across the compiled assembly, identifies sources (action method parameters in ASP.NET MVC and Web API, message bodies in MassTransit and NServiceBus consumers, fields in any Deserialize call), tracks taint forward through assignments and method calls, and flags any path reaching a sink (ADO.NET executor, Process.Start, XML parser, deserializer) without traversing a sanitizer. The same engine catches configuration patterns — missing encoder context in Razor, MD5.Create(), Aes.Create() with ECB mode, hardcoded keys in appsettings.json — that no taint flow needs to reason about. Visual Studio integration matters for adoption: a Visual Studio plugin that surfaces findings in the Error List as you type closes the feedback loop tighter than any CI gate, and JetBrains Rider users get the same surface through the dedicated extension.
Prevention Checklist for C# Codebases
Six rules close the overwhelming majority of real-world .NET vulnerabilities. They assume the team has already wired SAST into the pull-request gate; without that, even the strongest checklist degrades to a wiki page nobody re-reads.
- Parameterize every database call. Use
SqlParameter, Entity Framework LINQ, Dapper anonymous parameters, or EF Core'sFromSqlInterpolated. ForbidSqlCommandwith concatenated strings at code review and gate it in CI. - Treat
BinaryFormatteras removed. ReplaceBinaryFormatter,NetDataContractSerializer,SoapFormatter, andObjectStateFormatterwithSystem.Text.Json, Protocol Buffers, or MessagePack against a known schema. Audit every.Deserializecall site. - Harden every XML parser at construction. Set
XmlReaderSettings.DtdProcessing = DtdProcessing.Prohibit, setXmlResolver = nullonXmlDocumentandXmlTextReader, and preferXDocumentwith the safe defaults. Share the configuration through a single factory class. - Replace weak cryptography. Migrate
MD5andSHA1toSHA256or stronger; replaceDESCryptoServiceProviderandAesin ECB mode withAesGcm; never hardcode keys or IVs in source. Surface everyHashAlgorithm.CreateandSymmetricAlgorithm.Createin code review. - Avoid the single-string
Process.Start. UseProcessStartInfowithArgumentListandUseShellExecute = false, or — better — a managed library that performs the operation without spawning a process. The string overload routes through the shell on Windows. - Move secrets out of source. Replace hardcoded connection strings in
appsettings.json,web.config, and C# constants with the .NET Secret Manager for development and a secret store (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) for production. Pair the migration with a SAST and secret-scanning gate that fails the build on regression.
Where GraphNode SAST Fits
GraphNode SAST ships native C# and .NET support as a first-class language alongside twelve others, with deep data flow tracking across method, class, and assembly boundaries on the patterns this guide describes — taint into ADO.NET sinks, deserialization on legacy formatters, XML parsers without hardening, weak crypto, hardcoded connection strings, and the ASP.NET Core mass-assignment shapes that bypass [Bind]. The Visual Studio plugin surfaces findings on the keystroke that introduced them; the CI integration gates the pull request before the build leaves the developer's branch. For a broader landscape view, the SAST Tools Buyer's Guide compares ten platforms.
Closing
C#'s vulnerability classes are old, well-documented, and individually preventable with a one-line fix. The reason they continue to ship is structural: codebases are large, the dangerous APIs — BinaryFormatter, the single-string Process.Start, the unparameterized SqlCommand — are ergonomic enough that nobody pauses on them, and the safe alternatives require a different mental model or wholesale migration off a legacy mechanism the framework has marked obsolete but not yet removed. Static analysis works on C# because Roslyn gives it the call graph, the type information, and the explicit signatures that make taint flow tractable end to end. The teams that stop shipping deserialization RCEs and SQL injection breaches are the ones that move detection upstream into the diff and treat the SAST finding as a blocker rather than a backlog ticket.
GraphNode SAST traces taint flow through C# and .NET codebases on the diff that introduced the bug — request a demo.
Request Demo