Step 193. SSTI: 템플릿 인젝션 → RCE — 입력이 서버의 코드가 될 때

Step 193. SSTI: 템플릿 인젝션 → RCE — 입력이 서버의 코드가 될 때

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 3.5시간

전제: Step 135~137의 인젝션 사고방식(입력이 문법이 된다), Step 138의 XSS. 파이썬 Flask로 간단한 서버를 띄울 수 있다.

  • 준비물: 파이썬 3 + Flask(취약 서버 직접 제작), requests 또는 브라우저.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. PortSwigger Web Security Academy는 공격 연습을 위해 만들어진 합법 학습 플랫폼입니다.
  • 주의: 오늘의 핵심 — {{7*7}} 탐지부터 {{config}} 노출까지 — 는 여러분이 직접 띄우는 로컬 서버에서 전부 실측합니다. RCE 단계는 파괴력 때문에 개념과 페이로드 구조 설명에 그치며, 직접 실행하지 않습니다. PortSwigger 랩 화면은 화면 예시입니다.

인젝션 패밀리의 마지막 변종을 만납니다. SQL 인젝션은 입력이 SQL 문법이 됐고, XSS는 입력이 HTML 문법이 됐습니다. SSTI(Server-Side Template Injection) 는 입력이 템플릿 엔진의 문법이 됩니다. 웹 프레임워크가 화면을 그릴 때 쓰는 {{ name }} 같은 템플릿 문법에 사용자 입력이 끼어들면, {{7*7}}49로 계산되고 — 여기서 끝나지 않습니다. 템플릿이 실행되는 곳은 서버 안이므로, 잘 파고들면 서버의 OS 명령 실행(RCE) 까지 닿습니다. 오늘은 취약 서버를 직접 만들어 이 사슬의 앞부분을 실측합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • SSTI가 SQL 인젝션·XSS와 같은 뿌리(입력이 코드가 됨)임을 설명한다
  • {{7*7}}, ${7*7} 계열 탐지 페이로드로 SSTI 지점을 찾는다
  • {{7*'7'}}의 결과 차이로 템플릿 엔진을 식별한다
  • Jinja2에서 {{config}} 노출과 객체 계층 탐색(__class__, __mro__)의 의미를 설명한다
  • SSTI → RCE 사슬의 구조와 방어(render_template_string에 입력을 넣지 않기)를 말한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask(Jinja2 내장) — 취약 서버와 안전 서버를 직접 제작, requests(탐지 스크립트)
오늘의 페이로드 {{7*7}}(탐지), {{7*'7'}}(엔진 식별), {{config}}(설정 노출), {{ self.__class__.__mro__ }}(계층 탐색)
필요한 개념 템플릿 엔진(Jinja2/Twig), 서버 측 렌더링, 파이썬 객체 계층, 샌드박스의 한계
오늘의 산출물 lab193.py(취약 서버) + lab193_safe.py(안전 서버) + probe193.py(탐지기) + SSTI 공격 사슬 정리

2-1. 템플릿 엔진 — 화면을 만드는 공장

웹 프레임워크는 HTML을 문자열 더하기로 만들지 않고 템플릿 엔진으로 만듭니다. 파이썬 Flask의 기본 엔진이 Jinja2, PHP의 Symfony는 Twig를 씁니다. 둘은 문법이 거의 같습니다.

# 정상 사용: 템플릿과 데이터가 분리돼 있다
render_template_string("<h1>Hello {{ name }}!</h1>", name=name)

여기서 name데이터입니다. 사용자가 {{7*7}}을 내도 그 글자가 그대로 화면에 나올 뿐입니다 — 템플릿 코드가 아니라 변수의 값이니까요.

2-2. 취약한 사용 — 입력이 템플릿 "문자열"이 아니라 템플릿 "코드"가 될 때

# 취약: 사용자 입력이 템플릿 본문에 이어 붙는다
render_template_string("<h1>Hello " + name + "!</h1>")

이 코드에서는 사용자가 보낸 {{7*7}}템플릿의 일부로 파싱됩니다. Jinja2는 {{ }}를 "표현식 평가"로 해석하므로, 화면에는 49가 나옵니다. 이 한 줄의 차이가 SSTI의 전부입니다 — 익숙한 그 문장, 입력이 데이터가 아니라 문법이 될 때 인젝션이 태어납니다.

2-3. 공격 사슬 — 탐지 → 식별 → 탐색 → RCE

