基于OpenResty与ModSecurity规则构建轻量级WAF实战指南

基于OpenResty与ModSecurity规则构建轻量级WAF实战指南

1. 项目概述:为什么我们需要一个轻量级WAF?

在今天的网络环境中,Web应用防火墙(WAF)早已不是大型企业的专属。无论是个人博客、小型电商站点,还是内部的管理系统,都面临着SQL注入、跨站脚本(XSS)、路径遍历等常见攻击的威胁。传统的商业WAF方案固然强大,但往往伴随着高昂的成本和复杂的部署流程,对于个人开发者或中小团队来说,显得有些“杀鸡用牛刀”。

这时,一个基于开源组件、能够快速部署且性能可控的WAF方案就显得极具吸引力。OpenResty,这个基于Nginx和LuaJIT的高性能Web平台,为我们提供了绝佳的土壤。它允许我们直接在Nginx的请求处理流程中嵌入Lua脚本,这意味着我们可以用极低的性能损耗,实现请求和响应的深度检查与过滤,这正是WAF的核心功能。而ModSecurity作为一款久经考验的开源WAF引擎,其庞大的规则库(如OWASP CRS)是防御已知威胁的宝贵财富。

所以,这个项目的核心价值在于:利用OpenResty的高性能处理能力作为“引擎”,结合ModSecurity的成熟规则库作为“武器库”,在5分钟级别的时间内,搭建一个属于你自己的、可高度定制化的开源WAF。它不仅能有效拦截常见Web攻击,还能让你深入理解WAF的工作原理,为后续的规则定制和性能调优打下基础。无论你是运维工程师、安全爱好者,还是希望为自己项目增加一道安全防线的开发者,这个实践都极具意义。

2. 核心组件选型与架构解析

在动手之前,我们必须搞清楚几个核心组件的关系以及为什么选择它们。这决定了我们搭建的WAF是否高效、稳定。

2.1 OpenResty:不只是Nginx

很多人会把OpenResty简单地理解为“Nginx的Lua增强版”,这其实低估了它的能力。OpenResty的核心是将Nginx变成了一个完整的Web应用服务器。它通过ngx_lua模块,将LuaJIT虚拟机无缝嵌入到Nginx的各个请求处理阶段(如access_by_lua*,body_filter_by_lua*,log_by_lua*)。这意味着你可以在请求生命周期的几乎任何时刻,执行自定义的Lua逻辑。

对于WAF来说,这个特性是革命性的。我们可以在access阶段(认证和权限检查)执行请求头、请求URI的检测;在rewrite阶段进行URL重写和攻击特征匹配;更重要的是,我们可以通过access_by_lua*在请求体还未被上游应用读取时,就完成对POST数据等内容的检查,一旦发现攻击,立即返回403等状态码阻断请求,而无需将恶意流量传递到后端。这种“请求边处理边检查”的模式,是高性能WAF的基石。

选择OpenResty而非原生Nginx+Lua模块手动编译,主要是因为其开箱即用生态完整。OpenResty的发行版已经集成了数十个常用的Nginx模块和Lua库,省去了繁琐的依赖管理和编译过程,让我们能专注于WAF逻辑本身。

2.2 ModSecurity与规则库:站在巨人的肩膀上

ModSecurity是一个跨平台的、开源的Web应用防火墙引擎。它本身是一个安全工具包,提供了请求/响应分析、攻击特征匹配、日志记录等核心功能。它的强大之处在于其规则语言(SecLanguage)和庞大的社区规则集

我们本次搭建的重点之一,就是导入ModSecurity的规则。最著名、最常用的规则集莫过于OWASP Core Rule Set (CRS)。CRS提供了针对SQLi、XSS、RCE、文件包含等数十种攻击类型的数千条检测规则。直接使用CRS,相当于直接接入了全球安全社区的经验积累,能防御绝大多数已知的自动化攻击和常见漏洞利用尝试。

但是,ModSecurity原生是一个Apache模块,也有Nginx的版本(modsecurity-nginx连接器)。在我们的方案中,我们并非直接运行ModSecurity for Nginx模块,而是采取一种更灵活、对OpenResty更友好的方式:将ModSecurity的规则“翻译”或“适配”成能在OpenResty的Lua环境中运行的逻辑。这是因为原生的modsecurity-nginx模块与OpenResty的Lua协程模型在集成上可能存在一些复杂性和性能开销。我们的目标是提取规则的精髓(正则表达式、检测条件、动作),用Lua来实现匹配和动作执行。

