vsftpd安全加固:从事故复盘到最小配置

一、为什么到了 2026 年还要谈 FTP

每次在群里提到 FTP,总有人说这东西早该进博物馆了。这话在纯前端项目上是对的:代码走 Git,静态资源走对象存储,日志走日志服务,根本用不着 FTP。但只要你维护过一批「甲方有自己的一套系统」的项目,就会发现 FTP 不但没消失,反而活得挺好——财务软件定时抓报表、老式进销存导出对账单、电视台的素材交换、印刷厂的制版文件、报关行的单据回传,这些场景里对接方的 IT 只会配置一个 FTP 地址,你把 S3 签名 URL 发过去,对面回一句「我们系统不支持」。

所以现实的做法不是消灭 FTP,而是把它关进笼子里:单独一个账号、单独一个目录、只允许被动模式、限制来源 IP、限速、留审计日志、定期清空。本文基于一次真实的小事故展开——2025 年底我们一台素材交换机被扫端口,攻击者用匿名账号登录后往 /var/ftp/pub 上传了三个伪装成图片的 PHP 文件。虽然那台机器没跑 PHP,也没有被进一步利用,但暴露出来的配置问题很典型,值得完整复盘一遍。

二、事故复盘:三个同时成立的条件

事后翻日志,问题不是「密码太弱」这一个原因,而是三个条件同时具备:

  • 匿名登录是开着的。配置文件里 anonymous_enable=YES,本来是为了让内部同事拖素材方便,结果外网也能拖,还能写。
  • 登录后的根目录是整个数据盘。local_root=/data,而 /data 下面除了素材目录,还放着几份备份包和一份旧站点的源码。
  • pasv_address 没配。服务器在 NAT 后面,被动模式返回的是内网地址 10.x.x.x,于是对面客户端时好时坏;管理员为了「让它能用」,把 pasv_min_port 到 pasv_max_port 的整段 50000-50100 全部在防火墙上放开,并且顺手把 iptables 里那条限制来源网段的规则删了。

三个条件叠加,等于把一台有公网 IP 的机器变成了公开网盘。日志里能看到扫描器在 11 分钟内试了 40 多个用户名,然后用 anonymous 登录成功,一共上传 3 个文件、下载 0 个。攻击者的目的很明确:不是偷东西,是找地方放文件。

三、从零开始的 vsftpd 最小可用配置

下面是现在这台机器在跑的配置。原则是默认全关,逐个开,而不是拿发行版自带的示例文件改两行。

# /etc/vsftpd.conf —— 只保留需要的项
listen=YES
listen_ipv6=NO

# 1. 身份:禁匿名、禁本地系统账号直接登录
anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=022
# 只允许白名单账号登录,其余一律拒绝
userlist_enable=YES
userlist_deny=NO
userlist_file=/etc/vsftpd.userlist

# 2. 笼子:chroot 到各自家目录,且不允许跳出
chroot_local_user=YES
allow_writeable_chroot=YES
# 家目录属主必须是 root,否则 vsftpd 会拒绝 chroot(安全设计)
# chown root:root /home/ftp_xxx && chmod 755 /home/ftp_xxx

# 3. 被动模式:明确告诉客户端外面该连哪个地址、哪几个端口
pasv_enable=YES
pasv_address=203.0.113.10
pasv_min_port=50000
pasv_max_port=50010
pasv_addr_resolve=NO

# 4. 传输与会话限制
local_max_rate=2048          # 单连接 2MB/s,避免一条大文件拖死上行
max_clients=20
max_per_ip=3
idle_session_timeout=300
data_connection_timeout=120
connect_from_port_20=YES

# 5. 明确关掉不用或不安全的
ssl_enable=NO                # 见第五节:为什么这里先关掉
port_enable=YES
pasv_promiscuous=NO
ls_recurse_enable=NO
download_enable=YES
dirlist_enable=YES
use_localtime=YES
xferlog_enable=YES
xferlog_file=/var/log/vsftpd.log
log_ftp_protocol=YES         # 记录每条命令,排查时救命

