浅谈PyYAML反序列化漏洞

2020-06-30 10:50:24 Author: xz.aliyun.com
觉得文章还不错?,点我收藏



基础知识

Yaml简介

YAML是一种直观的能够被电脑识别的的数据序列化格式,容易被人类阅读,并且容易和脚本语言交互,YAML类似于XML,但是语法比XML简单得多,对于转化成数组或可以hash的数据时是很简单有效的。

Yaml语法

基本规则

  • 大小写敏感
  • 使用缩进表示层级关系
  • 缩进时不允许使用Tab,只允许使用空格
  • 缩进的空格数目不重要,只要相同层级的元素左对齐即可
  • '#'表示注释,从它开始到行尾都被忽略

yaml转字典

yaml中支持映射或字典的表示,如下:

# 下面格式读到Python里会是个dict
name: Al1ex
age: 0
job: Tester

输出结果:

{'name': 'Al1ex', 'age': 0, 'job': 'Tester'}

yaml转列表

yaml中支持列表或数组的表示,如下:

# 下面格式读到Python里会是个list
- Al1ex
- 0
- Tester

输出结果:

['Al1ex', 0, 'Tester']

复合结构类型

字典和列表可以复合起来使用,如下:

# 下面格式读到Python里是个list里包含dict
- name: Al1ex
  age: 0
  job: Tester
- name: James
  age: 30

输出结果:

[{'name': 'Al1ex', 'age': 0, 'job': 'Tester'}, {'name': 'James', 'age': 30}]

基本类型使用

yaml中有以下基本类型:

  • 字符串
  • 整型
  • 浮点型
  • 布尔型
  • null
  • 时间
  • 日期

我们写个例子来看下:

# 这个例子输出一个字典,其中value包括所有基本类型
str: "Hello World!"
int: 110
float: 3.141
boolean: true  # or false
None: null  # 也可以用 ~ 号来表示 null
time: 2020-06-20t11:43:30.20+08:00  # ISO8601,写法百度
date: 2020-06-20  # 同样ISO8601

输出结果:

{'str': 'Hello World!', 'int': 110, 'float': 3.141, 'boolean': True, 'None': None,'time':datetime.datetime(2020, 6, 20, 0, 28, 20, 44000), 'date': datetime.date(2020, 6, 20)}

引用的使用

& 和 * 用于引用示例:

name: &name Al1ex
tester: *name

这个相当于以下脚本:

name: Al1ex
tester: Al1ex

输出结果:

{'name': 'Al1ex', 'tester': 'Al1ex'}

强制类型转换

yaml是可以进行强制转换的,用 !! 实现,如下

str: !!str 3.14
int: !!int "123"

输出结果:

{'int': 123, 'str': '3.14'}

明显能够看出字符串类型的123被强转成了int类型,而float型的3.14则被强转成了str型

分段规则使用

在同一个yaml文件中,可以用 — 来分段,这样可以将多个文档写在一个文件中

---
name: James
age: 20
---
name: Lily
age: 19

构造/表示/解析器

yaml.YAMLObject

yaml.YAMLObject用元类来注册一个构造器(也就是代码里的init() 方法),让你把yaml节点转为Python对象实例,用表示器(也就是代码里的 repr() 函数)来让你把Python对象转为yaml节点,看代码:

import yaml
class Person(yaml.YAMLObject):
    yaml_tag = '!person'

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __repr__(self):
        return '%s(name=%s, age=%d)' % (self.__class__.__name__, self.name, self.age)

james = Person('James', 20)

print (yaml.dump(james))  # Python对象实例转为yaml

lily = yaml.load('!person {name: Lily, age: 19}')

print (lily)  # yaml转为Python对象实例

输出结果:

!person {age: 20, name: James}

Person(name=Lily, age=19)

add_constructor/add_representer

你可能在使用过程中并不想通过上面这种元类的方式,而是想定义正常的类,那么,可以用这两种方法:

import yaml

class Person(object):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __repr__(self):
        return 'Person(%s, %s)' % (self.name, self.age)

james = Person('James', 20)
print (yaml.dump(james))  # 没加表示器之前


def person_repr(dumper, data):
    return dumper.represent_mapping(u'!person', {"name": data.name, "age": data.age})  # mapping表示器,用于dict