2.3 整体架构设计

我们的轻量级WAF架构可以清晰地分为三层:

  1. 接入层(OpenResty):接收所有HTTP/HTTPS流量,是流量的唯一入口。
  2. 检测引擎层(Lua脚本):这是我们WAF的核心。我们将编写或配置Lua脚本,这些脚本内嵌了从ModSecurity规则转化而来的检测逻辑。脚本运行在OpenResty的上下文中,对请求的各个部分(方法、URI、头部、参数、Body)进行实时分析。
  3. 动作执行层:根据检测结果执行相应动作。通常包括:
    • 通过(PASS):未发现威胁,请求被转发至后端真实服务器(proxy_pass)。
    • 阻断(DENY):发现明确攻击,立即中断连接,向客户端返回403 Forbidden或自定义错误页面。
    • 记录(LOG):无论是否阻断,都将详细的检测日志(攻击类型、匹配的规则、请求片段)记录到文件或发送到远程日志服务器,用于后续审计和分析。

这个架构的优势在于轻量、高性能和灵活可控。所有逻辑集中在OpenResty内,无需额外进程间通信,减少了延迟。同时,用Lua实现规则,让我们可以非常方便地添加自定义规则、调整规则灵敏度、或者与业务逻辑进行深度集成。

3. 五分钟快速部署:OpenResty安装与基础配置

“5分钟搞定”并非虚言,前提是有一个干净的Linux环境(如CentOS 7/8, Ubuntu 20.04/22.04)。我们这里以Ubuntu 22.04为例。

3.1 一键安装OpenResty

OpenResty官方提供了预编译的软件包,安装非常便捷。首先导入官方GPG密钥并添加软件源:

sudo apt-get update sudo apt-get install -y software-properties-common sudo add-apt-repository -y "deb https://openresty.org/package/ubuntu $(lsb_release -sc) main" sudo apt-get update

然后安装OpenResty全家桶:

sudo apt-get install -y openresty

这个命令会安装OpenResty核心、Nginx以及常用的Lua库。安装完成后,OpenResty的二进制文件是openresty,其配置文件目录通常位于/usr/local/openresty/nginx/conf/(通过源码编译安装)或/etc/openresty/(通过包管理安装)。你可以通过which openrestyopenresty -V来确认安装路径和编译参数。

注意:在一些使用1panel等面板的环境中,可能已经自带了OpenResty。你需要确认其版本和安装路径。启动方式可能是systemctl start openresty或通过面板的服务管理功能。如果存在冲突,建议先卸载面板自带的版本,或使用我们新安装的版本,并注意修改默认的监听端口(如从80改为8080)避免冲突。

3.2 启动与验证

启动OpenResty服务:

sudo systemctl start openresty sudo systemctl enable openresty # 设置开机自启

检查服务状态和默认页面:

sudo systemctl status openresty curl http://localhost

如果看到OpenResty的欢迎页面,说明服务已经正常运行。默认的配置文件通常位于/usr/local/openresty/nginx/conf/nginx.conf。在继续之前,建议备份原始配置:

sudo cp /etc/openresty/nginx.conf /etc/openresty/nginx.conf.backup

3.3 基础WAF配置骨架

我们并不直接修改主配置文件,而是采用include的方式,让结构更清晰。在/etc/openresty/下创建一个专门存放WAF配置的目录:

sudo mkdir -p /etc/openresty/waf/ sudo mkdir -p /etc/openresty/waf/rules/ sudo mkdir -p /etc/openresty/waf/logs/

接下来,创建我们的核心WAF Lua脚本。我们将创建一个名为waf.lua的文件,它将是我们的总控中心:

sudo vim /etc/openresty/waf/waf.lua

我们先搭建一个最简单的框架,这个框架定义了WAF的工作流程:

