先说结论:用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(比如pass=1),攻击者完全可以直接伪造这个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.php+gate.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维度的频率统计依然能发现异常。
- 撞库、批量注册、批量查询等“高频但伪装成正常行为”的流量。
防不住:
- 纯网络层DDoS(SYN 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锁那个坑,回来翻翻这篇文章就行——我已经帮你提前踩了。