Get a Demo

Let's Patch It!

Book a short call with one our specialists, we'll walk you through how Endor Patches work, and ask you a few questions about your environment (like your primary programming languages and repository management). We'll also send you an email right after you fill out the form, feel free to reply with any questions you have in advance!

CVE

CVE-2026-73061

Scriban: Template Writes to Arbitrary CLR Properties via `TypedObjectAccessor` (Mass Assignment + `private` / `init` / `internal` Setter Bypass)
Back to all
CVE

CVE-2026-73061

Scriban: Template Writes to Arbitrary CLR Properties via `TypedObjectAccessor` (Mass Assignment + `private` / `init` / `internal` Setter Bypass)

<!-- obsidian --><h2 data-heading="Description">Description</h2>

<p>When a host pushes a CLR object into a Scriban <code>TemplateContext</code> via the standard, documented pattern —</p>

<pre><code class="language-csharp">var so = new ScriptObject();

so["user"] = currentUser;   // direct CLR reference

context.PushGlobal(so);

</code></pre>

<p>— <code>TypedObjectAccessor</code> exposes every public-getter property for <strong>both reading and writing</strong>, and writes land on the live host object and <strong>persist after <code>Render()</code> returns</strong>. The write path performs no <code>CanWrite</code> and no setter-visibility check, producing two related but distinct weaknesses:</p>

<p><strong>(A) Mass assignment of public setters — CWE-915 (originally F-002).</strong> Any <code>{ get; set; }</code> property is writable from template code (<code>{{ user.isadmin = true }}</code>, <code>{{ order.totalprice = 0 }}</code>). This is "surprising but technically consistent with the setter being public" — and crucially, Scriban offers <strong>no way to expose such a property read-only</strong>, because <code>MemberFilter</code> is read/write-symmetric.</p>

<p><strong>(B) Access-modifier bypass — CWE-284 (originally F-007).</strong> Properties the developer <strong>deliberately</strong> restricted are also writable, because reflection ignores C# accessibility:</p>

Declaration | Developer intent | Actual behavior

-- | -- | --

{ get; set; } | writable | writable (mass assignment — A)

{ get; private set; } | only the owning class writes | template writes freely

{ get; internal set; } | only the declaring assembly writes | template writes freely