-- /etc/openresty/waf/waf.lua local _M = {} _M.version = "1.0" -- 规则加载模块 local rule_loader = require "waf.rule_loader" -- 检测引擎模块 local check_engine = require "waf.check_engine" -- 日志记录模块 local logger = require "waf.logger" function _M.access() -- 获取当前请求的变量 local ctx = { remote_addr = ngx.var.remote_addr, request_uri = ngx.var.request_uri, request_method = ngx.var.request_method, args = ngx.req.get_uri_args(), headers = ngx.req.get_headers(), -- 注意:获取请求体需要在合适的阶段,且可能影响性能 } -- 1. 加载规则(可缓存,避免每次请求都加载) local rules = rule_loader.load_rules() -- 2. 执行检测 local action, matched_rule = check_engine.run(ctx, rules) -- 3. 根据检测结果执行动作 if action == "DENY" then -- 记录攻击日志 logger.log_attack(ctx, matched_rule) -- 返回403并阻断 return ngx.exit(ngx.HTTP_FORBIDDEN) elseif action == "LOG" then -- 仅记录日志,不阻断 logger.log_attack(ctx, matched_rule) end -- 如果action是PASS,则什么都不做,继续向下执行 end return _M

这个骨架包含了模块化的思想。现在,我们需要修改Nginx主配置,在需要防护的server块中引入这个WAF逻辑。编辑/etc/openresty/nginx.conf,在http块内添加以下内容,用于加载我们的WAF模块路径:

