关注

php+最新的swoole WAF(Web 应用防火墙)

PHP 8.3 + Swoole 6.x WAF(Web 应用防火墙) · 完整实现

  WAF 的核心矛盾:OWASP TOP10 全覆盖 + 极低误杀 + 高吞吐反向代理 + 规则可热更新 + CC 攻击防御。
  本方案是透明反向代理型 WAF(部署在 Nginx 之后 / 业务之前),拦截 SQLi / XSS / RCE / 路径穿越 / CC / 恶意爬虫。

  ---
  一、整体流程(大白话)

                  Internet
                     │
                Nginx / SLB
                     │
         ┌───────────▼───────────┐
         │   Swoole WAF :8080    │
         │                       │
         │ ①IP/国家/ASN 黑名单  │ ←Swoole\Table(纳秒)
         │ ②JS challenge cookie │ ←防自动化爬虫
         │ ③CC 限流(IP+URL)   │ ←Redis Lua 令牌桶
         │ ④Bot UA 检测         │
         │ ⑤请求规范化(URL解码、JSON 解析)
         │ ⑥规则引擎:           │
         │    • SQLi 30+ pattern │
         │    • XSS  30+ pattern │
         │    • RCE / Cmd inj    │
         │    • 路径穿越         │
         │    • 文件包含         │
         │    • 自定义 DSL       │
         │ ⑦累计风险分:         │
         │    < 40 →ALLOW       │
         │    40-80→CHALLENGE   │
         │    > 80 →BLOCK       │
         │ ⑧Proxy →upstream    │
         │ ⑨Response 过滤:      │
         │    • 隐藏 Server 头   │
         │    • 敏感数据 DLP     │
         │ ⑩异步审计(Kafka)   │
         └───────────┬───────────┘
                     │
                Upstream Apps

  核心思想:多层评分 + 分级响应(放行/挑战/阻断),热点规则在内存,所有判定 < 1ms。

  ---
  二、最佳技术选型

  ┌────────────┬───────────────────────────────┬──────────────────────┐
  │     层     │            库/技术            │         原因         │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 运行时     │ Swoole 6.x                    │ 反向代理 + 协程      │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 反向代理   │ Swoole\Coroutine\Http\Client  │ 协程化 upstream 调用 │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ GeoIP      │ maxmind-db/reader(MMDB)       │ 业界标准库           │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 规则 DSL   │ symfony/expression-language   │ 自定义规则           │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 限流       │ Redis Lua 令牌桶              │ 原子                 │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 共享状态   │ Swoole\Table                  │ 跨 worker IP 黑名单  │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 攻击特征库 │ 自研 + OWASP CRS 移植         │ 业界基线             │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 审计       │ ClickHouse + 异步 task        │ 千万级日志           │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 监控       │ promphp/prometheus_client_php │ 标准                 │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 路由       │ nikic/fast-route              │ 管控 API             │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ JWT/Cookie │ firebase/php-jwt              │ challenge token      │
  ├────────────┼───────────────────────────────┼──────────────────────┤
  │ 日志       │ monolog/monolog               │ 结构化               │
  └────────────┴───────────────────────────────┴──────────────────────┘

  composer require swoole/ide-helper maxmind-db/reader symfony/expression-language \
                   nikic/fast-route firebase/php-jwt promphp/prometheus_client_php \
                   smi2/phpclickhouse monolog/monolog

  ---
  三、完整代码

  1. 入口:反向代理 + Worker

  <?php
  // server.php
  declare(strict_types=1);
  require __DIR__ . '/vendor/autoload.php';

  use Swoole\Http\Server;
  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use App\WAF\Container;
  use App\WAF\Pipeline;

  \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL);

  $c = Container::boot();

  $server = new Server('0.0.0.0', 8080, SWOOLE_PROCESS);
  $server->set([
      'worker_num'        => swoole_cpu_num() * 2,
      'task_worker_num'   => 8,
      'task_enable_coroutine' => true,
      'enable_coroutine'  => true,
      'max_request'       => 100000,
      'hook_flags'        => SWOOLE_HOOK_ALL,
      'reactor_num'       => swoole_cpu_num() * 2,
      'backlog'           => 65535,
      'tcp_fastopen'      => true,
      'open_tcp_nodelay'  => true,
      'http_parse_post'   => true,                     // WAF 需要解析 body
      'http_parse_cookie' => true,
      'package_max_length'=> 16 * 1024 * 1024,
      'send_yield'        => true,
  ]);

  $server->on('WorkerStart', function($s, $wid) use ($c) {
      $c->initPools();
      $c->loadRules();          // SQLi/XSS pattern + 自定义规则
      $c->loadBlocklists();     // IP/UA/国家
      $c->loadUpstreams();      // upstream 路由表
      $c->loadGeoIP();
      if ($wid === 0) {
          \Swoole\Coroutine::create(fn() => $c->ruleReloader()->run());
          \Swoole\Coroutine::create(fn() => $c->blocklistReloader()->run());
      }
  });

  $server->on('Request', function(Request $req, Response $res) use ($c, $server) {
      (new Pipeline($c, $server))->handle($req, $res);
  });

  // Task:异步审计 / 告警
  $server->on('Task', function($s, $task) use ($c) {
      if ($task->data['type'] === 'audit')  $c->auditWriter()->write($task->data);
      if ($task->data['type'] === 'alert')  $c->alerter()->send($task->data);
  });
  $server->on('Finish', fn() => null);

  $server->start();

  解释:
  - http_parse_post=true + http_parse_cookie=true:WAF 必须看完整请求,跟前面对象存储网关相反
  - tcp_fastopen + backlog=65535:抗 SYN flood + 突发流量
  - 规则热更新独立协程:改规则不重启

  ---
  2. Container:规则 + 黑名单 + 上游

  <?php
  // src/WAF/Container.php
  namespace App\WAF;

  use Swoole\Coroutine\Channel;
  use Swoole\Coroutine\Redis;
  use Swoole\Coroutine\MySQL;
  use Swoole\Table;
  use MaxMind\Db\Reader as GeoReader;
  use ClickHouseDB\Client as CH;

  class Container
  {
      public Table $ipBlock;          // IP 黑名单(运行时动态加)
      public Table $ipWhite;
      public Table $countryBlock;     // 国家 ISO Code →reason
      public Table $rules;            // 自定义规则
      public Table $upstreams;        // host →upstream URL
      public Channel $redisPool;
      public Channel $mysqlPool;
      public ?GeoReader $geo = null;
      public CH $ch;

      public array $sqliPatterns = [];
      public array $xssPatterns  = [];
      public array $cmdPatterns  = [];
      public array $pathPatterns = [];
      public array $botUaPatterns = [];

      public static function boot(): self
      {
          $c = new self();

          $c->ipBlock = new Table(1 << 18);
          $c->ipBlock->column('reason', Table::TYPE_STRING, 64);
          $c->ipBlock->column('expire', Table::TYPE_INT, 8);
          $c->ipBlock->create();

          $c->ipWhite = new Table(1 << 12);
          $c->ipWhite->column('note', Table::TYPE_STRING, 64);
          $c->ipWhite->create();

          $c->countryBlock = new Table(256);
          $c->countryBlock->column('reason', Table::TYPE_STRING, 64);
          $c->countryBlock->create();

          $c->rules = new Table(2048);
          $c->rules->column('name',       Table::TYPE_STRING, 64);
          $c->rules->column('expression', Table::TYPE_STRING, 4096);
          $c->rules->column('score',      Table::TYPE_INT,    4);
          $c->rules->column('action',     Table::TYPE_STRING, 16);
          $c->rules->column('enabled',    Table::TYPE_INT,    1);
          $c->rules->create();

          $c->upstreams = new Table(256);
          $c->upstreams->column('host_pattern', Table::TYPE_STRING, 128);
          $c->upstreams->column('upstream',     Table::TYPE_STRING, 128);
          $c->upstreams->create();

          $c->ch = new CH([
              'host'=>'127.0.0.1','port'=>8123,
              'username'=>'default','password'=>'',
          ]);
          $c->ch->database('waf');

          return $c;
      }

      public function initPools(): void
      {
          $this->redisPool = new Channel(64);
          for ($i=0;$i<64;$i++) {
              $r = new Redis(); $r->connect('127.0.0.1', 6379);
              $this->redisPool->push($r);
          }
          $this->mysqlPool = new Channel(8);
          for ($i=0;$i<8;$i++) {
              $db = new MySQL();
              $db->connect(['host'=>'127.0.0.1','user'=>'admin','password'=>'admin',
                  'database'=>'waf','charset'=>'utf8mb4']);
              $this->mysqlPool->push($db);
          }
      }

      public function loadRules(): void
      {
          // 内置高质量特征库(节选;真实生产从配置中心拉)
          $this->sqliPatterns = [
              '/(\\bunion\\b.*\\bselect\\b)/i',
              '/(\\bselect\\b.*\\bfrom\\b)/i',
              '/(\\binto\\s+(?:out|dump)file\\b)/i',
              '/(\\bdrop\\s+table\\b)/i',
              '/(\\bload_file\\s*\\()/i',
              '/(\\bsleep\\s*\\(\\s*\\d+\\s*\\))/i',
              '/(\\bbenchmark\\s*\\()/i',
              "/(\\bor\\b\\s+['\"\\d]+\\s*=\\s*['\"\\d]+)/i",
              "/(\\band\\b\\s+['\"\\d]+\\s*=\\s*['\"\\d]+)/i",
              '/(\\binformation_schema\\b)/i',
              '/(\\bgroup_concat\\s*\\()/i',
              '/(--[\\s\\r\\n]|#|\\/\\*!)/',
              '/(\\bhex\\s*\\(|\\bunhex\\s*\\()/i',
              '/(\\bextractvalue\\s*\\(|\\bupdatexml\\s*\\()/i',
          ];
          $this->xssPatterns = [
              '/<script\\b[^>]*>/i',
              '/<\\/script>/i',
              '/javascript\\s*:/i',
              '/\\bon(?:error|load|click|mouseover|focus|blur)\\s*=/i',
              '/<iframe\\b/i',
              '/<svg\\b[^>]*>/i',
              '/<img[^>]+src\\s*=\\s*["\']?javascript:/i',
              '/document\\.(?:cookie|location|write)/i',
              '/window\\.location/i',
              '/eval\\s*\\(/i',
              '/expression\\s*\\(/i',
              '/data:text\\/html/i',
              '/\\balert\\s*\\(/i',
              '/<object\\b|<embed\\b/i',
              '/&#x?[0-9a-f]{2,};/i',                      // 编码绕过
          ];
          $this->cmdPatterns = [

  '/[;&|`]\\s*(?:cat|ls|wget|curl|nc|bash|sh|chmod|chown|rm|mv|cp|kill|ps|whoami|id|uname|netstat|ifconfig|ping)\\b/i',
              '/\\$\\([^)]+\\)/',                          // $(cmd)
              '/`[^`]+`/',                                  // `cmd`
              '/\\b(?:wget|curl)\\s+https?:/i',
              '/\\/etc\\/(?:passwd|shadow|hosts)/i',
          ];
          $this->pathPatterns = [
              '/\\.\\.[\\/\\\\]/',
              '/\\.\\.%2[fF]/',
              '/%2e%2e[\\/\\\\]/i',
              '/\\.\\.\\\\/',
              '/etc[\\/\\\\]passwd/i',
              '/proc[\\/\\\\]self/i',
              '/windows[\\/\\\\]system32/i',
          ];
          $this->botUaPatterns = [
              '/sqlmap/i','/nikto/i','/nmap/i','/masscan/i','/zgrab/i','/dirbuster/i',
              '/wpscan/i','/nessus/i','/acunetix/i','/burp/i','/havij/i','/jaeles/i',
              '/python-requests/i','/curl\\/[\\d.]+$/i','/go-http-client/i','/scrapy/i',
          ];

          // 自定义规则从 DB 加载
          $this->withMySQL(function($db) {
              $rows = $db->query("SELECT * FROM waf_rules WHERE enabled=1");
              foreach ($rows as $r) {
                  $this->rules->set((string)$r['id'], [
                      'name'=>$r['name'],'expression'=>$r['expression'],
                      'score'=>(int)$r['score'],'action'=>$r['action'],'enabled'=>1,
                  ]);
              }
          });
      }

      public function loadBlocklists(): void
      {
          $this->withMySQL(function($db) {
              foreach ($db->query("SELECT ip,reason FROM ip_blocklist WHERE enabled=1") as $r) {
                  $this->ipBlock->set($r['ip'], ['reason'=>$r['reason'],'expire'=>0]);
              }
              foreach ($db->query("SELECT ip,note FROM ip_whitelist WHERE enabled=1") as $r) {
                  $this->ipWhite->set($r['ip'], ['note'=>$r['note']]);
              }
              foreach ($db->query("SELECT country_iso,reason FROM country_blocklist") as $r) {
                  $this->countryBlock->set($r['country_iso'], ['reason'=>$r['reason']]);
              }
          });
      }

      public function loadUpstreams(): void
      {
          $this->withMySQL(function($db) {
              foreach ($db->query("SELECT host_pattern,upstream FROM upstreams WHERE enabled=1") as $i => $r) {
                  $this->upstreams->set((string)$i, [
                      'host_pattern'=>$r['host_pattern'],
                      'upstream'=>$r['upstream'],
                  ]);
              }
          });
      }

      public function loadGeoIP(): void
      {
          if (is_file('/etc/waf/GeoLite2-Country.mmdb')) {
              $this->geo = new GeoReader('/etc/waf/GeoLite2-Country.mmdb');
          }
      }

      public function withRedis(callable $fn) {
          $r = $this->redisPool->pop();
          try { return $fn($r); } finally { $this->redisPool->push($r); }
      }
      public function withMySQL(callable $fn) {
          $db = $this->mysqlPool->pop();
          try { return $fn($db); } finally { $this->mysqlPool->push($db); }
      }

      public function ruleReloader(): RuleReloader { return new RuleReloader($this); }
      public function blocklistReloader(): BlocklistReloader { return new BlocklistReloader($this); }
      public function auditWriter(): AuditWriter { return new AuditWriter($this); }
      public function alerter(): Alerter { return new Alerter($this); }
  }

  解释:
  - 内置 SQLi / XSS / Cmd / Path / Bot UA 五大类共 60+ 特征(节选)
  - 自定义规则 + 黑白名单 + GeoIP + 上游路由全在 Swoole\Table,跨 worker 共享,纳秒级查询
  - 真生产用 MaxMind GeoLite2(免费 mmdb)做国家识别

  ---
  3. Pipeline:核心决策流水线

  <?php
  // src/WAF/Pipeline.php
  namespace App\WAF;

  use Swoole\Http\Request;
  use Swoole\Http\Response;
  use Swoole\Http\Server;
  use App\WAF\Detector\{IpDetector, RateLimiter, BotDetector, AttackDetector, RuleEngine};

  class Pipeline
  {
      public function __construct(private Container $c, private Server $server) {}

      public function handle(Request $req, Response $res): void
      {
          $startMs = (int)(microtime(true)*1000);
          $clientIp = $this->realIp($req);
          $host     = $req->header['host'] ?? '';
          $uri      = $req->server['request_uri'];
          $ua       = $req->header['user-agent'] ?? '';

          $ctx = [
              'ip'        => $clientIp,
              'host'      => $host,
              'uri'       => $uri,
              'method'    => $req->server['request_method'],
              'ua'        => $ua,
              'referer'   => $req->header['referer'] ?? '',
              'headers'   => $req->header ?? [],
              'get'       => $req->get ?? [],
              'post'      => $req->post ?? [],
              'cookie'    => $req->cookie ?? [],
              'body'      => $req->rawContent() ?: '',
              'country'   => $this->geoCountry($clientIp),
          ];

          try {
              // 1. 白名单直通
              if ($this->c->ipWhite->exist($clientIp)) {
                  return $this->proxy($req, $res, $ctx, ['decision'=>'WHITELIST']);
              }

              // 2. IP/国家黑名单(零成本)
              $ipHit = (new IpDetector($this->c))->check($ctx);
              if ($ipHit) {
                  return $this->block($res, $ctx, $ipHit, 0, $startMs);
              }

              // 3. CC 限流(IP 维度 + URI 维度)
              $rlHit = (new RateLimiter($this->c))->check($ctx);
              if ($rlHit) {
                  return $this->block($res, $ctx, $rlHit, 0, $startMs);
              }

              // 4. Bot UA
              $botHit = (new BotDetector($this->c))->check($ctx);
              $score = 0;
              $hits = [];
              if ($botHit) { $hits[] = $botHit; $score += 60; }

              // 5. 攻击特征(SQLi/XSS/Cmd/Path)
              $attackHits = (new AttackDetector($this->c))->scan($ctx);
              foreach ($attackHits as $h) {
                  $hits[] = $h; $score += $h['score'];
              }

              // 6. 自定义规则引擎
              $ruleHits = (new RuleEngine($this->c))->evaluate($ctx);
              foreach ($ruleHits as $h) {
                  $hits[] = $h; $score += $h['score'];
              }

              // 7. 决策
              $decision = match(true) {
                  $score >= 80 => 'BLOCK',
                  $score >= 40 => 'CHALLENGE',
                  default      => 'ALLOW',
              };

              // 8. CHALLENGE →JS 挑战(已通过则放行)
              if ($decision === 'CHALLENGE') {
                  $challenger = new Challenger($this->c);
                  if (!$challenger->verifyCookie($req)) {
                      return $challenger->serveChallenge($res, $clientIp);
                  }
                  $decision = 'ALLOW';   // 挑战通过
              }

              // 9. BLOCK
              if ($decision === 'BLOCK') {
                  // 高分行为加入 IP 临时黑名单(指数加深)
                  $this->autoBlockIp($clientIp, $score, $hits);
                  return $this->block($res, $ctx, $hits[0] ?? ['reason'=>'high_risk'], $score, $startMs);
              }

              // 10. ALLOW →反代到上游
              $this->proxy($req, $res, $ctx, ['decision'=>'ALLOW','score'=>$score,'hits'=>$hits]);
              $this->audit($ctx, 'ALLOW', $score, $hits, $startMs);

          } catch (\Throwable $e) {
              // WAF 自身异常不能阻断业务
              error_log("[waf] error: ".$e->getMessage());
              $this->proxy($req, $res, $ctx, ['decision'=>'FAIL-OPEN']);
          }
      }

      private function block(Response $res, array $ctx, array $hit, int $score, int $startMs): void
      {
          $res->status(403);
          $res->header('Content-Type', 'text/html; charset=utf-8');
          $res->header('X-WAF-Block', $hit['name'] ?? 'attack');
          $res->end(
              '<html><head><title>403 Forbidden</title></head><body>'
              . '<h1>Access Denied</h1>'
              . '<p>Your request has been blocked.</p>'
              . '<p>Request ID: ' . bin2hex(random_bytes(8)) . '</p>'
              . '</body></html>'
          );
          $this->audit($ctx, 'BLOCK', $score, [$hit], $startMs);
          // 推告警(异步)
          if ($score >= 100) {
              $this->server->task([
                  'type'=>'alert','severity'=>'high',
                  'ip'=>$ctx['ip'],'rule'=>$hit['name']??'','uri'=>$ctx['uri'],
              ]);
          }
      }

      private function proxy(Request $req, Response $res, array $ctx, array $verdict): void
      {
          $upstream = $this->matchUpstream($ctx['host']);
          if (!$upstream) {
              $res->status(502); $res->end('No upstream'); return;
          }

          $u = parse_url($upstream);
          $client = new \Swoole\Coroutine\Http\Client(
              $u['host'], $u['port'] ?? ($u['scheme']==='https'?443:80),
              ($u['scheme'] ?? 'http') === 'https'
          );
          $client->set(['timeout'=>30]);

          // 透传 headers + 加 X-Forwarded
          $headers = $req->header ?? [];
          $headers['X-Forwarded-For']   = $ctx['ip'];
          $headers['X-Forwarded-Host']  = $ctx['host'];
          $headers['X-Forwarded-Proto'] = $req->header['x-forwarded-proto'] ?? 'http';
          $headers['X-WAF-Score']       = (string)($verdict['score'] ?? 0);
          unset($headers['connection'], $headers['host']);
          $client->setHeaders($headers);

          $client->setMethod($req->server['request_method']);
          if ($body = $req->rawContent()) $client->setData($body);
          $client->execute($ctx['uri']);

          // 响应头过滤(隐藏后端 server 等)
          $respHeaders = $client->headers ?? [];
          unset($respHeaders['server'], $respHeaders['x-powered-by'], $respHeaders['x-aspnet-version']);
          foreach ($respHeaders as $k => $v) $res->header($k, $v);
          $res->status($client->statusCode);

          // 响应体 DLP(可选)—这里只做大对象流式
          $res->end($client->body);
          $client->close();
      }

      private function matchUpstream(string $host): ?string
      {
          foreach ($this->c->upstreams as $u) {
              if (fnmatch($u['host_pattern'], $host)) return $u['upstream'];
          }
          return null;
      }

      private function realIp(Request $req): string
      {
          $xff = $req->header['x-forwarded-for'] ?? '';
          if ($xff) {
              $first = trim(explode(',', $xff)[0]);
              if (filter_var($first, FILTER_VALIDATE_IP)) return $first;
          }
          return $req->server['remote_addr'] ?? '0.0.0.0';
      }

      private function geoCountry(string $ip): string
      {
          if (!$this->c->geo) return '';
          try {
              $r = $this->c->geo->get($ip);
              return $r['country']['iso_code'] ?? '';
          } catch (\Throwable $e) { return ''; }
      }

      private function autoBlockIp(string $ip, int $score, array $hits): void
      {
          // 一次高分攻击 →临时拉黑 5 分钟,反复触发延长到 1 天
          $this->c->withRedis(function($r) use ($ip, $score, $hits) {
              $cnt = $r->incr("waf:autoblock:$ip");
              if ($cnt === 1) $r->expire("waf:autoblock:$ip", 86400);
              $ttl = min(86400, 300 * (2 ** ($cnt - 1)));    // 5m / 10m / 20m / ...
              $reason = implode(',', array_column($hits, 'name'));
              $this->c->ipBlock->set($ip, ['reason'=>"auto:$reason",'expire'=>time()+$ttl]);
          });
      }

      private function audit(array $ctx, string $decision, int $score, array $hits, int $startMs): void
      {
          $this->server->task([
              'type'    => 'audit',
              'ts'      => $startMs,
              'ip'      => $ctx['ip'],
              'country' => $ctx['country'],
              'host'    => $ctx['host'],
              'uri'     => $ctx['uri'],
              'method'  => $ctx['method'],
              'ua'      => substr($ctx['ua'], 0, 256),
              'decision'=> $decision,
              'score'   => $score,
              'hits'    => array_column($hits, 'name'),
              'dur_ms'  => (int)(microtime(true)*1000) - $startMs,
          ]);
      }
  }

  解释:
  - 10 步流水线 严格按代价从低到高:白名单→黑名单→限流→特征→规则→挑战→反代
  - FAIL-OPEN 原则:WAF 自身异常时放行而非阻断,不能因为 WAF 挂了让全站宕机
  - 自动学习黑名单:同 IP 多次攻击,5分钟→1020...→指数延长
  - autoBlockIp 写 Swoole Table + Redis:跨 worker 立即生效,重启后从 DB 重建

  ---
  4. Detectors:IP / 限流 / Bot / 攻击

  <?php
  // src/WAF/Detector/IpDetector.php
  namespace App\WAF\Detector;

  use App\WAF\Container;

  class IpDetector
  {
      public function __construct(private Container $c) {}

      public function check(array $ctx): ?array
      {
          // 1. IP 黑名单
          $hit = $this->c->ipBlock->get($ctx['ip']);
          if ($hit) {
              if ($hit['expire'] > 0 && $hit['expire'] < time()) {
                  $this->c->ipBlock->del($ctx['ip']);
              } else {
                  return ['name'=>'ip_blocklist','score'=>100,'reason'=>$hit['reason']];
              }
          }
          // 2. 国家黑名单
          if ($ctx['country'] && $this->c->countryBlock->exist($ctx['country'])) {
              return ['name'=>'country_block','score'=>100,'reason'=>$ctx['country']];
          }
          return null;
      }
  }

  <?php
  // src/WAF/Detector/RateLimiter.php
  namespace App\WAF\Detector;

  use App\WAF\Container;

  class RateLimiter
  {
      // Lua:固定窗口 + 多键计数,原子
      private const LUA = <<<'LUA'
          local k1, k2, win1, win2, lim1, lim2 = KEYS[1], KEYS[2], tonumber(ARGV[1]), tonumber(ARGV[2]),
  tonumber(ARGV[3]), tonumber(ARGV[4])
          local n1 = redis.call('INCR', k1)
          if n1 == 1 then redis.call('EXPIRE', k1, win1) end
          if n1 > lim1 then return {1, n1} end
          local n2 = redis.call('INCR', k2)
          if n2 == 1 then redis.call('EXPIRE', k2, win2) end
          if n2 > lim2 then return {2, n2} end
          return {0, n1}
      LUA;

      public function __construct(private Container $c) {}

      public function check(array $ctx): ?array
      {
          $ip = $ctx['ip'];
          $uri = strtok($ctx['uri'], '?');
          // 同一 IP 每秒 50,每分钟 600;同一 (IP+URI) 每秒 20
          $r = $this->c->withRedis(fn($r) => $r->eval(
              self::LUA,
              ["waf:rl:$ip:1s", "waf:rl:$ip:".md5($uri).":1s", 1, 1, 50, 20],
              2
          ));
          if (is_array($r) && $r[0] > 0) {
              return ['name'=>'rate_limit_'.$r[0],'score'=>100,'reason'=>"hit n={$r[1]}"];
          }
          // 慢窗口:每分钟
          $r2 = $this->c->withRedis(function($rc) use ($ip) {
              $key = "waf:rl:$ip:60s";
              $n = $rc->incr($key);
              if ($n === 1) $rc->expire($key, 60);
              return $n > 600 ? $n : 0;
          });
          if ($r2) return ['name'=>'rate_limit_min','score'=>100,'reason'=>"min n=$r2"];
          return null;
      }
  }

  <?php
  // src/WAF/Detector/BotDetector.php
  namespace App\WAF\Detector;

  use App\WAF\Container;

  class BotDetector
  {
      public function __construct(private Container $c) {}

      public function check(array $ctx): ?array
      {
          $ua = $ctx['ua'];
          if ($ua === '') return ['name'=>'empty_ua','score'=>60,'reason'=>'no UA'];
          foreach ($this->c->botUaPatterns as $p) {
              if (preg_match($p, $ua)) {
                  return ['name'=>'bad_bot','score'=>80,'reason'=>$p];
              }
          }
          // headless 检测:Chrome headless 有特征
          if (str_contains($ua, 'HeadlessChrome')) {
              return ['name'=>'headless_chrome','score'=>50,'reason'=>'headless'];
          }
          // 无 Accept-Language 大概率脚本
          if (empty($ctx['headers']['accept-language']) && str_contains($ua, 'Mozilla')) {
              return ['name'=>'fake_browser','score'=>30,'reason'=>'no Accept-Language'];
          }
          return null;
      }
  }

  <?php
  // src/WAF/Detector/AttackDetector.php
  namespace App\WAF\Detector;

  use App\WAF\Container;

  class AttackDetector
  {
      public function __construct(private Container $c) {}

      public function scan(array $ctx): array
      {
          $hits = [];

          // 1. 规范化:多重 URL 解码,防止 %2527 双重 encode 绕过
          $blob = $this->buildScanBlob($ctx);

          // 2. SQLi
          foreach ($this->c->sqliPatterns as $i => $p) {
              if (preg_match($p, $blob)) {
                  $hits[] = ['name'=>'sqli_'.$i,'score'=>60,'reason'=>'SQLi pattern'];
                  break;   // 一个 SQLi 命中就够,避免重复加分
              }
          }
          // 3. XSS
          foreach ($this->c->xssPatterns as $i => $p) {
              if (preg_match($p, $blob)) {
                  $hits[] = ['name'=>'xss_'.$i,'score'=>60,'reason'=>'XSS pattern'];
                  break;
              }
          }
          // 4. Command Injection
          foreach ($this->c->cmdPatterns as $i => $p) {
              if (preg_match($p, $blob)) {
                  $hits[] = ['name'=>'cmd_'.$i,'score'=>80,'reason'=>'cmd injection'];
                  break;
              }
          }
          // 5. Path Traversal
          foreach ($this->c->pathPatterns as $i => $p) {
              if (preg_match($p, $blob)) {
                  $hits[] = ['name'=>'path_'.$i,'score'=>80,'reason'=>'path traversal'];
                  break;
              }
          }
          // 6. 大体积可疑(防长 payload 暴力 fuzz)
          if (strlen($ctx['body']) > 100000 && empty($ctx['headers']['content-type'])) {
              $hits[] = ['name'=>'big_no_ct','score'=>30,'reason'=>'large body w/o CT'];
          }
          // 7. 异常方法
          if (in_array($ctx['method'], ['TRACE','TRACK','CONNECT','DEBUG'])) {
              $hits[] = ['name'=>'odd_method','score'=>50,'reason'=>$ctx['method']];
          }
          return $hits;
      }

      private function buildScanBlob(array $ctx): string
      {
          // 把所有可疑位置拼成一个串过特征
          $parts = [
              $ctx['uri'],
              json_encode($ctx['get'], JSON_UNESCAPED_UNICODE),
              json_encode($ctx['post'], JSON_UNESCAPED_UNICODE),
              json_encode($ctx['cookie'], JSON_UNESCAPED_UNICODE),
              $ctx['referer'],
              substr($ctx['body'], 0, 16384),
          ];
          $blob = implode(' ', $parts);

          // 多重 URL 解码(最多 3 次,避免死循环)
          for ($i=0; $i<3; $i++) {
              $dec = urldecode($blob);
              if ($dec === $blob) break;
              $blob = $dec;
          }
          // 替换大小写无关的常见混淆
          return strtolower($blob);
      }
  }

  <?php
  // src/WAF/Detector/RuleEngine.php
  namespace App\WAF\Detector;

  use App\WAF\Container;
  use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

  class RuleEngine
  {
      private ExpressionLanguage $expr;

      public function __construct(private Container $c)
      {
          $this->expr = new ExpressionLanguage();
          $this->expr->register('contains', fn($a,$b)=>"contains($a,$b)",
              fn($args,$a,$b) => is_string($a) && str_contains(strtolower($a), strtolower($b)));
          $this->expr->register('regex', fn($a,$b)=>"regex($a,$b)",
              fn($args,$a,$b) => (bool)preg_match($b, (string)$a));
          $this->expr->register('starts_with', fn($a,$b)=>"sw($a,$b)",
              fn($args,$a,$b) => str_starts_with((string)$a, $b));
          $this->expr->register('ip_in_cidr', fn($a,$b)=>"cidr($a,$b)",
              fn($args,$ip,$cidr) => $this->cidrMatch($ip, $cidr));
      }

      public function evaluate(array $ctx): array
      {
          $hits = [];
          foreach ($this->c->rules as $id => $rule) {
              if (!$rule['enabled']) continue;
              try {
                  if ($this->expr->evaluate($rule['expression'], $ctx)) {
                      $hits[] = [
                          'name'=>$rule['name'],'score'=>$rule['score'],
                          'reason'=>$rule['name'],'action'=>$rule['action'],
                      ];
                  }
              } catch (\Throwable $e) {}
          }
          return $hits;
      }

      private function cidrMatch(string $ip, string $cidr): bool
      {
          [$subnet, $bits] = explode('/', $cidr);
          $ipL = ip2long($ip); $netL = ip2long($subnet);
          if ($ipL === false || $netL === false) return false;
          $mask = -1 << (32 - (int)$bits);
          return ($ipL & $mask) === ($netL & $mask);
      }
  }

  解释:
  - AttackDetector 一个匹配就 break:避免同攻击多 pattern 重复加分(原本 60360 误杀)
  - 多重 URL 解码 + 小写规范化:抵御绝大部分编码绕过(%2527 SeLeCt 等)
  - RuleEngine 是兜底:业务可写 contains(uri,"/admin") and not ip_in_cidr(ip,"10.0.0.0/8") 这类规则

  ---
  5. Challenger:JS 5 秒盾

  <?php
  // src/WAF/Challenger.php
  namespace App\WAF;

  use Swoole\Http\Request;
  use Swoole\Http\Response;

  class Challenger
  {
      private const COOKIE = 'waf_pass';
      private const SECRET = 'waf-challenge-secret-do-change';

      public function __construct(private Container $c) {}

      public function verifyCookie(Request $req): bool
      {
          $val = $req->cookie[self::COOKIE] ?? '';
          if (!$val) return false;
          $parts = explode('.', $val);
          if (count($parts) !== 2) return false;
          [$expIp, $sig] = $parts;
          $expectedSig = hash_hmac('sha256', $expIp, self::SECRET);
          if (!hash_equals($expectedSig, $sig)) return false;
          // 时间 + IP 校验
          [$ts, $ip] = explode('|', base64_decode($expIp), 2) + [null,null];
          if (!$ts || $ts < time()) return false;
          return true;
      }

      public function serveChallenge(Response $res, string $clientIp): void
      {
          // 生成 challenge:客户端 JS 计算简单 PoW 提交回来,通过则发 cookie
          $nonce = bin2hex(random_bytes(8));
          $diff  = 4;   // 前 N 位 0
          $this->c->withRedis(fn($r) => $r->setEx("waf:ch:$nonce", 60, "$clientIp|$diff"));

          $exp = base64_encode((time()+1800).'|'.$clientIp);
          $sig = hash_hmac('sha256', $exp, self::SECRET);
          $cookieValue = "$exp.$sig";

          // 简化:对外暴露 /waf/verify 接口,JS 算完 hash 提交,服务端发 cookie
          $res->status(429);
          $res->header('Content-Type','text/html; charset=utf-8');
          $res->end(<<<HTML
  <!DOCTYPE html>
  <html><head><title>正在校验您的浏览器...</title></head>
  <body><h2>正在校验您的浏览器,请稍候...</h2>
  <script>
  (async () => {
    const nonce = "$nonce";
    const diff  = $diff;
    const target = "0".repeat(diff);
    let n = 0;
    while (true) {
      const buf = new TextEncoder().encode(nonce + n);
      const h = await crypto.subtle.digest('SHA-256', buf);
      const hex = Array.from(new Uint8Array(h)).map(b=>b.toString(16).padStart(2,'0')).join('');
      if (hex.startsWith(target)) {
        await fetch('/waf/verify', {method:'POST', headers:{'Content-Type':'application/json'},
          body: JSON.stringify({nonce, answer: n.toString()})});
        location.reload();
        break;
      }
      n++;
    }
  })();
  </script></body></html>
  HTML);
      }

      public function handleVerify(Request $req, Response $res, string $clientIp): void
      {
          $d = json_decode($req->rawContent(), true) ?? [];
          $nonce = $d['nonce'] ?? '';
          $answer= $d['answer'] ?? '';
          $stored = $this->c->withRedis(fn($r) => $r->get("waf:ch:$nonce"));
          if (!$stored) { $res->status(400); $res->end('expired'); return; }
          [$ip, $diff] = explode('|', $stored);
          if ($ip !== $clientIp) { $res->status(403); $res->end('ip mismatch'); return; }
          $h = hash('sha256', $nonce . $answer);
          if (!str_starts_with($h, str_repeat('0', (int)$diff))) {
              $res->status(403); $res->end('bad answer'); return;
          }
          // 发 cookie,1800s 内放行
          $exp = base64_encode((time()+1800).'|'.$clientIp);
          $sig = hash_hmac('sha256', $exp, self::SECRET);
          $res->cookie(self::COOKIE, "$exp.$sig", time()+1800, '/', '', false, true);
          $this->c->withRedis(fn($r) => $r->del("waf:ch:$nonce"));
          $res->end('OK');
      }
  }

  解释:
  - PoW(Proof of Work)挑战:浏览器算几万次 SHA256 才能通过,人类秒过,脚本极慢
  - HMAC 签名 cookie:服务端无状态校验,通过后 30 分钟放行
  - 比图形验证码用户体验好太多(用户根本看不到),适合中等风险

  ---
  6. AuditWriter:异步审计 + Alerter

  <?php
  // src/WAF/AuditWriter.php
  namespace App\WAF;

  class AuditWriter
  {
      private array $buffer = [];
      private float $lastFlush;

      public function __construct(private Container $c) { $this->lastFlush = microtime(true); }

      public function write(array $log): void
      {
          $this->buffer[] = $log;
          if (count($this->buffer) >= 500 || microtime(true) - $this->lastFlush > 1.0) {
              try {
                  $this->c->ch->insertAssocBulk('waf_log', $this->buffer);
              } catch (\Throwable $e) {
                  foreach ($this->buffer as $r) {
                      file_put_contents('/var/log/waf_failed.jsonl',
                          json_encode($r, JSON_UNESCAPED_UNICODE)."\n", FILE_APPEND);
                  }
              }
              $this->buffer = [];
              $this->lastFlush = microtime(true);
          }
      }
  }

  <?php
  // src/WAF/Alerter.php
  namespace App\WAF;

  use Swoole\Coroutine\Http\Client;

  class Alerter
  {
      public function __construct(private Container $c) {}
      public function send(array $payload): void
      {
          // 推钉钉/飞书/Alertmanager
          try {
              $client = new Client('open.feishu.cn', 443, true);
              $client->setHeaders(['Content-Type'=>'application/json']);
              $client->post('/open-apis/bot/v2/hook/xxxxx', json_encode([
                  'msg_type'=>'text',
                  'content'=>['text'=>"[WAF] {$payload['severity']} ip={$payload['ip']} rule={$payload['rule']}
  uri={$payload['uri']}"]
              ]));
              $client->close();
          } catch (\Throwable $e) {}
      }
  }

  ---
  7. ClickHouse 审计表

  CREATE DATABASE IF NOT EXISTS waf;
  CREATE TABLE waf.waf_log (
      ts        UInt64,
      ip        String,
      country   LowCardinality(String),
      host      LowCardinality(String),
      uri       String,
      method    LowCardinality(String),
      ua        String,
      decision  LowCardinality(String),
      score     UInt16,
      hits      String,
      dur_ms    UInt16,
      INDEX idx_ip (ip) TYPE bloom_filter GRANULARITY 4
  )
  ENGINE = MergeTree
  PARTITION BY toYYYYMMDD(toDateTime(ts/1000))
  ORDER BY (ts, ip)
  TTL toDateTime(ts/1000) + INTERVAL 90 DAY
  SETTINGS index_granularity = 8192;

  ---
  8. 配置库(MySQL)

  CREATE TABLE waf_rules (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(64) NOT NULL,
    expression TEXT NOT NULL,
    score INT DEFAULT 0,
    action ENUM('log','challenge','block') DEFAULT 'block',
    enabled TINYINT DEFAULT 1
  );
  INSERT INTO waf_rules(name,expression,score,action) VALUES
  ('admin外部访问','starts_with(uri,"/admin") and not ip_in_cidr(ip,"10.0.0.0/8")', 100, 'block'),
  ('wp-config 扫描','contains(uri,"wp-config") or contains(uri,".env")', 80, 'block'),
  ('过短 referer','method=="POST" and referer=="" and contains(uri,"/api/")', 30, 'log'),
  ('国家高危','country=="XX"', 40, 'challenge');

  CREATE TABLE ip_blocklist (
    ip VARCHAR(45) PRIMARY KEY,
    reason VARCHAR(64),
    enabled TINYINT DEFAULT 1,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );
  CREATE TABLE ip_whitelist (
    ip VARCHAR(45) PRIMARY KEY,
    note VARCHAR(64),
    enabled TINYINT DEFAULT 1
  );
  CREATE TABLE country_blocklist (
    country_iso CHAR(2) PRIMARY KEY,
    reason VARCHAR(64)
  );
  CREATE TABLE upstreams (
    id INT PRIMARY KEY AUTO_INCREMENT,
    host_pattern VARCHAR(128),       -- *.example.com
    upstream VARCHAR(128),           -- http://10.0.0.50:8080
    enabled TINYINT DEFAULT 1
  );

  ---
  四、部署 + 使用

  # 1. 准备配置 + GeoIP
  mysql < schema.sql
  wget https://download.maxmind.com/.../GeoLite2-Country.mmdb -O /etc/waf/GeoLite2-Country.mmdb

  # 2. 配置上游
  INSERT INTO upstreams(host_pattern, upstream) VALUES
    ('api.example.com',   'http://10.0.0.50:8080'),
    ('www.example.com',   'http://10.0.0.51:80');

  # 3. 启动
  php server.php

  # 4. Nginx 前置
  upstream waf { server 127.0.0.1:8080; }
  server {
    listen 443 ssl http2;
    location / { proxy_pass http://waf; proxy_set_header X-Real-IP $remote_addr; ... }
  }

  # 测试攻击(应该 403)
  curl "https://api.example.com/?id=1' OR 1=1--"
  curl "https://api.example.com/?q=<script>alert(1)</script>"
  curl -A "sqlmap/1.0" https://api.example.com/

  # 正常请求(应该 200)
  curl https://api.example.com/

  ---
  五、性能参考(单机 4C8G,反代到本机 nginx)

  ┌──────────────────────────┬──────────────────────┐
  │           指标           │         数值         │
  ├──────────────────────────┼──────────────────────┤
  │ 纯转发 QPS(无规则)6w-10w               │
  ├──────────────────────────┼──────────────────────┤
  │ 全规则模式 QPS           │ 3-5w                 │
  ├──────────────────────────┼──────────────────────┤
  │ P99 延迟(规则全开)< 5ms                │
  ├──────────────────────────┼──────────────────────┤
  │ 阻断 IP 命中             │ < 50μs(SwooleTable) │
  ├──────────────────────────┼──────────────────────┤
  │ CC 限流决策              │ < 1ms(Redis Lua)     │
  ├──────────────────────────┼──────────────────────┤
  │ 规则数 1000 条无明显损耗 │ ✅                   │
  ├──────────────────────────┼──────────────────────┤
  │ 内存                     │ 每 worker ~150MB     │
  └──────────────────────────┴──────────────────────┘

  ---
  六、踩坑提示

  ┌───────────────────────────────────────┬──────────────────────────────────────────────────────────────┐
  │                  坑                   │                             解决                             │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ 误杀正常用户(SQLi 关键词在合法内容里) │ 规则上线先 log 一周观察,再切 block                           │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ 真实 IP 取错(透过 CDN)                │ 严格定义可信代理链:CDN→Nginx→WAF,只取X-Forwarded-For 第一段 │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ 双重编码绕过                          │ 多重 URL 解码(本方案已做) + Unicode 规范化                   │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ 大文件上传被 body scan 卡             │ body 截断到 16KB 做特征检测                                  │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ WAF 自身崩溃影响业务                  │ FAIL-OPEN:异常时放行,绝不能 FAIL-CLOSED                      │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ 限流误伤 NAT 后大量用户               │ IP+UA hash 组合维度;企业用户加白                             │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ CC 攻击导致 Redis 雪崩                │ Redis Cluster + 本地令牌桶兜底                               │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ WebSocket 升级被 WAF 拦               │ 检测 Upgrade: websocket 直接透传                             │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ HTTPS 终结点                          │ 在 Nginx 上做,WAF 接 HTTP                                    │
  ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ 规则热更新顺序错乱                    │ reload 时原子替换整个规则表,不增量改                         │
  └───────────────────────────────────────┴──────────────────────────────────────────────────────────────┘

  ---
  七、安全清单

  ┌─────────────────────────────────────┬────────────────────────────────────┐
  │                风险                 │                防御                │
  ├─────────────────────────────────────┼────────────────────────────────────┤
  │ 管理后台被攻击                      │ 仅内网 + 双因子 + IP 白名单        │
  ├─────────────────────────────────────┼────────────────────────────────────┤
  │ Challenge 被绕过                    │ 加 IP 绑定 + 时间戳签名            │
  ├─────────────────────────────────────┼────────────────────────────────────┤
  │ Cookie 签名密钥泄漏                 │ KMS 拉取,定期轮转                  │
  ├─────────────────────────────────────┼────────────────────────────────────┤
  │ 审计日志被改                        │ ClickHouse 仅 append + hash 链     │
  ├─────────────────────────────────────┼────────────────────────────────────┤
  │ 规则被恶意 SQL 注入(自定义规则字段) │ 表达式 DSL 沙箱,不允许执行系统函数 │
  ├─────────────────────────────────────┼────────────────────────────────────┤
  │ 上游被绕过(直连后端)                │ 后端只允许 WAF IP 访问             │
  ├─────────────────────────────────────┼────────────────────────────────────┤
  │ 慢攻击(Slowloris)                   │ 设 timeout + 限连接 + 启用 HTTP/2  │
  └─────────────────────────────────────┴────────────────────────────────────┘

  ---
  八、可扩展方向

  1. 机器学习评分:基于历史日志训练 XGBoost,对异常请求打分
  2. 行为基线:每个 URI 学正常 QPS / UA 分布,统计偏离即告警
  3. 设备指纹:JS 采集浏览器特征,抗 IP 切换攻击
  4. 滑块验证码集成:接极验/腾讯防水墙
  5. API 模式:OpenAPI Schema 驱动,字段类型/范围严格校验
  6. API 资产识别:自动发现新接口并归类
  7. CSRF 防护:同源策略 + Token 自动校验
  8. DDoS 联动:接 BGP 黑洞 / Cloudflare Magic Transit
  9. 联邦学习:跨企业共享攻击 IoC,不泄漏数据
  10. 告警降噪:相似攻击合并、夜间阈值放宽

  ---
  九、和商业 WAF 的真实差距

  ┌────────────┬─────────────────────────┬────────────────┬─────────────────────────┐
  │    维度    │ Cloudflare / 阿里云 WAF │  ModSecurity   │   本方案 (PHP+Swoole)   │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 规则数     │ 数千内置                │ OWASP CRS 数千 │ 60+ 自带,可扩展         │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 边缘部署   │ 全球                    │ 否             │ 否                      │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 智能识别   │ ML 模型                 │ 否             │ 可加                    │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 性能       │ 极高                    │ 中             │ 中-高                   │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 自定义能力 │ 中                      │ 高             │ 极高(PHP 写规则)        │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 业务集成   │ 一般                    │ 难             │ 天然契合(本方案强项)    │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 价格       │ 贵                      │ 免费           │ 免费                    │
  ├────────────┼─────────────────────────┼────────────────┼─────────────────────────┤
  │ 适合       │ 大流量公网防护          │ Apache 站点    │ 企业内部 + 业务深度集成 │
  └────────────┴─────────────────────────┴────────────────┴─────────────────────────┘

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_37805832/article/details/161428494

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--