1. 先从一次典型的拦截说起
1.1 你会在哪一层被挡下?
前阵子我的一个采集脚本跑得好好的,突然某天开始拿不到正文数据了。返回内容不是熟悉的HTML文档,而是几行JavaScript,外加一个让我手动点一下才能继续的提示页。我第一反应是对方改版了页面结构,赶紧打开浏览器去看,结果浏览器里一切正常,页面、接口、数据结构全都和之前一模一样。这就很有意思了——同样的URL,浏览器能打开,脚本却拿不到内容,基本可以确定:不是页面改了,是有人在请求链路上做了手脚。
这个"做手脚"的角色,就是云锁这类WAF(Web Application Firewall)机制。它不像防火墙那样只在网络层拦数据包,而是挂在应用层,能看懂HTTP请求里的业务语义。云锁是国内常见的网站防护产品之一,很多人把它装在服务器上做安全加固,默认就带防SQL注入、防XSS、防盗链、防恶意爬虫等功能。爬虫脚本一旦被它识别,轻则返回验证页面,重则直接把IP封掉一段时间。
你在哪一层被挡,决定了你怎么去排查。以我的经验,云锁WAF的拦截层级可以分成四层:
- 第一层是网络层,特征很直接:连续高强度请求触发单IP访问频率上限,服务器直接丢弃连接或者拒绝握手。
- 第二层是会话层,典型表现是要求携带特定Cookie才能继续,否则一律重定向到验证页。
- 第三层是应用层,最常见的返回码是403、405、503,或者是200但返回一个被改写过的HTML。
- 第四层是内容层,也就是前面提到的——给你返回一段JS,让浏览器自己算出一个结果再跳转,脚本如果不去执行这段JS,就永远拿不到真实数据。
搞清楚自己卡在哪一层,比瞎猜要高效得多。后面几节我会展开讲每一层到底长什么样。
1.2 判断是否撞上WAF的五个信号
很多初学者遇到请求失败,第一反应是"IP被封了",其实"被封"和"被WAF拦截"是两回事。被封是彻底断了路,被拦截是对方在让路,但要求你证明你是个正常人。我总结出五个高识别度的信号,基本可以快速判断你遇到的到底是哪种情况。
| 信号 | 典型表现 | 大概率指向 |
|---|---|---|
| 状态码异常 | 403、405、419、444之类;或200但内容是被JS改写的 | 应用层WAF规则命中 |
| 响应头特征 | 返回头里出现自定义字段,或者多个响应头顺序明显异常 | WAF主动注入标记 |
| 内容中带挑战脚本 | HTML里出现document.cookie赋值、location重定向、动态JS计算 | 会话层Cookie挑战 |
| 浏览器与脚本结果不一致 | 同一URL浏览器正常、脚本异常 | WAF在做客户端指纹识别 |
| 频率敏感 | 降低请求频率后恢复,提高后又触发 | 行为统计模型触发 |
这里的第三点是云锁这类产品特别喜欢用的:它给客户端一个带加密算法的JS,浏览器执行完会在Cookie里种下一个值,下一次请求带上这个值才算"验证通过"。爬虫脚本默认不会执行JS,所以直接被卡住。
2. WAF的检测链路:它凭什么认出你不是真人
2.1 请求特征指纹:你留下的那些蛛丝马迹
WAF要在一个正常流量里找出异常请求,靠的是"指纹差异"。也就是说,它会从你的请求里提取一系列特征,拿去和它认为的"正常浏览器请求"做对比,偏离度越高,越可疑。
第一类指纹是请求头。比如User-Agent,云锁默认规则里就有一大批"禁用UA列表",包括空UA、非主流UA、明显是爬虫框架默认UA。更隐蔽的是Accept、Accept-Language、Accept-Encoding这几个头,真实浏览器的取值顺序和取值范围都是相对固定的,但很多脚本框架默认不设这些字段,或者设得极其敷衍,一眼就能看出来不是真实浏览器。
第二类指纹是TLS指纹和HTTP/2指纹。这个很多做爬虫的同行容易忽略——你的请求经历了真正的TLS握手,而不同客户端(浏览器、脚本库、命令行工具)在TLS握手时呈现的特征差异很大,WAF后端只要做过对比,就能认出你是哪种客户端。这部分不是你随便加几个Header就能解决的,因为它发生在更底层。
第三类指纹是请求的"前后一致性"。比如一个请求声称自己是Chrome浏览器,但又没有携带Chrome必然会带的Sec-Fetch-*系列请求头,或者Cookie结构对不上,WAF规则基本可以判定这个请求是伪造的。云锁这类产品在规则引擎里大量使用了这种一致性校验。
2.2 行为统计模型:单个请求正常,不代表整体正常
比请求特征更难以伪造的,是行为模式。WAF的行为统计模型会记录一个IP在时间窗口内的全部请求,然后从三个维度给这个IP打分:请求密度、请求节奏、请求路径。
请求密度好理解,就是单位时间内同一个IP发起了多少次请求。云锁的默认阈值通常设得比较保守,比如单个IP在5秒内超过20次动态请求就会触发临时封禁。请求节奏指的是相邻请求之间的时间间隔,正常用户浏览网页时,两次请求之间总有思考停顿、鼠标移动、滚动等动作,间隔呈现"无规律性";而爬虫的请求间隔往往是均匀的,或者仅仅是一个睡死循环,每隔固定秒数发一次,这个规律性特征非常容易被识别。
请求路径维度更有意思。正常用户访问网站,一定会有先首页、再列表页、再详情页的"浏览路径";而很多爬虫脚本会把中间过程全部跳过,一上来就直接请求深层API接口,或者请求路径的Pattern非常规整(比如只请求list和detail两种路径),这在行为模型里是巨大的异常分数。
我做过一个测试:一个脚本在目标站点上只请求详情页接口,每次间隔5秒,固定配置,十分钟后被拦截;改成完全相同的URL,但先随机访问几个首页和列表页,间隔变成3-8秒的随机值,跑了半小时都没事。这就是行为统计模型在起作用——它不看你的某一个请求好不好,而是看你整体像不像一个真人。
2.3 主动挑战机制:它会亲手试一试你
被动检查之外,云锁WAF还会有一些"主动挑战"动作,最典型的就是JS挑战和验证码挑战。
JS挑战的原理前面提过:服务器先给你返回一段JavaScript,里面包含加密算法和一份Cookie生成逻辑,浏览器执行后得到的值会被种进Cookie,并自动重定向到目标URL。下一次请求如果没带这个Cookie,或者Cookie里的值算错了,就直接拒绝。这套机制的好处是——真实用户毫无感知,因为浏览器自动实现了,而爬虫脚本如果不嵌入一个JS执行引擎,就很难跨过去。
验证码挑战就更直接了。当行为检测分数达到一定阈值,服务器会要求在页面上完成滑块验证、点选验证,或者按顺序点击图标,通过之后才发放一个放行Cookie。这类机制的恶意不在拦截本身,而在于它迫使你在「继续伪装成真人」和「放弃访问」之间做选择。
有意思的是,云锁这类WAF产品通常还带"动态封禁"能力——它不仅仅针对你当前的IP,如果检测到你尝试了多次不同的挑战路径,它会把整个IP段加入观察名单,后续所有请求都进入最高审查模式。这一点对爬虫开发者来说非常致命,因为你感觉自己只是被拦了一下,实际上已经被标记了。
3. 合规视角下的爬虫适配:不是绕过,是收敛
3.1 先把请求频率压到对方能接受的水平
如果你做的只是正当的数据采集,目标站点没有明确禁止爬虫,那么遇到WAF拦截时最应该做的事情不是研究怎么"破解",而是先反思自己的访问行为是否过于粗暴。我个人实践里的第一条铁律:把请求频率压到一个"即使在白天高峰期也不会影响对方服务器正常服务"的水平。
什么水平算合理?这需要具体站点具体测试。我的习惯是:从单IP、单个并发、1秒1次的节奏起步,持续观察10分钟;如果零拦截,再尝试提升到每秒两次,再观察10分钟;一旦出现任何异常信号,立刻回退到上一个安全档位。这个"逐步逼近再回退"的策略,比一上来就用当前主流的并行采集框架要好得多——那些框架默认就开20个并发,几乎是直奔封禁而去的。
还有一点非常重要:额外的重试逻辑。采集脚本很容易忽略重试对服务器的二次冲击。正确的做法是出现请求失败时,先退避——从30秒、60秒、120秒逐渐拉长重试间隔,而不是立即重新发起请求。很多WAF的行为统计模型都考虑了"失败请求的追加重试"这个特征,你越执着,它越认定你是异常流量。
3.2 模拟真实用户的关键细节
在合理频率之下,还要让单个请求尽量"像一个真人发出的请求"。我整理了一份自己长期维护的请求头配置清单,每条背后都有对应的原因,你抄作业的时候要理解为什么这么设。
第一是User-Agent,不能随便填一个静态字符串。实际生产环境里的做法是准备一份常见的UA池(覆盖Windows和Mac的Chrome、Firefox、Edge不同版本),每次请求随机取用。但注意,UA的版本要和请求里其他特征大体匹配,比如声称是Chrome 120,但支持的头和TLS特征还是老一套,反而更容易被规则命中。
第二是Accept系请求头。Accept、Accept-Language、Accept-Encoding这三个字段普通浏览器必有,且取值比较固定。用脚本发起请求时,就算你已经设置了UA,大概率也没设这三个头,或者设得不对。推荐直接复制浏览器开发者工具里真实请求的值,原样照抄。
第三是Referer和请求路径的来源衔接。你请求一个详情页,理论上应该先访问过列表页或首页,Referer最好能对应上。很多爬虫请求直接就Referer为空,这在WAF眼里是极大的异常点。
3.3 会话保持与Cookie策略
遇见"浏览器正常、脚本被拦截"的情况,十有八九是Cookie没有正确携带或更新。你需要把一个完整浏览会话里的Cookie状态管理好,包括服务端首次下发的会话Cookie、JS挑战种下的放行Cookie,以及页面业务逻辑产生的其他Cookie。
工程落地并不复杂:准备一个持久化的Cookie存储,每个目标站点用独立的Cookie容器,每次请求前检查容器里是否已有放行Cookie,没有的话先访问一次验证页完成Cookie初始化,再继续后续采集。实际操作中我踩过的坑是——某些WAF会周期性刷新放行Cookie的有效期,比如每5分钟重新校验一次。如果脚本一直用同一个旧Cookie,前几分钟正常,后期又开始被拦截。解决方法是解析服务端返回的set-cookie指令,发现放行Cookie需要更新时就重置会话,而不是硬等到被拦截才发现。
还有一个软化细节:让请求"看起来有生命周期"。真实用户不可能一天24小时不间断浏览你的站点。我在调度层加了昼夜节律模拟——运行一段时间,停一段时间,把请求分散到一天的不同时段,效果非常明显。
4. 一次被拦截请求的完整排查链路
4.1 抓包对比:找出你的请求和真实浏览器请求的差异
排查被拦截的请求,我不建议直接猜,而是建议做一版"请求对比基线"。具体操作分三步:先打开浏览器开发者工具,清空网络日志,手动访问目标页面,把正常请求的全部HTTP信息(URL、请求头、请求体、Cookie、响应头、响应体)导出;再用自己的脚本请求同一个URL,把脚本这边的全部HTTP信息也导出;最后把两边逐一对比。
对比的时候重点看几个位置:请求头字段数量是否少于浏览器、Cookie是否缺失、请求头顺序是否暴露了非浏览器特征、某个自定义头(如X-Requested-With)是否带了不该带的值。绝大部分情况下,问题都会在这几个位置露出马脚。
这里给一个快速排查用的命令,把请求头完整打出来,仔细看每一个字段:
curl -i -s -o /dev/null \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \ -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" \ -H "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8" \ -H "Referer: https://example.com/" \ https://example.com/detail/123如果这样请求正常,就逐步删减字段,找到哪个字段是触发拦截的关键。如果直接加了完整头还是被拦,那基本可以判断拦截点不在普通Header,而在JS挑战或TLS指纹上。
4.2 触发链路回放:从第几次请求开始被加强校验
排查还有一个非常实用的手段:触发链路回放。做法是把脚本的历史请求日志按时间线列出来,找到第一次出现拦截响应的那个节点,然后往前回溯——看从那次被拦之前,脚本一共连续发了多少次请求?间隔是多少?目标URL是哪个?
实际操作中,我把请求日志做了切片,用二分法缩小嫌疑范围:先用脚本连续请求10次,观察是否被拦截;把请求次数降到5次再试;如果5次也拦,就想起始频率是不是太高;如果10次不拦、20次拦,就说明阈值在10到20之间。这么反复两三轮,基本就能定位到底是一次请求触发了规则,还是累积请求触发了频率阈值。
有一次我遇到一个看似随机的问题:脚本运行业分钟时就正常,运行超过一定时间必定被拦。后来用链路回放才发现,被拦之前脚本恰好访问了一个特殊URL和一个动态接口,而云锁的规则里刚好对这个接口有定点检测。问题根本不在"访问量",而在"访问路径"。这说明回放链路很多时候能发现单点特征问题,这种发现靠猜是猜不出来的。
4.3 适配调整后的验证与观察
排查完,调整完,别急着开大步。我会设置一个观察期:冷启动阶段的前5分钟,请求量压低到计划的50%,观察服务端有没有异常的set-cookie指令或者响应头变化;如果10分钟内没有出现任何拦截类响应,再把请求量逐步提升到目标水平,同时保持对返回内容的实时校验。这里的校验不是简单看状态码,而是识别"这段HTML到底是不是我要采集的那个数据结构"。因为WAF有时并不返回403,而是返回一个正常的200页面,但内容被替换成了验证页。我的脚本里加了内容指纹判断——取页面关键正文的哈希值,和预设值对比,不一致就触发告警。
在观察期内,日志要记全:请求时间、URL、状态码、响应头的前几十个字节、是否有set-cookie。这样一旦再次被拦截,可以快速从信息里定位问题。
5. 关于对抗这件事,我的边界感
5.1 爬虫开发中哪些行为是红线
我在这个系列前面写了不少爬虫实战,但有一点我始终强调:合法采集和恶意攻击之间,有一条绝对不能越过的线。
具体来说,这些行为都属于我坚决不会做的:绕过验证码识别机制、主动探测WAF规则漏洞、利用畸形请求去试探WAF业务漏洞、对目标服务器发起超出正常访问量级的高频压力测试、在没有授权的前提下采集需要登录才能访问的非公开数据。做这些事的收益往往是短暂的,但法律和安全上的风险是长期的——一旦被定性强攻击行为,就不是"爬虫写得好不好"的问题了。
还有一条容易被忽视:即使目标站点没有明确在robots协议里写"禁止爬虫",你也要尊重它的技术防护信号。如果WAF已经明确拦截了你的请求,这本身就是站点运营方表达的"不希望被这么访问"的意愿。继续变着法去"适配"、去"收敛"是可以的,但目的是让自己像一个温和的访问者,而不是像一个非要挤进去的破门者。
5.2 更高阶但合规的做法:去理解,而不是去攻破
云锁WAF这类产品真正值得研究的价值,不在"怎么绕过它",而在于"它如何定义异常"。你吃透了它的检测维度,不仅是个更好的爬虫开发者,其实也是个更好的Web开发者——你会意识到,页面性能、用户行为埋点、缓存策略这些东西,在安全视角下都有自己的位置。
在实际项目中,如果你的采集需求是持续、大规模、有明确目标的,我更建议走合规授权这条路:直接和目标站点的运营方或数据接口负责人沟通,说明采集用途,申请API或者白名单权限。我试过几次,大部分有数据意识且不需要保密数据的站点,反而愿意给一条更宽敞的路——毕竟你主动申请,说明你有敬畏之心,不会乱来。白名单机制通常能做到不触发WAF的挑战,因为你在请求里带上对方签发的标记,直接走放行通道。
所以我心里的优先级永远是:沟通授权优先于技术适配,技术适配优先于强行挑战。把对抗的精力放在理解机制、优化工程上,你会走得更远,也更安全。这篇云锁WAF机制的实战记录就到这里,下一篇我会接着讲遇到动态JavaScript渲染数据时的处理思路,那种场景和WAF拦截又完全是另一套玩法。