yaml.add_representer(Person, person_repr)  # 用add_representer方法为对象添加表示器
print (yaml.dump(james))  # 加了表示器之后


def person_cons(loader, node):
    value = loader.construct_mapping(node)  # mapping构造器,用于dict
    name = value['name']
    age = value['age']
    return Person(name, age)

yaml.add_constructor(u'!person', person_cons)  # 用add_constructor方法为指定yaml标签添加构造器
lily = yaml.load('!person {name: Lily, age: 19}')
print (lily)

输出结果:

!!python/object:__main__.Person {age: 20, name: James}
!person {age: 20, name: James}
Person(Lily, 19)

第一行是没加表示器之前,中间那行是加了表示器之后,变成了规范的格式,下面添加了构造器,能够把 !person 标签转化为Person对象~
yaml是一种很清晰、简洁的格式,跟Python非常合拍,非常容易操作,我们在搭建自动化测试框架的时候,可以采用yaml作为配置文件,或者用例文件,下面给出一个用例的示例:

# Test using included Django test app
# First install python-django
# Then launch the app in another terminal by doing
#   cd testapp
#   python manage.py testserver test_data.json
# Once launched, tests can be executed via:
#   python resttest.py http://localhost:8000 miniapp-test.yaml
---
- config:
    - testset: "Tests using test app"

- test: # create entity
    - name: "Basic get"
    - url: "/api/person/"
- test: # create entity
    - name: "Get single person"
    - url: "/api/person/1/"
- test: # create entity
    - name: "Get single person"
    - url: "/api/person/1/"
    - method: 'DELETE'
- test: # create entity by PUT
    - name: "Create/update person"
    - url: "/api/person/1/"
    - method: "PUT"
    - body: '{"first_name": "Gaius","id": 1,"last_name": "Baltar","login": "gbaltar"}'
    - headers: {'Content-Type': 'application/json'}
- test: # create entity by POST
    - name: "Create person"
    - url: "/api/person/"
    - method: "POST"
    - body: '{"first_name": "Willim","last_name": "Adama","login": "theadmiral"}'
    - headers: {Content-Type: application/json}

PyYaml的基本使用

load():返回一个对象

我们先创建一个yml文件,config.yml:

name: Tom Smith
age: 37
spouse:
    name: Jane Smith
    age: 25
children:
 - name: Jimmy Smith
   age: 15
 - name1: Jenny Smith
   age1: 12

之后使用以下python代码读取yml文件:

import yaml
f = open('config.yml','r')
y = yaml.load(f)
print (y)

执行结果如下:

load_all():生成一个迭代器

如果string或文件包含几块yaml文档,你可以使用yaml.load_all来解析全部的文档,例如:

yaml.dump:python对象转yaml文档

import yaml
aproject = {'name': 'Silenthand Olleander',
            'race': 'Human',
            'traits': ['ONE_HAND', 'ONE_EYE']
            }

print(yaml.dump(aproject,))

运行结果如下所示:

yaml.dump接收的第二个参数一定要是一个打开的文本文件或二进制文件,yaml.dump会把生成的yaml文档写到文件里,例如:

Al1ex.yml文档内容如下所示:

yaml.dump_all():多个段输出到一个文件

import yaml

obj1 = {"name": "James", "age": 20}
obj2 = ["Lily", 19]

with open('yaml_dump_all.yml', 'w') as f:
    yaml.dump_all([obj1, obj2], f)

yaml_dump_all.yml文件内容如下所示:

PyYAML小于5.1

测试环境

这里使用PyYAML==4.2b4进程测试,PyYAML历史版本可以参考:https://pypi.org/project/PyYAML/#history

测试示例

在PyYAML 5.1版本之前我们有以下反序列化方法:

  • load(data)
  • load(data, Loader=Loader)
  • load_all(data)
  • load_all(data, Loader=Loader)

这里编写一个简单的Demo,首先,我们使用yaml_test.py来创建一个poc对象,之后再调用yaml.dump()将其序列化为一个字符串,其中第10行代码主要用于将默认的"main"替换为该文件名"yamltest",这样做的目的是为了后面yaml.load()反序列化该字符串的时候会根据yaml文件中的指引去读取yaml test.py中的poc这个类,否则无法正确执行,下面运行该yaml_test.py来生成simple.yml文件(第一次运行时会调用init所以会执行一次calc.exe):

