CWE-94: Code Injection - C# / .NET
Overview
Code Injection in C# occurs when untrusted input is compiled and executed as .NET code at runtime. This typically involves the Roslyn scripting API (CSharpScript.EvaluateAsync/RunAsync), the Roslyn compiler API (CSharpCodeProvider, CSharpCompilation), the Microsoft.CSharp.RuntimeBinder, or dynamic expression evaluators like NCalc and DynamicExpresso without input restrictions. The Roslyn approach is especially dangerous: it compiles and loads a full .NET assembly, giving an attacker unrestricted access to the file system, network, and reflection APIs within the running process.
Unlike command injection, code injection in C# does not require OS-level access - an attacker can abuse the .NET runtime directly, calling System.IO.File.Delete(), opening sockets, or spawning processes entirely within managed code.
Primary Defence: Replace dynamic compilation with static dispatch logic (dictionaries of delegates, strategy interfaces). If a configurable expression language is unavoidable, use a purpose-built sandboxed evaluator with an explicit allowlist of permitted types.
Common Vulnerable Patterns
Roslyn Scripting (CSharpScript)
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
public class RuleGlobals
{
public decimal OrderTotal { get; set; }
}
[HttpPost("preview-rule")]
public async Task<IActionResult> PreviewRule([FromBody] string rule, [FromQuery] decimal total)
{
var options = ScriptOptions.Default
.WithReferences(typeof(object).Assembly) // "core types only" - this is not a sandbox
.WithImports("System");
// VULNERABLE - rule is arbitrary C#, compiled and executed in this process
bool matched = await CSharpScript.EvaluateAsync<bool>(
rule, options, new RuleGlobals { OrderTotal = total });
return Ok(matched);
}
// rule = System.IO.File.ReadAllText("appsettings.Production.json").Length > 0
// rule = System.Type.GetType("System.Diagnostics.Process, System.Diagnostics.Process")
// .GetMethod("Start", new[] { typeof(string) })
// .Invoke(null, new object[] { "cmd" }) != null
Why this is vulnerable:
CSharpScript.EvaluateAsyncandCSharpScript.RunAsynccompile the submitted text as a C# script and execute it in the calling process, with the application's identity, connection strings, and file handles. The<bool>return type constrains only the value that comes back, not what the script does before returning it.ScriptOptions.WithReferences(...)is a compile-time name-resolution setting, not a security boundary.typeof(object).AssemblyisSystem.Private.CoreLib, which containsSystem.IO.File,System.IO.Directory,System.Environment,System.Type, andSystem.Reflection.Assembly- so the first payload above compiles under exactly this "restricted" option set.- Even a genuinely minimal reference list does not hold, because reflection resolves assemblies at runtime:
Type.GetType("...")andAssembly.Load(...)reach anything the process can load from disk, which is how the second payload gets toProcess.StartwithoutSystem.Diagnostics.Processever being referenced. - There is no in-process sandbox to fall back on. Code Access Security and restricted
AppDomains do not exist in .NET 5+, and the Roslyn team does not treat the scripting API as a mechanism for running untrusted code. Isolation, if you need it, has to be a separate process or container with its own identity and filesystem - not aScriptOptionsconfiguration. RunAsynccarries the same exposure and adds state: the returnedScriptStatecan be extended withContinueWithAsync, so an attacker who gets one submission accepted can build on its variables in the next.
Roslyn Runtime Compilation
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis;
using System.Reflection;
[HttpPost("execute")]
public IActionResult Execute([FromBody] string code)
{
// VULNERABLE - compiles and runs arbitrary C# submitted by the user
var syntaxTree = CSharpSyntaxTree.ParseText(code);
var compilation = CSharpCompilation.Create("Dynamic",
new[] { syntaxTree },
new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) },
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
using var ms = new MemoryStream();
compilation.Emit(ms);
ms.Seek(0, SeekOrigin.Begin);
var assembly = Assembly.Load(ms.ToArray());
var type = assembly.GetType("DynamicClass");
var method = type?.GetMethod("Run");
return Ok(method?.Invoke(null, null));
}
Why this is vulnerable:
CSharpCompilationandAssembly.Load()give the attacker a full .NET assembly. The code runs with the same permissions as the web application.- An attacker can call
System.IO.File.Delete(),System.Net.WebClient, orSystem.Diagnostics.Process.Start().
CSharpCodeProvider (Legacy .NET Framework)
using System.CodeDom.Compiler;
using Microsoft.CSharp;
public object RunUserCode(string userExpression)
{
var provider = new CSharpCodeProvider();
var parameters = new CompilerParameters { GenerateInMemory = true };
// VULNERABLE - userExpression can contain any C# code
var source = $"public class D {{ public static object R() {{ return {userExpression}; }} }}";
var results = provider.CompileAssemblyFromSource(parameters, source);
return results.CompiledAssembly.GetType("D")
.GetMethod("R").Invoke(null, null);
}
Why this is vulnerable:
- String interpolation embeds user input directly into C# source.
System.IO.File.ReadAllText("/etc/passwd")is a valid C# expression.
DynamicExpresso With Reflection or Dangerous Types Enabled
using DynamicExpresso;
// VULNERABLE - EnableReflection() re-opens the type graph to the expression
[HttpGet("formula")]
public double EvaluateFormula([FromQuery] string formula, [FromQuery] double x)
{
var interpreter = new Interpreter().EnableReflection();
return interpreter.Eval<double>(formula, new Parameter("x", typeof(double), x));
}
// formula = x.GetType().Assembly.GetType("System.IO.File") ...
// walks from the one parameter you exposed to any type in the runtime
// VULNERABLE - a referenced type is fully reachable, every public member of it
public class ReportEvaluator
{
private readonly Interpreter _interpreter = new Interpreter()
.Reference(typeof(System.IO.File)); // added "so templates can read a footer file"
public object Eval(string expr) => _interpreter.Eval(expr);
}
// expr = File.WriteAllText("wwwroot/shell.aspx", payload) - executes
Why this is vulnerable:
EnableReflection()turnsGetType(),GetMethod(), andAssemblyaccess back on. One registered parameter of any type is then enough to reachSystem.IO.File,System.Diagnostics.Process, or anything else in the loaded assemblies - the attacker does not need you to have referenced the dangerous type, only to have referenced a type.Reference(typeof(T))is all-or-nothing. It makesTresolvable by name and every public member on it callable; there is no way to referenceFilefor one method and not the rest.- The reverse of both is what makes the secure pattern below work: a default
new Interpreter()has reflection off and resolves no namespaces, sotypeof(System.IO.File)andSystem.IO.File.ReadAllText(...)both fail withUnknownIdentifierExceptionbefore anything runs. Check which of these two calls the finding actually involves - a barenew Interpreter()with onlySetFunction/SetVariableregistrations is usually not the bug.
Secure Patterns
Dispatch Table (Recommended)
using System.Collections.Generic;
[ApiController]
[Route("api/[controller]")]
public class CalculatorController : ControllerBase
{
// SECURE - predefined delegates - no runtime compilation
private static readonly Dictionary<string, Func<double, double>> _ops = new()
{
["double"] = x => x * 2,
["square"] = x => x * x,
["negate"] = x => -x,
["sqrt"] = Math.Sqrt,
};
[HttpGet]
public IActionResult Calculate([FromQuery] string operation, [FromQuery] double value)
{
if (!_ops.TryGetValue(operation, out var op))
return BadRequest($"Unknown operation: {operation}");
return Ok(op(value));
}
}
Why this works:
- The dictionary maps user-controlled strings to pre-compiled lambdas. The user can never supply new code - only a key that selects from the fixed set.
- Unknown keys are rejected before any computation. There is no eval path.
Strategy Interface Pattern
public interface IRule
{
string Name { get; }
decimal Apply(decimal price, int quantity);
}
public class BulkDiscount : IRule
{
public string Name => "bulk";
public decimal Apply(decimal price, int quantity) =>
quantity > 10 ? price * 0.9m : price;
}
public class LoyaltyDiscount : IRule
{
public string Name => "loyalty";
public decimal Apply(decimal price, int quantity) => price * 0.95m;
}
// Registered in Program.cs:
// builder.Services.AddSingleton<IRule, BulkDiscount>();
// builder.Services.AddSingleton<IRule, LoyaltyDiscount>();
public class PricingService
{
private readonly IReadOnlyDictionary<string, IRule> _rules;
public PricingService(IEnumerable<IRule> rules)
{
// Explicit Name, not GetType().Name - renaming a class must not
// silently change the identifier callers send
_rules = rules.ToDictionary(r => r.Name, StringComparer.Ordinal);
}
// SECURE - user picks a name; logic is always compiled .NET code
public decimal ApplyRule(string ruleName, decimal price, int quantity)
{
if (!_rules.TryGetValue(ruleName, out var rule))
throw new ArgumentException($"Unknown rule: {ruleName}");
return rule.Apply(price, quantity);
}
}
Why this works:
- Concrete rule classes are defined at compile time and registered in DI. User input navigates to a class, never creates one.
DynamicExpresso with Locked-Down Scope (When Scripting is Required)
using DynamicExpresso;
public class SafeFormulaEvaluator
{
private readonly Interpreter _interpreter;
public SafeFormulaEvaluator()
{
// SECURE - the default interpreter has reflection OFF and resolves no
// namespaces. Do not call .EnableReflection(), and do not .Reference()
// a type such as System.IO.File - either one undoes this.
_interpreter = new Interpreter(InterpreterOptions.Default);
// Register only the identifiers the formula language actually needs
_interpreter.SetFunction("sqrt", (Func<double, double>)Math.Sqrt);
_interpreter.SetFunction("abs", (Func<double, double>)Math.Abs);
}
public double Evaluate(string formula, double x)
{
if (formula is null || formula.Length > 200)
throw new ArgumentException("Formula invalid or too long");
return _interpreter.Eval<double>(formula, new Parameter("x", typeof(double), x));
}
}
Why this works:
- Reflection is disabled by default in current DynamicExpresso, so
x.GetType().AssemblyandGetMethod()raiseReflectionNotAllowedExceptionwithout any opt-out call. There is noDisableReflection()method to call - onlyEnableReflection(), which is the thing to keep out of the codebase. - The interpreter resolves no namespaces of its own, so
System.IO.File.ReadAllText(...)andtypeof(System.IO.File)fail withUnknownIdentifierException. Only the identifiers passed toSetFunction,SetVariableandParameterexist in the expression's world. Eval<double>pins the result type, so an expression evaluating to anything other than a number is rejected at the boundary rather than flowing on asobject.
Testing
- Normal input: run every supported operation or expression that should remain available after replacing dynamic compilation.
- Boundary input: test unknown operation names, long expressions, nested expressions, and invalid syntax for predictable rejection.
- Malicious input: submit file, network, reflection, process, and type-construction payloads; confirm they cannot reach compilation or evaluation sinks.
Common Pitfalls
- Reflection blocked in the grammar, but a registered function forwards to it: DynamicExpresso's default reflection block stops the expression language from reaching
typeof()/GetMethod(), but a function registered withSetFunction()that accepts aTypeor performsActivator.CreateInstanceon a string argument re-exposes the same capability through application code. Everything you register is a hole you opened deliberately, and each one needs reviewing as though it were the sink. - Treating
ScriptOptions.WithReferences/WithImportsas a sandbox: they decide which names the compiler will resolve, and nothing more. Reflection loads assemblies at runtime regardless, andSystem.Private.CoreLibalone already carriesFile,Directory, andEnvironment. ACSharpScriptcall over untrusted input is a code-execution sink no matter how the options are configured. - Assuming "expression only" Roslyn parsing is inherently limited: Parsing with
CSharpSyntaxTree.ParseTextand compiling only a single expression is still full C# - it can contain method calls, object construction, and static member access (System.IO.File.ReadAllText(...)). There is no scoped-down "expression mode" in Roslyn that restricts what a parsed expression can reference. - Type-restriction lists without a matching member/method allowlist: Registering only "safe" types with
DynamicExpresso.Interpreter.Reference()blocks unregistered types, but every public member and method on a registered type remains reachable. There is no way to allow one method on a referenced type and not the rest.