04 /ExperimentsArticle · 2026-07-05
← Back to experiments

Chat-Template SSTI: The Inference-Time Supply-Chain Risk in Modern Model Files, and Testing Scanner Coverage

Correction, 2026-07-21: The original version described a 16-of-20 fixture count as a clean scanner-coverage result and called the two misses responsibly disclosed gaps. Upstream review of PR #1728 showed that its proposed generic regex duplicated findings, flagged quoted or locally shadowed values, and still missed legal whitespace. A safer AST/GGUF direction in PR #1733 also remained incomplete and was closed. Neither patch merged, and this work did not establish a genuine vulnerability. The historical experiment is retained below with its limits made explicit.

Reference writeup. cipher security research, 2026-07-05, corrected 2026-07-21. Defensive and educational. It contains no operational exploit payloads; the gadget families named are long-documented public Jinja2 SSTI knowledge.

The vector: your chat template is code that runs at inference

Most model-file security attention goes to load-time deserialization (pickle and friends, covered in the companion writeup). There is a second, newer surface that load-time scanning alone misses: the chat template.

Modern local-LLM files (GGUF, used by llama.cpp, Ollama, LM Studio, and many others) embed a Jinja2 chat template in metadata (tokenizer.chat_template). Every time you format a conversation for the model, something renders that template. Jinja2 is a full template engine, and untrusted templates are a classic Server-Side Template Injection (SSTI) surface. A hostile chat template shipped inside a model file is therefore an inference-time code path, not a load-time one, which is exactly why it slips past a mental model built around "scan the weights before loading." CVE-2024-34359 (the llama-cpp-python chat_template SSTI) is the canonical instance.

The same risk exists in Keras Lambda layers and any place a model file carries a template or a serialized callable that a framework later renders or reconstructs.

How a scanner is supposed to catch it

A model scanner has to do two things here. First, parse the container (GGUF) and pull the embedded tokenizer.chat_template metadata. Second, inspect that template for SSTI constructs. ModelAudit (promptfoo) does both: its GGUF scanner extracts the chat-template metadata (up to 50 KB) and hands it to the same Jinja2 SSTI pattern set its standalone template scanner uses, and it can additionally run the template through a sandboxed Jinja2 render probe with memory and output budgets. That is a good design, so the interesting question is coverage: which SSTI constructs does the pattern set actually recognize?

The coverage test

Fully defensive and reproducible. We built a matrix of Jinja2 chat-template fixtures, each exercising one SSTI construct class with a benign marker (the "payload" only reaches a canary string, so the fixtures are inert). We tested each construct two ways: as a standalone template file, and embedded as the tokenizer.chat_template metadata inside a minimal GGUF. We also included benign control templates (normal role-loop chat templates) to measure false positives. Output is a detection count table with no raw template bodies.

Results

construct family standalone GGUF-embedded
attribute traversal (__class__ / __mro__ / __subclasses__) detected detected
config / self.__init__.__globals__ detected detected
direct eval / import markers detected detected
os.environ / config item access detected detected
cycler / joiner / namespace globals gadgets detected detected
request object walk detected detected
|attr() filter / item-dunder obfuscation detected (S604) detected (S604)
lipsum.__globals__ gadget MISSED MISSED
get_flashed_messages.__globals__ gadget MISSED MISSED
benign control templates no false positive no false positive

The initial harness reported 16 of 20 construct fixtures detected in each delivery and no failures on its small benign-control set. That number is a historical fixture count, not a valid precision claim. The harness discarded native finding cardinality and lacked adversarial cases for quoted text, local shadowing, whitespace-separated access, dead branches, aliases, and receiver provenance. Later review proved that a patch could improve the motivating positive cases while adding duplicate or false-positive findings and still missing equivalent legal syntax.

Two things worth calling out. First, the GGUF path is as strong as the standalone path: every construct the standalone scanner caught was also caught when hidden inside the GGUF chat-template metadata, so the container did not hide anything. That is the reassuring result, and it is the part most people would assume is weakest. Second, the two misses are real and verifiable: ModelAudit's Jinja2 SSTI pattern set explicitly enumerates the cycler, joiner, namespace, request, and config globals-access gadgets, but does not name the lipsum or get_flashed_messages gadgets, which are staples of every Jinja2 SSTI reference. A template reaching the global namespace through those two objects, in a form that also avoids the generic __globals__[...] subscript pattern, is not flagged.

Correction and upstream outcome

The two motivating misses were reported to the ModelAudit maintainers in PR #1728, but the proposed generic .__globals__ regex was not safe to merge. It overlapped named patterns, produced duplicate and false-positive findings, and was syntax-sensitive. PR #1733 moved toward scoped AST and GGUF analysis, but exact-head review found unresolved provenance, scope, and fallback cases. It was also closed.

The accurate outcome is a scanner-QA lesson, not a successful vulnerability disclosure: detection changes need parser-aware semantics, paired positive and negative fixtures, native finding-count checks, and exact-source reproduction through every delivery path. The earlier wording overstated what this experiment established.

What to actually do

  • Treat the chat template as code. A GGUF or Keras file is not just weights. Whatever renders a chat template at inference is inside your trust boundary; scope it the same way you scope model loading.
  • Prefer allowlist template validation over denylist patterns. Denylists of SSTI gadgets are valuable but, as this study shows, are only as complete as their last update. Where you control the serving stack, render chat templates in a sandboxed environment (Jinja2's SandboxedEnvironment) rather than relying solely on pattern matching, and constrain templates to a known-good shape.
  • Scan the container, not just the weights. Make sure your model scanner actually extracts and inspects embedded metadata (chat templates, custom code) and does not stop at the tensor buffer.
  • Keep gadget denylists current. If you maintain SSTI detection, treat the gadget list as a living artifact and test it against a construct matrix, which is cheap to do and catches exactly this class of drift.

Published as corrected defensive reference material. The upstream proposal was closed after review; no merged fix or genuine vulnerability claim is implied. The SSTI gadget families referenced are public knowledge, and no operational payloads or working evasion strings appear here.

More security experiments and build notes live in the experiments index.