어떤 수업을 찾고 있나요?

예: PowerShell, 네트워크, XSS, Step 138

전체 커리큘럼 둘러보기 →

웹 보안

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

Step 193

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__는 "이 객체의 클래스 계보"입니다 — TemplateReference → object로 올라가는 사다리가 보입니다. 이 사다리의 꼭대기(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' * 7은 7777777. 반면 숫자 계산을 하는 엔진은 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 완료입니다.

한 단계 더 가까이

수업을 마쳤나요?

본문의 완료 기준을 확인한 뒤 직접 표시하세요.

화면이나 링크에 문제가 있나요? 알려주세요.
다음 수업