http { ... # 添加Lua包路径,指向我们的WAF目录 lua_package_path "/etc/openresty/waf/?.lua;;"; # 初始化共享字典,用于缓存规则或限流计数 lua_shared_dict waf_cache 10m; ... }

然后,在你想要保护的网站对应的server配置块中,在location /处理之前,加入access_by_lua_file指令:

server { listen 80; server_name your_domain.com; # 启用WAF检测 access_by_lua_file /etc/openresty/waf/waf.lua; location / { proxy_pass http://your_backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 可以定义一个用于输出错误日志的location location /waf_denied { internal; # 内部访问,不对外暴露 default_type text/html; content_by_lua_block { ngx.say("Request Blocked by WAF") } } }

配置完成后,检查配置语法并重载Nginx:

sudo openresty -t sudo systemctl reload openresty

至此,一个具备基础框架的WAF就已经“挂载”到了你的Web服务器前。虽然它现在还没有任何实际的检测规则,但流程已经打通。接下来,就是为其注入灵魂——规则。

4. ModSecurity规则解析与Lua化导入实战

这是整个项目的核心难点和亮点。我们不是简单地安装一个ModSecurity模块,而是理解其规则,并将其转化为Lua可执行的逻辑。

4.1 理解ModSecurity规则格式

一条典型的ModSecurity规则(以CRS v3为例)看起来像这样:

SecRule ARGS "@rx (\bunion\b.*?\bselect\b|\bselect\b.*?\bunion\b)" \ "id:942100,\ phase:2,\ block,\ t:none,t:urlDecodeUni,t:htmlEntityDecode,t:lowercase,\ msg:'SQL Injection Attack Detected',\ logdata:'Matched Data: %{MATCHED_VAR} found within %{MATCHED_VAR_NAME}',\ tag:'attack-sqli',\ severity:'CRITICAL'"

我们来拆解关键部分:

  • SecRule: 规则定义开始。
  • ARGS: 检查的变量,这里是所有请求参数(GET和POST)。其他常见变量有REQUEST_URI,REQUEST_HEADERS,REQUEST_BODY等。
  • @rx: 操作符,表示使用正则表达式匹配。
  • (\bunion\b.*?\bselect\b|\bselect\b.*?\bunion\b): 正则表达式,用于检测常见的SQL联合查询注入模式。
  • phase:2: 规则执行的阶段。phase:1是请求头,phase:2是请求体。在我们的Lua WAF中,可以对应到access_by_lua*阶段。
  • block: 动作,表示阻断请求。
  • t:none,...: 变换函数链(transformation),用于对变量进行预处理,如URL解码、HTML实体解码、转小写等,以绕过简单的混淆。
  • id, msg, tag, severity: 规则的元信息,用于日志和标识。

我们的任务,就是将成千上万条这样的规则,转化为Lua中的数据结构(如表)和匹配函数。

4.2 规则转换策略与Lua实现

我们不可能手动转换所有CRS规则。一个实用的策略是:先实现一个核心的、支持基本规则语法的Lua检测引擎,然后编写脚本,将ModSecurity规则文件(.conf)解析并转换为这个引擎能识别的Lua规则表

首先,创建规则加载模块/etc/openresty/waf/rule_loader.lua

-- /etc/openresty/waf/rule_loader.lua local _M = {} local lrucache = require "resty.lrucache" local shared_dict = ngx.shared.waf_cache -- 创建一个简单的LRU缓存,缓存解析后的规则 local rule_cache, err = lrucache.new(100) -- 缓存100条规则解析结果 if not rule_cache then ngx.log(ngx.ERR, "failed to create rule cache: ", err) end -- 规则数据结构 -- rules = { -- { id = 942100, phase = 2, var = "ARGS", operator = "@rx", pattern = "union.*select", transform = {"lowercase"}, action = "block", msg = "SQLi Attack" }, -- ... -- } function _M.load_rules() -- 首先尝试从缓存获取 local cached_rules = shared_dict:get("compiled_rules") if cached_rules then return cached_rules end -- 缓存未命中,从文件加载 local rule_files = { "/etc/openresty/waf/rules/request-942-application-attack-sqli.conf", "/etc/openresty/waf/rules/request-943-application-attack-session-fixation.conf", -- 添加更多规则文件... } local all_rules = {} for _, file_path in ipairs(rule_files) do local rules_from_file = _M.parse_modsec_file(file_path) if rules_from_file then for _, rule in ipairs(rules_from_file) do table.insert(all_rules, rule) end end end -- 将规则表序列化后存入共享字典缓存(设置过期时间,如3600秒) shared_dict:set("compiled_rules", all_rules, 3600) return all_rules end -- 这是一个简化的解析函数,实际解析ModSecurity规则需要更复杂的逻辑 function _M.parse_modsec_file(file_path) local rules = {} local file, err = io.open(file_path, "r") if not file then ngx.log(ngx.ERR, "failed to open rule file: ", file_path, " error: ", err) return nil end for line in file:lines() do line = line:match("^%s*(.-)%s*$") -- 去除首尾空白 if line and line ~= "" and not line:match("^#") then -- 忽略空行和注释 -- 这里需要实现一个真正的ModSecurity规则解析器 -- 作为示例,我们假设规则已经被预处理成简单的Lua表格式 -- 实际项目中,你可能需要先使用Python/Go等语言编写一个规则转换器,将.conf文件批量转换成.lua文件 if line:match("^{.*}$") then local ok, rule_table = pcall(loadstring("return " .. line)) if ok and rule_table then table.insert(rules, rule_table) end end end end file:close() return rules end return _M

由于完整解析ModSecurity规则文件非常复杂,一个更可行的工程化方案是两步走

  1. 离线转换:使用一个外部脚本(如Python),读取OWASP CRS的.conf文件,解析每条SecRule,将其转换为一个更简单的、Lua易读的JSON或Lua表格式的文件。这个脚本只需要在规则更新时运行。
  2. 在线加载:我们的rule_loader.lua只负责加载这个已经转换好的、格式清晰的规则文件。

例如,转换后的规则文件rules.lua可能看起来像这样:

-- /etc/openresty/waf/rules/compiled_rules.lua local _M = { { id = 942100, phase = 2, variables = {"ARGS", "ARGS_NAMES", "REQUEST_BODY"}, operator = "rx", pattern = [[\bunion\b.*?\bselect\b|\bselect\b.*?\bunion\b]], transformations = {"none", "urlDecodeUni", "htmlEntityDecode", "lowercase"}, action = "block", msg = "SQL Injection Attack Detected", tag = "attack-sqli", severity = "CRITICAL" }, -- ... 成千上万条其他规则 } return _M

然后,rule_loader.luaload_rules函数就简化为:

function _M.load_rules() local cached_rules = shared_dict:get("compiled_rules") if cached_rules then return cached_rules end -- 直接require转换好的规则文件 local ok, rules_table = pcall(require, "rules.compiled_rules") if not ok then ngx.log(ngx.ERR, "failed to load compiled rules: ", rules_table) return {} end shared_dict:set("compiled_rules", rules_table, 3600) return rules_table end

4.3 检测引擎的实现

有了规则,我们需要一个引擎来执行它们。创建/etc/openresty/waf/check_engine.lua

-- /etc/openresty/waf/check_engine.lua local _M = {} -- 变换函数映射表 local transformations = { none = function(val) return val end, lowercase = function(val) return string.lower(val) end, urlDecodeUni = function(val) -- 实现一个简单的URL解码(实际需要更完善的实现) return val:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end) end, htmlEntityDecode = function(val) -- 实现简单的HTML实体解码(实际需要更完善的实现) local replacements = {["&lt;"] = "<", ["&gt;"] = ">", ["&amp;"] = "&", ["&quot;"] = "\""} for k, v in pairs(replacements) do val = val:gsub(k, v) end return val end, -- 可以添加更多变换函数... } -- 应用变换函数链 local function apply_transformations(value, trans_list) if not trans_list or #trans_list == 0 then return value end local result = value for _, trans_name in ipairs(trans_list) do local trans_func = transformations[trans_name] if trans_func then result = trans_func(result) else ngx.log(ngx.WARN, "unknown transformation: ", trans_name) end end return result end -- 检查单个变量是否匹配某条规则 local function check_variable(var_value, rule) if not var_value or type(var_value) ~= "string" then return false end -- 应用变换 local transformed_value = apply_transformations(var_value, rule.transformations) -- 根据操作符进行匹配 if rule.operator == "rx" then -- 正则表达式匹配 local from, to, captures = ngx.re.find(transformed_value, rule.pattern, "joi") -- 'j'启用PCRE JIT,'o'编译一次,'i'忽略大小写(如果规则需要) if from then return true, transformed_value:sub(from, to) end elseif rule.operator == "@contains" then -- 包含匹配 if string.find(transformed_value, rule.pattern, 1, true) then -- plain text search return true, rule.pattern end end -- 可以扩展更多操作符,如`@beginsWith`, `@endsWith`, `@eq`等 return false end -- 主检测函数 function _M.run(ctx, rules) if not rules then return "PASS" end for _, rule in ipairs(rules) do -- 根据规则中定义的变量,从ctx中获取值进行检查 for _, var_name in ipairs(rule.variables) do local var_values = {} if var_name == "ARGS" then -- 获取所有查询字符串参数的值 for _, v in pairs(ctx.args or {}) do if type(v) == "table" then for _, subv in ipairs(v) do table.insert(var_values, subv) end else table.insert(var_values, v) end end elseif var_name == "REQUEST_URI" then table.insert(var_values, ctx.request_uri) elseif var_name == "REQUEST_METHOD" then table.insert(var_values, ctx.request_method) elseif var_name == "REQUEST_HEADERS" then for k, v in pairs(ctx.headers or {}) do table.insert(var_values, k .. ": " .. v) end -- 需要处理POST body,这里比较复杂,涉及到`ngx.req.read_body()`和`ngx.req.get_post_args()` end -- 对获取到的每个值进行检查 for _, val in ipairs(var_values) do local is_match, matched_data = check_variable(tostring(val), rule) if is_match then -- 匹配成功,返回阻断动作和规则信息 return rule.action:upper(), {id = rule.id, msg = rule.msg, matched = matched_data, var = var_name} end end end end return "PASS" -- 所有规则都未匹配 end return _M

这个检测引擎是一个高度简化的版本,但它阐述了核心原理:遍历规则 -> 根据变量名从请求中提取数据 -> 应用变换函数 -> 使用操作符(主要是正则)进行匹配 -> 返回结果。真实的工业级实现需要考虑性能优化(如规则分组、快速失败)、更完整的变换函数库、对JSON/XML等结构化body的解析,以及处理文件上传等复杂情况。

5. 高级配置、性能调优与运维实践

一个能用于生产环境的WAF,除了核心检测功能,还需要考虑性能、可维护性和可观测性。

5.1 规则管理与更新

规则来源:首选OWASP CRS。你可以从GitHub(https://github.com/coreruleset/coreruleset)下载最新版本。我们的“离线转换脚本”就需要针对CRS v3的规则文件进行解析。

规则更新流程

  1. 在测试环境下载新版CRS,使用转换脚本生成新的compiled_rules.lua
  2. 在测试环境进行全面的回归测试,确保新规则不会阻断正常的业务请求(误报)。
  3. 将新的规则文件同步到生产服务器的/etc/openresty/waf/rules/目录下。
  4. 通过API或信号通知OpenResty重载规则。一种简单的方式是让rule_loader.lua检查规则文件的修改时间(mtime),如果发现更新,则清空共享字典缓存,触发重新加载。也可以发送HUP信号给OpenResty进程,但更优雅的方式是提供一个内部API接口:
location /waf-admin/reload-rules { internal; # 只允许内部访问 allow 127.0.0.1; deny all; content_by_lua_block { local shared_dict = ngx.shared.waf_cache shared_dict:delete("compiled_rules") ngx.say("Rules cache cleared.") } }

然后通过curl -X POST http://localhost/waf-admin/reload-rules来触发重载。

5.2 性能优化要点

Lua代码在Nginx中运行虽然高效,但不合理的实现仍会成为性能瓶颈。

  1. 缓存,缓存,缓存!

    • 规则缓存:如上所述,一定要将解析后的规则缓存在lua_shared_dict中,避免每个请求都去解析文件。
    • 正则表达式编译缓存ngx.re中的正则表达式如果使用o选项,会被编译并缓存。确保在循环或频繁调用的函数中,正则模式是常量或来自缓存。
    • 复杂运算结果缓存:对于一些复杂的变换或检查结果,如果在一定时间内不变(如IP地理位置),可以考虑缓存。
  2. 减少不必要的检查

    • 白名单:对于已知安全的IP、URL路径(如静态文件/static/,/uploads/),可以在WAF逻辑最前端直接跳过所有检测。
    • 规则分组与阶段:并非所有规则都需要在每个请求中检查。可以将规则按phasetag分组,根据请求特征决定启用哪些组。例如,对GET请求可能不需要检查REQUEST_BODY相关的规则。
  3. 优化Lua代码

    • 避免在热路径中创建大量临时表:Lua的垃圾回收(GC)可能带来波动。在_M.run这样的高频函数中,尽量减少临时对象的分配。
    • 使用JIT编译:OpenResty使用的LuaJIT对大多数Lua代码都能很好地进行即时编译。确保你的代码是JIT友好的(例如,避免使用某些不被JIT支持的Lua原语,如ipairs在5.1版本下某些情况)。
  4. 调整Nginx/OpenResty参数

    • lua_shared_dict大小:根据规则数量和并发量调整。
    • worker_processes:设置为CPU核心数。
    • worker_connections:根据服务器内存和预期并发连接调整。

5.3 日志记录与监控

“只阻断不记录”的WAF是盲目的。详细的日志对于分析攻击趋势、调整规则、排查误报至关重要。

完善/etc/openresty/waf/logger.lua

local _M = {} local cjson = require "cjson" function _M.log_attack(ctx, matched_rule) local log_entry = { time = ngx.localtime(), remote_addr = ctx.remote_addr, request_uri = ctx.request_uri, request_method = ctx.request_method, rule_id = matched_rule.id, rule_msg = matched_rule.msg, matched_var = matched_rule.var, matched_data = matched_rule.matched, -- 注意:记录匹配到的数据可能包含敏感信息,生产环境需脱敏或哈希处理 user_agent = ctx.headers["User-Agent"] or "", severity = matched_rule.severity or "UNKNOWN" } -- 使用ngx.log记录到Nginx error log,级别为WARN ngx.log(ngx.WARN, cjson.encode(log_entry)) -- 同时可以写入到单独的日志文件,或发送到远程syslog/ELK/Sentry等系统 local file, err = io.open("/etc/openresty/waf/logs/waf_attack.log", "a") if file then file:write(cjson.encode(log_entry) .. "\n") file:close() end end -- 还可以记录通过(PASS)的请求用于审计,但要注意日志量 function _M.log_access(ctx) -- ... 实现访问日志记录 end return _M

在Nginx配置中,可以将WAF日志定向到独立的文件:

http { ... # 定义WAF日志格式 log_format waf_json escape=json '{' '"time":"$time_local",' '"remote_addr":"$remote_addr",' '"request":"$request",' '"status":"$status",' '"waf_action":"$waf_action",' # 需要设置变量 '"waf_rule_id":"$waf_rule_id",' '"http_user_agent":"$http_user_agent"' '}'; # 在server中配置 server { ... set $waf_action ""; set $waf_rule_id ""; access_by_lua_block { local waf = require "waf" local action, rule = waf.access() if action == "DENY" then ngx.var.waf_action = "DENY" ngx.var.waf_rule_id = rule.id else ngx.var.waf_action = "PASS" end } access_log /var/log/nginx/waf_access.log waf_json; ... } }

5.4 常见问题与排查技巧

  1. 误报(False Positive)太高,阻塞了正常用户

    • 排查:首先查看WAF日志,找到被阻断的请求和触发的规则ID。使用curl或浏览器开发者工具,模拟正常用户请求,看是否触发相同规则。
    • 解决
      • 禁用规则:在转换后的规则集中,找到对应ID的规则,将其action改为log(仅记录)或直接注释掉。
      • 调整规则:分析规则的正则表达式是否过于宽泛。有时需要修改规则,增加更精确的边界条件(\b)或排除特定模式。
      • 添加白名单:对于特定的、安全的参数或路径,在WAF逻辑中增加前置白名单检查。
    • 心得永远不要在生产环境直接启用全套CRS而不经过测试。建议先在action设为log的模式下运行一段时间,分析日志,确认没有大量误报后再开启block模式。
  2. 漏报(False Negative),攻击未被拦截

    • 排查:确认请求是否经过了WAF检测的location。检查规则是否已正确加载(查看错误日志)。使用已知的攻击Payload(如' OR 1=1 --)进行测试。
    • 解决
      • 确保检测阶段覆盖:如果攻击在POST Body中,确保你的WAF在access阶段读取了请求体(ngx.req.read_body()),并且规则中包含了REQUEST_BODY变量。
      • 更新规则库:攻击技术在进化,确保你使用的CRS是最新版本。
      • 添加自定义规则:针对特定的应用漏洞或攻击模式,编写你自己的Lua规则。这是自建WAF的最大优势。
  3. 性能影响显著,服务器负载升高

    • 排查:使用openresty -V查看是否启用了--with-debug,生产环境应关闭。使用ngx.location.capture或第三方模块(如lua-resty-bench)进行压测,对比开启和关闭WAF时的QPS和延迟。
    • 解决
      • 优化正则:一些复杂的正则表达式是性能杀手。使用更高效的正则,或将其拆分成多条简单规则。
      • 限制检查范围:对静态资源(如图片、CSS、JS)的请求,在Nginx层面通过location匹配直接跳过WAF。
      • 升级硬件:WAF的正则匹配是CPU密集型操作,更强的CPU会直接提升处理能力。
      • 考虑分层防护:将基于正则的精确匹配放在第二层,第一层先用更简单的规则(如IP黑名单、频率限制)过滤掉大部分恶意流量。
  4. OpenResty重启后规则丢失?

    • 原因lua_shared_dict是内存缓存,进程重启后消失。
    • 解决:在init_by_lua*阶段(在Master进程启动时执行一次)预加载规则到共享字典,或者确保每次请求时,如果缓存不存在就从磁盘加载。我们之前的rule_loader模块已经实现了缓存机制,重启后第一个请求会触发加载。
  5. 如何测试WAF是否生效?

    • 基础测试:访问http://your_server/?id=1' OR '1'='1。如果返回403或自定义拦截页面,说明SQLi规则可能生效了。
    • 使用专业工具:使用sqlmapXSStrike等自动化扫描工具(务必在授权环境下测试!),观察其请求是否被拦截,并查看WAF日志。
    • 发送测试Payload:有专门的WAF测试字符串,如<script>alert(1)</script>(测试XSS)、../../etc/passwd(测试路径遍历)。

搭建这样一个WAF,从“能用”到“好用”还有很长的路要走。它需要你持续地维护规则、分析日志、调优性能。但这个过程本身,就是对你Web安全体系理解的一次深度提升。这个自建的WAF可能没有商业产品那样华丽的界面和自动化的威胁情报,但它给你带来了无与伦比的透明度和控制力。你可以精确地知道每一条规则在做什么,可以根据自己业务的特殊性量身定制防护策略,这在应对一些针对性强的攻击时,往往能起到奇效。