我的产品被恶意扫描了6000次之后 我给服务器加了三道防线

——从一次凌晨CORS报错说起,聊聊小团队怎么低成本防爬虫

昨天深夜,我在群里收到一条消息:

前端 CORS 报错,是不是后台挂了?

我登上服务器一看——

进程没了,日志停在凌晨两点,nginx 错误日志里一长串 connect() failed (111: Connection refused)

进程被系统回收了。

为什么回收?

我翻 access.log,翻到头皮发麻:

一个 IP 三个小时内打了 1800+ 次请求,全是在扫 .envwp-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/.envGET /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; }
    # ...
}

444403 更好:

  • 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; }

踩过的坑:之前我把 curlwgetpython-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、专业的安全运营),那是另一篇文章的事。

如果你也在做独立产品,欢迎一起交流。