GHSA-XCX6-VP38-8HR5

Vulnerability from github – Published: 2026-03-24 22:15 – Updated: 2026-03-24 22:15
VLAI?
Summary
Scriban has Uncontrolled Recursion in `object.to_json` Causing Unrecoverable Process Crash via StackOverflowException
Details

Summary

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() at TemplateContext.Helpers.cs:98 checks ObjectRecursionLimit (default 20)
  • EnterRecursive() at TemplateContext.cs:957 calls RuntimeHelpers.EnsureSufficientExecutionStack()
  • CheckAbort() at TemplateContext.cs:464 also calls EnsureSufficientExecutionStack()

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:

  1. Template creates a ScriptObject: {{ x = {} }}
  2. Sets a self-reference: x.self = x — stores a reference in ScriptObject.Store dictionary
  3. Pipes to object.to_json: x | object.to_json → calls ToJson() at line 477
  4. ToJson() calls WriteValue(context, writer, value) at line 488
  5. WriteValue enters the else branch (line 515), gets members via accessor, finds "self"
  6. TryGetValue returns x itself, WriteValue recurses with the same object — infinite loop
  7. StackOverflowException is 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 — StackOverflowException terminates the .NET process.
  • 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.
  • No authentication required: object.to_json is a default builtin function (registered in BuiltinFunctions.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();
    }
}
Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…