simple.yml文件内容如下所示:

之后构建yaml_verify.py,并通过yaml.load()读取目标yaml文件,之后"!!python/object"标签解析其中的名为yaml_test的module中的poc类,最后执行了该类对象的init()方法从而执行了命令:

漏洞成因

通过跟踪$PYTHON_HOME/lib/site-packages/yaml/constructor.py文件,查看PyYAML源码可以得到其针对Python语言特有的标签解析的处理函数对应列表,其中有三个和对象相关:

  • !!python/object: => Constructor.construct_python_object
  • !!python/object/apply: => Constructor.construct_python_object_apply
  • !!python/object/new: => Constructor.construct_python_object_new

下面跟进到$PYTHON_HOME/lib/site-packages/yaml/constructor.py中查看一下这三个特殊的Python标签源码:

!!python/object标签

!!python/object/new标签

!!python/object/apply标签


从上面的代码中可以看到" !!python/object/new " 标签的代码实现其实就是" !!python/object/apply "标签的代码实现,只是最后newobj参数值不同而已,其次可以看到的是这3个Python标签中都是调用了make_python_instance()函数,之后查看该函数

从上述代码中可以看到,该函数会根据参数来动态创建新的Python类对象或通过引用module的类创建对象,从而可以执行任意命令~

通用POC

经过上面的了解与验证,我们知道只要存在yaml.load()且参数可控,则可以利用yaml反序列化漏洞,下面为常用的Payload:

!!python/object/apply:os.system ["calc.exe"]
!!python/object/new:os.system ["calc.exe"]    
!!python/object/new:subprocess.check_output [["calc.exe"]]
!!python/object/apply:subprocess.check_output [["calc.exe"]]




漏洞修复

在PyYAML>=5.1版本中有两个补丁限制了反序列化内置类方法以及导入并使用不存在的反序列化代码:
Path 1:

Path 2:

PyYAML >=5.1

测试环境

这里使用最新的PyYAML版本进行本地测试:

版本变化

在PyYAML>=5.1版本中,提供了以下方法:

  • load(data) [works under certain conditions]
  • load(data, Loader=Loader)
  • load(data, Loader=UnsafeLoader)
  • load(data, Loader=FullLoader)
  • load_all(data) [works under certain condition]
  • load_all(data, Loader=Loader)
  • load_all(data, Loader=UnSafeLoader)
  • load_all(data, Loader=FullLoader)
  • full_load(data)
  • full_load_all(data)
  • unsafe_load(data)
  • unsafe_load_all(data)

在5.1之后,使用load()进行序列化操作时我们需要在方法里面加一个loader的请求参数,直接使用load请求时会显示以下warning,默认FullLoader:

import yaml
f = open('config.yml','r')
y = yaml.load(f)
print(y)


此时,我们需要增加一个loader请求参数:

import yaml
f = open('config.yml','r')
y = yaml.load(f,Loader=yaml.FullLoader)
print(y)


针对不同的需要,加载器有如下几种类型:

  • BaseLoader:仅加载最基本的YAML
  • SafeLoader:安全地加载YAML语言的子集,建议用于加载不受信任的输入(safe_load)
  • FullLoader:加载完整的YAML语言,避免任意代码执行,这是当前(PyYAML 5.1)默认加载器调用yaml.load(input) (出警告后)(full_load)
  • UnsafeLoader(也称为Loader向后兼容性):原始的Loader代码,可以通过不受信任的数据输入轻松利用(unsafe_load)

测试示例

我们在YAML 5.3.1版本中使用之前的Payload发现已无法实现RCE了,通用的POC不再有效:

针对之前的Path1和Path2,我们可以使用subprocess.Popen来绕过,subprocess意在替代其他几个老的模块或者函数,比如:os.system os.spawn os.popen popen2. commands.,而subprocess模块定义了一个类:Popen

class subprocess.Popen( args, 
  bufsize=0, 
  executable=None,
  stdin=None,
  stdout=None, 
  stderr=None, 
  preexec_fn=None, 
  close_fds=False, 
  shell=False, 
  cwd=None, 
  env=None, 
  universal_newlines=False, 
  startupinfo=None, 
  creationflags=0)

