
先说结论用PHP的$_SESSION变量做验证码来防DDoS不是让你跟大流量硬刚而是给应用层加一道“行为闸门”——当检测到单客户端访问频率异常时强制要求先过验证码验证通过后放行一段时间。这套方案没法对付Gbps级别的流量轰炸但对CC类攻击、低频慢速扫描、撞库脚本、垃圾注册机器人这些“靠大量请求消耗应用资源”的玩法能起到很实际的作用。我去年在两个中小规模项目上完整落地过这套机制一个日活几十万的内容系统一个对外提供API查询的服务。上线后异常流量明显被挡在门外正常用户几乎无感知。这篇文章把核心原理、完整代码、上线踩坑、调优经验一次讲透适合正在做PHP服务防护、被脚本请求骚扰过、或者想给登录/注册/查询接口加一道保险的朋友。1. 它到底解决什么问题先搞明白DDoS打的是哪一层1.1 流量型攻击与“慢刀子”攻击的区别很多人一想到DDoS脑子里就是“流量把带宽打满、服务器上不去网”。那是网络层的事情规模通常在几十Gbps以上靠PHP代码根本管不了那是防火墙和IDC机房该干的活。但实际项目里更常遇到的是应用层攻击——单个请求本身不大但每秒几万次地打你的登录接口、查询接口、验证码接口每次请求都要走一次PHP解析、MySQL查询、Redis读取、模板渲染一小撮请求就能把CPU和数据库连接池耗到报警。这类攻击有个特点单IP请求不一定很高分布到很多IP上日志里看不出明显异常但总体请求量就是比平时高好几倍。我自己踩过的案例某天凌晨三点收到告警数据库慢查询一下子多起来排查半天发现是某个API接口的请求量从每分钟几百涨到了每分钟几万分布在上百个IP上单个IP每分钟其实也就两三百次。传统IP限频按单IP算的话很容易漏掉。验证码防DDoS恰好就是针对这种场景的它不管你是哪个IP、流量多少而是直接看“一个会话在单位时间内干了多少事”。一旦发现某个会话的请求频率超过合理范围就要求它做一次验证验证不通过就不放行。这不是防御网络层的DDoS而是防御应用层“以小博大”式的资源消耗攻击。1.2 为什么不只靠IP限频很多人第一反应是做IP限频Nginx里limit_req一配一个IP每秒最多两个请求多出来的直接503。听上去简单但落地时有一个绕不开的坑IP是天然的“不可信标识”。攻击者可以伪造源IP尤其UDP协议限频只对TCP层有一定约束力。很多公司出口是NAT一个公网IP后面可能是几十上百个真实用户。限频一旦设得太紧整个办公网的人一起访问你站点会被误伤一片。有些攻击出发点就是“动态代理池”IP本来就频繁变化按IP限频根本追不上。而Session是服务端维持的会话标识攻击者要伪造session得先通过验证码这道关伪造成本高得多。换句话说IP限频是“看脸”Session验证码是“验身”。两者可以配合用但不能互相替代。2. 核心设计思路平时不打扰异常才验证2.1 这套机制的工作流程拆解整套方案的主体模块分为三个频率监控模块、验证码门禁模块、放行与失效模块。平时用户正常访问Session里会记录一个“请求计数”每隔一段时间重置。只要这个计数没超过阈值验证码逻辑完全不会触发用户压根感知不到。当请求频率超过阈值系统会返回一个页面要求用户输入验证码验证通过后Session里打上“已验证”标记并附带一个过期时间。在标记有效期内即使请求量依然很高也不会再要求验证。流程图可以用文字描述一遍请求进入 - 检查会话是否已验证已验证且未过期放行 - 未验证统计窗口内请求数超过阈值则跳转到验证码页面 - 用户提交验证码校验通过设置验证标记跳回原页面 - 校验失败重新生成验证码最多允许尝试N次。这套流程看起来简单但每个环节都有需要刻意设计的地方统计窗口怎么选、阈值设多少、验证码过期时间多长、验证通过后有效期多长。这些参数直接决定了“误伤率”和“防护效果”的平衡点。2.2 为什么选Session而不是Cookie这里有个关键选型逻辑验证状态必须存在服务端不能存在客户端。如果验证通过后只是给客户端种一个Cookie比如pass1攻击者完全可以直接伪造这个Cookie或者在本地把值改了防护形同虚设。而Session数据存在服务端信息不容易被客户端篡改。攻击者拿不到合法的Session ID就过不了验证码这道门。另外还要注意默认PHP的$_SESSION数据是存放在本地文件里的测下来并发量在中小规模下完全够用。如果并发量特别大、或者有多台应用服务器需要考虑把Session层换成Redis不然文件锁会成为瓶颈。这个点我在后面的常见问题部分单独展开。2.3 几个决定成败的关键参数参数不能拍脑袋定要结合自己的业务去设计。我给出经验值但每个人业务形态不同最好是先观察一段时间正常流量分布再定统计窗口建议用“滑动窗口”而非固定窗口。固定窗口比如每分钟为一段有个问题第59秒请求999次第61秒又请求999次会被误判成两个窗口各自只有999次实际两秒内干了近2000次。滑动窗口看的是“当前时刻往前的60秒”能比较平滑地反映真实频率。触发阈值对普通页面建议60秒内超过30次就触发验证对接口类登录、查询、提交可以更敏感一些比如60秒内10~15次就触发。验证码有效期单个验证码的有效期建议控制在2分钟内太长了容易被破解后反复利用。通过后放行时间建议5~10分钟。太短了正常用户频繁被要求验证体验受损太长了攻击者如果拿到一个验证通过的Session可以长期蹭这个“通行证”。10分钟是个比较稳妥的折中点。3. 完整PHP实现从模块到集成的可复现代码3.1 环境准备与基础目录结构建议PHP 7.4以上需要开启GD库验证码图像生成和Session扩展。目录结构建议这么组织project/ ├── index.php # 业务入口统一接入防护 ├── captcha.php # 验证码生成与图片输出 ├── verify.php # 验证码提交校验 ├── defense/ │ ├── frequency.php # 频率统计模块 │ ├── gate.php # 门禁控制 │ └── config.php # 阈值等各项配置这样一个模块一个职责出了问题好排查。不要把所有逻辑堆在index.php里。3.2 频率统计模块滑动窗口计数这部分的核心思路是Session里存一个当前窗口的计数以及窗口起始时间。每来一个请求先判断当前时间是否已经滑出窗口如果滑出去了重置计数和窗口起始时间然后计数加一。// defense/frequency.php class FrequencyCounter { private int $windowSize; // 窗口大小单位秒 private int $threshold; // 阈值 public function __construct(int $windowSize 60, int $threshold 30) { $this-windowSize $windowSize; $this-threshold $threshold; } public function isExceeded(): bool { $now time(); if (!isset($_SESSION[freq_window_start])) { $_SESSION[freq_window_start] $now; $_SESSION[freq_count] 0; } // 窗口滑动判断距离开窗时间超过一个窗口周期则重置 if ($now - $_SESSION[freq_window_start] $this-windowSize) { $_SESSION[freq_window_start] $now; $_SESSION[freq_count] 0; } $_SESSION[freq_count]; return $_SESSION[freq_count] $this-threshold; } public function reset(): void { unset($_SESSION[freq_window_start]); unset($_SESSION[freq_count]); } }这段逻辑有个细节点窗口是“懒重置”——请求来了才检查是否需要开新窗口避免每次请求都做无谓的清理操作。另外考虑到Session文件本身有锁计数操作只做一次读一次写没有引入额外锁逻辑。从项目实践来说这个模块适合放在入口文件的“最早位置”确保所有请求都经过计数。但需要注意的是计数本身会写Session如果业务逻辑里后面也会写Session建议把这个类的请求放在最靠前的位置尽量早。3.3 验证码门禁控制动态启停与跳转逻辑门禁模块负责判断“该不该要验证码”。它检查两类信息当前Session是否带有“已通过验证”的标记标记未过期则直接放行。没有标记时调用频率计数模块判断是否超限。// defense/gate.php class Gate { private FrequencyCounter $counter; private int $verifiedDuration; // 验证通过后免验证时长单位秒 public function __construct(FrequencyCounter $counter, int $verifiedDuration 600) { $this-counter $counter; $this-verifiedDuration $verifiedDuration; } public function shouldChallenge(): bool { // 已验证且在有效期内直接放行 if (isset($_SESSION[verified_until]) time() $_SESSION[verified_until]) { return false; } // 未验证/验证已过期看频率是否超限 return $this-counter-isExceeded(); } public function markVerified(): void { $_SESSION[verified_until] time() $this-verifiedDuration; // 通过验证后顺便把计数清零避免刚通过又被阈值拦截 $this-counter-reset(); } public function clearVerification(): void { unset($_SESSION[verified_until]); } }门禁模块的入口流程大致这样// index.php 文件开头的防护逻辑 session_start(); require defense/frequency.php; require defense/gate.php; $gate new Gate(new FrequencyCounter(60, 30), 600); if ($gate-shouldChallenge()) { // 进入验证码流程带上回跳地址 $returnUrl $_SERVER[REQUEST_URI] ?? index.php; header(Location: captcha.php?return . urlencode($returnUrl)); exit; } // 通过防护继续业务逻辑这里有个细节很多人会漏跳转前要用session_write_close()把Session锁释放掉。因为PHP的Session默认是文件存储同一时刻一个Session ID只能有一个脚本持有锁如果不显式释放跳转后的验证码页面要等到当前请求完全结束才能拿到Session操作权在整个高并发触发阶段会造成大量请求排队阻塞。这个问题我在第4节还会单独讲。3.4 验证码生成不只是一个简单图片验证码生成模块负责输出一张带有随机字符串的图片。为了让脚本没那么容易识别我在这个模块里做了几件事// captcha.php session_start(); // 生成验证码优先用不易混淆的字符集 $chars ABCDEFGHJKMNPQRSTUVWXYZ23456789; $captcha ; $length 5; for ($i 0; $i $length; $i) { $captcha . $chars[random_int(0, strlen($chars) - 1)]; } // 保存验证码和过期时间 $_SESSION[captcha_text] strtolower($captcha); $_SESSION[captcha_expire] time() 120; $_SESSION[captcha_tries] 0; // 释放Session锁避免阻塞其他请求 session_write_close(); // ---------- 绘制图片 ---------- $width 120; $height 40; $image imagecreatetruecolor($width, $height); // 背景色用浅灰渐变背景 $bg imagecolorallocate($image, 245, 245, 245); imagefill($image, 0, 0, $bg); // 写一些噪点 for ($i 0; $i 80; $i) { $noiseColor imagecolorallocate($image, rand(150, 200), rand(150, 200), rand(150, 200)); imagesetpixel($image, rand(0, $width - 1), rand(0, $height - 1), $noiseColor); } // 画干扰线 for ($i 0; $i 3; $i) { $lineColor imagecolorallocate($image, rand(120, 180), rand(120, 180), rand(120, 180)); imageline($image, rand(0, $width / 3), rand(0, $height), rand($width / 3, $width), rand(0, $height), $lineColor); } // 逐个字符画旋转随机颜色 $textColor imagecolorallocate($image, 40, 40, 40); $fontFile __DIR__ . /fonts/arial.ttf; // 建议用外部字体默认内置字体容易出问题 for ($i 0; $i $length; $i) { $angle rand(-25, 25); $charX 10 $i * 22; $charY rand(10, 28); imagettftext($image, 22, $angle, $charX, $charY, $textColor, $fontFile, $captcha[$i]); } header(Content-Type: image/png); imagepng($image); imagedestroy($image);几个细节字体PHP内置的imagestring字体不仅难看而且字符形状规整、容易被识别。推荐用外部TTF字体比如arial.ttf或开源中文字体再配合随机角度旋转识别难度上升很多。字符集刻意去掉了容易混淆的字符比如0/O、1/I、Z/2能避免用户输对代码却因为字符混淆被判错误的尴尬。验证码存Session不要放在Cookie里也不要只放在页面上。图片输出前先把验证码写入Session并立即释放锁防止同一Session并发请求时验证码被覆盖。加尝试次数限制captcha_tries用来记录这个验证码验证失败了几次。超过5次就直接重新生成避免攻击者拿着同一个验证码图片做暴力碰撞。3.5 验证码校验与放行逻辑校验逻辑需要处理三种情况验证码过期、验证码错误、验证码正确。// verify.php session_start(); $code strtolower(trim($_POST[code] ?? )); $returnUrl urldecode($_POST[return] ?? index.php); $captchaExpire $_SESSION[captcha_expire] ?? 0; $captchaText $_SESSION[captcha_text] ?? ; $tries $_SESSION[captcha_tries] ?? 0; // 已经失败太多次强制重新生成 if ($tries 5) { unset($_SESSION[captcha_text]); $_SESSION[captcha_error] 尝试次数过多请刷新验证码; header(Location: captcha.php?return . urlencode($returnUrl)); exit; } // 验证码过期 if (time() $captchaExpire) { $_SESSION[captcha_error] 验证码已过期请重新输入; header(Location: captcha.php?return . urlencode($returnUrl)); exit; } // 校验内容 if ($code ! $captchaText) { $_SESSION[captcha_tries] $tries; $_SESSION[captcha_error] 验证码错误请重新输入; header(Location: captcha.php?return . urlencode($returnUrl)); exit; } // 校验通过设置验证标记 $gate new Gate(new FrequencyCounter(60, 30), 600); $gate-markVerified(); // 清理验证码临时数据 unset($_SESSION[captcha_text]); unset($_SESSION[captcha_expire]); unset($_SESSION[captcha_tries]); header(Location: . ($returnUrl ?: index.php)); exit;校验通过后的markVerified()里有一行很关键把频率计数器清零。这既是为了让用户通过后不会被马上再次拦截也是为了避免一个正常用户因为偶尔手速快被验证码反复折腾。3.6 集成到具体业务场景的完整流程很多朋友拿到代码不知道接到哪个位置。我直接说一个最小可运行示例的接入逻辑index.php开头统一引入防护链路frequency.phpgate.php所有请求先过一遍shouldChallenge()。captcha.php独立输出验证码图片同时接收return参数用于回跳。verify.php处理表单提交校验通过后跳回原页面。如果某个接口你不想启用验证码比如纯静态资源接口、健康检查接口可以在Gate里加一个可配置的路由白名单按请求URI前缀判断跳过。实际项目里登录接口、注册接口、短信发送接口、搜索接口、导出接口往往是重点防护对象而公开的静态页面、接口文档可以适度放宽。4. 上线后踩过的坑与调优记录4.1 最坑的Session锁导致的请求排队第一版上线后验证码触发时整体请求P95延迟飙升到十几秒。排查发现是因为Session默认文件锁造成的当验证码页面还在持锁时同一个会话的其他请求都在排队等锁释放。修法很简单在验证码页面、图片输出页面这些不需要后续写Session的地方尽早调用session_write_close()。我一共在三个位置做了这个操作captcha.php写入captcha_text、captcha_expire后立即释放。verify.php在处理完校验、设置完验证标记后也立即释放再做跳转。在业务请求走完防护判断后如果后续业务逻辑不依赖Session也可以提前释放锁。这招几乎是立竿见影的瓶颈消除法。4.2 验证码被脚本批量破解了怎么办上线第二周遇到个问题某个登录接口虽然已经加了验证码但攻击脚本通过OCR识别验证码图片精度居然不低。验证码还没起到应有的拦截作用攻击者就已经在过闸了。我的应对思路不是追求“绝对不可识别”那是不现实的而是提高单次破解的成本加大干扰强度干扰线从2根加到4~5根噪点数量提升字符旋转角度从±15度扩到±25度。字符变形给每个字符叠加正弦波畸变破坏OCR的直线特征。验证码图片用JPEG而非PNG输出在压缩过程中引入一些不可预测的“模糊感”进一步干扰识别。验证码频繁旋转失效生成验证码时记录时间戳超过90秒必须刷新哪怕用户没输过。这些措施没法完全阻止脚本但能把识别成功率压到15%以下脚本成本一高攻击者往往会换目标。4.3 正常用户被误伤的真实案例有一次上线后某个内容运营团队反馈同事在后台批量编辑商品信息时频繁被要求输验证码而验证码又恰好因为字符混淆选错了三次直接被拒之门外。排查后发现后台管理页面操作密集一分钟内保存了好几次数据频率超限了就触发验证码。这里有两个改进点后台IP白名单内部管理系统可以做IP白名单完全跳过验证码这是最直截了当的解决方案。业务类型分层不同的接口使用不同的阈值。比如浏览类的普通页面放宽到60次/分钟后台保存类接口阈值提高到20次/分钟登录接口保持较低的10次/分钟。这比“一刀切”要合理得多。我还把“通过验证后的免验证期”从10分钟调整到了15分钟内部用户偶尔高峰操作一下基本不会再碰到验证码。4.4 日志与监控不只看请求量还要看验证码通过率上线一段时间后建议把两个指标加进监控面板验证码触发率每分钟有多少请求被要求验证。验证码通过率触发验证的请求中有多少是成功通过验证的。正常用户通过率通常在90%以上如果某段时间通过率突然降低到50%以下要么是验证码图形变形得太厉害要么是有攻击流量正在批量尝试破解。这两个指标能帮你区分“防护过度”和“正在被攻击”。另外日志里建议额外记录这些字段验证码的ID方便回溯、触发阈值时的请求频率、用户提交的验证码内容埋点无意义的内容不要记录记captcha_tries就够了、从触发到提交的时间间隔。这些数据对调优很有价值。4.5 参数速查表我的推荐初始配置给一个可以直接照抄的经验配置运行后再按自己的业务调整参数推荐值说明统计窗口60秒滑动窗口不宜过短普通页面阈值30次/分钟正常用户要连续高频操作才会触发登录/接口阈值10次/分钟敏感接口从紧验证码有效期120秒过期强制换图最大尝试次数5次超过后重新生成通过后免验证时长600~900秒10~15分钟太长有风险验证码长度5位太短易破解太长影响输入体验字符集排除易混淆字符减少误输入5. 这套方案的边界与下一步演进方向5.1 它能防什么防不了什么防得住CC攻击Challenge Collapsar的变体靠大量应用层请求消耗资源——因为你直接从会话行为上挡人。低频慢速扫描——很多扫描器会刻意放慢速度绕过IP限频但Session维度的频率统计依然能发现异常。撞库、批量注册、批量查询等“高频但伪装成正常行为”的流量。防不住纯网络层DDoSSYN Flood、UDP Flood带宽都被打满了PHP逻辑根本没机会执行。高级分布式攻击者会把每个Session的请求数控制在阈值之下均匀分布大量IP。这种情况验证码门禁基本不会触发需要靠更底层的流量清洗或专业抗D设备。攻击者如果知道你系统的Session机制可以先通过正常手段获得一个Session并人为放慢请求频率然后手动做过一次验证之后在10分钟内疯狂用这个Session。不过这个窗口期短而且频率计数会再次触发影响有限。所以这套方案定位是“应用层的第一道行为防线”不是“最后一道保命符”。上生产环境时依然建议配合Nginx限频、网络层防火墙、云厂商的DDoS防护一起用各管各的层。5.2 从“验证码门禁”到“行为风控中心”的演进路径这套基于Session验证码的机制本质上是一种很轻量的“风控规则”。把思路延伸一下可以做成一个更通用的行为风控模块多维度特征采集除了请求频率还可以采集请求间隔标准差、User-Agent一致性、点击路径合理性、Cookie合法性、JS执行结果等。采集后组合成“风险分”。分级处置风险低的不做任何处理风险中等的增加验证码风险高的直接返回403特别高的拉黑Session甚至返回假数据误导攻击者。验证手段升级从纯图形验证码逐步演进到行为验证点选、滑块、无感验证用户体验更好机器识别成本也更高。策略配置化把阈值、放行时间、处置动作做成可配置项运营人员不需要改代码就能调整。我在一个项目里把这个模块单独抽出来之后后面加新业务接口只需要几行配置就能接入防护不用再把验证码逻辑到处复制一遍。这个架构演进比预想的要实用很多。5.3 性能层面的补充建议前面提到的Session锁问题在单机部署下可以靠代码层面规避但一旦要做多机器负载均衡PHP默认的Session文件存储就不够用了。两个机器各自存Session用户在A机器上过了验证码下次请求轮到B机器B机器压根不知道又要重新验证。两条路线Session存储切Redis用session_set_save_handler或者直接引入现成的Redis Session Handler插件。这几乎是多机部署的必选方案。Sessin ID绑定验证状态时把验证状态也同步到Redis的独立Key上比直接切Session存储更轻量还可以把验证状态按业务接口粒度拆分粒度更小、更灵活。如果还想再做一层性能优化可以把“频率计数”从Session里挪到Redis的INCR命令上配合EXPIRE设置过期窗口读写开销极小还能实现跨机器共享计数。技术栈升级后这层的实现成本并不高。6. 最后的一点经验这套方案我实际跑下来之后最大的体会是验证码防DDoS这个思路之所以被很多人忽略是因为大家习惯把DDoS想成一种“网络层的大规模杀伤性武器”总觉得应用层代码做不了什么。但在中小项目上“大规模杀伤性武器”其实很少见最常见的是“大规模低质量请求”在慢慢消耗你。而这种请求恰恰是应用层就能拦截的。如果你正被脚本频繁骚扰手头又暂时没有云厂商的复杂风控产品建议先别急着去买一堆昂贵的安全服务。把Session验证码这套逻辑在项目里搭起来观察半个月大概率会明显看到异常流量下降。过程中别忘记留好日志把上面提到的参数记录好方便后来人调优。如果哪天跟前我一样踩到了Session锁那个坑回来翻翻这篇文章就行——我已经帮你提前踩了。