針對 HTTP 層面的 CC 攻擊、惡意掃描、暴力破解,應用層防火牆 (WAF) 是最後防線。本文實作一套 雙引擎防禦系統:靜態 IP 黑名單 (永久封禁) + 動態行為分析 (頻率限制、異常模式識別、自動升級封禁),可作為中間件接入任何 PHP 框架。
<?php
/**
* PHP 輕量級 WAF 中間件
* 用法:在框架入口 (index.php) 最早處 require 並實例化
* $waf = new Waf(); $waf->guard();
*/
class Waf {
private $config;
private $ip;
private $uri;
private $now;
private $dataDir;
public function __construct(array $config = []) {
$this->config = array_merge([
'ban_time_window' => 120, // 統計時間窗 (秒)
'ban_max_requests' => 20, // 最大請求數
'ban_duration' => 3600, // 封禁時長 (秒)
'permanent_ban_threshold' => 5, // 觸發永久封禁次數
'whitelist' => ['127.0.0.1', '::1'], // 白名單
'data_dir' => __DIR__ . '/waf_data',
'log_file' => __DIR__ . '/waf.log',
], $config);
$this->dataDir = $this->config['data_dir'];
if (!is_dir($this->dataDir)) mkdir($this->dataDir, 0750, true);
$this->ip = $this->getRealIp();
$this->uri = $_SERVER['REQUEST_URI'] ?? '/';
$this->now = time();
}
/** 核心防禦入口 */
public function guard(): void {
if ($this->isWhitelisted()) return;
// 1. 檢查永久黑名單
if ($this->checkPermanentBan()) {
$this->log('PERMANENT_BAN', "IP {$this->ip} 在永久黑名單中");
$this->respond(403, 'IP 已被永久封禁');
}
// 2. 檢查暫時封禁
if ($this->checkTemporaryBan()) {
$this->log('TEMP_BAN', "IP {$this->ip} 在暫時封禁期內");
$this->respond(429, '請求過於頻繁,請稍後再試');
}
// 3. 記錄請求並分析行為
$this->recordRequest();
// 4. 清理過期數據 (低概率觸發,避免性能影響)
if (mt_rand(1, 100) === 1) $this->cleanup();
}
/** 獲取真實客戶端 IP (支援反向代理) */
private function getRealIp(): string {
$headers = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ips = explode(',', $_SERVER[$header]);
return trim($ips[0]);
}
}
return '0.0.0.0';
}
/** 白名單檢查 */
private function isWhitelisted(): bool {
return in_array($this->ip, $this->config['whitelist']);
}
/** 檢查永久黑名單 (.htaccess 格式,可直接被 Nginx/Apache include) */
private function checkPermanentBan(): bool {
$file = $this->dataDir . '/permanent_ban.txt';
if (!file_exists($file)) return false;
$banned = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
return in_array($this->ip, $banned);
}
/** 檢查暫時封禁 */
private function checkTemporaryBan(): bool {
$file = $this->dataDir . '/temp_ban_' . md5($this->ip) . '.dat';
if (!file_exists($file)) return false;
$data = json_decode(file_get_contents($file), true);
if (!$data) return false;
if ($this->now - $data['time'] > $this->config['ban_duration']) {
unlink($file); // 過期自動解封
return false;
}
return true;
}
/** 記錄請求並進行行為分析 */
private function recordRequest(): void {
$key = md5($this->ip . $this->uri);
$file = $this->dataDir . '/requests_' . $key . '.dat';
$record = [
'ip' => $this->ip,
'uri' => $this->uri,
'count' => 1,
'first' => $this->now,
'last' => $this->now,
'ban_count' => 0,
];
if (file_exists($file)) {
$record = json_decode(file_get_contents($file), true) ?: $record;
$record['count']++;
$record['last'] = $this->now;
}
// 行為分析:時間窗內超頻
if ($record['count'] > $this->config['ban_max_requests']
&& $this->now - $record['first'] < $this->config['ban_time_window']) {
$this->triggerBan($record, $file);
return;
}
// 異常模式:掃描器特徵 (短時間請求大量不同 URI)
$this->detectScanner($record);
file_put_contents($file, json_encode($record), LOCK_EX);
}
/** 觸發封禁邏輯 */
private function triggerBan(array &$record, string $file): void {
$record['ban_count']++;
$record['last_ban'] = $this->now;
// 寫入暫時封禁
$banFile = $this->dataDir . '/temp_ban_' . md5($this->ip) . '.dat';
file_put_contents($banFile, json_encode([
'time' => $this->now,
'reason' => "頻率超限: {$record['count']} reqs in {$this->config['ban_time_window']}s",
'uri' => $this->uri,
]), LOCK_EX);
// 升級為永久封禁
if ($record['ban_count'] >= $this->config['permanent_ban_threshold']) {
$this->addPermanentBan($record);
}
$this->log('AUTO_BAN', "IP {$this->ip} 第 {$record['ban_count']} 次觸發封禁");
$this->respond(429, '請求過於頻繁,已被暫時封禁');
}
/** 掃描器特徵檢測 */
private function detectScanner(array $record): void {
// 簡單啟發式:同 IP 短時間請求超過 10 個不同路徑
$patternFile = $this->dataDir . '/patterns_' . md5($this->ip) . '.dat';
$patterns = file_exists($patternFile)
? json_decode(file_get_contents($patternFile), true)
: ['uris' => [], 'window' => $this->now];
// 清理舊窗口
if ($this->now - $patterns['window'] > 60) {
$patterns = ['uris' => [], 'window' => $this->now];
}
$patterns['uris'][$this->uri] = true;
if (count($patterns['uris']) > 15) { // 閾值可調
$this->addPermanentBan([
'ip' => $this->ip,
'reason' => '掃描器特徵: 1分鐘內訪問 ' . count($patterns['uris']) . ' 個不同路徑',
]);
}
file_put_contents($patternFile, json_encode($patterns), LOCK_EX);
}
/** 加入永久黑名單 */
private function addPermanentBan(array $info): void {
$file = $this->dataDir . '/permanent_ban.txt';
$line = $info['ip'] . " # " . date('Y-m-d H:i:s') . " - " . ($info['reason'] ?? '自動封禁') . "\n";
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
$this->log('PERMANENT_BAN_ADD', $line);
}
/** 清理過期數據 */
private function cleanup(): void {
$files = glob($this->dataDir . '/*.dat');
foreach ($files as $file) {
$data = json_decode(file_get_contents($file), true);
if (!$data) { unlink($file); continue; }
$expireTime = isset($data['time']) ? $data['time'] : ($data['last'] ?? 0);
if ($this->now - $expireTime > 86400) { // 24小時無活動刪除
unlink($file);
}
}
}
/** 統一日誌記錄 */
private function log(string $type, string $msg): void {
$line = sprintf("[%s] %s | IP: %s | URI: %s | %s\n",
date('Y-m-d H:i:s'), $type, $this->ip, $this->uri, $msg);
file_put_contents($this->config['log_file'], $line, FILE_APPEND | LOCK_EX);
}
/** 統一響應並終止 */
private function respond(int $code, string $msg): void {
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
// 可選:返回 JSON 供前端處理
// echo json_encode(['error' => $msg, 'code' => $code]);
exit($msg);
}
}
// ========== 接入範例 (Laravel/ThinkPHP/原生) ==========
/*
// 原生 PHP / 任何框架入口
require_once __DIR__ . '/Waf.php';
$waf = new Waf([
'ban_max_requests' => 30,
'ban_time_window' => 60,
'whitelist' => ['10.0.0.0/8', '192.168.0.0/16'], // 支援 CIDR 需擴展
]);
$waf->guard();
// 後續正常業務邏輯...
*/
?>
請求進入
│
▼
┌─────────────────┐
│ 白名單檢查 │──Yes──▶ 放行
└────────┬────────┘
│ No
▼
┌─────────────────┐
│ 永久黑名單 │──Yes──▶ 403 Forbidden
└────────┬────────┘
│ No
▼
┌─────────────────┐
│ 暫時封禁檢查 │──Yes──▶ 429 Too Many Requests
└────────┬────────┘
│ No
▼
┌─────────────────┐
│ 記錄請求 │
│ - IP+URI 計數 │
│ - 時間窗統計 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 行為分析引擎 │
│ 1. 頻率超限 │──Yes──▶ 暫時封禁 → 累計升級永久封禁
│ 2. 掃描器特徵 │──Yes──▶ 直接永久封禁
│ 3. 異常 User-Agent/參數 │ (可擴展)
└────────┬────────┘
│ No
▼
業務邏輯處理
| 層面 | 檔案儲存方案 | Redis 方案 (推薦高併發) |
|---|---|---|
| 儲存介質 | 本地檔案系統 (SSD) | Redis Cluster / Sentinel |
| 計數器實現 | JSON 檔案 + flock | INCR + EXPIRE 原子操作 |
| 分布式一致性 | 不支援 (單機) | 天然支援多節點共享狀態 |
| 效能 | ~5000 QPS (SSD) | >100,000 QPS |
| 持久化 | 自動 | AOF/RDB 配置 |
| Nginx 整合 | include /path/waf_data/permanent_ban.txt; |
Lua 腳本 access_by_lua_block 直接查 Redis |
# nginx.conf http 段
lua_shared_dict waf_ban 10m;
# 初始化定時同步永久黑名單到 shared dict
init_by_lua_block {
local function load_bans()
local file = io.open("/path/waf_data/permanent_ban.txt", "r")
if file then
for line in file:lines() do
local ip = line:match("^(%S+)")
if ip then ngx.shared.waf_ban:set(ip, true) end
end
file:close()
end
end
load_bans()
-- 定時刷新
local timer = ngx.timer.every(60, load_bans)
}
# server 段
location / {
access_by_lua_block {
local ip = ngx.var.remote_addr
-- 1. 永久黑名單 (共享記憶體,極快)
if ngx.shared.waf_ban:get(ip) then
return ngx.exit(403)
end
-- 2. 頻率限制 (Redis)
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(10)
red:connect("127.0.0.1", 6379)
local key = "waf:req:" .. ip
local count = red:incr(key)
if count == 1 then red:expire(key, 60) end
if count > 100 then
-- 寫入永久黑名單 (異步)
ngx.timer.at(0, function()
local f = io.open("/path/waf_data/permanent_ban.txt", "a")
f:write(ip .. " # auto " .. ngx.time() .. "\n")
f:close()
end)
return ngx.exit(429)
end
}
# 正常代理到 PHP-FPM
fastcgi_pass unix:/run/php/php8.1-fpm.sock;
include fastcgi_params;
}
本 WAF 實作了 靜態黑名單 + 動態行為分析 + 自動升級封禁 三層防護,採用檔案儲存適合中小流量單機部署,高併發場景建議遷移至 Nginx + Lua + Redis 架構,將防禦前置到 Web 伺服器層,實現零 PHP 開銷攔截。核心原則:白名單優先、分級封禁、自動清理、詳盡審計、前置攔截。