各参数含义如下:

  • args: 可以是一个字符串,可以是一个包含程序参数的列表,要执行的程序一般就是这个列表的第一项,或者是字符串本身。
  • bufsize: 如果指定了bufsize参数作用就和内建函数open()一样:0表示不缓冲,1表示行缓冲,其他正数表示近似的缓冲区字节数,负数表示使用系统默认值,默认是0。
  • executable: 指定要执行的程序,它很少会被用到,一般程序可以由args 参数指定,如果shell=True ,executable 可以用于指定用哪个shell来执行(比如bash、csh、zsh等),*nix下,默认是 /bin/sh ,windows下,就是环境变量COMSPEC的值,windows下,只有当你要执行的命令确实是shell内建命令(比如dir ,copy 等)时,你才需要指定shell=True ,而当你要执行一个基于命令行的批处理脚本的时候,不需要指定此项
  • stdin/stdout和stderr:分别表示子程序的标准输入、标准输出和标准错误,可选的值有PIPE或者一个有效的文件描述符(其实是个正整数)或者一个文件对象,还有None,如果是PIPE,则表示需要创建一个新的管道,如果是None,不会做任何重定向工作,子进程的文件描述符会继承父进程的,另外,stderr的值还可以是STDOUT,表示子进程的标准错误也输出到标准输出
  • preexec_fn: 如果把preexec_fn设置为一个可调用的对象(比如函数),就会在子进程被执行前被调用(仅限*nix)
  • close_fds: 如果把close_fds设置成True,*nix下会在开子进程前把除了0、1、2以外的文件描述符都先关闭,在 Windows下也不会继承其他文件描述符
  • shell:如果把shell设置成True,指定的命令会在shell里解释执行
  • cwd:如果cwd不是None,则会把cwd做为子程序的当前目录,注意,并不会把该目录做为可执行文件的搜索目录,所以不要把程序文件所在目录设置为cwd
  • env:如果env不是None,则子程序的环境变量由env的值来设置,而不是默认那样继承父进程的环境变量。
  • universal_newlines: 如果把universal_newlines 设置成True,则子进程的stdout和stderr被视为文本对象,并且不管是*nix的行结束符('/n'),还是老mac格式的行结束符('/r'),还是windows 格式的行结束符('/r/n' )都将被视为 '/n' 。
  • startupinfo和creationflags:如果指定了startupinfo和creationflags,将会被传递给后面的CreateProcess()函数,用于指定子程序的各种其他属性,比如主窗口样式或者是子进程的优先级等(仅限Windows)

下面进行简单测试:
Example 1

from yaml import *
data = b"""!!python/object/apply:subprocess.Popen
- calc"""
deserialized_data = load(data, Loader=Loader) # deserializing data
print(deserialized_data)


Example 2

from yaml import *
data = b"""!!python/object/apply:subprocess.Popen
- calc"""
deserialized_data = unsafe_load(data) # deserializing data
print(deserialized_data)

ruamel.yaml

ruamel.yaml的用法和PyYAML基本一样,并且默认支持更新的YAML1.2版本
ruamel.yaml的API文档:https://yaml.readthedocs.io/en/latest/overview.html

环境准备

pip2 install ruamel.yaml

简易测试

若要在ruamel.yaml中反序列化带参数的序列化类方法,我们有以下方法:

  • load(data)
  • load(data, Loader=Loader)
  • load(data, Loader=UnsafeLoader)
  • load(data, Loader=FullLoader)
  • load_all(data)
  • load_all(data, Loader=Loader)
  • load_all(data, Loader=UnSafeLoader)
  • load_all(data, Loader=FullLoader)

我们可以使用上述任何方法,甚至我们也可以通过提供数据来反序列化来直接调用load(),它将完美地反序列化它,并且我们的类方法将被执行:

从上面可以看到命令被成功执行,由此可见当使用ruamel.yaml.load()处理用户提供的参数时,易受RCE攻击~

防御策略

在处理YAML数据的过程中,可以使用以下函数来反序列化数据,避免RCE:

要序列化数据,可以使用下面的安全函数:

参考链接

https://www.cnblogs.com/klb561/p/9326677.html
http://www.polaris-lab.com/index.php/archives/375/
https://dl.packetstormsecurity.net/papers/general/yaml-deserialization.pdf




觉得文章还不错?,点我收藏



如果文章侵犯到您的版权,请联系我:buaq.net[#]pm.me