# Scriban: Sandbox escape due to TypedObjectAccessorcache bypassing MemberFilter after TemplateContext reuse

Scriban caches TypedObjectAccessor by Type only, so tightening MemberFilter on a reused TemplateContext still exposes previously allowed members like Secret.

- Payout: Research
- Severity: critical
- Program: Scriban
- Found by: Zwique, adamus2
- Disclosed: 2026-03-24
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-5wr9-m6jw-xx44
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-5wr9-m6jw-xx44
- Tags: area:oss domain:oss impact:data-leak impact:integrity kind:research platform:ghsa sev:critical tech:dotnet technique:source-review vuln:access-control vuln:misconfiguration vuln:sandbox-escape

**Target:** Scriban template engine TemplateContext member accessor cache
**Root cause:** TemplateContext._memberAccessors caches TypedObjectAccessor keyed only by Type, but the accessor precomputes its exposed member set from the MemberFilter/MemberRenamer at construction time; TemplateContext.Reset() does not clear the cache, so later filter changes are ignored.
**Steps:**
1. Create a TemplateContext and push a ScriptObject with a sensitive model.
2. Set `context.MemberFilter = _ => true;` and render `{{ model.secret }}` — accessor for the type is cached with all members exposed.
3. Call `context.Reset()` and push a new globals object with the same model type.
4. Set `context.MemberFilter = member => member.Name == nameof(SensitiveModel.Public);` and render again.
5. Observe output `second=leaked`: the stale cached accessor still exposes `Secret` despite the tightened filter.
**How found:** Code review of the member-access path: noticed GetMemberAccessor caches by Type only while TypedObjectAccessor precomputes members from the current filter, and that Reset() omits clearing _memberAccessors — hypothesized filter changes wouldn't apply on reuse, confirmed with a two-render PoC.
**Exploitation:** An app that pools/reuses a TemplateContext across requests or tenants and relies on MemberFilter as a sandbox boundary: an earlier permissive render caches an accessor exposing all members; subsequent renders with a restrictive filter still read (and potentially write, if setters exist) filtered members, leaking data across users/tenants.
**Escalation:** Cross-request/cross-tenant policy bypass when contexts are pooled; write access possible if the filtered member has a setter.
**Insight:** When a framework caches security-relevant derived objects (accessors, serializers, permission maps), check the cache key: if it omits the policy/config inputs used to build the value, and the reset/teardown path doesn't evict it, policy changes silently don't apply on reused contexts.

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

**C# PoC reusing TemplateContext to bypass MemberFilter**

```
using System.Reflection;
using Scriban;
using Scriban.Runtime;

var template = Template.Parse("{{ model.secret }}");

var context = new TemplateContext
{
    EnableRelaxedMemberAccess = false
};

var globals = new ScriptObject();
globals["model"] = new SensitiveModel();
context.PushGlobal(globals);

context.MemberFilter = _ => true;
Console.WriteLine("first=" + template.Render(context));

context.Reset();

var globals2 = new ScriptObject();
globals2["model"] = new SensitiveModel();
context.PushGlobal(globals2);

context.MemberFilter = member => member.Name == nameof(SensitiveModel.Public);

Console.WriteLine("second=" + template.Render(context));

sealed class SensitiveModel
{
    public string Public => "ok";
    public string Secret => "leaked";
}
```