SSTI 공격은 정해진 순서를 따릅니다.

  1. 탐지: 모든 입력 지점에 {{7*7}}을 넣어 49가 나오는 곳을 찾습니다. 엔진이 다를 것을 대비해 ${7*7}, <%= 7*7 %>도 번갈아 넣습니다.
  2. 식별: {{7*'7'}}을 넣습니다. 결과가 7777777(문자열 7번 반복)이면 Jinja2/Twig, 49(숫자 곱셈)면 다른 엔진(예: 일부 PHP 엔진)입니다. 엔진이 달라지면 이후 페이로드가 전부 달라지므로 이 단계가 중요합니다.
  3. 탐색: Jinja2라면 {{config}}로 서버 설정을 들여다보고, __class__, __mro__ 같은 파이썬 객체 속성을 타고 올라가며 쓸 수 있는 클래스를 찾습니다.
  4. RCE: 객체 계층 어딘가의 subprocess 계열 클래스를 찾아 OS 명령을 실행합니다. 오늘은 이 단계를 실행하지 않고 구조만 봅니다 (2-4).

2-4. 왜 RCE까지 되는가 — 그리고 오늘의 경계

Jinja2 템플릿 안에서는 파이썬 객체에 접근할 수 있습니다. self.__class__.__mro__처럼 클래스 계층을 타고 올라가 최상위 object에서 모든 하위 클래스 목록(__subclasses__())을 얻으면, 그 안에 파일 읽기·프로세스 실행이 가능한 클래스가 있습니다. 전형적인 사슬:

{{ ''.__class__.__mro__[1].__subclasses__() }}   ← 하위 클래스 전체 목록
   → 그중 subprocess.Popen 계열을 찾아
   → .__init__나 메서드로 OS 명령 실행

오늘의 경계: 여기까지가 "개념 설명"입니다. 이 챕터의 로컬 실측은 3단계(설정 노출·계층 탐색)까지로 제한합니다. 이유는 두 가지 — ① RCE 페이로드는 복사해 어디에든 쓸 수 있는 완제품이라 입문 책에 실행 결과를 싣는 것이 부적절하고, ② __subclasses__()의 인덱스는 환경마다 달라 어차피 그대로 복사해 쓸 수 없기 때문입니다. 원리를 이해한 여러분이 내 랩에서 스스로 탐색하는 것, 그것이 정석입니다.


3. 따라 하기

3-1. 취약 서버와 안전 서버 — 한 줄 차이의 두 세계

lab193.py (교육용 취약 코드 — localhost 전용, 어디에도 배포 금지):

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route("/hello")
def hello():
    name = request.args.get("name", "guest")
    # 취약점: 사용자 입력이 템플릿 "코드"로 파싱된다
    return render_template_string("<h1>Hello " + name + "!</h1>")

if __name__ == "__main__":
    app.run(port=5193)

lab193_safe.py — 고친 버전:

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route("/hello")
def hello():
    name = request.args.get("name", "guest")
    # 안전: 입력은 데이터일 뿐, 템플릿 코드가 아니다
    return render_template_string("<h1>Hello {{ name }}!</h1>", name=name)

if __name__ == "__main__":
    app.run(port=5194)

두 파일을 나란히 열어 차이를 눈으로 확인하세요. 한 줄, 정확히는 인자 하나의 차이입니다. 이것이 방어의 전부라는 것을 기억해 두세요.

3-2. 탐지와 엔진 식별

두 서버를 띄우고 probe193.py를 실행합니다.

import requests

BASE = "http://127.0.0.1:5193/hello"

def inject(payload):
    return requests.get(BASE, params={"name": payload}).text

print("== 1. 탐지: {{7*7}} ==")
print("응답:", inject("{{7*7}}"))

print("== 2. 엔진 식별: {{7*'7'}} ==")
print("응답:", inject("{{7*'7'}}"))

출력 (2026-09-09 실측):

== 1. 탐지: {{7*7}} ==
응답: <h1>Hello 49!</h1>
== 2. 엔진 식별: {{7*'7'}} ==
응답: <h1>Hello 7777777!</h1>

읽는 법: ① {{7*7}}49로 계산됐습니다 — 입력이 템플릿 코드로 실행됐다는 확정 증거. ② {{7*'7'}}7777777 — 파이썬은 문자열 × 숫자를 반복으로 계산하므로, 이 서버는 Jinja2(파이썬 계열) 임을 식별했습니다. 같은 페이로드가 PHP Twig에서도 7777777을 내지만, 숫자 49를 내는 엔진과는 확실히 갈립니다.

안전 서버(5194)에 같은 것을 내면 (2026-09-09 실측):

안전 서버 응답: <h1>Hello {{7*7}}!</h1>

