257 lines
7.8 KiB
Python
257 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
FTP 协议抓取工具 — 模拟 FTP Server,打印客户端每一条命令和交互细节
|
||
用法:python ftp_capture.py [--port 21]
|
||
需要管理员权限(Linux 无需,Windows 需要管理员权限绑定 21 端口)
|
||
"""
|
||
|
||
import socket
|
||
import threading
|
||
import sys
|
||
import os
|
||
import time
|
||
import struct
|
||
import re
|
||
|
||
HOST = "0.0.0.0"
|
||
PORT = 21
|
||
DATA_DIR = "ftp_capture_data"
|
||
|
||
def log(fmt, *args):
|
||
ts = time.strftime("%H:%M:%S", time.localtime())
|
||
msg = fmt % args if args else fmt
|
||
print(f"[{ts}] {msg}")
|
||
|
||
def recv_line(sock):
|
||
data = b""
|
||
while True:
|
||
ch = sock.recv(1)
|
||
if not ch:
|
||
return None
|
||
data += ch
|
||
if data.endswith(b"\r\n"):
|
||
return data
|
||
|
||
def send_line(sock, line):
|
||
sock.sendall((line + "\r\n").encode())
|
||
log(">>> %s", line)
|
||
|
||
class FtpHandler(threading.Thread):
|
||
def __init__(self, ctrl_sock, addr):
|
||
super().__init__(daemon=True)
|
||
self.ctrl = ctrl_sock
|
||
self.addr = addr
|
||
self.cwd = "/"
|
||
self.data_listener = None
|
||
self.data_port = 0
|
||
self.pasv_socket = None
|
||
|
||
def run(self):
|
||
log("=== New connection from %s:%d ===", *self.addr)
|
||
send_line(self.ctrl, "220 Service ready for new user.")
|
||
|
||
while True:
|
||
raw = recv_line(self.ctrl)
|
||
if raw is None:
|
||
log("=== Connection closed ===")
|
||
break
|
||
|
||
line = raw.decode(errors="replace").rstrip()
|
||
log("<<< %s", line)
|
||
|
||
parts = line.split()
|
||
cmd = parts[0].upper()
|
||
arg = parts[1] if len(parts) > 1 else None
|
||
try:
|
||
self.handle(cmd, arg)
|
||
except Exception as e:
|
||
log("!!! ERROR: %s", e)
|
||
send_line(self.ctrl, "550 Internal error.")
|
||
|
||
self.cleanup()
|
||
|
||
def handle(self, cmd, arg):
|
||
handler = getattr(self, f"cmd_{cmd}", None)
|
||
if handler:
|
||
handler(arg)
|
||
else:
|
||
send_line(self.ctrl, f"502 Command not implemented.")
|
||
|
||
def cmd_USER(self, arg):
|
||
send_line(self.ctrl, "230 User logged in, proceed.")
|
||
|
||
def cmd_PASS(self, arg):
|
||
send_line(self.ctrl, "230 User logged in, proceed.")
|
||
|
||
def cmd_FEAT(self, arg):
|
||
send_line(self.ctrl, "211-System status, or system help reply.")
|
||
send_line(self.ctrl, " EPSV")
|
||
send_line(self.ctrl, " PASV")
|
||
send_line(self.ctrl, " SIZE")
|
||
send_line(self.ctrl, " NLST")
|
||
send_line(self.ctrl, "211 System status, or system help reply.")
|
||
|
||
def cmd_PWD(self, arg):
|
||
send_line(self.ctrl, f'257 "{self.cwd}"')
|
||
|
||
def cmd_CWD(self, arg):
|
||
if arg == "." or arg == "/":
|
||
self.cwd = "/"
|
||
send_line(self.ctrl, "250 Requested file action okay, completed.")
|
||
else:
|
||
send_line(self.ctrl, "550 Requested action not taken.")
|
||
|
||
def cmd_TYPE(self, arg):
|
||
send_line(self.ctrl, "200 Command okay.")
|
||
|
||
def cmd_SYST(self, arg):
|
||
send_line(self.ctrl, "215 UNIX Type: L8")
|
||
|
||
def cmd_NOOP(self, arg):
|
||
send_line(self.ctrl, "200 Command okay.")
|
||
|
||
def cmd_QUIT(self, arg):
|
||
send_line(self.ctrl, "221 Service closing control connection.")
|
||
self.ctrl.close()
|
||
|
||
def cmd_SIZE(self, arg):
|
||
send_line(self.ctrl, "550 File unavailable.")
|
||
|
||
def cmd_PASV(self, arg):
|
||
"""创建 PASV 数据监听"""
|
||
self.cleanup_data()
|
||
pasv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
pasv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
pasv.bind(("0.0.0.0", 0))
|
||
pasv.listen(1)
|
||
self.pasv_socket = pasv
|
||
|
||
port = pasv.getsockname()[1]
|
||
p1, p2 = port >> 8, port & 0xFF
|
||
# 使用 127.0.0.1 本地环回避免防火墙问题
|
||
send_line(self.ctrl,
|
||
f"227 Entering Passive Mode (127,0,0,1,{p1},{p2}).")
|
||
log(" PASV listener on port %d (127.0.0.1:port)", port)
|
||
|
||
def cmd_EPSV(self, arg):
|
||
self.cleanup_data()
|
||
pasv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
pasv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
pasv.bind(("0.0.0.0", 0))
|
||
pasv.listen(1)
|
||
self.pasv_socket = pasv
|
||
port = pasv.getsockname()[1]
|
||
send_line(self.ctrl, f"229 Entering Extended Passive Mode (|||{port}|)")
|
||
log(" EPSV listener on port %d", port)
|
||
|
||
def cmd_LIST(self, arg):
|
||
send_line(self.ctrl, "150 File status okay; about to open data connection.")
|
||
conn = self.accept_data()
|
||
if conn:
|
||
log(" LIST: sending directory listing (empty)")
|
||
conn.sendall(b"-rw-rw-rw- 1 owner group 123 Jan 01 1970 test.txt\r\n")
|
||
conn.close()
|
||
log(" LIST: data closed")
|
||
send_line(self.ctrl, "226 Closing data connection.")
|
||
|
||
def cmd_NLST(self, arg):
|
||
self.cmd_LIST(arg)
|
||
|
||
def cmd_STOR(self, arg):
|
||
"""接收文件上传"""
|
||
size = 0
|
||
send_line(self.ctrl, "150 File status okay; about to open data connection.")
|
||
conn = self.accept_data()
|
||
if conn:
|
||
log(" STOR: receiving file '%s'...", arg)
|
||
data = b""
|
||
while True:
|
||
try:
|
||
chunk = conn.recv(4096)
|
||
if not chunk:
|
||
break
|
||
data += chunk
|
||
size += len(chunk)
|
||
except socket.timeout:
|
||
break
|
||
conn.close()
|
||
log(" STOR: received %d bytes, data closed", size)
|
||
else:
|
||
log(" STOR: data connection failed!")
|
||
send_line(self.ctrl, f"226 Closing data connection.")
|
||
|
||
def cmd_RETR(self, arg):
|
||
send_line(self.ctrl, "550 File unavailable.")
|
||
|
||
def cmd_DELE(self, arg):
|
||
send_line(self.ctrl, "250 Requested file action okay, completed.")
|
||
|
||
def accept_data(self):
|
||
"""接受数据连接(5秒超时)"""
|
||
if self.pasv_socket is None:
|
||
log(" ERROR: no PASV listener!")
|
||
return None
|
||
try:
|
||
self.pasv_socket.settimeout(5)
|
||
conn, addr = self.pasv_socket.accept()
|
||
log(" Data connection from %s:%d", *addr)
|
||
return conn
|
||
except socket.timeout:
|
||
log(" Data accept TIMEOUT (5s)")
|
||
return None
|
||
except Exception as e:
|
||
log(" Data accept ERROR: %s", e)
|
||
return None
|
||
|
||
def cleanup_data(self):
|
||
if self.pasv_socket:
|
||
try:
|
||
self.pasv_socket.close()
|
||
except:
|
||
pass
|
||
self.pasv_socket = None
|
||
|
||
def cleanup(self):
|
||
self.cleanup_data()
|
||
try:
|
||
self.ctrl.close()
|
||
except:
|
||
pass
|
||
|
||
|
||
def main():
|
||
import argparse
|
||
parser = argparse.ArgumentParser(description="FTP Protocol Capture")
|
||
parser.add_argument("--port", type=int, default=PORT,
|
||
help=f"FTP port (default {PORT}, may need admin)")
|
||
args = parser.parse_args()
|
||
|
||
# Windows 下低端口需要管理员权限
|
||
if sys.platform == "win32" and args.port < 1024:
|
||
import ctypes
|
||
if not ctypes.windll.shell32.IsUserAnAdmin():
|
||
log("WARNING: Port %d may need admin rights on Windows", args.port)
|
||
log(" Run as Administrator or use --port 2121")
|
||
|
||
os.makedirs(DATA_DIR, exist_ok=True)
|
||
|
||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
server.bind((HOST, args.port))
|
||
server.listen(5)
|
||
log("FTP capture server on port %d", args.port)
|
||
log("Connect with MobaXterm/FileZilla and try operations")
|
||
log("=" * 50)
|
||
|
||
try:
|
||
while True:
|
||
conn, addr = server.accept()
|
||
FtpHandler(conn, addr).start()
|
||
except KeyboardInterrupt:
|
||
log("Stopped")
|
||
finally:
|
||
server.close()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|