Skip to content

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.EvaluateAsync and CSharpScript.RunAsync compile 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).Assembly is System.Private.CoreLib, which contains System.IO.File, System.IO.Directory, System.Environment, System.Type, and System.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("...") and Assembly.Load(...) reach anything the process can load from disk, which is how the second payload gets to Process.Start without System.Diagnostics.Process ever 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 a ScriptOptions configuration.
  • RunAsync carries the same exposure and adds state: the returned ScriptState can be extended with ContinueWithAsync, 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:

  • CSharpCompilation and Assembly.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, or System.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() turns GetType(), GetMethod(), and Assembly access back on. One registered parameter of any type is then enough to reach System.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 makes T resolvable by name and every public member on it callable; there is no way to reference File for 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, so typeof(System.IO.File) and System.IO.File.ReadAllText(...) both fail with UnknownIdentifierException before anything runs. Check which of these two calls the finding actually involves - a bare new Interpreter() with only SetFunction/SetVariable registrations is usually not the bug.

Secure Patterns

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().Assembly and GetMethod() raise ReflectionNotAllowedException without any opt-out call. There is no DisableReflection() method to call - only EnableReflection(), which is the thing to keep out of the codebase.
  • The interpreter resolves no namespaces of its own, so System.IO.File.ReadAllText(...) and typeof(System.IO.File) fail with UnknownIdentifierException. Only the identifiers passed to SetFunction, SetVariable and Parameter exist 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 as object.

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 with SetFunction() that accepts a Type or performs Activator.CreateInstance on 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/WithImports as a sandbox: they decide which names the compiler will resolve, and nothing more. Reflection loads assemblies at runtime regardless, and System.Private.CoreLib alone already carries File, Directory, and Environment. A CSharpScript call 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.ParseText and 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.

Additional Resources