「我家的網站是不是被攻擊了?」當被這樣問起時,答案多半就在伺服器的存取記錄裡。只是單純用 tail 盯著原始日誌看,也無法從一天數萬行裡把異常挑出來。
這篇文章是為了能透過 SSH 登入伺服器的網站負責人、網站管理者、製作公司而寫的,內容整理了如何把存取記錄經過 7 個指令,縮小到「只剩值得看的幾行」,再進一步用 fail2ban 自動封鎖找到的對象。前提是 nginx 或 Apache 的 combined 格式日誌(也會順帶提到 WordPress 特有的觀察重點)。
如果不先確認這一步,後面的統計全部都會白做。在 Cloudflare、負載平衡器、反向代理的架構下,若沒有額外設定,記錄下來的只會是代理伺服器的 IP。上游全部都變成同一個 IP,若直接拿去餵給 fail2ban,就可能把自己網站的入口整個封掉。
tail -1 /var/log/nginx/access.log
如果最前面的位址不是訪客的 IP,而是 CDN 或負載平衡器的位址,就必須做還原設定。
combined 格式的開頭是 $remote_addr。要把它改成代理伺服器傳來的標頭值,就要用 realip 模組。
# 列出信任的代理範圍(Cloudflare 的所有範圍請見 https://www.cloudflare.com/ips/)
set_real_ip_from 173.245.48.0/20;
# ... 請把公開的所有範圍都列出來
real_ip_header CF-Connecting-IP; # 預設是 X-Real-IP,請依你的路徑調整
real_ip_recursive off; # 預設 off = 採用標頭中的最後一個值
可用 nginx -V 2>&1 | tr ' ' '\n' | grep realip 確認模組是否存在(套件版通常都會內建)。沒有寫在 set_real_ip_from 裡的來源所帶來的標頭會被忽略。 如果把它設成 0.0.0.0/0 這種全開狀態,任何人都可以偽造 IP,所以一定要只寫經路上的範圍。
用 mod_remoteip 還原,並且把日誌格式從 %h 改成 %a(%a = 還原後的客戶端 IP,%{c}a = 前一個連線來源 = 代理伺服器)。
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined_realip
CustomLog ${APACHE_LOG_DIR}/access.log combined_realip
套用後,用自己網路開啟網站,若 tail -1 看到的是自己的 IP,就代表準備完成。
combined 格式在 nginx 與 Apache 的欄位位置相同,因此可以直接共用同一段 awk。
203.0.113.10 - - [10/Sep/2026:07:12:33 +0900] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "curl/8.5.0"
$1 $2$3 $4 $5 $6 $7 $8 $9 $10 $11 $12
$1 = 來源 IP,$6 = 方法(前面會帶 "),$7 = 路徑,$9 = 狀態碼,$10 = 回應大小。因為也想看已輪替的舊日誌,所以先把目標集中到變數裡。GNU 的 zcat -f 會直接處理未壓縮檔,因此 .gz 和原始日誌可以混著丟。
LOGS="/var/log/nginx/access.log /var/log/nginx/access.log.1 /var/log/nginx/access.log.*.gz"
zcat -f $LOGS | wc -l # 確認目標行數
Apache 則改成 /var/log/apache2/access.log*(RHEL 系統則是 /var/log/httpd/access_log*)。
zcat -f $LOGS | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
先看整體輪廓。前幾名出現自家監控服務、爬蟲、CDN 都算正常。如果某個不認識的 IP 以明顯異常的請求數衝到第一名,再進一步看它的內容。
zcat -f $LOGS | awk '$6=="\"POST" && $7 ~ /wp-login\.php|xmlrpc\.php/ {print $1}' \
| sort | uniq -c | sort -rn | head -20
這是最容易辨識的跡象。正常情況下,wp-login.php 只有管理者登入時才會送出 POST,因此若同一個 IP 出現數百到數千次,幾乎就是暴力破解。xmlrpc.php 如果根本沒在用,理論上應該是 0 筆;只要有數字就很可疑。
zcat -f $LOGS | awk '$9==404 {print $1}' | sort | uniq -c | sort -rn | head -20
這代表對方在從頭到尾亂打不存在的路徑,也就是弱點掃描器的行為。若單一 IP 產生了數百筆 404,就可以視為正在被針對性探測。
zcat -f $LOGS | awk '$9==404 {print $7}' | sed 's/?.*//' \
| sort | uniq -c | sort -rn | head -30
被鎖定的目標會直接列出來。像是 /.env、/.git/config、/wp-config.php.bak、/phpinfo.php,以及沒在用的外掛路徑等等。這份清單同時也是「自己網站絕對不應該回傳 200 的路徑清單」。
zcat -f $LOGS | awk '$9 ~ /^2/ && $7 ~ /\.env|\.git|\.bak|\.old|\.sql|\.zip|wp-config|phpinfo|adminer|phpmyadmin|\.DS_Store/ {print $7}' \
| sed 's/?.*//' | sort | uniq -c | sort -rn | head -30
這是最優先要確認的統計。只要這裡出現一行,就代表不是「正在被攻擊」,而是「已經被拿走了」。表示備份檔或環境變數檔真的被對外提供了,因此在封鎖之前,必須先刪除那些檔案,並修改其中包含的認證資訊。目標是確認這裡的輸出為空。
zcat -f $LOGS | awk '$9 ~ /^2/ {b[$1]+=$10} END {for (i in b) printf "%15.0f %s\n", b[i], i}' \
| sort -rn | head -20
將回應大小($10)依 IP 累計。即使單筆不明顯,但總量特別突出的來源,可能是在大量抓取內容或外洩檔案(Apache 的 %b 在 0 bytes 時會顯示 -,不過在 awk 加總時會視為 0,因此不影響)。
zcat -f $LOGS | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -20
以雙引號切分後,第 6 欄就是 User-Agent。如果空白的 - 或某些工具名稱排在前面,就要跟統計 1~3 一起交叉比對。不過 User-Agent 是可以自由偽裝的,不能單獨當成證據。
人工手動檢查只需要第一次,之後改成每天跑一次,只看跟前一天的差異。
#!/bin/bash
# /usr/local/bin/access-log-digest.sh
set -euo pipefail
LOG=/var/log/nginx/access.log.1 # 因為是在 logrotate 之後執行,所以看前一天的內容
OUT=/var/backups/access-digest
install -d -m 700 "$OUT"
today=$(date +%F)
{
echo "== 404最多的前10個 IP =="
awk '$9==404 {print $1}' "$LOG" | sort | uniq -c | sort -rn | head -10
echo "== 以 2xx 回應的可疑路徑(應為空) =="
awk '$9 ~ /^2/ && $7 ~ /\.env|\.git|\.bak|\.sql|wp-config|phpinfo/ {print $7}' "$LOG" | sort | uniq -c | sort -rn | head -10
} > "$OUT/$today.txt"
prev=$(ls -1 "$OUT"/*.txt | tail -2 | head -1)
[ "$prev" = "$OUT/$today.txt" ] || diff -u "$prev" "$OUT/$today.txt" || true
# 每天早上 6:05 以電子郵件接收前一天摘要的差分
5 6 * * * /usr/local/bin/access-log-digest.sh | mail -s "access log digest $(hostname)" [email protected]
不可能長期每天逐行閱讀整份日誌。重點是只看差分,只有在有新增內容時才打開原始日誌。這才是能持續下去的方法。
若在統計 2、3 中找到常客,就可以交給自動封鎖。fail2ban 的 jail.conf 預設值是 maxretry = 5 / findtime = 10m / bantime = 10m(1.1.0),也就是「10 分鐘內 5 次就封鎖 10 分鐘」。
要注意的是,內建的 nginx 類 jail(nginx-http-auth / nginx-limit-req / nginx-botsearch)都是看錯誤日誌。但 wp-login.php 的 POST 只會出現在存取日誌,所以需要自己做一個 filter。
# /etc/fail2ban/filter.d/wordpress-auth.conf
[Definition]
failregex = ^<HOST> .* "POST [^"]*/(wp-login\.php|xmlrpc\.php)
ignoreregex =
# /etc/fail2ban/jail.local
[wordpress-auth]
enabled = true
filter = wordpress-auth
port = http,https
logpath = /var/log/nginx/access.log
# 放寬到不至於誤傷正常的登入失誤
maxretry = 10
findtime = 10m
bantime = 1h
# 一定要先把公司內網與監控服務的 IP 加進去
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24
# 對反覆被封鎖的慣犯做長期封鎖(預設 bantime 1w / findtime 1d)
[recidive]
enabled = true
套用前,一定要先用真實日誌確認過濾條件是否命中。
# 確認手邊的日誌會命中幾筆(不會執行封鎖)
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/wordpress-auth.conf
fail2ban-client reload
fail2ban-client status wordpress-auth # 目前的封鎖清單
fail2ban-client set wordpress-auth unbanip 203.0.113.10 # 解除誤封
為了避免誤封,有 3 個重點:
ignoreip。 最常見的事故就是把自己擋掉。maxretry 設太嚴。 先用 bantime = 10m 觀察幾天,再視情況延長。另外,封鎖不能取代修補。 BAN 只能阻止重複嘗試,弱點本身仍然存在。
/etc/logrotate.d/nginx 的 rotate 設定,搭配 compress 之後可延長到大約 90 天。access_log ... combined buffer=64k flush=5m;,那麼最近幾行不會立刻寫出來。調查期間要留意 flush 的影響。# nginx:不要把監控的健康檢查寫進日誌
location = /healthz {
access_log off;
return 200;
}
# Apache:條件式記錄(用 env= 排除)
SetEnvIf Request_URI "^/healthz$" dontlog
CustomLog ${APACHE_LOG_DIR}/access.log combined_realip env=!dontlog
ignoreip,再用 fail2ban-regex 確認命中後才啟用日誌不只是在回答「有沒有被攻擊」,還能回答設定漏洞是否真的被踩中。先從跑一次統計 5、確認輸出是否為空開始吧。
.git、.env 被公開像本文這類設定上的缺口,可以透過能以 9 道防線整體守護網站的 SiteDock 一次性整合對策 → https://sitedock.jp
原文出處:https://qiita.com/jiis-sasaki/items/7affa2e2ef22ebf93fa6