{ get; init; } | immutable after construction (C# 9 language guarantee) | template writes freely post-construction

<p>The <code>init</code>-only post-construction write — the highest false-positive risk — was explicitly confirmed against the shipped 7.2.1 package.</p>

<h2 data-heading="Affected Versions">Affected Versions</h2>

<p>All releases that ship <code>TypedObjectAccessor</code> (<code>&#x3C;= 7.2.1</code>). <code>PrepareMembers</code> has used the getter-only filter since the accessor was introduced, and <code>TrySetValue</code> has never checked the setter. The <code>init</code> bypass applies on .NET 5+; <code>private set</code> / <code>internal set</code> apply on every supported runtime. No patched version exists.</p>

<h2 data-heading="Steps to Reproduce">Steps to Reproduce</h2>

<blockquote>

<p>Copy-paste. Run from the engagement root (the folder containing both <code>scriban/</code> and <code>reports/</code>).</p>

</blockquote>

<p><strong>Prereqs:</strong></p>

<pre><code class="language-bash">test -d scriban || { echo "scriban source missing"; exit 1; }

( command -v dotnet >/dev/null &#x26;&#x26; dotnet --list-sdks | grep -q '^10.' ) \

  || ( "$HOME/.dotnet/dotnet" --list-sdks | grep -q '^10.' ) \

  || { echo ".NET 10 SDK missing"; exit 1; }

export PATH="$HOME/.dotnet:$PATH"

</code></pre>

<p><strong>Run both PoCs (native):</strong></p>

<pre><code class="language-bash">( cd reports/f002/poc &#x26;&#x26; dotnet run -c Release )   # (A) public-setter mass assignment

( cd reports/f007/poc &#x26;&#x26; dotnet run -c Release )   # (B) private/internal/init bypass

</code></pre>

<p><strong>Docker fallback (no native SDK required):</strong></p>

<pre><code class="language-bash">docker run --rm -v "$PWD":/work -w /work/reports/f007/poc \

  mcr.microsoft.com/dotnet/sdk:10.0 bash -lc "dotnet run -c Release"

</code></pre>

<p><strong>Confirm the published package is affected (not just master):</strong> swap the <code>ProjectReference</code> in <code>reports/f007/poc/poc.csproj</code> for <code>&#x3C;PackageReference Include="Scriban" Version="7.2.1" /></code> and re-run — the four bypasses still succeed.</p>

<p>Each PoC prints <code>[1]</code> original CLR values, <code>[2]</code> template output (reads originals → writes → reads back), and <code>[3]</code> the <strong>C#-side</strong> read after <code>Render()</code> proving the live host object was permanently altered.</p>

<h2 data-heading="Remediation">Remediation</h2>

<p>Fixes are listed flat. Note that (B) has a clean, clearly-correct code fix; (A) requires a <em>new control</em> because public-setter writes are otherwise by-design.</p>

<ul>

<li><strong>Fix 1 — block restricted setters in <code>TrySetValue</code> (<code>TypedObjectAccessor.cs</code> L108–L123). Fixes (B).</strong> Before the L120 <code>SetValue</code>, require a public, non-<code>init</code> setter:

<pre><code class="language-csharp">var setM = propertyAccessor.GetSetMethod(nonPublic: false);

if (setM is null) return false;   // private / internal / protected setters

if (setM.ReturnParameter.GetRequiredCustomModifiers()

      .Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit"))

    return false;                 // init-only: setter IS public, so the IsExternalInit check is REQUIRED

</code></pre>

A plain <code>GetSetMethod(nonPublic:false) != null</code> check is <strong>not</strong> sufficient for <code>init</code> — the init setter is public; only the <code>IsExternalInit</code> modreq distinguishes it.</li>

<li><strong>Fix 2 — give hosts a read/write distinction (addresses (A)).</strong> Add a <code>MemberWriteFilter</code> on <code>TemplateContext</code> (separate from <code>MemberFilter</code>) and/or a <code>[ScriptMemberReadOnly]</code> attribute, and split <code>members</code> into <code>readableMembers</code> / <code>_writableMembers</code> in <code>PrepareMembers</code> (L126–L186). Public-settable mass assignment cannot be blocked without one of these, because <code>MemberFilter</code> is read/write-symmetric today.</li>

<li><strong>Fix 3 — restore read-only-by-default on <code>ScriptObject.Import</code> (<code>ScriptObjectExtensions.cs</code> L320–L324).</strong> Gate the Liquid-compatibility relaxation behind an explicit opt-in instead of removing write protection globally.</li>

<li><strong>Fix 4 — documentation (<code>site/docs/runtime/safe-runtime.md</code>).</strong> State explicitly that templates can write CLR properties via reflection (including <code>private</code>/<code>internal</code>/<code>init</code> setters), and that <code>MemberFilter</code> does not separate read from write.</li>

<li><strong>Fix 5 — regression tests (<code>src/Scriban.Tests/</code>).</strong> Assert <code>private set</code> / <code>internal set</code> / <code>init</code> are non-writable from templates, that <code>MemberWriteFilter</code> / <code>[ScriptMemberReadOnly]</code> gate writes, and that only public <code>set</code> is writable.</li>

</ul>

<h2 data-heading="References">References</h2>

<ul>

<li>Vulnerable write path (no setter check): <code>scriban/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs</code> L108–L123 (<code>TrySetValue</code>), sink at L120 <code>propertyAccessor.SetValue(target, context.ToObject(span, value, propertyAccessor.PropertyType));</code></li>

<li>Getter-only member filter: <code>TypedObjectAccessor.cs</code> L126–L186 (<code>PrepareMembers</code>), enumeration at L150, gate at L156; same <code>_members</code> consumed by <code>TryGetValue</code> (L66–L83)</li>

<li>Member-assignment dispatch: <code>scriban/src/Scriban/ScribanAsync.generated.cs:2297</code> (<code>accessor.TrySetValue(...)</code>) and the synchronous evaluator</li>

<li>No read/write separation: <code>MemberFilter</code> declared <code>TemplateContext.cs:286</code>, applied <code>TemplateContext.cs:1026</code>; <code>ScriptObject.Import</code> read-only removal <code>ScriptObjectExtensions.cs:320–324</code></li>

<li>.NET reflection bypasses access modifiers: <a href="https://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue" class="external-link" target="_blank" rel="noopener nofollow">https://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue</a></li>

<li><code>init</code> accessors (C# 9): <a href="https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/init" class="external-link" target="_blank" rel="noopener nofollow">https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/init</a></li>

<li>CWE-915 — <a href="https://cwe.mitre.org/data/definitions/915.html" class="external-link" target="_blank" rel="noopener nofollow">https://cwe.mitre.org/data/definitions/915.html</a></li>

<li>CWE-284 — <a href="https://cwe.mitre.org/data/definitions/284.html" class="external-link" target="_blank" rel="noopener nofollow">https://cwe.mitre.org/data/definitions/284.html</a></li>

</ul>

Package Versions Affected

Package Version
patch Availability
No items found.

Automatically patch vulnerabilities without upgrading

Fix Without Upgrading
Detect compatible fix
Apply safe remediation
Fix with a single pull request

CVSS Version

Severity
Base Score
CVSS Version
Score Vector
C
H
U
9.3
-
4.0
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
C
H
U
0
-
C
H
U
7.5
-
3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N

Related Resources

No items found.

References

https://github.com/scriban/scriban/security/advisories/GHSA-7jvp-hj45-2f2m, https://github.com/scriban/scriban

Severity

7.5

CVSS Score
0
10

Basic Information

Base CVSS
7.5
EPSS Probability
0%
EPSS Percentile
0%
Introduced Version
0,3.8.0,0.7.0,0.4.0,0.1.0,2.1.0,0.9.0
Fix Available
7.2.2,7.2.3

Fix Critical Vulnerabilities Instantly

Secure your app without upgrading.
Fix Without Upgrading