GHSA-XCX6-VP38-8HR5
Vulnerability from github – Published: 2026-03-24 22:15 – Updated: 2026-03-24 22:15Summary
The object.to_json builtin function in Scriban performs recursive JSON serialization via an internal WriteValue() static local function that has no depth limit, no circular reference detection, and no stack overflow guard. A Scriban template containing a self-referencing object passed to object.to_json triggers unbounded recursion, causing a StackOverflowException that terminates the hosting .NET process. This is a fatal, unrecoverable crash — StackOverflowException cannot be caught by user code in .NET.
Details
The vulnerable code is the WriteValue() static local function at src/Scriban/Functions/ObjectFunctions.cs:494:
static void WriteValue(TemplateContext context, Utf8JsonWriter writer, object value)
{
var type = value?.GetType() ?? typeof(object);
if (value is null || value is string || value is bool ||
type.IsPrimitiveOrDecimal() || value is IFormattable)
{
JsonSerializer.Serialize(writer, value, type);
}
else if (value is IList || type.IsArray) {
writer.WriteStartArray();
foreach (var x in context.ToList(context.CurrentSpan, value))
{
WriteValue(context, writer, x); // recursive, no depth check
}
writer.WriteEndArray();
}
else {
writer.WriteStartObject();
var accessor = context.GetMemberAccessor(value);
foreach (var member in accessor.GetMembers(context, context.CurrentSpan, value))
{
if (accessor.TryGetValue(context, context.CurrentSpan, value, member, out var memberValue))
{
writer.WritePropertyName(member);
WriteValue(context, writer, memberValue); // recursive, no depth check
}
}
writer.WriteEndObject();
}
}
This function has none of the safety mechanisms present in other recursive paths:
ObjectToString()atTemplateContext.Helpers.cs:98checksObjectRecursionLimit(default 20)EnterRecursive()atTemplateContext.cs:957callsRuntimeHelpers.EnsureSufficientExecutionStack()CheckAbort()atTemplateContext.cs:464also callsEnsureSufficientExecutionStack()
The WriteValue() function bypasses all of these because it is a static local function that only takes the TemplateContext for member access — it never calls EnterRecursive(), never checks ObjectRecursionLimit, and never calls EnsureSufficientExecutionStack().
Execution flow:
- Template creates a ScriptObject:
{{ x = {} }} - Sets a self-reference:
x.self = x— stores a reference inScriptObject.Storedictionary - Pipes to
object.to_json:x | object.to_json→ callsToJson()at line 477 ToJson()callsWriteValue(context, writer, value)at line 488WriteValueenters theelsebranch (line 515), gets members via accessor, finds "self"TryGetValuereturnsxitself,WriteValuerecurses with the same object — infinite loopStackOverflowExceptionis thrown — fatal, cannot be caught, process terminates
PoC
{{ x = {}; x.self = x; x | object.to_json }}
In a hosting application:
using Scriban;
// This will crash the entire process with StackOverflowException
var template = Template.Parse("{{ x = {}; x.self = x; x | object.to_json }}");
var result = template.Render(); // FATAL: process terminates here
Even without circular references, deeply nested objects can exhaust the stack since no depth limit is enforced:
{{ a = {}
b = {inner: a}
c = {inner: b}
d = {inner: c}
# ... continue nesting ...
result = deepest | object.to_json }}
Impact
- Process crash DoS: Any application embedding Scriban for user-provided templates (CMS platforms, email template engines, report generators, static site generators) can be crashed by a single malicious template. The crash is unrecoverable —
StackOverflowExceptionterminates the .NET process. - No try/catch protection possible: Unlike most exceptions,
StackOverflowExceptioncannot be caught by application code. The hosting application cannot wraptemplate.Render()in a try/catch to survive this. - No authentication required:
object.to_jsonis a default builtin function (registered inBuiltinFunctions.cs), available in all Scriban templates unless explicitly removed. - Trivial to exploit: The PoC is a single line of template code.
Recommended Fix
Add a depth counter parameter to WriteValue() and check it against ObjectRecursionLimit, consistent with how ObjectToString is protected. Also add EnsureSufficientExecutionStack() as a safety net:
static void WriteValue(TemplateContext context, Utf8JsonWriter writer, object value, int depth = 0)
{
if (context.ObjectRecursionLimit != 0 && depth > context.ObjectRecursionLimit)
{
throw new ScriptRuntimeException(context.CurrentSpan,
$"Exceeding object recursion limit `{context.ObjectRecursionLimit}` in object.to_json");
}
try
{
RuntimeHelpers.EnsureSufficientExecutionStack();
}
catch (InsufficientExecutionStackException)
{
throw new ScriptRuntimeException(context.CurrentSpan,
"Exceeding recursive depth limit in object.to_json, near to stack overflow");
}
var type = value?.GetType() ?? typeof(object);
if (value is null || value is string || value is bool ||
type.IsPrimitiveOrDecimal() || value is IFormattable)
{
JsonSerializer.Serialize(writer, value, type);
}
else if (value is IList || type.IsArray) {
writer.WriteStartArray();
foreach (var x in context.ToList(context.CurrentSpan, value))
{
WriteValue(context, writer, x, depth + 1);
}
writer.WriteEndArray();
}
else {
writer.WriteStartObject();
var accessor = context.GetMemberAccessor(value);
foreach (var member in accessor.GetMembers(context, context.CurrentSpan, value))
{
if (accessor.TryGetValue(context, context.CurrentSpan, value, member, out var memberValue))
{
writer.WritePropertyName(member);
WriteValue(context, writer, memberValue, depth + 1);
}
}
writer.WriteEndObject();
}
}
{
"affected": [
{
"package": {
"ecosystem": "NuGet",
"name": "Scriban"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "7.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-674"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-24T22:15:13Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nThe `object.to_json` builtin function in Scriban performs recursive JSON serialization via an internal `WriteValue()` static local function that has no depth limit, no circular reference detection, and no stack overflow guard. A Scriban template containing a self-referencing object passed to `object.to_json` triggers unbounded recursion, causing a `StackOverflowException` that terminates the hosting .NET process. This is a fatal, unrecoverable crash \u2014 `StackOverflowException` cannot be caught by user code in .NET.\n\n## Details\n\nThe vulnerable code is the `WriteValue()` static local function at `src/Scriban/Functions/ObjectFunctions.cs:494`:\n\n```csharp\nstatic void WriteValue(TemplateContext context, Utf8JsonWriter writer, object value)\n{\n var type = value?.GetType() ?? typeof(object);\n if (value is null || value is string || value is bool ||\n type.IsPrimitiveOrDecimal() || value is IFormattable)\n {\n JsonSerializer.Serialize(writer, value, type);\n }\n else if (value is IList || type.IsArray) {\n writer.WriteStartArray();\n foreach (var x in context.ToList(context.CurrentSpan, value))\n {\n WriteValue(context, writer, x); // recursive, no depth check\n }\n writer.WriteEndArray();\n }\n else {\n writer.WriteStartObject();\n var accessor = context.GetMemberAccessor(value);\n foreach (var member in accessor.GetMembers(context, context.CurrentSpan, value))\n {\n if (accessor.TryGetValue(context, context.CurrentSpan, value, member, out var memberValue))\n {\n writer.WritePropertyName(member);\n WriteValue(context, writer, memberValue); // recursive, no depth check\n }\n }\n writer.WriteEndObject();\n }\n}\n```\n\nThis function has **none** of the safety mechanisms present in other recursive paths:\n\n- `ObjectToString()` at `TemplateContext.Helpers.cs:98` checks `ObjectRecursionLimit` (default 20)\n- `EnterRecursive()` at `TemplateContext.cs:957` calls `RuntimeHelpers.EnsureSufficientExecutionStack()`\n- `CheckAbort()` at `TemplateContext.cs:464` also calls `EnsureSufficientExecutionStack()`\n\nThe `WriteValue()` function bypasses all of these because it is a static local function that only takes the `TemplateContext` for member access \u2014 it never calls `EnterRecursive()`, never checks `ObjectRecursionLimit`, and never calls `EnsureSufficientExecutionStack()`.\n\n**Execution flow:**\n\n1. Template creates a ScriptObject: `{{ x = {} }}`\n2. Sets a self-reference: `x.self = x` \u2014 stores a reference in `ScriptObject.Store` dictionary\n3. Pipes to `object.to_json`: `x | object.to_json` \u2192 calls `ToJson()` at line 477\n4. `ToJson()` calls `WriteValue(context, writer, value)` at line 488\n5. `WriteValue` enters the `else` branch (line 515), gets members via accessor, finds \"self\"\n6. `TryGetValue` returns `x` itself, `WriteValue` recurses with the same object \u2014 infinite loop\n7. `StackOverflowException` is thrown \u2014 **fatal, cannot be caught, process terminates**\n\n## PoC\n\n```scriban\n{{ x = {}; x.self = x; x | object.to_json }}\n```\n\nIn a hosting application:\n\n```csharp\nusing Scriban;\n\n// This will crash the entire process with StackOverflowException\nvar template = Template.Parse(\"{{ x = {}; x.self = x; x | object.to_json }}\");\nvar result = template.Render(); // FATAL: process terminates here\n```\n\nEven without circular references, deeply nested objects can exhaust the stack since no depth limit is enforced:\n\n```scriban\n{{ a = {}\n b = {inner: a}\n c = {inner: b}\n d = {inner: c}\n # ... continue nesting ...\n result = deepest | object.to_json }}\n```\n\n## Impact\n\n- **Process crash DoS**: Any application embedding Scriban for user-provided templates (CMS platforms, email template engines, report generators, static site generators) can be crashed by a single malicious template. The crash is unrecoverable \u2014 `StackOverflowException` terminates the .NET process.\n- **No try/catch protection possible**: Unlike most exceptions, `StackOverflowException` cannot be caught by application code. The hosting application cannot wrap `template.Render()` in a try/catch to survive this.\n- **No authentication required**: `object.to_json` is a default builtin function (registered in `BuiltinFunctions.cs`), available in all Scriban templates unless explicitly removed.\n- **Trivial to exploit**: The PoC is a single line of template code.\n\n## Recommended Fix\n\nAdd a depth counter parameter to `WriteValue()` and check it against `ObjectRecursionLimit`, consistent with how `ObjectToString` is protected. Also add `EnsureSufficientExecutionStack()` as a safety net:\n\n```csharp\nstatic void WriteValue(TemplateContext context, Utf8JsonWriter writer, object value, int depth = 0)\n{\n if (context.ObjectRecursionLimit != 0 \u0026\u0026 depth \u003e context.ObjectRecursionLimit)\n {\n throw new ScriptRuntimeException(context.CurrentSpan,\n $\"Exceeding object recursion limit `{context.ObjectRecursionLimit}` in object.to_json\");\n }\n\n try\n {\n RuntimeHelpers.EnsureSufficientExecutionStack();\n }\n catch (InsufficientExecutionStackException)\n {\n throw new ScriptRuntimeException(context.CurrentSpan,\n \"Exceeding recursive depth limit in object.to_json, near to stack overflow\");\n }\n\n var type = value?.GetType() ?? typeof(object);\n if (value is null || value is string || value is bool ||\n type.IsPrimitiveOrDecimal() || value is IFormattable)\n {\n JsonSerializer.Serialize(writer, value, type);\n }\n else if (value is IList || type.IsArray) {\n writer.WriteStartArray();\n foreach (var x in context.ToList(context.CurrentSpan, value))\n {\n WriteValue(context, writer, x, depth + 1);\n }\n writer.WriteEndArray();\n }\n else {\n writer.WriteStartObject();\n var accessor = context.GetMemberAccessor(value);\n foreach (var member in accessor.GetMembers(context, context.CurrentSpan, value))\n {\n if (accessor.TryGetValue(context, context.CurrentSpan, value, member, out var memberValue))\n {\n writer.WritePropertyName(member);\n WriteValue(context, writer, memberValue, depth + 1);\n }\n }\n writer.WriteEndObject();\n }\n}\n```",
"id": "GHSA-xcx6-vp38-8hr5",
"modified": "2026-03-24T22:15:13Z",
"published": "2026-03-24T22:15:13Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/scriban/scriban/security/advisories/GHSA-xcx6-vp38-8hr5"
},
{
"type": "PACKAGE",
"url": "https://github.com/scriban/scriban"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Scriban has Uncontrolled Recursion in `object.to_json` Causing Unrecoverable Process Crash via StackOverflowException"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.