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

Scriban's TypedObjectAccessor lets template code write any CLR property, including private, internal and init-only setters, permanently mutating host objects after Render().

- Payout: Research
- Severity: high
- Program: Scriban
- Found by: CyberWarrior9
- Disclosed: 2026-07-06
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-7jvp-hj45-2f2m
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-7jvp-hj45-2f2m
- Tags: area:oss domain:oss impact:integrity kind:research platform:ghsa sev:high tech:dotnet technique:source-review vuln:access-control

**Target:** Scriban template engine TypedObjectAccessor (NuGet library)
**Root cause:** TrySetValue calls PropertyInfo.SetValue without checking CanWrite or setter visibility, and PrepareMembers builds a single getter-filtered member list shared by reads and writes, so reflection ignores C# accessibility and init-only guarantees.
**Steps:**
1. Push a CLR object into context: `so["user"] = currentUser; context.PushGlobal(so);`
2. Render template `{{ user.is_admin = true }}` (or `{{ order.total_price = 0 }}`)
3. Observe template reads original value, writes, reads back modified value
4. After Render() returns, C#-side read of the host object shows the mutated value persisted
5. Repeat against private set / internal set / init-only properties; all succeed on package 7.2.1
**How found:** Code review of TypedObjectAccessor: noticed PrepareMembers filters on getter only and TrySetValue never checks the setter, then hypothesized reflection would ignore private/internal/init modifiers and confirmed against shipped 7.2.1.
**Exploitation:** Any host that renders untrusted templates with CLR objects in context allows the template author to set arbitrary writable properties (e.g. is_admin, total_price) on live host objects, persisting past Render().
**Insight:** When a template/expression engine exposes CLR objects via reflection, check whether the member enumeration used for reads is reused for writes; getter-only filters silently grant write access, and init-only setters are public so only the IsExternalInit modreq distinguishes them.

## Proof of concept (copied exactly from the original report)

**Host pushes CLR object into TemplateContext**

```
var so = new ScriptObject();
so["user"] = currentUser;   // direct CLR reference
context.PushGlobal(so);
```

**Docker fallback to run PoC**

```
docker run --rm -v "$PWD":/work -w /work/reports/f007/poc \
  mcr.microsoft.com/dotnet/sdk:10.0 bash -lc "dotnet run -c Release"
```