改完先 vsftpd -olisten=NO /etc/vsftpd.conf 之类的空跑是拿不到结果的(vsftpd 没有 -t),实际做法是 systemctl restart vsftpd 后立刻 systemctl status 加 ss -lntp | grep :21 确认端口在听,前端再拿 ftp 或 lftp 实测一次。

四、chroot 的三条硬规则

chroot 是最容易被误判的配置,因为错误只体现在「登录被拒绝」,日志里一句 500 OOPS: vsftpd: refusing to run with writable root inside chroot() 就算给面子了。三条规则记住即可:

第一,家目录本身必须不可写。vsftpd 认为「如果 chroot 的根目录可写,用户就能在里面构造出可执行路径」,所以它默认拒绝。规范做法是家目录 root 所有、755,真正可写的子目录再单独建:

mkdir -p /home/ftp_a/upload /home/ftp_a/download
chown root:root /home/ftp_a
chmod 755 /home/ftp_a
chown ftp_a:ftp_a /home/ftp_a/upload /home/ftp_a/download
chmod 750 /home/ftp_a/upload

第二,绝对不能打开 chroot_list_enable 却写反语义。chroot_local_user=YES 表示「所有人都关」,chroot_list_enable=YES 表示「清单里的人是例外」,于是 chroot_list_file 里那几个人反而被放出去了。要么全关(推荐),要么单独给某人做例外并明确知道自己在干什么。

第三,allow_writeable_chroot=YES 能用但要想清楚。它只是把上面那条检查关掉,并没有消除风险;我们的做法是不用它,宁可多建一层子目录。本文上一条配置里保留它,是因为有几台老机器的目录结构改不动,作为过渡,并在注释里写明将来要去掉。

五、明文 FTP 的真实风险,以及为什么建议配 FTPS

FTP 的控制通道和数据通道都是明文,账号密码在网络上就是明文。这件事在「局域网内部」,风险还能接受;但只要有跨公网的一跳,就等于把密码写在明信片上。更麻烦的是中文用户的被动模式协商:不少客户端在 NAT 后面会返回内网地址,需要 pasv_address 兜底,而这条路本身也是明文的,中途可被篡改。

两条路可走。一是改用 SFTP(走 SSH 的 22 端口),缺点是对接方不一定支持,而且给了对方一个 shell 账号,安全边界反而变宽。二是在 FTP 上套 TLS,也就是 FTPS(显式 AUTH TLS):

ssl_enable=YES
allow_anon_ssl=NO
force_local_data_ssl=YES
force_local_logins_ssl=YES
ssl_tlsv1_2=YES
ssl_tlsv1_3=YES
ssl_sslv2=NO
ssl_sslv3=NO
require_ssl_reuse=NO          # 部分客户端不支持会话复用,先关
rsa_cert_file=/etc/ssl/ftp/fullchain.pem
rsa_private_key_file=/etc/ssl/ftp/privkey.pem

三个实际踩到的点:require_ssl_reuse=YES 是默认值,但 FileZilla 之外的一批老客户端(尤其是工控软件自带的那个)会因此报 522 SSL connection failed,所以先关掉;证书要用完整链,只配 cert.pem 不带中间证书时,Windows 资源管理器会静默拒绝;最后,客户端那边必须选「显式 FTP over TLS」,默认的「隐式 990」在这个配置下连不上。

六、防火墙与被动模式的端口收敛

被动模式是 FTP 出事最多的地方,本质是「服务端告诉客户端该去连哪个端口」,而这个端口必须是外网可达的。pasv_min_port 和 pasv_max_port 之间不要超过 10 个端口,然后只放这一小段:

# nftables 片段
table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iif lo accept
    tcp dport 22 ip saddr { 198.51.100.0/24 } accept
    tcp dport 21 ip saddr { 198.51.100.0/24, 192.0.2.0/24 } accept
    tcp dport 50000-50010 ip saddr { 198.51.100.0/24, 192.0.2.0/24 } accept
  }
}

两个细节:ip saddr 用集合而不是多条规则,方便用 nft -f 整份重载,不容易漏;被动端口段的来源限制必须和 21 端口一致,只放 21 忘记放数据端口,表现是「能登录、ls 卡住不动」,很多人把它误诊成服务端磁盘问题。

