Do not run request input as code. Roslyn's CSharpScript executes the formula with the server's privileges, and a denylist of dangerous APIs cannot make it safe: it misses Environment.GetEnvironmentVariable and is never complete. Parse the small grammar the feature needs (numbers, the allowlisted fields and the operators) and evaluate that, so there is no type or member to reach.
- Find every place request input is evaluated: Microsoft.CodeAnalysis scripting (CSharpScript), DataTable.Compute, a dynamic LINQ parser, or a reflection-built delegate. Treat each as remote code execution.
- Decide the small grammar the feature needs. Here it is arithmetic over three named fields, so the grammar is numbers, those field names, + - * / and parentheses.
- Parse that grammar yourself (a short recursive-descent parser) or with a library whose evaluator is data-only, and evaluate the parsed form over the row. An identifier resolves to a field value, never to a type, member or global.
- Reject anything outside the grammar with a 400: an unknown name, a member access, a call. Never a 500 and never a fallback to scripting.
- If you must run user-authored code, isolate it in a separate process with an OS sandbox; a cleared ScriptOptions is not isolation.
- Add regression tests that send formulas reading the environment through both the short and the fully-qualified form, and assert the secret never reaches the response.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public double Evaluate(string formula, QuoteRow row) =>
CSharpScript.EvaluateAsync<double>(formula, Options, globals: row).GetAwaiter().GetResult();With IMPL=vulnerable, 2 of 6 tests fail (2 exploit tests).
The fix
public double Evaluate(string formula, QuoteRow row) => new Parser(formula, row).Parse();
private sealed class Parser
{
private static readonly HashSet<string> Fields = new() { "price", "quantity", "discount" };
private readonly string _src;
private readonly QuoteRow _row;
private int _pos;
private string? _token;
public Parser(string src, QuoteRow row)
{
_src = src;
_row = row;
Next();
}
public double Parse()
{
double value = Expr();
if (_token is not null) throw new FormatException($"unexpected token \"{_token}\"");
return value;
}
// The next number, name, operator or parenthesis, or null at the end.
private void Next()
{
while (_pos < _src.Length && char.IsWhiteSpace(_src[_pos])) _pos++;
if (_pos >= _src.Length) { _token = null; return; }
char c = _src[_pos];
if (char.IsDigit(c) || c == '.')
{
int start = _pos;
while (_pos < _src.Length && (char.IsDigit(_src[_pos]) || _src[_pos] == '.')) _pos++;
_token = _src[start.._pos];
}
else if (char.IsLetter(c) || c == '_')
{
int start = _pos;
while (_pos < _src.Length && (char.IsLetterOrDigit(_src[_pos]) || _src[_pos] == '_')) _pos++;
_token = _src[start.._pos];
}
else if ("+-*/()".IndexOf(c) >= 0) { _token = c.ToString(); _pos++; }
else throw new FormatException($"unexpected character '{c}'");
}
// expr := term (('+' | '-') term)*
private double Expr()
{
double value = Term();
while (_token is "+" or "-")
{
string op = _token;
Next();
double rhs = Term();
value = op == "+" ? value + rhs : value - rhs;
}
return value;
}
// term := factor (('*' | '/') factor)*
private double Term()
{
double value = Factor();
while (_token is "*" or "/")
{
string op = _token;
Next();
double rhs = Factor();
value = op == "*" ? value * rhs : value / rhs;
}
return value;
}
// factor := '-' factor | '(' expr ')' | number | field
private double Factor()
{
if (_token == "-") { Next(); return -Factor(); }
if (_token == "(")
{
Next();
double value = Expr();
if (_token != ")") throw new FormatException("missing closing parenthesis");
Next();
return value;
}
string token = _token ?? throw new FormatException("unexpected end of formula");
if (char.IsDigit(token[0]) || token[0] == '.')
{
Next();
return double.Parse(token, System.Globalization.CultureInfo.InvariantCulture);
}
if (char.IsLetter(token[0]) || token[0] == '_')
{
if (!Fields.Contains(token)) throw new FormatException($"unknown field \"{token}\"");
Next();
return token switch
{
"price" => _row.price,
"quantity" => _row.quantity,
_ => _row.discount,
};
}
throw new FormatException($"unexpected token \"{token}\"");
}
}With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
// The API names that appear in copied Roslyn-to-RCE payloads. Case-sensitive.
private static readonly string[] Blocked =
{
"Process", "Diagnostics", "Runtime", "File", "Directory",
"Assembly", "Activator", "AppDomain", "Reflection", "GetType",
};
public double Evaluate(string formula, QuoteRow row)
{
foreach (var word in Blocked)
{
if (formula.Contains(word, StringComparison.Ordinal))
{
throw new InvalidOperationException("formula contains a blocked API");
}
}
// The denylist blocks the gadgets but not Environment.GetEnvironmentVariable, so a secret
// read still compiles and runs.
return CSharpScript.EvaluateAsync<double>(formula, Options, globals: row).GetAwaiter().GetResult();
}With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).
Route: a formula is data, not a 500 risk
// The caller sends a pricing formula and a row of numbers. The evaluator returns a number or
// throws; a formula it will not compute is a 400, never a 500, and the formula is never run as code.
app.MapPost("/quote", (QuoteRequest body, IFormulaEvaluator evaluator) =>
{
if (string.IsNullOrEmpty(body.Formula) || body.Formula.Length > 500)
{
return Results.BadRequest(new { error = "formula must be a non-empty string under 500 chars" });
}
var row = body.Row ?? new QuoteRow();
try
{
double result = evaluator.Evaluate(body.Formula, row);
return double.IsFinite(result)
? Results.Ok(new { result })
: Results.BadRequest(new { error = "formula did not produce a finite number" });
}
catch (Exception e)
{
return Results.BadRequest(new { error = $"cannot evaluate formula: {e.Message}" });
}
});The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
public sealed class ExploitTests(RulesApiFactory factory) : RulesApiTests(factory)
{
// No formula may read the server's environment or reach a type. The secret is a number, so if
// it reaches the result, code ran (the same vector runs System.Diagnostics.Process.Start). A
// safe evaluator rejects these with 400.
private async Task AssertNoCodeRan(string formula)
{
var (status, body) = await Quote(formula);
Assert.False(body.Contains(Secret), $"formula ran as code and leaked the secret env var: {body}");
Assert.True(status == 400, $"expected 400 for a non-arithmetic formula, got {status}: {body}");
}
[Fact]
public async Task AFormulaCannotReadAnEnvironmentVariable() =>
await AssertNoCodeRan("double.Parse(Environment.GetEnvironmentVariable(\"FIXCARD_SECRET\"))");
[Fact]
public async Task AFullyQualifiedTypeReferenceStillCannotReachTheRuntime() =>
await AssertNoCodeRan("double.Parse(System.Environment.GetEnvironmentVariable(\"FIXCARD_SECRET\"))");
}- Blocking a list of 'dangerous' APIs (Process, Diagnostics, File, Runtime, reflection) and then running the script anyway. The list is never complete, and it misses Environment.GetEnvironmentVariable, so the script still reads the server's secrets. That is this card's wrong implementation: a denylist is not a sandbox.
- A denylist of type names is defeated by namespace aliases, global::, fully-qualified names and reflection; the allowed grammar must be defined by what you accept.
- Returning the script's value straight to the caller leaks whatever it computed; even a numeric result can carry exfiltrated data.
- Reaching for a scripting or dynamic-expression engine for a problem a 60-line parser solves, which adds a large attack surface for little benefit.
- Catching the scripting exception and retrying with more permissive options to make a formula work.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs code-injection/csharp-aspnetcore does this for you).
- Against your own endpoint, send formulas that read an environment secret with both Environment.GetEnvironmentVariable and System.Environment.GetEnvironmentVariable, with a known number in the environment. A safe evaluator answers 400 and the number never appears; if it does, the string ran as code.
- The vulnerable evaluator leaks through the short form; the wrong evaluator leaks through the fully-qualified form (ScriptOptions.Default is not a sandbox). Do not rely on a scanner: Semgrep's C# packs flag neither, so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/csharp | 0 findings and 0 parse errors on src/. No registry rule models CSharpScript.EvaluateAsync as a code-execution sink, so the vulnerable, fixed and wrong evaluators look the same.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (src only) |
| CodeQL | cs/code-injection | Models dynamic code execution (including Microsoft.CodeAnalysis scripting) as a sink and request input as a source, so it is expected to flag the vulnerable and wrong evaluators, where the request formula reaches CSharpScript.EvaluateAsync. The fixed parser has no such sink.Checked: github/codeql main; not executed |
No Semgrep CE rule flags Roslyn scripting as a code sink; CodeQL models it. The point the scanners miss is that a denylist of API names (the wrong evaluator) does not sandbox the script: a formula reads a secret with Environment.GetEnvironmentVariable, which no gadget list blocks. The regression tests are the control.
| Component | Version |
|---|---|
| Language | C# |
| Runtime | ASP.NET Core 10 minimal API on .NET 10.0.12 |
| Framework | ASP.NET Core 10 (minimal API) |
| Microsoft.CodeAnalysis.CSharp.Scripting (Roslyn, vulnerable and wrong only) | 5.6.0 |
| Microsoft.AspNetCore.Mvc.Testing (test only) | 10.0.12 |
| xunit.v3 (test only) | 4.0.2 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the code-injection/csharp-aspnetcorefolder.
dotnet restore tests/Rules.Tests --locked-mode# Expected: every test passes
IMPL=fixed FIXCARD_SECRET=730199138246 dotnet test --project tests/Rules.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable FIXCARD_SECRET=730199138246 dotnet test --project tests/Rules.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong FIXCARD_SECRET=730199138246 dotnet test --project tests/Rules.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs code-injection/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- aspNetCoreRuntime 10.0.12
- roslynScripting 5.6.0
- xunitV3 4.0.2
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 2 of 6 tests fail (2 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 6/6 pass; IMPL=vulnerable 2/6 fail (both exploit: reading FIXCARD_SECRET through the short and the fully-qualified System.Environment forms each leaks the secret number); IMPL=wrong 2/6 fail (both exploit: the denylist blocks the RCE gadgets but not Environment.GetEnvironmentVariable, so both secret reads still leak). FIXCARD_SECRET is a number so the leak is unambiguous; the same access runs System.Diagnostics.Process.Start.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Top 10:2025 A05 Injection
- CWE-94: Improper Control of Generation of Code ('Code Injection')
- CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
- Roslyn: Scripting API samples (CSharpScript.EvaluateAsync, RunAsync and Create)
- .NET source browser: ScriptOptions (WithImports, WithReferences)
- OWASP Code Injection
Attack patterns: