本文是《docker + WordPress 加固部署方案(雷池 WAF)》的续作。上一篇我们让所有流量都经过雷池 WAF 的审查;这一篇更进一步——让攻击者被自动封禁,并把他们的 IP 上报到 AbuseIPDB 威胁情报库,让全互联网一起防御。
实测效果:部署当天就从日志里揪出了一个 24 小时内发起 1200+ 次攻击的 IP;端到端测试验证,从攻击触发到 iptables 封禁、再到 AbuseIPDB 收到报告,全链路自动完成。
〇、整体架构
攻击者 ──CDN──> 雷池WAF(80/443) ──> WordPress(内网,不暴露公网)
攻击者 ──直连源站──> 雷池WAF ──> 403/404/429
│
└──> tengine 访问日志
│
fail2ban(宿主机)
│
┌─────────────┴─────────────┐
iptables 封禁IP 上报 AbuseIPDB
思路一句话:所有攻击都会先被雷池拦截并写进访问日志(带状态码和攻击者 IP),fail2ban 负责解析日志、计数、达到阈值后封禁 + 上报。
三个角色分工:
- 雷池 WAF:正面防线,拦截所有攻击,产生带攻击证据的日志;
- fail2ban:日志侦探,盯着雷池日志数坏账,达标就动手;
- AbuseIPDB:全球威胁情报库,上报一次,全网受益。
一、先读懂雷池的返回码
动手前先搞清楚雷池日志里每个状态码的含义——不是所有异常状态码都该封。以下是雷池官方的返回码语义:
| 返回码 | 含义 | 是否纳入 fail2ban |
|---|---|---|
| 403 | 攻击检测与黑名单 | ✅ 主力规则,封 24h,上报 Web 攻击类 |
| 429 | 频率限制(CC 攻击被限速) | ✅ 封 1h,上报 DDoS 类 |
| 404 | 页面/应用不存在(目录爆破、漏洞扫描) | ✅ 封 1h,上报扫描类 |
| 468 | 人机验证(可疑 bot) | ⚠️ 不建议——RSS 阅读器和友站爬虫会命中 |
| 467 / 465 | 身份认证失败 / 等候室 | ❌ 业务行为 |
| 502 / 504 / 466 | 网关错误 / 超时 / 维护 | ❌ 是自己服务的问题,封了访客就冤了 |
处罚力度和证据确凿度严格递减:403 是铁证(重罚 24 小时),429 是强嫌疑(1 小时),404 是弱信号(1 小时 + 阈值放宽)。
二、前置准备
2.1 安装 fail2ban
sudo apt update && sudo apt install fail2ban curl -y
fail2ban-client version
2.2 定位雷池的访问日志
注意踩坑:/data/safeline/logs/nginx/error.log 是个“僵尸文件”(早期版本遗留,早已不写入)。真正的活跃日志在:
/data/safeline/logs/nginx/safeline/accesslog_1
约 50MB 轮转一次(轮转后变成 accesslog_1_日期 文件)。日志是管道分隔格式:
边缘IP | - | 29/Aug/2026:22:24:51 +0800 | "域名" | "GET /?id=1'... HTTP/1.1" | 403 | 10317 | "referrer" | "UA" "真实IP"
两个关键字段:
- 走 CDN 的请求:行首是 CDN 边缘节点 IP,行尾引号里才是访客真实 IP(雷池从 X-Forwarded-For 提取后附加在行尾);
- 直连源站的请求:行尾是
"-",行首就是攻击者本尊。
2.3 用一次攻击确认日志格式
浏览器访问 https://你的域名/?id=1%27%20or%20%271%27=%271 触发雷池拦截,然后:
sudo grep -a "你的公网IP" /data/safeline/logs/nginx/safeline/accesslog_1 | tail -3
应能看到一条 | 403 | 的记录,行尾是你的真实 IP。
三、fail2ban 配置
3.1 三个过滤器
/etc/fail2ban/filter.d/safeline-waf.conf(403 = 攻击检测与黑名单):
[Definition]
datepattern = %%d/%%b/%%Y:%%H:%%M:%%S %%z
# 走 CDN:真实 IP 在行尾引号里
failregex = \| 403 \| [0-9]+ .* "<HOST>"$
# 直连源站:行首即攻击者 IP,行尾以 "-" 结束
<HOST> \| - \| .* \| 403 \| .*"-"[ \t\r]*$
ignoreregex =
/etc/fail2ban/filter.d/safeline-404.conf(404 = 目录爆破/漏洞扫描):
[Definition]
datepattern = %%d/%%b/%%Y:%%H:%%M:%%S %%z
failregex = \| 404 \| [0-9]+ .* "<HOST>"$
<HOST> \| - \| .* \| 404 \| .*"-"[ \t\r]*$
ignoreregex = \| 404 \| .*(favicon\.ico|apple-touch|robots\.txt).*
/etc/fail2ban/filter.d/safeline-cc.conf(429 = CC 攻击被限速):
[Definition]
datepattern = %%d/%%b/%%Y:%%H:%%M:%%S %%z
failregex = \| 429 \| [0-9]+ .* "<HOST>"$
<HOST> \| - \| .* \| 429 \| .*"-"[ \t\r]*$
ignoreregex =
四个踩坑点,全部亲测:
datepattern里的%必须写成%%——fail2ban 配置走 configparser,单%直接报InterpolationSyntaxError;- 正则里不能用
^锚定行首——fail2ban 匹配前会把行内时间戳抠掉,^对上的是被剥离后的字符串,不是原行首; - 判别直连要靠行尾
"-"——不能裸匹配行首 IP,否则会把 CDN 边缘节点 IP 抓进来(封了它 = 封了 CDN,全站陪葬); - 404 的 ignoreregex 排除 favicon/robots.txt 等「正常缺失」,避免浏览器自动请求拉高计数。
3.2 验证过滤器
sudo fail2ban-regex /data/safeline/logs/nginx/safeline/accesslog_1 /etc/fail2ban/filter.d/safeline-waf.conf
sudo fail2ban-regex /data/safeline/logs/nginx/safeline/accesslog_1 /etc/fail2ban/filter.d/safeline-404.conf
sudo fail2ban-regex /data/safeline/logs/nginx/safeline/accesslog_1 /etc/fail2ban/filter.d/safeline-cc.conf
matched 数应约等于日志中该状态码的总数(404 会少 favicon 类)。429 为 0 很正常(CC 防护没被触发就没有),想单独验证正则:
echo '1.2.3.4 | - | 30/Aug/2026:00:00:00 +0800 | "www.example.com" | "GET / HTTP/1.1" | 429 | 512 | "-" | "curl/8.0" "5.6.7.8"' \
| sudo fail2ban-regex - /etc/fail2ban/filter.d/safeline-cc.conf
3.3 AbuseIPDB 上报 action(注意命名!)
到 abuseipdb.com 注册 → API → 创建 Key(免费版每月 1000 次上报额度)。
⚠️ 关键坑:action 不要叫 abuseipdb。 fail2ban 自带一个同名 action 模板(参数名是 abuseipdb_category),.local 覆盖后它仍会以自带模板为准——结果就是命令拼出来 -H "Key: " 是空的、categories 没被替换,ban 时日志报 Failed to execute ban action,封禁正常但上报静默失败。换个名字新建一个独立 action,彻底绕开优先级问题:
/etc/fail2ban/action.d/abuseipdb-report.conf:
[Definition]
actionban = curl -sS -X POST "https://api.abuseipdb.com/api/v2/report" \
-H "Key: <apikey>" \
-H "Content-Type: application/json" \
-d '{"ip":"<ip>","categories":"<category>","comment":"Blocked by Safeline WAF (SQLi/XSS/scanner)"}'
actionunban =
actionstart =
actionstop =
actioncheck =
上报类别(category,以 AbuseIPDB 官方文档为准):21 = Web 攻击(SQL 注入/XSS),4 = DDoS/洪水,18 = 扫描/暴力破解,22 = SSH 滥用。
3.4 jail 配置
/etc/fail2ban/jail.d/safeline.local:
[DEFAULT]
# 自己的网段永不封禁(后台编辑偶尔会被雷池误拦 403,防止自己封自己;
# 宽带和手机流量是不同网段,都要加)
ignoreip = 127.0.0.1/8 宽带网段.0/24 手机网段.0/24
[safeline-waf]
enabled = true
backend = auto
filter = safeline-waf
logpath = /data/safeline/logs/nginx/safeline/accesslog_1
maxretry = 10
findtime = 600
bantime = 86400
banaction = iptables-multiport
action = %(banaction)s[name=safeline-waf, port="http,https", protocol=tcp]
abuseipdb-report[apikey="你的KEY", category="21"]
[safeline-404]
enabled = true
backend = auto
filter = safeline-404
logpath = /data/safeline/logs/nginx/safeline/accesslog_1
maxretry = 100
findtime = 600
bantime = 3600
banaction = iptables-multiport
action = %(banaction)s[name=safeline-404, port="http,https", protocol=tcp]
abuseipdb-report[apikey="你的KEY", category="18"]
[safeline-cc]
enabled = true
backend = auto
filter = safeline-cc
logpath = /data/safeline/logs/nginx/safeline/accesslog_1
maxretry = 50
findtime = 600
bantime = 3600
banaction = iptables-multiport
action = %(banaction)s[name=safeline-cc, port="http,https", protocol=tcp]
abuseipdb-report[apikey="你的KEY", category="4"]
阈值设计的思路:
- 403 阈值 10 次/10 分钟:正常后台编辑偶尔误触发 1~2 次,攻击扫描动辄几十上百次连发,10 次的门槛互不干扰;
- 404 阈值 100 次/10 分钟:正常访客 + 搜索引擎远达不到,只有批量扫路径的才会触发;
- 429 阈值 50 次/10 分钟:刻意放宽——运营商 NAT 出口下多个正常用户共享一个 IP,偶发限速不该封;只有持续高频被限速的才是真 CC。
sudo chmod 600 /etc/fail2ban/jail.d/safeline.local
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status # 应列出: sshd, safeline-waf, safeline-404, safeline-cc
3.5 ⚠️ 本文第二大的坑:backend = systemd
Ubuntu 打包的 /etc/fail2ban/jail.local 默认设置了 backend = systemd。不在 jail 里显式写 backend = auto 的话,fail2ban 会静默地去读系统 journal 而不是雷池日志文件——fail2ban-client status 一切正常,实际上颗粒无收。
检查方法(必须看到 pyinotify):
sudo tail /var/log/fail2ban.log | grep "uses"
# Jail 'safeline-waf' uses pyinotify {} ← 正确
# Jail 'safeline-waf' uses systemd {} ← 错误,检查 backend 设置
以及确认 File list 指向雷池日志:
sudo fail2ban-client status safeline-waf | grep "File list"
# File list: /data/safeline/logs/nginx/safeline/accesslog_1
四、端到端验证
# 手动 ban 测试 IP,验证 iptables + AbuseIPDB 两条链路
sudo fail2ban-client set safeline-waf banip 192.0.2.123
sudo iptables -L f2b-safeline-waf -n # 应有该 IP 的 DROP 规则
# 到 AbuseIPDB 后台 → Reports 确认上报记录出现
sudo fail2ban-client set safeline-waf unbanip 192.0.2.123
真实触发测试(我是用手机流量 + 家庭宽带各测了一遍):临时调低阈值,用浏览器访问 https://你的域名/?id=1%27%20or%20%271%27=%271 几次:
sudo fail2ban-client set safeline-waf maxretry 2
# 触发几次拦截后观察:
sudo tail -f /var/log/fail2ban.log | grep -iE "Found|Ban|ERROR"
sudo fail2ban-client status safeline-waf # Banned IP list 出现你的 IP
sudo iptables -L f2b-safeline-waf -n # DROP 规则出现
# AbuseIPDB 后台 → Reports 出现 category 21 的记录 ← 全链路打通!
# 测完恢复并解封自己:
sudo fail2ban-client set safeline-waf maxretry 10
sudo fail2ban-client set safeline-waf unbanip 你的IP
验证时盯紧 fail2ban.log 里有没有 ERROR ... Failed to execute ban action——我第一轮测试时封禁成功了、AbuseIPDB 却没数据,翻日志才发现 action 静默失败(就是 3.3 节那个命名坑)。
五、历史攻击补报脚本(含 SSH 爆破)
fail2ban 只处理启动后的新日志。这个脚本负责扫描历史日志(含轮转文件)+ SSH 登录失败,把漏网的攻击者批量补报——/usr/local/bin/report-abusers.sh:
#!/bin/bash
# 扫描最近 N 天雷池日志 + SSH 登录失败,达到阈值的攻击 IP 上报 AbuseIPDB
# 用法: report-abusers.sh [天数],默认 7
DAYS=${1:-7}
LOGDIR=/data/safeline/logs/nginx/safeline
APIKEY="你的AbuseIPDB_KEY"
WAF_THRESHOLD=5 # 403 次数阈值
NF_THRESHOLD=100 # 404 次数阈值
SSH_THRESHOLD=10 # SSH 登录失败次数阈值
MYNET=你的网段前缀 # 例: 122.245.64
SINCE=$(date -d "-${DAYS} days" "+%Y%m%d%H%M%S")
########## Part 1: 雷池日志(403/404,含直连)##########
for f in "$LOGDIR"/accesslog_1*; do
awk -F' \\| ' -v since="$SINCE" -v mynet="$MYNET" '
{
# $3 形如 29/Aug/2026:22:36:31 +0800,转成 YYYYMMDDhhmmss 字符串比较
split($3, d, /[/: ]/)
mo = int((index(" Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", " " d[2]) - 1) / 4) + 1
ts = sprintf("%s%02d%s%s%s%s", d[3], mo, d[1], d[4], d[5], d[6])
if (ts < since) next
# 行尾带真实 IP(走CDN);行尾 "-" 则行首即攻击者(直连)
match($0, /"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+"[ \t\r]*$/)
if (RSTART > 0) {
ip = substr($0, RSTART+1, RLENGTH-2)
} else {
match($0, /"-"[ \t\r]*$/)
if (RSTART == 0) next
ip = $1
}
gsub(/["\r \t]/, "", ip)
if (ip ~ "^" mynet "\\.") next
print ip, $6
}' "$f"
done | awk '
{ count[$1" "$2]++ }
END { for (k in count) { split(k, a, " "); print a[1], a[2], count[k] } }' \
| sort > /tmp/abuser_stats_web.txt
########## Part 2: SSH 登录失败 ##########
: > /tmp/abuser_stats_ssh.txt
journalctl _COMM=sshd --since "-${DAYS} days" --no-pager 2>/dev/null \
| grep -aE "Failed password|Invalid user|Connection closed by authenticating user" \
| grep -aoE 'from ([0-9]{1,3}\.){3}[0-9]{1,3}' | awk '{print $2}' \
| grep -v "^${MYNET}\." | sort | uniq -c \
| awk -v t="$SSH_THRESHOLD" '$1>=t {print $2, "ssh", $1}' >> /tmp/abuser_stats_ssh.txt
########## Part 3: 汇总 + 去重 + 上报 ##########
awk -v wt="$WAF_THRESHOLD" -v nt="$NF_THRESHOLD" '
$2==403 && $3>=wt { waf[$1]=$3 }
$2==404 && $3>=nt { nf4[$1]=$3 }
END {
for (ip in waf) print ip, 21, "waf", waf[ip]
for (ip in nf4) if (!(ip in waf)) print ip, 18, "scan", nf4[ip]
}' /tmp/abuser_stats_web.txt > /tmp/abuser_report.txt
cat /tmp/abuser_stats_ssh.txt >> /tmp/abuser_report.txt
awk '!seen[$1]++' /tmp/abuser_report.txt > /tmp/rr.txt && mv /tmp/rr.txt /tmp/abuser_report.txt
total=$(wc -l < /tmp/abuser_report.txt)
echo "共 $total 个 IP 待上报,名单预览(IP | 类别 | 类型 | 次数):"
awk '{printf "%-18s cat%-3s %-5s x%s\n", $1, $2, $3, $4}' /tmp/abuser_report.txt
[ "$total" -eq 0 ] && exit 0
read -p "确认上报?(y/N) " ans
[ "$ans" = "y" ] || exit 0
while read -r ip cat kind cnt; do
echo "reporting $ip ($kind x$cnt)..."
curl -sS -X POST "https://api.abuseipdb.com/api/v2/report" \
-H "Key: ${APIKEY}" -H "Content-Type: application/json" \
-d "{\"ip\":\"${ip}\",\"categories\":\"${cat}\",\"comment\":\"${kind} activity (${cnt} hits, last ${DAYS}d) on WordPress blog / SSH, blocked by Safeline WAF & fail2ban\"}"
echo
sleep 1.2
done < /tmp/abuser_report.txt
echo "完成。"
sudo chmod +x /usr/local/bin/report-abusers.sh
sudo /usr/local/bin/report-abusers.sh 7
设计要点:
- 带确认预览——先看名单再上报,防止把自己的动态 IP 报上去(脚本已按网段排除);
- 脚本里的月份换算必须用
int((index-1)/4)+1。直觉写法index(...)/4会让所有月份差 1(Aug 算成 7),导致“最近 7 天”永远扫出 0 条——非常隐蔽的 off-by-one,还不报错; - SSH 部分用
journalctl _COMM=sshd,与改过的 SSH 端口无关; - 想定期自动跑:
sudo crontab -e加0 5 * * 1 /usr/local/bin/report-abusers.sh 7(记得先去掉脚本里的 read 确认段)。
六、日常运维
sudo fail2ban-client status safeline-waf # 封禁列表与统计
sudo fail2ban-client status safeline-404
sudo fail2ban-client status safeline-cc
sudo fail2ban-client unbanip x.x.x.x # 手动解封
sudo tail -f /var/log/fail2ban.log # 实时观赏防线工作
部署后第一天 Total banned 就会开始增长——按我这个博客的攻击密度(日志里躺着单日 1500 次 403 的记录),基本每天都有进账。
七、踩坑总账(血泪换来的,共 11 条)
backend = systemd陷阱:不显式backend = auto,jail 静默读 journal,表面正常颗粒无收;- action 不能叫
abuseipdb:和 fail2ban 自带模板撞名后参数不匹配,封禁成功但上报静默失败,必须看日志里的Failed to execute ban action才能发现——换名(如abuseipdb-report)新建独立 action; ^锚失效:fail2ban 剥离时间戳后才匹配,行首锚定对不上原行首;%必须双写成%%:configparser 的插值语法;- 直连判别靠行尾
"-":裸抓行首 IP 会把 CDN 节点封掉,全站陪葬; - 月份换算 off-by-one:awk 里
index/4差一个月,时间过滤全军覆没还不报错; - CDN 场景 iptables 封真实 IP 拦不住 CDN 流量(包来自边缘节点)——走 CDN 的攻击靠雷池持续拦,iptables 的真正价值是断直连源站的旁路;
- 不是所有异常状态码都该封:502/504 是自己服务的锅,封了访客就闹乌龙;468 人机验证会误伤 RSS/友站爬虫,个人博客慎用;
- 别把自己报上 AbuseIPDB:ignoreip 加自家网段(宽带 + 手机都要),上报脚本同步排除;
- 雷池日志 50MB 轮转:偶尔确认 File list 还在跟文件,跟丢就把 logpath 改成
accesslog_1*; - AbuseIPDB 免费额度每月 1000 次:不够时优先砍 404 类上报,留证据确凿的 403/SSH。
八、最后一刀:安全组收紧回源网段
fail2ban 是“看到攻击才反应”。真正治本的是云安全组把 80/443 入站限制为 CDN 回源网段——我日志里 CensysInspect 扫描、PROPFIND 探测这类直连源站的旁路攻击(单日可达 1500 次)会在防火墙层直接死亡。做完这一步,fail2ban 就从“补漏”升级为“双保险”。
写在最后
至此我的博客防线是:CDN(隐藏源站)→ 安全组(只放行回源)→ 雷池 WAF(语义分析拦截)→ fail2ban(自动封禁)→ AbuseIPDB(情报反哺社区)。
五层,跑在一台小破云服务器上,成本为零。折腾的过程踩了 11 个坑,全部写进第七节——如果你也要搭,希望能帮你省下这些时间。
有问题欢迎来评论区交流,或者直接去我的雷池攻击日志里看看你的 IP(开玩笑的,正常访客进不了那个列表)。