如果机器前面还有云厂商的安全组,安全组和本机防火墙是两套,两边都要放。我们这次事故里,攻击者能扫到 21 端口,就是因为本机规则删了、安全组却是 0.0.0.0/0。

七、审计:xferlog 里该看什么

vsftpd 有两份日志:/var/log/vsftpd.log 记事件,xferlog_file 记传输。真正有用的是把二者对起来看。日常巡检只要三条:

# 1) 今天有哪些账号登录成功、从哪个 IP
grep 'OK LOGIN' /var/log/vsftpd.log | awk '{print $NF}' | sort | uniq -c | sort -rn | head

# 2) 失败登录 TOP IP(暴破信号)
grep 'FAIL LOGIN' /var/log/vsftpd.log | grep -oE '([0-9]{1,3}[.]){3}[0-9]{1,3}' | sort | uniq -c | sort -rn | head

# 3) 谁在传大文件(字节数超过 50MB 的传输)
awk '$10 > 52428800 {print $9, $10, $NF}' /var/log/vsftpd.log

再配一条 cron,把「同 IP 连续 5 次 FAIL LOGIN」自动加进 nftables 集合,比装 fail2ban 轻得多:

#!/bin/bash
set -euo pipefail
LOG=/var/log/vsftpd.log
TMP=$(mktemp)
grep 'FAIL LOGIN' "$LOG" | grep -oE '([0-9]{1,3}[.]){3}[0-9]{1,3}' | sort | uniq -c \
  | awk '$1 >= 5 {print $2}' > "$TMP"
while read -r ip; do
  [ -z "$ip" ] && continue
  nft add element inet filter blackhole4 "{ $ip }" 2>/dev/null || true
done < "$TMP"
rm -f "$TMP"

注意 set -euo pipefail 与 grep 的组合:没有匹配时 grep 返回 1,整条管道就退出了,后面两个 IP 段都轮不到。所以在 cron 里应当写成 grep ... || true,或者干脆把 grep 的输出重定向到文件后再判断文件是否为空。

八、用完就关:临时账号的生命周期

FTP 账号最大的问题不是技术,是没人删。我们的规矩是:所有账号都带过期日,建账号的脚本同时写一条到期任务。

#!/bin/bash
set -euo pipefail
U=ftp_vendor01
D=/home/$U
FILE=/etc/vsftpd.userlist
mkdir -p "$D/upload" "$D/download"
useradd -d "$D" -s /sbin/nologin "$U"
echo "$U:$(openssl rand -base64 15)" | chpasswd
chown root:root "$D"; chmod 755 "$D"
chown "$U:$U" "$D/upload" "$D/download"; chmod 750 "$D/upload"
grep -qxF "$U" "$FILE" || echo "$U" >> "$FILE"
# 到期自动清理:30 天后禁用并提示
echo "usermod -L $U && sed -i '/^$U\$/d' $FILE" | at now + 30 days

顺带一个结论:不要用 shell 给 FTP 账号开 bash。vsftpd 本身不需要 shell,但 useradd 默认给的是 /bin/bash,等于多了一个能把机器登进去的入口。统一 /sbin/nologin,并且确认 /etc/shells 里没有它(有些发行版的 su 会检查这一点)。

九、小结

FTP 在 2026 年没必要消灭,但必须当成一台「对公网的、只服务于一个目的」的服务来管:匿名关闭、账号白名单、chroot 到独立目录、被动端口收敛到 10 个以内并做来源限制、能开 FTPS 就开、日志按命令级记录并按 IP 做自动封禁、账号带到期日。上面那次事故的三个条件——匿名、过宽的根目录、全开的端口段——正好对应了这套清单里的前三项,照着改一遍就能把这类风险收口。

下一次如果你要对接一个只会用 FTP 的老系统,不妨先把这篇文章里的 /etc/vsftpd.conf 和 nftables 两段存下来,剩下的只是改地址和账号名。

Last modification:October 29th, 2026 at 07:24 pm

Leave a Comment