个人博客的安全防护实践:从被全网扫描到自动封禁
你以为建个博客就完了,但互联网上的扫描器可不这么想。
一、起因:一个静态博客,被扫出了 DDoS 的架势
上周我刚把这个个人博客上线——技术栈是 Astro + Nginx,跑在一台 Linux 云服务器上。纯静态站点,没有数据库,没有后端框架,连个登录框都没有。
然后我看了一眼 Nginx 访问日志。
三天,一千多次请求。独立 IP 两百多个。
对于一个刚上线、还没有在任何渠道推广过的个人博客来说,这个数字不太正常。
二、访问日志分析:谁在访问我的博客?
流量构成
我把 personal-blog-access.log 拉出来跑了一遍统计:
| 类型 | 请求数 | 占比 |
|---|---|---|
| 正常人类访问 | ~667 | 58.5% |
| 爬虫/扫描器 | ~473 | 41.5% |
其中”正常人类访问”里,我自己贡献了很大一部分,因为我在测试站点、浏览文章、检查部署效果。去掉我自己之后,真实外部读者的 PV 大概是每天 100 出头。
而扫描器那 41.5% 的流量,来自至少 20+ 种不同的扫描工具。
扫描器图鉴
从 User-Agent 和请求特征来看,主要有这几类:
1. 全网资产测绘平台
这些是合法的安全研究项目,但它们不分青红皂白地扫全网所有 IP:
- zgrab/0.x — Censys 的全网扫描器,28 次请求
- CensysInspect/1.1 — Censys 官方标识,29 次
- Infrawatch/1.0 — 另一个资产测绘项目,19 次
- Palo Alto Networks — 企业安全公司的扫描,UA 里还留了文档链接
2. 漏洞探测工具
这类就比较恶意了:
- Nmap Scripting Engine — 12 次,经典端口扫描
- l9explore/1.2.2 — 47 次,自动化漏洞扫描器
- Python-urllib/3.11 — 272 次,大概率是自动化脚本在批量探测
3. 手动/半自动攻击尝试
有人(或脚本)在有针对性地探测敏感路径:
| 探测路径 | 次数 | 意图 |
|---|---|---|
/.env、/api/.env、/backend/.env | 18 | 找环境变量里的数据库密码 |
/phpinfo.php、/phpinfo/ | 14 | 探测 PHP 信息泄露 |
/SDK/webLanguage | 22 | 海康威视设备漏洞探测 |
/login、/boaform/admin/formLogin | 7 | 路由器/后台登录爆破 |
/phpmyadmin/index.php | 2 | 数据库管理后台探测 |
blog/..%2f.env、blog/..%2f..%2f.env | 2 | 路径穿越攻击 |
4. 非法协议探测
这部分最离谱——日志里充斥着大量的二进制垃圾请求:
"\x16\x03\x01\x00\x8C\x01\x00\x00\x88\x03\x03..." 400 150
"MGLNDD_182.92.218.162_80" 400 150
"" 400 0
这些是扫描器在 HTTP 端口上尝试 HTTPS 握手、SOCKS 代理、Redis 协议等各种非 HTTP 协议。它们的逻辑很简单:对全网 IP 的常用端口发一堆协议试探,哪个端口有响应就标记下来。
三、已有的防护层
在部署 fail2ban 之前,博客其实已经有了一层防护——Nginx 层面的规则拦截。
Nginx WAF 配置
我在 nginx.conf 里配置了这几层:
# 第一层:IP 黑名单(geo 模块)
geo $blocked_ip {
default 0;
x.x.x.x 1; # 之前发现 Nmap 扫描,手动加入
}
# 第二层:恶意 URI 拦截
map $request_uri $block_uri {
default 0;
~*\.env$ 1;
~*phpinfo 1;
~*\.git 1;
~*webmanager 1;
}
# 第三层:恶意 UA 拦截
map $http_user_agent $block_ua {
default 0;
~*sqlmap 1;
~*nmap 1;
}
# 第四层:请求注入检测
map $args $block_injection {
default 0;
~*(union.*select) 1;
~*(<script>) 1;
}
# 组合判定:命中任意一层 → 403
map "$blocked_ip:$block_uri:$block_ua:$block_injection" $blocked {
default 0;
~.*1.* 1;
}
然后在 server 块里:
limit_req zone=global burst=20 nodelay;
if ($blocked) {
return 403;
}
这层防护效果不错——所有针对 .env、phpinfo 的请求都被拦截并记录到了 security.log 里。
但它有几个明显短板:
- 只能拦截已知的——IP 黑名单需要手动维护,新扫描器来了拦不住
- 不管 SSH——非标准端口的 SSH 登录爆破完全没有防护
- 不自动封禁——同一个 IP 可以反复扫描,每次只返回 403,但消耗服务器资源
四、fail2ban 部署:让防护自动化
为什么选 fail2ban
fail2ban 的逻辑很简单:监控日志 → 匹配正则 → 触发封禁 → 写 iptables。不需要复杂的架构,也不需要额外的数据库。
对于个人博客这种场景,它正好填补了 Nginx WAF 的短板。
安装
Rocky Linux / CentOS / RHEL 系统自带 EPEL 仓库,一行搞定:
dnf install -y epel-release
dnf install -y fail2ban fail2ban-systemd
自定义 filter
fail2ban 自带了一些 Nginx 过滤器(nginx-botsearch、nginx-bad-request),但都不太贴合我的场景。我写了一个自定义的:
/etc/fail2ban/filter.d/nginx-scan.conf
[Definition]
# 匹配 400 错误请求(二进制垃圾、非法 HTTP 方法等)
failregex = ^<HOST> - \S+ \[\] "(?:[^"]*)" 400 \d+
# 匹配 400/403/404 状态码
^<HOST> - \S+ .* ".*" 40[034] \d+
ignoreregex =
datepattern = {^LN-BEG}%ExY(?P<_sep>[-/.])%m(?P=_sep)%d[T ]%H:%M:%S(?:[.,]%f)?(?:\s*%z)?
^[^\[]*\[({DATE})
{^LN-BEG}
核心就两条正则:
- 第一条匹配空请求或畸形请求导致的 400(比如直接发二进制数据)
- 第二条匹配所有 400、403、404 状态码的请求
jail 配置
/etc/fail2ban/jail.local
[DEFAULT]
# 白名单:本地 + 自己的 IP
ignoreip = 127.0.0.1/8 ::1 x.x.x.x/32
banaction = iptables-multiport
banaction_allports = iptables-allports
backend = polling
# 保守参数
bantime = 24h
findtime = 10m
maxretry = 5
# ========== Jail ==========
# SSH 防爆破
[sshd]
enabled = true
port = <你的SSH端口>
filter = sshd
logpath = /var/log/secure
maxretry = 3
bantime = 24h
# Nginx 高频扫描
[nginx-scan]
enabled = true
port = http,https
filter = nginx-scan
logpath = /var/log/nginx/personal-blog-access.log
maxretry = 10
findtime = 10m
bantime = 720h # 30 天
几个关键参数的选择逻辑:
| 参数 | 值 | 原因 |
|---|---|---|
backend | polling | Nginx 写的是文件日志,不是 journald |
maxretry | 10 | 太低会误封(正常访问偶尔也会触发 404) |
findtime | 10分钟 | 10 分钟内 10 次异常请求才算扫描 |
bantime | 720小时 | 扫描器直接封 30 天,不浪费精力 |
ignoreip | 本地回环 + 自己的 IP | 别把自己锁外面 |
验证测试
用 fail2ban-regex 测试 filter 匹配率:
fail2ban-regex /var/log/nginx/personal-blog-access.log \
/etc/fail2ban/filter.d/nginx-scan.conf
结果:98 次匹配,61 行命中,0 行误报(200 响应全部跳过)。
然后手动模拟触发:
# 写入 12 条测试日志
for i in $(seq 1 12); do
echo '99.88.77.66 - - [18/May/2026:13:00:00 +0800] "GET /test HTTP/1.1" 400 150 "-" "BadBot"' \
>> /var/log/nginx/personal-blog-access.log
done
5 秒后查看状态:
Status for the jail: nginx-scan
|- Filter
| |- Currently failed: 1
| |- Total failed: 12
`- Actions
|- Currently banned: 1
`- Banned IP list: 99.88.77.66
iptables 规则已生效:
Chain f2b-nginx-scan
REJECT all -- 99.88.77.66 0.0.0.0/0 reject-with icmp-port-unreachable
解封测试也正常:
fail2ban-client set nginx-scan unbanip 99.88.77.66
五、整体安全架构
部署完成后,博客的防护层级变成了这样:
┌──────────────────────────────────────────────────┐
│ Layer 1: 阿里云安全组 │
│ 只放行 80 端口,SSH 限制信任 IP │
├──────────────────────────────────────────────────┤
│ Layer 2: Nginx WAF │
│ geo IP 黑名单 + URI 拦截 + UA 拦截 + SQL 注入检测 │
│ limit_req 速率限制 10r/s │
├──────────────────────────────────────────────────┤
│ Layer 3: fail2ban │
│ 自动监控日志 → 10 分钟 10 次异常 → iptables REJECT │
│ SSH 防爆破:3 次失败 → 封 24h │
└──────────────────────────────────────────────────┘
三层叠加的效果:
- 安全组挡住端口级扫描(除了 80)
- Nginx WAF拦截已知的恶意请求模式
- fail2ban自动封禁未知扫描器,不需要手动维护黑名单
六、踩坑记录
坑 1:backend 选错了
一开始配置了 backend = systemd,以为日志会自动走 journald。结果 fail2ban 启动后 Total failed: 0——它根本没读到 Nginx 的日志文件。
改成 backend = polling 后才正常。如果你的 Nginx 没有配置写 journald,一定要用 polling。
坑 2:datepattern 的百分号转义
在 .conf 文件里,% 需要用 %% 转义,但在 fail2ban-regex 命令行测试时又不用。一开始正则总报 InterpolationSyntaxError,排查了好久才发现是配置文件里多转了一层。
坑 3:正则不能太宽泛
最早写的 40[034] 正则是 .* 40[034] .*,结果把响应体里包含 “403” 字符串的正常 200 响应也匹配了。后来改成精确匹配状态码位置:".*" 40[034] \d+,确保只匹配 HTTP 状态码字段。
七、日常运维
几个常用命令:
# 查看封禁状态
fail2ban-client status nginx-scan
fail2ban-client status sshd
# 查看被封的 IP
fail2ban-client get nginx-scan banned
# 解封
fail2ban-client set nginx-scan unbanip <IP>
# 看实时日志
journalctl -u fail2ban -f
八、总结
一个个人博客的安全防护,做到三层就够了:
- 云厂商安全组——最外层,硬件级隔离
- Nginx WAF——应用层,拦截已知模式
- fail2ban——自动封禁,应对未知扫描
不需要上 WAF 商业产品,不需要搞 IDS/IPS 集群。对于一个静态博客来说,过度防御只会增加运维负担。
扫描器永远不会停止,但让它们每次扫完都被封 30 天,至少让它们知道:这个站不好惹。