읽는 법: 템플릿과 데이터가 분리된 서버에서는 페이로드가 그냥 글자입니다. 공격의 성공과 실패가 한 줄 차이로 갈리는 것을 두 서버의 대비로 확인했습니다.

3-3. 서버 설정 노출 — {{config}}

print("== 3. 서버 설정 노출: {{config}} ==")
print("응답:", inject("{{config}}"))

print("== 4. 객체 계층 탐색: self.__class__.__mro__ ==")
print("응답:", inject("{{ self.__class__.__mro__ }}"))

출력 (2026-09-09 실측, 일부 생략·HTML 엔티티는 원래 모습):

== 3. 서버 설정 노출: {{config}} ==
응답: <h1>Hello &lt;Config {&#39;DEBUG&#39;: False, &#39;TESTING&#39;: False,
... &#39;SECRET_KEY&#39;: None, ... &#39;SESSION_COOKIE_NAME&#39;: &#39;session&#39;,
...}&gt;!</h1>
== 4. 객체 계층 탐색: self.__class__.__mro__ ==
응답: <h1>Hello (&lt;class &#39;jinja2.runtime.TemplateReference&#39;&gt;, &lt;class &#39;object&#39;&gt;)!</h1>

읽는 법: ① config는 Flask 앱의 설정 객체입니다 — 세션 쿠키 이름, 각종 플래그가 통째로 노출됐습니다. 실전에서 여기에 SECRET_KEY가 박혀 있으면 세션 쿠키 위조로 이어집니다 (Step 134의 서명을 떠올리세요 — 비밀키가 새면 서명은 무력합니다). ② self.__class__.__mro__는 "이 객체의 클래스 계보"입니다 — TemplateReferenceobject로 올라가는 사다리가 보입니다. 이 사다리의 꼭대기(object)에서 __subclasses__()를 부르면 프로세스에 로드된 모든 클래스가 열립니다. RCE로 가는 문이 여기 있습니다.

: 탐색 단계의 의미는 "서버 안에서 내가 손댈 수 있는 것의 지도"를 그리는 일입니다. config 노출만으로도 치명적인 경우가 많고(비밀키), 계층 탐색은 지도의 끝 — 명령 실행 경로 — 를 찾는 과정입니다.

3-4. RCE 단계의 구조 (개념 — 실행하지 않습니다)

여기서부터는 구조만 봅니다. 전형적인 Jinja2 RCE 사슬은 이렇게 생겼습니다 (개념 예시):

1단계: {{ ''.__class__.__mro__[1] }}            → 최상위 object 클래스 획득
2단계: ...__subclasses__()                      → 로드된 모든 하위 클래스 목록
3단계: 목록에서 subprocess.Popen 계열을 검색    → 인덱스는 환경마다 다름
4단계: 해당 클래스로 OS 명령 실행               → 서버 권한으로 명령이 돈다

2-4에서 말한 대로 오늘은 여기까지를 직접 실행하지 않습니다. 대신 두 가지를 기억하세요. ① 이 사슬이 가능한 이유는 템플릿이 파이썬 객체 공간에 접근하기 때문이고, ② 방어는 사슬의 중간을 끊는 게 아니라 입력이 템플릿 코드가 되는 것 자체를 막는 것(3-1의 안전 서버)입니다.

3-5. PortSwigger 랩에 적용하기 (화면 예시)

Academy의 "Server-side template injection" 경로도 같은 순서입니다.

  1. 랩의 입력 지점(상품 이름, 템플릿 편집기 등)에 {{7*7}}을 넣어 계산되는 곳을 찾습니다.
  2. {{7*'7'}}으로 엔진을 식별합니다 — 랩은 대부분 설명에 엔진을 밝힙니다.
  3. 엔진 문서(랩에 링크 있음)를 보고 설정 객체 노출 → 객체 탐색 순으로 진행합니다.
  4. 일부 랩은 샌드박스가 있습니다 — 우회가 랩의 주제이며, 힌트가 해당 엔진의 우회 기법을 가리킵니다.

로컬에서 직접 서버를 만들어 본 여러분은, 랩의 서버가 어떤 코드로 돼 있을지 상상할 수 있습니다 — 그 상상력이 SSTI 랩의 진짜 무기입니다.


4. 미션과 연습문제

미션 — 취약 서버 재현과 공격 사슬 문서화

  1. lab193.py, lab193_safe.py, probe193.py를 완성해 네 가지 출력(49, 7777777, config 노출, 클래스 계보)을 재현합니다.
  2. {{config.SECRET_KEY}}처럼 특정 키만 찍는 페이로드를 추가하고, 실제 서비스라면 여기에 무엇이 들어 있을지 조사해 적습니다.
  3. 안전 서버에 동일 페이로드를 보내 무력함을 확인하고, 두 서버의 코드 차이 한 줄을 캡처해 둡니다.
  4. 위키에 SSTI사슬.md — 탐지 → 식별 → 탐색 → RCE 4단계와 각 단계의 페이로드·주의를 정리합니다.

연습문제

문제 1. render_template_string("<h1>Hello " + name + "!</h1>")render_template_string("<h1>Hello {{ name }}!</h1>", name=name)의 차이를 "입력이 어느 단계에서 처리되는가"로 설명해 보세요.

문제 2. {{7*'7'}}7777777이면 Jinja2/Twig로 식별할 수 있는 이유를 파이썬의 연산 규칙으로 설명해 보세요.

문제 3. {{config}} 노출이 왜 단순 정보 유출을 넘어 위험한지, Step 134의 세션 서명과 연결해 설명해 보세요.

문제 4. SSTI의 근본 방어가 "필터 강화"가 아닌 이유를 오늘의 안전 서버 코드를 근거로 말해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

검증하는 법: ① 취약 서버에서 {{7*7}}49, {{7*'7'}}7777777이 확인되는가 (2026-09-09 실측 기준). ② {{config}} 응답에 설정 키 목록이 포함되는가. ③ 안전 서버에서는 모든 페이로드가 글자 그대로 출력되는가. ④ 정리 문서에 4단계 사슬과 "RCE 페이로드는 인덱스가 환경 의존적"이라는 주의가 있는가.

연습문제 해답

문제 1 해답. 취약 버전은 입력이 템플릿 문자열에 이어 붙어 엔진의 파싱 단계를 통과합니다 — {{ }}가 코드로 평가됩니다. 안전 버전은 템플릿이 먼저 컴파일되고 입력은 렌더링 단계에서 변수 값으로만 치환됩니다 — {{ }}를 포함한 입력은 그저 글자 데이터입니다. 같은 입력이 "파싱 전에 템플릿이 되느냐, 파싱 후에 값이 되느냐"의 차이입니다.

문제 2 해답. 파이썬에서 '문자열' * 숫자는 반복 연산입니다 — '7' * 77777777. 반면 숫자 계산을 하는 엔진은 7 * '7'을 49로 평가합니다. 같은 페이로드에 대한 답이 엔진의 언어 규칙을 드러내므로 식별 신호가 됩니다.

문제 3 해답. Flask의 세션 쿠키는 SECRET_KEY로 서명됩니다 (Step 134). config에 이 키가 노출되면 공격자가 임의의 세션을 스스로 서명해 위조할 수 있습니다 — 관리자 세션을 만드는 것까지 가능합니다. 정보 유출이 인증 우회로 이어지는 대표적인 연쇄입니다.

문제 4 해답. 안전 서버는 필터를 하나도 추가하지 않았습니다 — 입력을 템플릿 코드에 붙이지 않고 변수로 전달했을 뿐인데 모든 페이로드가 무력화됐습니다. 필터는 {{, config, __class__ 등을 계속 쫓아가야 하지만, 구조를 바꾸면 쫓아갈 목록 자체가 사라집니다. SQL 인젝션의 파라미터 바인딩과 정확히 같은 철학입니다.

완료 기준 체크리스트

  • [ ] SSTI가 "입력이 템플릿 코드로 파싱되는" 인젝션임을 한 문장으로 말할 수 있다
  • [ ] {{7*7}} 탐지와 {{7*'7'}} 엔진 식별을 로컬에서 재현했다
  • [ ] {{config}}로 설정이 노출됨을 확인하고 위험성을 설명할 수 있다
  • [ ] __class____mro____subclasses__() 계층 탐색의 의미를 말할 수 있다
  • [ ] SSTI → RCE 사슬 4단계를 순서대로 설명할 수 있다 (실행 없이 개념으로)
  • [ ] 안전한 코드(템플릿/데이터 분리)가 왜 근본 방어인지 설명할 수 있다
  • [ ] 미션: 두 서버 재현 + SSTI사슬.md 정리를 마쳤다

6. 흔한 실수와 해결

벽 1. {{7*7}}을 넣었는데 그대로 나와요

증상: <h1>Hello {{7*7}}!</h1>가 그대로 출력됩니다.

원인 1순위: 그 입력 지점은 템플릿 코드로 파싱되지 않습니다 — 안전한 구조입니다 (3-2의 안전 서버 실측 그대로). 2순위: 엔진이 Jinja2가 아니라 문법이 다릅니다.

해결: 다른 문법도 번갈아 넣으세요 — ${7*7}(일부 Java 엔진), <%= 7*7 %>(ERB 등). 전부 그대로 나오면 그 지점은 SSTI가 아닙니다. 다음 입력 지점으로 이동하세요.

벽 2. 페이로드를 넣으면 500 오류가 나요

증상: Internal Server Error만 돌아옵니다.

원인: 템플릿 코드로 파싱되기는 했는데 문법이 틀렸거나 존재하지 않는 속성을 참조했습니다. 역설적으로, 500은 SSTI 지점을 찾았다는 신호이기도 합니다 — 입력이 엔진에 도달했다는 뜻이니까요.

해결: 페이로드를 최소로 줄여 단계를 좁히세요. {{7*7}}(성공) → {{config}}(성공/실패) → 속성 하나씩 추가. 한 번에 긴 사슬을 던지지 않는 것이 SSTI 디버깅의 정석입니다.

벽 3. __subclasses__()의 인덱스가 문서와 달라요

원인: 로드된 클래스 목록은 파이썬 버전, 프레임워크, import 순서에 따라 매번 다릅니다. 인터넷 write-up의 [259] 같은 인덱스는 그 환경의 값입니다.

해결: 인덱스를 외우지 말고 검색하세요. 목록을 출력한 뒤 Popen, subprocess, os 같은 이름으로 눈으로 찾거나, 목록을 enumerate하는 템플릿 루프로 번호를 매기세요. "왜 인덱스가 환경 의존적인가"를 이해하는 것이 이 단계의 학습 목표입니다.

벽 4. 안전 서버인 줄 알았는데 뚫려요

원인: render_template_string("..." + name + "...") 패턴이 다른 경로에 남아 있습니다. 템플릿 문자열을 이어 붙이는 코드가 하나라도 있으면 그 경로는 취약합니다.

해결: 코드베이스에서 render_template_string( 호출을 전부 찾아, 첫 인자에 문자열 연결(+, f-string)이 있는지 검사하세요. 첫 인자는 항상 고정 문자열이어야 합니다.

벽 5. Jinja2라면서 {{config}}가 안 나와요

원인: config는 Flask 컨텍스트에서만 자동으로 주입됩니다. 다른 프레임워크/엔진 설정에서는 다른 전역 객체 이름을 씁니다 (Django는 엔진이 달라 {{ }}가 아예 다르게 동작).

해결: 엔진 식별(3-2)부터 다시 하고, 해당 엔진의 문서에서 "템플릿에 기본으로 노출되는 객체"를 찾으세요. Jinja2라도 Flask가 아니면 self, cycler, namespace 같은 Jinja2 내장 전역에서 탐색을 시작합니다.


7. 정리

오늘의 개념

개념 한 줄 설명
SSTI 사용자 입력이 템플릿 엔진의 코드로 파싱되는 인젝션 — 실행 장소는 서버
템플릿 엔진 {{ 변수 }} 문법으로 화면을 만드는 도구 — Jinja2(파이썬), Twig(PHP)
탐지 페이로드 {{7*7}}49면 템플릿 코드 실행 확정
엔진 식별 {{7*'7'}}7777777이면 파이썬 계열(Jinja2/Twig)
객체 계층 탐색 __class____mro____subclasses__()로 서버 객체 지도 그리기
SSTI → RCE 계층 속 subprocess 계열로 OS 명령 실행 — 인덱스는 환경 의존적

오늘의 명령어와 페이로드

명령·페이로드 하는 일
{{7*7}} SSTI 탐지 — 49면 실행됨
{{7*'7'}} 엔진 식별 — 7777777이면 Jinja2/Twig
{{config}} Flask 설정 객체 노출 (비밀키 위험)
{{ self.__class__.__mro__ }} 클래스 계보 확인 — 탐색의 시작
render_template_string("...", name=name) 안전한 렌더링 — 템플릿과 데이터 분리
render_template_string("..." + name) 취약한 렌더링 — 오늘의 공격 대상

명령어보다 중요한 감각

SSTI를 배우며 인젝션 패밀리의 지도가 완성됐습니다 — SQL(Step 135), 명령어, XSS(Step 138), 그리고 템플릿. 문법만 다를 뿐 전부 같은 병입니다: 코드와 데이터를 한 그릇에 섞는 실수. 그리고 전부 같은 처방을 공유합니다: 분리. 파라미터 바인딩, 출력 이스케이프, 템플릿과 데이터의 분리. 공격 기법이 하나 늘 때마다 이 대칭을 확인하세요 — 새로운 인젝션을 만나도 여러분은 이미 그 원리와 방어를 알고 있습니다.


전부 체크되면 Step 193 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.