1. 为什么要在自己的网关里养一套WAF规则集
1.1 从“告警一堆”到“裸奔”的真实处境
先说个真实经历。之前我们把服务挂在公网,每天安全扫描的告警堆成山,有扫路径的、有试登录的、有往上怼乱七八糟参数的。当时我们用的还只是网关自带的基础访问日志,能把来源IP记下来就不错了,至于请求里带的是什么payload,根本无从判断。
后来咬牙上了云上的商业WAF,按域名和QPS计费,一个月账单下来小团队确实肉疼。而且商业WAF对“请求里到底有没有攻击特征”这件事,给不了太多透明度:它拦了就拦了,放行就放行了,你想看具体的规则命中原因,得去控制台翻半天。
所以我们在评估Lucky这个自建网关项目时,最看重的一点就是它把CorazaWAF作为基础能力内置了。Lucky本身在架构里承担反向代理和站点统一入口的角色,而在入口这一层嵌入WAF,正好是拦截恶意请求成本最低的位置——请求还没进上游业务,就可以被审计、拦截、记录。这一点对我们这种做内部系统聚合、对外暴露多个管理入口的团队来说,意义非常大。
需要先解释一下这里的角色分工。CorazaWAF是一个开源的Web应用防火墙引擎,它兼容了ModSecurity的规则语法,而OWASP核心规则集(Core Rule Set,简称CRS)是OWASP组织维护的一套规则文件,相当于WAF引擎里的“杀毒库”。在Lucky网关里,Coraza负责执行规则,CRS负责定义“什么样的请求是可疑的”,两者是引擎与策略的关系。
1.2 开源规则集与规则自维护的边界
说实话,别指望任何WAF规则集能百分百拦住所有攻击。CRS能做的,是挡住绝大多数无差别扫描、脚本小子的常见payload,以及在出现真实攻击时给出足够清晰的日志线索。它的优势在于三点:稳定、透明、可审计。
- 稳定:CRS 4.x版本对规则的分类、编号、行为都有严格约定,升级路径比较平滑。
- 透明:所有规则都是纯文本,命中哪条、为什么命中、命中后的动作是什么,全部可查。
- 可审计:规则ID是全球通用的。你在日志里看到942100,全世界用CRS的人都知道那是SQL注入探测规则,这一点在安全合规审计时特别有用。
再讲一个容易被忽略的点:国内很多团队被“WAF = 买了就完事”这个思维坑过。商业WAF虽然省心,但它的规则更新跑在厂商的黑盒里,出了问题你只能反馈、等。自建Coraza + CRS的组合,规则文件在自己手里,误报可以自己改,绕过手法可以自己写规则补,整个安全能力是沉淀在自己团队内部的。长期看,这是真正能积累出安全运营能力的方式。
当然,代价也很现实:你要花时间理解规则集的结构和运行机制,否则就会出现我后面要说的那种“开箱即用、上线误报”的惨状。
2. Coraza与ModSecurity的兼容底座:CRS规则集是怎么跑起来的
2.1 规则语言一脉相承,不是两个物种
很多第一次接触Coraza的人会问一个问题:CRS不是给ModSecurity用的吗,怎么Coraza也能跑?
这得从规则引擎的语法层面看。CRS 4.x本质上是用ModSecurity规则语言写成的文本规则集,而Coraza的设计目标就是兼容这套规则语言的核心子集。也就是说,规则文件里写的SecRule、SecAction、SecRuleRemoveById这些指令,Coraza引擎认识,并且能按照同样的语义执行。两者不是两个物种,更像是同一套方言在不同宿主环境下的实现。
为了让你更直观理解,我列一下平时最常用的几个指令在两边的情况:
| 指令 | 作用 | Coraza支持情况 |
|---|---|---|
| SecRuleEngine | 设置引擎模式(On/DetectionOnly/Off) | 支持 |
| SecRequestBodyAccess | 是否对请求体做检查 | 支持 |
| SecRule | 定义一条检测规则 | 支持 |
| SecAction | 执行一个动作,不发变量匹配 | 支持 |
| ctl:ruleRemoveById | 在规则执行链中动态移除某条规则 | 支持 |
| SecAuditEngine | 审计日志开关 | 支持 |
这里有一个很重要的理解门槛:Coraza不是简单把规则文本读一遍,而是要解析规则里的变量、运算符、转换函数、动作、链式规则这些结构。所以CRS里那些带链式规则(chain)的复杂检测项,Coraza能不能正确执行,取决于它对这个语言子集的实现程度。建议在使用前看一下版本对应的兼容性说明,尤其是你用的CRS版本比较新的时候。
2.2 CRS的规则分类与请求生命周期
CRS里的规则ID是有规律的,这个规律在排错时价值巨大。我第一次排查误报时,靠的就是日志里那个rule id,一查就知道是哪个攻击类型。
规则ID编号段大致是这样分配的:
| 规则ID区间 | 攻击类型 |
|---|---|
| 900000起 | 规则配置与调整 |
| 910000起 | 请求头(如扫描器特征) |
| 920000起 | 协议与编码异常 |
| 930000起 | 文件与路径攻击 |
| 931000起 | 路径穿越 |
| 932000起 | 命令注入 |
| 933000起 | PHP注入 |
| 941000起 | XSS跨站脚本 |
| 942000起 | SQL注入 |
| 943000起 | 会话固定 |
| 944000起 | 反序列化攻击 |
| 949000起 | 异常分数汇总 |
| 950000起 | 附加规则 |
因为CRS的检测机制不是“一条规则命中就拦”,而是采用异常分数(anomaly score)累积的方式。每类规则命中后会给请求加一定分数,所有规则跑完之后,总分超过阈值(默认是5分或10分,具体看crs-setup.conf里的配置),才执行拦截动作。这个设计的好处是能对抗多特征组合的攻击,坏处是误报排查时不能只盯一条规则,要看多个命中点的叠加。
举个例子:一个请求可能因为UA头比较可疑加了2分,参数里又带了一个SQL关键字加了4分,单独看都不是实锤,但总分超过阈值就被拦了。这种情况在真实业务里非常常见。
2.3 Coraza在Lucky中如何执行CRS
我之前在Lucky里跑通CRS的时候,特意确认过整个请求链路:客户端请求先到Lucky的统一入口,Lucky解析出目标站点和路由后,在把请求转发给上游业务之前,先进入Coraza的检测阶段。
这个阶段的处理顺序是:加载规则文件,按顺序对请求执行各条规则,然后根据规则命中的异常分数决定放行、拒绝还是仅记录。整个过程是同步的,也就是说,请求停留在这个阶段的时间,会直接变成业务接口的响应延迟。
这也是为什么我后面会专门花一章讲性能调优——WAF不是免费的,它在网关层干的活越多,请求的延迟就越明显,关键是怎么把开销控制在合理范围内。
规则文件的加载顺序也值得注意。Coraza启动时会按你配置文件里声明的顺序加载规则文件,CRS的加载顺序有明确约定:先加载coraza.conf-recommended(或你自己写的coraza.conf),再加载crs-setup.conf,最后加载rules目录下的规则。顺序错了,后面引用的变量或配置可能还没定义,就会出现规则行为异常但引擎不报错的情况。
3. Lucky中挂载CRS的配置链路与生效验证
3.1 准备CRS文件目录
先把CRS规则集从GitHub拉下来,建议直接切到release tag,不要用master分支,方便后续升级和排查问题。
git clone --branch v4.0.0 https://github.com/coreruleset/coreruleset.git拉下来的目录结构大致是这样的:
coreruleset/ ├── crs-setup.conf.example ├── rules/ │ ├── REQUEST-910-IP-REPUTATION.conf │ ├── REQUEST-913-SCANNER-DETECTION.conf │ ├── REQUEST-920-PROTOCOL-ENFORCEMENT.conf │ ├── REQUEST-930-APPLICATION-ATTACK-LFI.conf │ ├── REQUEST-931-APPLICATION-ATTACK-RFI.conf │ ├── REQUEST-932-APPLICATION-ATTACK-RCE.conf │ ├── REQUEST-933-APPLICATION-ATTACK-PHP.conf │ ├── REQUEST-941-APPLICATION-ATTACK-XSS.conf │ ├── REQUEST-942-APPLICATION-ATTACK-SQLI.conf │ ├── REQUEST-943-APPLICATION-ATTACK-SESSION-FIXATION.conf │ ├── REQUEST-949-BLOCKING-EVALUATION.conf │ ├── RESPONSE-950-DATA-LEAKAGES.conf │ └── ...先把crs-setup.conf.example复制成crs-setup.conf。这个文件里大部分配置默认是注释掉的,但有几项我建议按需打开,比如:
- 异常分数阈值:默认是5和10,如果你在DetectionOnly模式观察期,可以先把阈值调低,多暴露一些可疑请求出来。
- 请求体检查开关:如果业务有表单提交、JSON提交,必须把SecRequestBodyAccess设为On,否则请求体里带的攻击特征完全不会被检查。
我自己的习惯是准备一个独立的custom.conf文件,用来放所有不属于CRS原始文件的配置和自定义规则,尽量不动CRS自己的文件。原因很简单:以后CRS升级,直接替换整个rules目录,不会被我们改过的东西拖累。
3.2 在Lucky配置里声明WAF规则
Lucky的配置接口不同版本可能略有差异,但大体上是一个基于yaml或json的站点配置块。下面是我当时用的一个配置写法,注释已经写得很完整了,你可以对照着看:
server: listen: ":8080" engine: "coraza" waf: enabled: true mode: "detection" # detection 只记录, enforcement 拦截 files: - "/etc/coraza/coraza.conf" - "/etc/coraza/crs/crs-setup.conf" - "/etc/coraza/crs/rules/*.conf" - "/etc/coraza/custom.conf" routes: - path: "/api" upstream: "http://127.0.0.1:9000"这里最关键的两个设计是mode和files。
mode我强烈建议第一次上线时先用detection。所谓detection就是引擎只记录命中,不真正拦截,引擎返回的结果和真实拦截时的判断逻辑完全一致,但不会影响业务。你跑个三五天,把误报全部清完,再切到enforcement,这样既能摸清规则集的脾气,又不会因为误杀把业务搞挂。
files的顺序我刚才提过,不能乱。coraza.conf里定义了引擎的全局参数,crs-setup.conf里定义了异常分数阈值等配置,rules目录里才是真正的检测规则,custom.conf放你的覆盖和放行规则。这个顺序如果乱了,后面的规则可能引用不到前面定义的配置,行为会变得很诡异。
3.3 验证规则生效的三种方式
配置写完了,怎么确认CRS真的在拦东西、而不是躺在那吃灰?我试过三种方法,按操作成本从低到高排列。
第一种,用curl带一个明显的测试payload,看返回码变化。比如访问一个接口带上SQL注入常见特征:
curl -i "http://your-gateway/api/demo?id=1'%20OR%20'1'='1"如果是enforcement模式,网关应该直接返回403;如果是detection模式,业务正常返回200,但审计日志里会多出命中记录。这个办法最快,但只适合验证最基本的链路。
第二种,开审计日志,看真实流量里有没有东西命中。Coraza的审计日志会在请求命中规则时把原始的请求头、请求体、命中规则ID都记录下来。这个方法特别适合上线初期,因为你会发现生产环境里的攻击请求远比想象的普遍,很多扫描器一天到晚在试各种路径和payload。
第三种,也是最靠谱的一种:把WAF切到detection模式,让它和业务并行跑一段时间,然后定期导出审计日志做分析。这一步能让你在误报真正变成事故之前,就把问题定位清楚。我后面误报那个案例,就是在detection模式下发现的,如果当时直接开enforcement,搜索结果接口就当场挂了。
4. 误报收敛实战:一次搜索接口被SQL规则误杀的处理过程
4.1 事故现场:一个“select”引发的拦截
当时我们的系统里有一个搜索接口,前端会调用/search?q=...直接把用户输入的原样传给服务端做分词检索。因为用户搜的内容什么都可能有,参数里出现过“select”、“update”、“and”这类词,本身没有任何攻击意图,但在CRS的SQL注入规则看来,这些词和数据特征组合在一起,就是疑似注入的特征。
我印象很深刻的一次误报,是同事反馈搜索“select top 10 stars”这种词组时,接口直接返回了403。我马上到审计日志里查,命中的规则ID是942100。顺着规则文件一查,这条规则是“SQL注入语法探测”,它匹配的参数里出现了SQL关键字组合,包括select、union、from这些。业务参数叫q,内容又是用户自由输入的文本,命中的概率自然高。
这里有一个很多新手会犯的错误:一看到误报,第一反应是“把这条规则关了”。但942100只是SQL注入规则里的冰山一角,如果你只是把这一个ID关掉,攻击者换个注入手法,规则照样拦不住。正确做法是找到误报的边界条件,让规则绕过“业务参数本来就允许包含这些关键字”的场景,同时保留对其他真正注入特征的检测能力。
4.2 用规则定位技术做定向放行,而不是关掉检测
Coraza支持在运行时通过ctl:ruleRemoveById动作,动态移除某条规则。你可以基于请求路径、参数名、来源IP等维度,在一个独立的规则文件里做定向放行。
我当时在custom.conf里加了这样一段规则:
SecRule REQUEST_URI "@beginsWith /search" "id:990001,phase:1,pass,nolog,t:lowercase,ctl:ruleRemoveById=942100"这条规则的含义是:当请求URI以/search开头时,在阶段1就移除942100的检测逻辑。这样既能保证搜索接口返回正常结果,又不会影响其他接口对SQL注入的检测。
需要注意,这里用的是phase:1。因为请求在进入阶段2(请求体检查)之前就要把942100移除,否则请求体里一带上SQL关键字,还是会触发。在实际生产环境里,你可能需要同时处理多个规则ID,可以直接写成:
SecRule REQUEST_URI "@beginsWith /search" "id:990001,phase:1,pass,nolog,t:lowercase,ctl:ruleRemoveById=942100,ctl:ruleRemoveById=941100"这里提一个排查时特别有用的点:审计日志里通常能看到当前请求命中的所有规则ID,以及每个规则加了多少分。你把它们列出来,如果发现某些规则只在一个特定路径上误报,那就说明这个路径的业务参数和规则特征冲突了,适合用定向放行;如果误报分散在很多路径上,那可能是规则里有编码解析逻辑和业务数据格式冲突,需要从更底层去调整。
4.3 同类误报的普适排查方法
这次误报处理完之后,我把整个排查过程沉淀成了一张查检表,后面遇到类似问题都是按这个流程走的,分享给你直接抄作业:
| 步骤 | 操作 | 意图 |
|---|---|---|
| 1 | 从审计日志拿到命中的规则ID | 明确是哪条规则导致的误报 |
| 2 | 到CRS规则文件里检索该ID,看规则匹配的变量和条件 | 搞清楚规则为什么判定可疑 |
| 3 | 对比业务参数的正常取值范围 | 判断是规则过严,还是请求确实有异常 |
| 4 | 找业务方确认参数是否有机会被外部恶意控制 | 确认风险等级 |
| 5 | 在custom.conf里写定向放行规则,最小化影响面 | 保留检测能力,只绕过冲突场景 |
| 6 | 切回detection模式跑一段时间验证 | 确保没有新误报 |
这套流程下来,绝大多数误报都能在不知不觉中被处理干净,而且不会破坏WAF整体的防护能力。
还有一个建议:在上线新接口之前,把接口的路径、参数、上传文件类型列个清单,提前对照CRS的规则分类过一遍,比上线后出问题再排查省事得多。
5. 性能开销从哪来:阈值调整与规则裁剪的平衡
5.1 开启WAF后接口延迟翻倍的实测
开WAF之前,我们有个上传接口的平均响应时延大概在40ms左右,开了CRS之后,直接涨到150ms。第一反应是“是不是规则有问题导致死循环”,后来用火焰图分析才知道,大部分时间消耗在规则引擎对请求体内容的扫描上。
Coraza在检测请求体时需要先把body读出来,然后按规则里的运算符和正则表达式跑匹配。CRS默认上百条规则,每条规则都要过一遍,尤其涉及正则的执行,叠加起来开销就很明显。请求体越大,耗时越长,这是物理规律。
这里有几个关键参数,直接决定WAF对性能的影响有多大:
| 参数 | 作用 | 默认值 | 建议 |
|---|---|---|---|
| SecRequestBodyAccess | 是否检查请求体 | Off | 有表单/JSON就开启 |
| SecRequestBodyLimit | 请求体最大检查字节数 | 13107200 | 按业务最大包体调整 |
| SecRequestBodyNoFilesLimit | 排除文件上传后的请求体大小限制 | 131072 | 上传多的场景调大 |
| SecPcreMatchLimit | 正则匹配执行上限 | 1000 | 防止正则灾难 |
| SecPcreMatchLimitRecursion | 正则递归上限 | 1000 | 防止正则灾难 |
我当时的做法是:文件上传接口因为文件内容本身不需要做文本攻击检测,把请求体限制调低,并且把这个路径的请求体访问关掉;普通JSON接口保持请求体检查,但把SecPcreMatchLimit从默认的1000改成300,减少单个请求上正则执行的总预算。
5.2 阈值调整与规则裁剪的平衡
异常分数阈值也是一个可以在性能和安全之间做取舍的点。默认情况下,CRS的阈值是5和10。阈值越低,单个请求越容易被判定为可疑,拦截越激进,但误报和引擎内部需要做的工作也越多。
你可以这样理解:异常分数阈值就是“多少个可疑特征叠加才会触发拦截”。阈值等于5,意味着一条高分规则命中或者两条低分规则叠加就会拦截;阈值等于10,需要更多特征叠加才会拦截。如果业务接口对实时性要求高、特征又复杂,适当调高阈值能显著降低误拦率和性能消耗。
我给的参考配置是这样的:
SecAction "id:900100,phase:1,nolog,pass,t:none,setvar:tx.blocking_threshold=5"如果业务接口比较多,我建议按路径设置不同阈值,而不是全局统一。比如登录接口这类攻击面大的路径,阈值维持5;搜索接口这类容易误伤的,阈值调到10或者更高。
5.3 裁剪不是禁用安全
最后讲裁剪。很多团队在性能优化时喜欢大范围关闭规则,比如直接把整个941000(XSS)或942000(SQL注入)注释掉。这种做法其实是把安全能力废掉了,非常危险。
合理裁剪的思路是:根据你业务实际用到的技术栈,把不会涉及的攻击类别关掉。举个例子,如果服务端完全不用PHP,933000(PHP注入)的规则就是纯冗余的,关了不影响安全;如果反序列化在架构里根本没出现,944000的规则也可以不加载。
我做过一个相对干净的方案:在一个纯Go写的API服务前面挂了WAF,服务端不解析PHP,也没有文件上传需求,我最后保留的是910(扫描器)、920(协议)、931(路径穿越)、941(XSS)、942(SQL注入)这几类,其他全部注释。整体规则文件小了三分之一,性能好了不少,实际攻击检测能力没有打折。
6. 用OWASP ZAP自测规则效果与白名单维护经验
6.1 ZAP和CRS是同一生态的两把家伙
OWASP ZAP是OWASP组织下的开源Web应用安全扫描器,它和CRS虽然分工不同,但搭配起来非常默契:ZAP负责“主动发起攻击测试”,CRS负责“在请求到达业务之前把攻击拦下来”。你可以把ZAP当作一个自动化攻击模拟器,用它来验证规则集是否真的在正常工作。
我当时在Lucky网关后面搭了一个测试环境,业务流程和线上保持一致,然后在本地启动ZAP,配置代理指向测试环境的网关地址,让它对整个站点跑了一轮主动扫描。
ZAP的主动扫描会生成很多攻击payload,包括SQL注入、XSS、路径穿越、命令注入等。这些请求会打在你的WAF上,如果CRS真的生效,扫描结果里大部分高风险告警应该是“连接被重置”或者“403响应”;如果ZAP报告里的漏洞是200响应,说明规则引擎可能漏了,或者请求根本没被WAF检查到。
这个验证的价值在于:它能直接告诉你,当前这套规则和配置在你的业务场景里,真实防护效果到底如何,而不是停留在“看起来拦了”的错觉里。
6.2 从测试结果反推规则事件
用ZAP跑完一轮扫描后,我从两个地方对照数据:ZAP告警列表和Coraza审计日志。
- 如果ZAP报告里某个注入点返回了200,同时Coraza日志里完全没有该请求的命中记录,那就是配置链路的问题,常见原因包括:该路径被规则排除了、请求体检查没开、WAF只挂在某个域名上而测试请求走了另一个入口。
- 如果Coraza日志里有命中但ZAP依然拿到非403响应,那就是引擎和规则之间的执行顺序或模式问题,需要单条规则单条规则地debug。
这个流程走下来,你对自家WAF的“脾气”会摸得特别清楚。
另外,ZAP扫描时一定要在隔离环境进行,不要对着生产环境扫。哪怕你觉得自己配置很稳,主动扫描的并发和payload组合也可能触发意想不到的异常行为。
6.3 文件上传场景的典型拦截与放行配置
相关热搜里一直有“owasp zap文件上传”,这里专门展开一下这个最常见的坑。
文件上传接口在CRS里非常容易被拦,原因五花八门:
- 请求体的Content-Type是multipart/form-data,某些协议规则会做额外检查;
- 文件名带特殊字符或很长的内容,会命中文件名长度规则;
- 上传内容本身是图片或压缩包,二进制数据里可能碰巧含有和文本攻击特征相似的字节序列。
我当时上传接口的误报集中在920420(请求体类型检查)和932160(命令注入特征探测)这两条上。前者是因为业务允许上传任意类型的附件,后者是因为某些文件名里带了“;”分号,被规则判定为疑似命令拼接。
针对这种情况,我给当时的配置加了一个定向放行:
SecRule REQUEST_URI "@beginsWith /api/upload" "id:990002,phase:1,pass,nolog,t:lowercase,ctl:ruleRemoveById=920420,ctl:ruleRemoveById=932160"但要强调,文件上传是安全重灾区,放行规则必须非常克制。我见过有些团队为了让功能跑通,把整个上传路径的WAF全关了,结果被传了个webshell上去,那才是真正的灾难。正确做法是:能不放行就不放行,确需放行的,要在上游做好文件类型校验、内存级内容检查、文件重命名、存储目录禁止执行脚本这些配套措施。
6.4 白名单维护的实践心得
白名单规则(也就是你写的那些定向放行规则)在一开始往往只有一两条,但随着业务迭代会越积越多。积累到一定量,就会变成一个新的维护负担。
我的习惯是把所有自定义规则按用途分类管理。custom.conf里用大段注释分隔出“路径放行”“参数放行”“用户代理放行”几个区块,每条规则后面注明添加人、日期和原因。比如:
# 2024-06-18 张三:搜索接口允许包含SQL关键字,定向移除942100 SecRule REQUEST_URI "@beginsWith /search" "id:990001,phase:1,pass,nolog,t:lowercase,ctl:ruleRemoveById=942100"这样做的好处是,规则集升级或者安全审计时,你能快速判断每条白名单规则是否还有存在的必要。很多团队的WAF配置最后变成一团乱麻,问题不在于规则本身写错,而在于没有人追踪“这条规则为什么在这里”。
我个人在实际操作中最深的体会是:WAF规则集不是装完就一劳永逸的基础设施,它更像一套需要持续保养的安全策略。上线前花一周做detection模式观察,每个季度定期用ZAP复测一轮规则有效性,业务接口改动时同步回查WAF配置——这套节奏养成了,CorazaWAF和OWASP CRS带来的价值会远远大于那点维护成本。