Skip to content

fix: sandbox chat template rendering to prevent RCE via poisoned models#1475

Open
AUTHENSOR wants to merge 1 commit into
guidance-ai:mainfrom
AUTHENSOR:fix/sandbox-chat-template-rendering
Open

fix: sandbox chat template rendering to prevent RCE via poisoned models#1475
AUTHENSOR wants to merge 1 commit into
guidance-ai:mainfrom
AUTHENSOR:fix/sandbox-chat-template-rendering

Conversation

@AUTHENSOR

Copy link
Copy Markdown

fix: sandbox chat template rendering to prevent RCE via poisoned models

Summary

Chat templates loaded from model files (HuggingFace Hub, local paths) were
rendered with an unsandboxed jinja2.Environment, enabling server-side
template injection (SSTI)
. A poisoned model file with a crafted
chat_template in its tokenizer_config.json achieves arbitrary code
execution
when loaded by guidance.

HuggingFace's transformers library uses ImmutableSandboxedEnvironment for
the same operation precisely because chat templates from model files are
untrusted. Guidance extracted the template string and re-rendered it outside
HF's sandbox, bypassing that protection.

Fix: Replace Environment with ImmutableSandboxedEnvironment (one-line
change). This is the same class HuggingFace uses for chat template rendering.

The vulnerability

# guidance/models/_engine/_engine.py:676 (before)
from jinja2 import BaseLoader, Environment

chat_template = self.get_chat_template().template_str
rtemplate = Environment(loader=BaseLoader).from_string(chat_template)  # ← UNSANDBOXED
rendered_prompt = rtemplate.render(...)

Environment (not sandboxed) allows full access to Python internals via
Jinja's template object chain. A malicious template executes arbitrary Python.

Guidance does not import or use any Jinja sandbox anywhere in the codebase
(confirmed: grep -rn "SandboxedEnvironment" guidance/ returns empty).

Reproduction

from jinja2 import Environment, BaseLoader

# A malicious chat_template (embedded in a poisoned model's tokenizer_config.json)
malicious_template = """{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}"""

# This is guidance's rendering code at _engine.py:676
rtemplate = Environment(loader=BaseLoader).from_string(malicious_template)
result = rtemplate.render(messages=[], tools=None, add_generation_prompt=True)

print(result)
# Output: uid=501(user) gid=20(staff) groups=...  ← arbitrary command execution

After the fix, the same template raises SecurityError: access to attribute '__init__' of 'TemplateReference' object is unsafe.

Supply-chain attack scenario

  1. Attacker uploads a model to HuggingFace Hub with a crafted
    tokenizer_config.json containing a malicious chat_template field
  2. Victim loads the model: model = guidance.models.Transformers("attacker/model")
  3. When the victim sends a chat message, guidance renders the chat template
    via the unsandboxed Environment
  4. The template executes arbitrary Python on the victim's machine
  5. The attacker has RCE on the developer's workstation

The victim does nothing wrong — loading a model from HuggingFace Hub is the
normal usage pattern.

Why this bypasses HuggingFace's protection

HuggingFace's transformers renders chat templates via apply_chat_template(),
which uses ImmutableSandboxedEnvironment internally. Guidance does NOT call
apply_chat_template(). Instead, it extracts the template string
(self.get_chat_template().template_str, sourced from
hf_tokenizer.chat_template at _transformers.py:85) and re-renders it with
its own unsandboxed Environment. This re-rendering bypasses the sandbox that
HuggingFace applies to the same template.

The fix

# after
from jinja2 import BaseLoader
from jinja2.sandbox import ImmutableSandboxedEnvironment

rtemplate = ImmutableSandboxedEnvironment(loader=BaseLoader).from_string(chat_template)

One-line change. ImmutableSandboxedEnvironment is the same class HuggingFace
uses. It blocks attribute access to Python internals (__init__, __globals__,
__builtins__, __class__) while rendering normal templates correctly.

Tests

5 tests in tests/unit/test_chat_template_sandbox.py:

  • test_sandbox_blocks_attribute_access_ssti: the __init__.__globals__
    chain that achieves RCE is blocked
  • test_sandbox_blocks_class_attribute_access: the __class__.__mro__
    chain is blocked
  • test_sandbox_renders_normal_chat_template: normal templates (loops,
    conditionals, variable access) render correctly (no false positive)
  • test_sandbox_renders_template_with_tojson_filter: the tojson filter
    (used in real chat templates for tool definitions) works under the sandbox
  • test_sandbox_renders_llama3_style_template: a Llama 3-style template
    with bos_token, header tokens, and trim filter renders correctly
$ python -m pytest tests/unit/test_chat_template_sandbox.py -v
============================== 5 passed in 0.01s ==============================

No regression: 30 existing unit tests pass unchanged.

Impact

  • Arbitrary code execution: a poisoned model file achieves RCE on any
    machine that loads it via guidance and sends a chat message
  • Supply-chain vector: HuggingFace Hub hosts user-uploaded models
  • Severity: Critical — RCE via the model-loading supply chain

Scope

Two files: guidance/models/_engine/_engine.py (the fix) and
tests/unit/test_chat_template_sandbox.py (new test file). Diff: +84/−2.

Chat templates loaded from model files (HuggingFace Hub, local paths) were
rendered with an unsandboxed jinja2.Environment, enabling server-side template
injection (SSTI). A poisoned model file with a crafted chat_template in its
tokenizer_config.json achieves arbitrary code execution when loaded by guidance.

HuggingFace's transformers uses ImmutableSandboxedEnvironment for the same
operation precisely because chat templates from model files are untrusted.
Guidance extracted the template string and re-rendered it outside HF's sandbox,
bypassing that protection.

Fix: replace Environment with ImmutableSandboxedEnvironment (the same class
HuggingFace uses). 5 tests: blocks __init__ chain SSTI, blocks __class__ chain
SSTI, renders normal templates, renders tojson filter, renders Llama3-style
template.

Signed-off-by: John Kearney <johndanielkearney@gmail.com>
@AUTHENSOR AUTHENSOR force-pushed the fix/sandbox-chat-template-rendering branch from 866f0af to faffde5 Compare July 2, 2026 02:02

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this independently rather than taking the vulnerability claim on faith. Ran the exact repro from the PR description locally:

from jinja2 import Environment, BaseLoader
malicious_template = "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}"
Environment(loader=BaseLoader).from_string(malicious_template).render(messages=[], tools=None, add_generation_prompt=True)
# -> real shell output, e.g. "uid=1001(...) gid=1001(...) groups=..."

Confirmed. Then confirmed the fix actually closes it:

from jinja2.sandbox import ImmutableSandboxedEnvironment
ImmutableSandboxedEnvironment(loader=BaseLoader).from_string(malicious_template).render(...)
# -> jinja2.exceptions.SecurityError: access to attribute '__init__' of 'TemplateReference' object is unsafe.

Also checked whether this is the only unsandboxed rendering path in the codebase before trusting the "only one call site" framing: grepped for Environment( and jinja2 imports repo-wide, and _engine.py:676 is the only real usage (the other hit, _tokenizer.py, is just a comment mentioning jinja2, no actual Environment call). So this one-line fix does close the whole hole, not just one instance of a wider pattern.

The new test file is solid too: it doesn't just check that the malicious payload raises something, it also has three tests confirming normal chat templates (loops, conditionals, tojson, Llama-3-style headers) still render correctly under the sandbox, so this isn't trading RCE for broken legitimate templates.

This is a real, serious fix (server-side template injection -> RCE from a poisoned model file's tokenizer_config.json is about as bad as it gets for a library that loads models from arbitrary sources). Looks correct and complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants