Skip to content

Commit 866f888

Browse files
1 parent 719cff0 commit 866f888

File tree

3 files changed

+165
-0
lines changed

3 files changed

+165
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-m2p3-hwv5-xpqw",
4+
"modified": "2026-03-24T22:15:43Z",
5+
"published": "2026-03-24T22:15:43Z",
6+
"aliases": [],
7+
"summary": "Scriban: Denial of Service via Unbounded Cumulative Template Output Bypassing LimitToString",
8+
"details": "## Summary\n\nThe `LimitToString` safety limit (default 1MB since commit `b5ac4bf`) can be bypassed to allocate approximately 1GB of memory by exploiting the per-call reset of `_currentToStringLength` in `ObjectToString`. Each template expression rendered through `TemplateContext.Write(SourceSpan, object)` triggers a separate top-level `ObjectToString` call that resets the length counter to zero, and the underlying `StringBuilderOutput` has no cumulative output size limit. An attacker who can supply a template can cause an out-of-memory condition in the host application.\n\n## Details\n\nThe root cause is in `TemplateContext.Helpers.cs`, in the `ObjectToString` method:\n\n```csharp\n// src/Scriban/TemplateContext.Helpers.cs:89-111\npublic virtual string ObjectToString(object value, bool nested = false)\n{\n if (_objectToStringLevel == 0)\n {\n _currentToStringLength = 0; // <-- resets on every top-level call\n }\n try\n {\n _objectToStringLevel++;\n // ...\n var result = ObjectToStringImpl(value, nested);\n if (LimitToString > 0 && _objectToStringLevel == 1 && result != null && result.Length >= LimitToString)\n {\n return result + \"...\";\n }\n return result;\n }\n // ...\n}\n```\n\nEach time a template expression is rendered, `TemplateContext.Write(SourceSpan, object)` calls `ObjectToString`:\n\n```csharp\n// src/Scriban/TemplateContext.cs:693-701\npublic virtual TemplateContext Write(SourceSpan span, object textAsObject)\n{\n if (textAsObject != null)\n {\n var text = ObjectToString(textAsObject); // fresh _currentToStringLength = 0\n Write(text);\n }\n return this;\n}\n```\n\nThe `StringBuilderOutput.Write` method appends unconditionally with no size check:\n\n```csharp\n// src/Scriban/Runtime/StringBuilderOutput.cs:47-50\npublic void Write(string text, int offset, int count)\n{\n Builder.Append(text, offset, count); // no cumulative limit\n}\n```\n\n**Execution flow:**\n1. Template creates a string of length 1,048,575 (one byte under the 1MB `LimitToString` default)\n2. A `for` loop iterates up to `LoopLimit` (default 1000) times\n3. Each iteration renders the string via `Write(span, x)` → `ObjectToString(x)`\n4. `ObjectToString` resets `_currentToStringLength = 0` since `_objectToStringLevel == 0`\n5. The string passes the `LimitToString` check (1,048,575 < 1,048,576)\n6. Full string is appended to `StringBuilder` — no cumulative tracking\n7. After 1000 iterations: ~1GB allocated in-memory\n\n## PoC\n\n```csharp\nusing Scriban;\n\n// Uses only default TemplateContext settings (LoopLimit=1000, LimitToString=1048576)\nvar template = Template.Parse(\"{{ x = \\\"\\\" | string.pad_left 1048575 }}{{ for i in 1..1000 }}{{ x }}{{ end }}\");\n// This will allocate ~1GB in the StringBuilder, likely causing OOM\nvar result = template.Render();\n```\n\nEquivalent Scriban template:\n```scriban\n{{ x = \"\" | string.pad_left 1048575 }}{{ for i in 1..1000 }}{{ x }}{{ end }}\n```\n\nEach of the 1000 loop iterations outputs a 1,048,575-character string. Each passes the per-call `LimitToString` check independently. Total output: ~1,000,000,000 characters (~1GB) allocated in the `StringBuilder`.\n\n## Impact\n\n- **Denial of Service:** An attacker who can supply Scriban templates (common in CMS, email templating, report generation) can crash the host application via out-of-memory\n- **Process-level impact:** OOM kills the entire .NET process, not just the template rendering — affects all concurrent users\n- **Bypass of safety mechanism:** The `LimitToString` limit was specifically introduced to prevent resource exhaustion, but the per-call reset makes it ineffective against cumulative abuse\n- **Low complexity:** The exploit template is trivial — a single line\n\n## Recommended Fix\n\nAdd a cumulative output size counter to `TemplateContext` that tracks total bytes written across all `Write` calls, independent of the per-object `LimitToString`:\n\n```csharp\n// In TemplateContext.cs — add new property and field\nprivate long _totalOutputLength;\n\n/// <summary>\n/// Gets or sets the maximum total output length in characters. Default is 10485760 (10 MB). 0 means no limit.\n/// </summary>\npublic int OutputLimit { get; set; } = 10485760;\n\n// In TemplateContext.Write(string, int, int) — add check before writing\npublic TemplateContext Write(string text, int startIndex, int count)\n{\n if (text != null)\n {\n if (OutputLimit > 0)\n {\n _totalOutputLength += count;\n if (_totalOutputLength > OutputLimit)\n {\n throw new ScriptRuntimeException(CurrentSpan, \n $\"The output limit of {OutputLimit} characters was reached.\");\n }\n }\n // ... existing indent/write logic\n }\n return this;\n}\n```\n\nThis provides defense-in-depth: `LimitToString` caps individual object serialization, while `OutputLimit` caps total template output.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "NuGet",
19+
"name": "Scriban"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "7.0.0"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/scriban/scriban/security/advisories/GHSA-m2p3-hwv5-xpqw"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/scriban/scriban"
44+
}
45+
],
46+
"database_specific": {
47+
"cwe_ids": [
48+
"CWE-770"
49+
],
50+
"severity": "MODERATE",
51+
"github_reviewed": true,
52+
"github_reviewed_at": "2026-03-24T22:15:43Z",
53+
"nvd_published_at": null
54+
}
55+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-xcx6-vp38-8hr5",
4+
"modified": "2026-03-24T22:15:13Z",
5+
"published": "2026-03-24T22:15:13Z",
6+
"aliases": [],
7+
"summary": "Scriban has Uncontrolled Recursion in `object.to_json` Causing Unrecoverable Process Crash via StackOverflowException",
8+
"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 — `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 — 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` — stores a reference in `ScriptObject.Store` dictionary\n3. Pipes to `object.to_json`: `x | object.to_json` → 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 — infinite loop\n7. `StackOverflowException` is thrown — **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 — `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 && depth > 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```",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "NuGet",
19+
"name": "Scriban"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "7.0.0"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/scriban/scriban/security/advisories/GHSA-xcx6-vp38-8hr5"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/scriban/scriban"
44+
}
45+
],
46+
"database_specific": {
47+
"cwe_ids": [
48+
"CWE-674"
49+
],
50+
"severity": "HIGH",
51+
"github_reviewed": true,
52+
"github_reviewed_at": "2026-03-24T22:15:13Z",
53+
"nvd_published_at": null
54+
}
55+
}

0 commit comments

Comments
 (0)