我的产品被恶意扫描了6000次之后 我给服务器加了三道防线
——从一次凌晨CORS报错说起,聊聊小团队怎么低成本防爬虫
昨天深夜,我在群里收到一条消息:
前端 CORS 报错,是不是后台挂了?
我登上服务器一看——
进程没了,日志停在凌晨两点,nginx 错误日志里一长串 connect() failed (111: Connection refused)。
进程被系统回收了。
为什么回收?
我翻 access.log,翻到头皮发麻:
一个 IP 三个小时内打了 1800+ 次请求,全是在扫 .env、wp-config.php、路径穿越、PHP 探针、Exchange 漏洞……
这就是今天这篇文章要讲的事:
一个独立开发者的产品,怎么用最低成本挡住互联网上 99% 的恶意扫描。
我会把排查到加固的完整过程复盘一遍——三道防线,全部在 nginx 层和 systemd 层搞定,没装一行新软件。
一、先说结论
今天部署的三道防线,按成本从低到高:
| 防线 | 位置 | 作用 | 性能损耗 |
|---|---|---|---|
| 第一道:systemd 守护 | OS 层 | 进程挂了自动拉起,开机自启 | 几乎为零 |
| 第二道:nginx 滥用拦截 | 反代层 | 命中黑名单直接关闭连接,不打后端 | 几乎为零 |
| 第三道:自动封禁脚本 | 定时任务 | 扫日志,高频攻击 IP 自动进黑名单 | 每次跑不到 1 秒 |
总成本:一个 nginx 配置文件 + 一个 shell 脚本 + 一段 systemd 单元。
加起来不到 200 行代码。
下面拆开讲。
二、排查过程:怎么发现是被爬虫打挂的
先说怎么定位问题,这是后续所有工作的前提。
2.1 第一步:看进程没了
最直接的命令:
ps aux | grep 'node dist/index.js' | grep -v grep
返回空,说明进程真的挂了。
2.2 第二步:看后端日志最后一截
tail -50 /var/log/your-app/stderr.log
看到最后几行是一堆 GET /api/.env、GET /api/wp-config.php ……
全 404,但请求一直在打。
2.3 第三步:看 nginx 日志找元凶
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
输出:
1818 103.153.183.166
29 5.61.209.43
7 3.129.187.38
...
一个 IP 干了 1818 次。
点开他的请求明细:扫 .env、扫 wp-config.php、扫 fonts../.env(路径穿越)、扫 PHP 探针……
完全是脚本化扫描。
关键判断:这不是偶发,这是被针对。
三、第一道防线:systemd 守护(防进程消失)
很多人跑 Node 服务喜欢用 nohup node xxx.js &。
问题:
- 服务器重启了,进程没了
- 进程 OOM 被杀,没人知道
- 进程崩了,没人在意
systemd 才是正经做法。
简化版的 systemd 单元配置:
[Unit]
Description=My App Backend Service
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=app
WorkingDirectory=/opt/myapp
Environment="NODE_ENV=production"
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=5
StartLimitInterval=60
StartLimitBurst=5
LimitNOFILE=65536
MemoryMax=1G
CPUQuota=200%
StandardOutput=append:/var/log/myapp/stdout.log
StandardError=append:/var/log/myapp/stderr.log
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target
几个关键点:
1. Restart=always + RestartSec=5
进程崩了,5 秒后自动拉起。
2. StartLimitInterval=60 + StartLimitBurst=5
60 秒内最多重启 5 次,避免崩溃风暴把系统拖死。
3. MemoryMax=1G
内存超 1G 直接杀掉,防内存泄漏。
4. NoNewPrivileges=true + PrivateTmp=true + ProtectSystem=full
基础安全加固,哪怕服务被攻破,攻击者也不能乱搞系统。
部署三步:
# 1. 写文件
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'EOF'
[你的服务配置]
EOF
# 2. 重新加载
sudo systemctl daemon-reload
# 3. 启动 + 开机自启
sudo systemctl start myapp
sudo systemctl enable myapp
验证自启:
PID=$(systemctl show -p MainPID --value myapp)
kill -9 $PID
sleep 7
systemctl status myapp # 应该已经自动重启了
这一道防线的本质:哪怕服务崩了,用户感知不到。
四、第二道防线:nginx 滥用拦截(防被打)
systemd 保证服务活着,但不代表服务能扛住海量请求。
爬虫一个 IP 每秒打 10 次,Node 进程照样会被打满 CPU。
第二道防线是 nginx 层的前置过滤——攻击请求在到达 Node 之前就被 nginx 拦掉。
4.1 黑名单 IP:直接 444
nginx 的 444 状态码表示直接关闭连接,不返回任何东西:
# 已知恶意 IP
map $remote_addr $is_banned_ip {
default 0;
"103.153.183.166" 1; # 扫 .env 大户
"5.61.209.43" 1;
}
server {
listen 443 ssl http2;
server_name api.example.com;
if ($is_banned_ip = 1) { return 444; }
# ...
}
444 比 403 更好:
403还要返回 HTML 页面,攻击者能确认你的服务存在444直接断 TCP,攻击者连确认都做不到
4.2 路径黑名单:拦截常见扫描
整理了 60+ 种常见扫描路径,覆盖最常见的安全探测:
map $request_uri $is_malicious_uri {
default 0;
# 配置文件
"~/\.env" 1;
"~/\.env\." 1;
"~/\.git" 1;
"~/\.ht" 1;
"~/\.svn" 1;
# WordPress 探测
"~/wp-config" 1;
"~/wp-admin" 1;
"~/wp-login" 1;
# PHP 探针
"~/\.php" 1;
"~/phpinfo" 1;
# 路径穿越
"~/\.\./" 1;
"~/fonts\.\." 1;
"~/data\.\." 1;
# 框架漏洞
"~/actuator/" 1; # Spring Boot
"~/_profiler" 1; # Symfony
"~/invoker" 1; # Struts2
"~/jolokia" 1; # Log4j
"~/ecp/" 1; # Exchange
# 备份文件
"~/\.bak" 1;
"~/\.sql" 1;
"~/dump\.sql" 1;
}
if ($is_malicious_uri = 1) { return 444; }
注意 map 块必须在 http 块里(我放在 conf.d 里),不能放 server 块里。
4.3 UA 黑名单:识别扫描器
map $http_user_agent $is_bad_ua {
default 0;
"~*sqlmap" 1;
"~*nikto" 1;
"~*nmap" 1;
"~*masscan" 1;
"~*zgrab" 1;
"~*shodan" 1;
"~*nessus" 1;
"~*acunetix" 1;
"~*wpscan" 1;
"~*gobuster" 1;
"~*ffuf" 1;
"~*hydra" 1;
"~*metasploit" 1;
}
if ($is_bad_ua = 1) { return 444; }
踩过的坑:之前我把 curl、wget、python-requests 也加进黑名单,结果我自己用 curl 测 API 全被拦了 444,差点以为是服务挂了。
教训:UA 黑名单只加真正的扫描器,不要把开发者工具也封了。
4.4 速率限制:兜底
万一规则漏了什么,速率限制是最后一道兜底:
# http 块里定义
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/s;
limit_conn_zone $binary_remote_addr zone=api_conn:10m;
server {
location / {
limit_req zone=api_limit burst=120 nodelay;
limit_conn api_conn 50;
# ...
}
}
参数说明:
rate=60r/s= 每 IP 每秒最多 60 个请求burst=120= 突发可以放 120 个(够正常用户用)nodelay= 突发不延迟,立即处理limit_conn 50= 每 IP 同时最多 50 个连接
正常用户的浏览,每秒不会超过 10 个请求。60 r/s 给的余量充足,但爬虫的 100 r/s 会被直接挡掉。
4.5 验证效果
# 1. 测试配置
sudo nginx -t
# 2. 重载
sudo nginx -s reload
# 3. 验证拦截
curl -o /dev/null -w "%{http_code}\n" https://api.example.com/.env
# 期望 000(连接关闭)
curl -A "sqlmap/1.5" -o /dev/null -w "%{http_code}\n" https://api.example.com/
# 期望 000
五、第三道防线:自动封禁脚本(防新的攻击者)
第二道防线是静态规则,但攻击者每天换 IP 怎么办?
让规则自己长出来。
60 行的自动封禁脚本:
#!/bin/bash
# 扫描 nginx access.log,统计触发 444 的 IP
# 1 小时内超过 20 次就封掉
NGINX_CONF="/etc/nginx/conf.d/00-anti-abuse.conf"
NGINX_ACCESS="/var/log/nginx/access.log"
THRESHOLD=20
# 收集触发 444 的 IP 和次数
RAW_IPS=$(awk -v threshold="$THRESHOLD" '
$9 == "444" { count[$1]++ }
END {
for (ip in count) {
if (count[ip] >= threshold) {
printf "%s %d\n", ip, count[ip]
}
}
}
' "$NGINX_ACCESS")
# 提取已封禁 IP
EXISTING=$(grep -oE '"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+"' "$NGINX_CONF" | tr -d '"' | sort -u)
# 找新 IP,加入黑名单
NEW_IPS=""
while read -r ip count; do
[ -z "$ip" ] && continue
if echo "$EXISTING" | grep -q "^$ip$"; then continue; fi
NEW_IPS="$NEW_IPS \"$ip\" 1; # $(date '+%Y-%m-%d') 自动封禁 ($count 次)\n"
done <<< "$RAW_IPS"
# 写回配置并重载
if [ -n "$NEW_IPS" ]; then
python3 -c "
import re
with open('$NGINX_CONF') as f: c = f.read()
new = '''$NEW_IPS'''
m = re.search(r'(map \\\$remote_addr \\\$is_banned_ip \\{\\s*default 0;)([^\\}]*?\\})', c, re.DOTALL)
if m:
c = c.replace(m.group(0), m.group(1) + '\n' + new + m.group(2), 1)
with open('$NGINX_CONF', 'w') as f: f.write(c)
"
nginx -t && nginx -s reload
fi
加个 cron 每小时跑一次:
0 * * * * /path/to/ban-bad-ips.sh >> /var/log/ban.log 2>&1
这个脚本的设计哲学:
- 只看
444状态码(被拦截的请求)→ 不误伤正常用户 - 阈值 20 次/小时 → 一个 IP 一小时 20 次扫 .env,肯定不是真人
- 写回 nginx map 块 → 规则可视化、可审计
- 自动 reload → 立即生效
实际效果:第一次部署后,已知的 3 个攻击 IP 立即被加进黑名单(手动操作,因为是历史日志);新攻击的 IP 在第二个小时就会被自动识别并封禁。
六、几个值得记的教训
6.1 互联网是黑暗森林
你的服务上线第一天,就会有扫描器来敲门。
这不是夸张——这次攻击发生时,距离产品上线不到一周。
攻击者用什么方式发现你的服务?
- Shodan / Censys 全网扫描
- 你的域名被 DNS 记录,他们按图索骥
- 撞库式全网探测
只要你的服务能被公网访问,就在被扫描。
6.2 CORS 报错是表象,根因在别处
那条 CORS 报错,本质原因是后端进程没了,nginx 报 502,CORS 头缺失。
如果当时只盯着 CORS 配,会浪费大量时间查跨域。
永远先看后端活着没。
6.3 别用 nohup 当生产方案
nohup 启动的进程:
- 不知道什么时候死的
- 服务器重启就没了
- 没法看历史日志
systemd 单元 + journalctl,几行命令就能查到所有生命周期事件。
6.4 防御要前置
不要在 Node 层做 IP 黑名单、UA 检查——你已经被打到了。
在 nginx 层做,连接还没建立就被掐断,零性能损耗。
七、写在最后
做产品不是做完就完,做完只是开始。
上线、运营、防御、迭代,每一步都是产品力。
这套方案是我们最现实的妥协——
- 不到 200 行代码
- 不引入新组件
- 不依赖商业服务
- 5 分钟搭起来
适合:个人开发者 / 小团队 / 早期产品的第一道防线。
至于企业级方案(WAF、CDN、专业的安全运营),那是另一篇文章的事。
如果你也在做独立产品,欢迎一起交流。