YAML基本语法
基本语法就自行参考菜鸟教程或者是其他的教程,挺简单的,一共就三种类型:数组,对象,纯量
可以用这个例子全部理解一下:
1 | string_0: |
1 | import yaml |
可以自行运行,输出结果和JSON格式一样
PyYAML的类型转换
在PY中增加了一个新的特性,那就是yaml的类型转换,这也是PyYAML反序列化漏洞的关键点所在。在PyYAML中通过!!
进行强制类型转换,也就是说a: !!str 1
和a: "1"
是一样的,也可以强转为任意的类型,下表是一个汇总图:
就以上面的a: !!str 1
为例,我们调试分析:
在loader方法之后就加载出了yaml_multi_constructors
变量,内置了很多tag,我们跟进:
这里存放的是加载器,从图中可以知道有五种加载器,而默认的就是constructor,而这个默认的加载器加了很多强制类型转换的tag:
而这些就是今天我们所讲的PyYAML反序列化漏洞的成因,详细的东西我们之后再分析
YAML版本<5.1攻击思路
在yaml早期版本有很多危险tag,因此产生了yaml反序列化漏洞,而对应Tag就是上述图片中我圈起来的几个tag
python/object/apply
python/object/apply
tag对应python函数中的construct_python_object_apply
:
再往下就是进入到make_python_instance
函数中:
继续往下跟进,进入make_python_instance函数中:
通过find_python_name
函数找到了system模块:
最后在return cls(*args, **kwds)
中触发命令执行,cls就是system模块,args就是whoami:
这就是完整的触发流程,细看其实Pyyaml反序列化的流程并不难,因为触发起来就是这几个地方,以下是python/object/apply
链的各种形式的payload
1 | yaml.load('exp: !!python/object/apply:os.system ["whoami"]') |
python/object/new
python/object/new
标签对应的函数是construct_python_object_new
:
而这个construct_python_object_apply
实际上就是上面分析的python/object/apply
tag对应的函数,也就是new实际上是apply的一个封装,那也就是说payload一模一样了,这里就不放出来了
python/object
该标签对应的函数为construct_python_object
:
可以看到这个函数也是调用了make_python_instance
,但是唯一不同的点就是没有参数,也就是只能通过无参方法命令执行
这个payload虽然可以输出copyright,但是会报错,这个错误证明了是一个bug,在5.3版本进行了修复:
这里的object本应该是instance,object类是不允许别setattr修改的,所以会报错
python/module:package.class
对应函数为 construct_python_module
,而该函数最终调用了find_python_module
,(前面是find_python_name)这个函数中调用了__import__
:
我们可以自定义一个恶意py,通过该标签即可import,也就导致了命令执行,在PYYAML.py文件下创建uploads文件夹,在里面放入恶意py:
接着通过该标签强制转换类型即可触发:
这里的命令可以改为反弹shell之类的^^
python/name
该方法对应的函数为construct_python_name
:
里面调用了find_python_name方法,这个方法在前三个标签中也有用到,是找模块或者属性用的,我们调试分析一波:
1 | # import yaml |
在这个标签中可以用来窃取变量的值,如上述例子:
进入方法中:
module_name为__main__,然后object_name就是我们的TOKEN,最后返回的就是__main__['object_name']
也就是TOKEN的值
版本>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 BreakOut
如何突破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 | yaml.load(""" |
不用 !!python/object/apply 的话,也有其他办法。
通过遍历 builtins 下的所有方法,可以找到这些看起来有点用的:
1 | bool、bytearray、bytes |
其中的map和tuple结合起来可以触发命令执行,在py2中map直接返回的是列表,在py3返回一个迭代器,配合tuple可以执行命令tuple(map(eval, ["__import__('os').system('whoami')"]))
其中的tuple可以用frozenset、Bytes
进行替代:
这里有个小bug,直接使用set和list也可以起到tuple一样的作用,解开map内容,但是通过 !!python/object/new 来使用时却会忽略参数,生成一个空的迭代对象:
extend Attack
在apply标签的construct_python_object_apply
函数中有这么一条语句:
如果存在listitem属性,那么会调用instance.extend(listitems)
,假如这里的instance.extend为eval或者exec,listitems为恶意的payload,那就可以执行命令了,比如:
1 | yaml.full_load(""" |
和我们分析的一致
setstate Attack
同样的也是在上面流程construct_python_object_apply
函数中有:
进入了set_python_instance_state函数中
如果instance中有__setstate__
属性,会调用instance.__setstate__(state)
,假如这时instance.__setstate__为eval或者exec,state为我们的恶意payload,这时候也和上面一样可以实现RCE,因此payload只需要改一改:
1 | yaml.full_load(""" |
同样和我们分析的一样
update Attack
那么假如上述2种方法都用不了该怎么办呢?别急,我们回到set_python_instance_state
函数流程中:
关键步骤在于slotstate.update(state)
,如果slotstate.update为eval,state为payload,那么就可以RCE,和上面一模一样,所以payload可以为如下内容:
1 | yaml.full_load(""" |
这个payload就比上面的长很多,因此分析的地方也复杂一些,首先流程是由内向外的,这句话怎么理解呢,当yaml解析的时候会先解析- !!python/object/new:staticmethod
下面的语句,然后解析完之后,再将解析结果放入state: !!python/tuple
,最后开始解析!!python/object/new:str
,说起来可能大家也不太懂,这里就跟大B(bushi)来调试看看:
第一次进入时,instance是一个staticmethod类型的对象,state就是最里面一层的,符合我们的解释,staticmethod方法是用来声明函数的静态方法的。在这里我们往里面存放了一个空值,不过问题不大,此时instance.__dict__为空(__dict__属性中存放了该对象的所有属性,变量,函数…..):
接着往下走,走到instance.__dict__.update(state)
,这时调用了update方法,往__dict__字典中存放了state对象,这为第二次进入set_python_instance_state做了铺垫:
那么随之就是第二次进入,回顾一开始说的顺序,由内到外,发现instance为str对象,state就是第一次进入后的结果加上恶意payload:
之后注意,在state, slotstate = state
中,完成了state和slsotstate的赋值,state是一个tuple元组,有2个元素,那么新的state和slotstate对象第一个和第二个元素:
最后就是进入slotstate.update(state)
触发rce:
到这里分析就结束啦,我觉得描述的已经很清晰了
5.2—6.0
- 在5.2中只额外支持 !!python/name、!!python/object、!!python/object/new 和 !!python/module,而不支持apply标签了
- 在5.3.1以上的版本中加了一个新的过滤机制,匹配到就报错
- 版本在5.4以上只支持!!python/name,!!python/object/apply、!!python/object、!!python/object/new了,moudle寄了
- 6.0以上的版本用户必须指定Loader了,否则报错
可爱的尾巴
PyYAML这个知识点一直拖欠着没有进行总结,因为总觉得是一个小知识点,今天分析的时候才发现调试和分析过程并没有我想象中的那么简单,但仍然是一个很有趣的过程,我也一直觉得CTF这项竞赛是比较优雅的智力比赛,不断的剖析原理,接触新的tricks才是CTF的乐趣所在,希望HnuSec的大家不要停下来啊!
About this Post
This post is written by Boogipop, licensed under CC BY-NC 4.0.