1. 整体设计与思路拆解1.1 为什么把过滤放在 Nginx 这一层先说结论Nginx 过滤规则不是应用安全的银弹但它是最容易被低估的一道入口拦截。大部分 Web 应用的安全检查都放在业务代码里比如登录接口校验输入、ORM 参数化查询、Razor 模板自动转义等等。问题是代码审查节奏一快业务接口几百条总有几个历史接口是裸奔的。这时候如果能在大门口先拦一道哪怕规则写得粗糙一点也能把大量通用目录扫描、自动化攻击脚本挡在业务被拖垮之前。我做过的某个项目里后端所有接口都用了参数化查询理论上 SQL 注入漏洞很少。但运维侧还是发现有异常 IP 持续请求/search.php?id1 and sleep(5)之类的路径。这类请求如果没有 Nginx 拦截会真实打到 PHP-FPM 进程上每个请求白白占用一个 worker几十个并发就能把服务拖到响应超时。加一层过滤之后同一类请求在接入层直接 403后端进程根本不用起床。适合谁参考这个方案主要是以下三类人一是运维手里管着很多网站来不及逐个改业务代码二是开发兼运维需要快速加固一个已经上线的系统三是做安全测试的朋友想理解反代层过滤规则的常见写法方便评估绕过面。无论哪一类核心思路都一样在请求路由到应用之前先按特征快速决定放行还是拒绝。1.2 攻击特征与 Nginx 过滤的契合点SQL 注入的经典特征是请求中出现数据库操作片段比如union select、sleep(3)、benchmark、information_schema、into outfile。XSS 的经典特征更直观比如script、onerror、javascript:、alert(1)这类片段出现在 URL 参数、表单提交内容或 Cookie 里。Nginx 本身不解析业务参数但它能看到完整的$request_uri、$args、$http_user_agent、$request_body这些原始文本。攻击者的 payload 无论怎么编码最终总要落到这些字符串里。用正则把这些原始字符串扫一遍命中疑似攻击特征就返回 403这就是整个方案的全部原理。需要强调的是这个方案是“粗过滤”而不是“深度检测”。它能挡住的是那些特征明显的廉价攻击真正高水平的定向攻击往往会做充分编码混淆靠正则很难面面俱到。所以正确心态是Nginx 过滤规则做第一道闸门业务层参数化查询、输出转义依然要做后端的 WAF 或风控系统也建议保留。层与层之间是互补关系不是替代关系。1.3 方案选型时的两点纠结有人会问直接用开源 WAF 模块不是更省事比如搭配 Lua 的 WAF 方案确实能做得更细能做状态管理、频率限制、精细化规则上下文。但如果只是为了快速止血引入额外模块意味着重新编译 Nginx、维护一套更复杂的配置文件、承受额外的内存和 CPU 开销。对于中小网站原生的if $request_uri return 403已经能挡住七八成脚本小子的乱打。还有人会纠结该用return 403还是rewrite到某个提示页。我的经验是一律return 403不要做跳转。跳转会多一次请求也会让扫描器确认目标存在交互性直接 403 干净利落日志里也容易统计拦截次数。2. 核心细节解析与实操要点2.1 Nginx 配置层级和匹配流程写过滤规则之前先把 Nginx 的处理顺序理清楚。请求进来后Nginx 会经过以下阶段解析 HTTP 头部和 URI → 按 server_name 匹配虚拟主机 → 按 location 前缀/正则匹配路径 → 执行该 location 内的 rewrite、if、proxy_pass 等指令。如果我们在最外层 server 块里定义规则则所有 location 都会继承如果在某个 location 内部定义则只有该路径生效。实操中建议在server块顶部放通用过滤规则在特定敏感 location 里再叠加更严格的规则。比如所有接口都防 SQL 注入但只有/api/upload这个路径额外检查文件内容相关参数这种层级关系更灵活。如果统一定在http块作用域全局生效但无法做到某个站独享配置一般不建议除非你明确知道所有站点都适用同一套标准。这里要说一个老梗Nginx 里if指令被官方文档称作“邪恶的”因为它在某些场景下行为反直觉比如if内写proxy_pass会引起指令上下文混乱。但用在return这种纯响应指令上是安全的。我们只做“命中即返回 403”不做复杂的跳转或转发正好规避了那个经典坑。2.2 常用正则的关键点和防误伤原则先看最基础的过滤正则。假设我们防护 SQL 注入和 XSS配置大概长这样# 统一拦截疑似注入与跨站脚本特征 if ($request_uri ~* (union[\s]select|select[\s].*?from|sleep\(|benchmark\(|concat\(|load_file\(|into[\s]outfile|information_schema\.|(script|iframe)[\s\S]*?(alert|javascript|onerror))) { return 403; }逐段拆开理解一下union[\s]select匹配union select[\s]兼容多个空格、换行、制表符。为什么强调多空格因为有些扫描器会故意构造union/*!*/select或union select来绕过简单的union select匹配。select[\s].*?from匹配select ... from这种典型的查询结构。.*?是非贪婪匹配能跨任意字符。这个正则杀伤力大误报率也高如果站点本身有英文单词“select”出现在 URL 里就要小心了后续会专门讲怎么降误伤。sleep\(|benchmark\(匹配延时注入常见的函数调用。\(转义左括号避免匹配到字段名前缀里的sleep单词。information_schema\.匹配数据库元信息查询特征后面跟的是点号因为正常请求里出现这个库名的概率极低。(script|iframe)[\s\S]*?(alert|javascript|onerror)匹配script...alert...这类经典 XSS 载荷。[\s\S]表示任意字符包括换行防止 payload 里拆成多行绕过。XSS 方面单独再补一组针对事件处理器的正则if ($request_uri ~* (on(load|error|click|mouseover|focus|blur)[\s]*|javascript:|eval\(|alert\(|document\.cookie|String\.fromCharCode)) { return 403; }事件处理器是 XSS 的重灾区攻击者经常注册img srcx onerroralert(1)URL 里onerror这个特征非常明显。javascript:冒号协议也值得拦因为浏览器执行 javascript 伪协议的场景在现代 Web 里基本只有攻击者会用。2.3 编码绕过问题靠正则怎么补URL 里的字符可以百分号编码。select写成%73elect、script写成%3Cscript%3E这是最基础的绕过姿势。上面正则匹配的是原始$request_uri遇到百分号编码就失效了所以必须在正则里把常见编码形态也覆盖进去。例union select整句编码后是union%20select原正则无法匹配因为%和数字不是[\s]。可以补一个等效匹配if ($request_uri ~* (union|select|sleep|benchmark|information_schema|script|alert|onerror)[\s%20]{0,3}) { return 403; }但这里要非常谨慎因为[\s%20]{0,3}这种写法会宽松到离谱。比如 URL 里出现“select 下拉框的图片 src”就可能因为匹配到select而误杀。实操上我通常只对明显攻击特征做编码兼容例如%27单引号、%3C小于号、%3E大于号、%20空格。更稳妥的方案是分两步先用$args拿到参数原始串再做一层rewrite by Lua解码后检测。但这里不展开因为目标是原生 Nginx。原生环境能做的就是多列几组特征把常规的单次 URL 编码覆盖住。双重编码、畸形编码这类更深层的绕过坦白讲原生正则很难防全得靠业务层或专业的 WAF 引擎来处理。2.4 不同请求位置的分工一套规则不可能覆盖所有位置需要按变量拆开处理请求位置对应 Nginx 变量主要攻击风险请求路径$uri/$request_uri路径穿越、注入、扫描器目录探测URL 参数$argsSQL 注入、XSS payloadUser-Agent$http_user_agent自动化工具标记、可疑客户端Cookie$http_cookie会话伪造、注入探测POST 请求体$request_body表单注入、存储型 XSS、文件上传其中$request_uri自带完整 URL 含参数所以最常用的就是它。$uri是解码后的规范化路径不含参数适合精确匹配路径是否在黑白名单里。$request_body在 Nginx 原生环境里有点特殊后面单独说。3. 实操过程与核心环节实现3.1 一套可以直接落地的基础配置下面是模拟项目里使用过的一套完整配置去掉了业务相关路径保留了通用规则。直接放在server块内即可生效。## 基础过滤SQL 注入特征 if ($request_uri ~* (union[\s]select[\s].*?from|union[\s]all[\s]select|select.*?from.*?where|sleep\([\s]*[0-9]|benchmark\([\s]*[0-9]|load_file\(|into[\s]outfile|into[\s]dumpfile|information_schema\.|order[\s]by[\s][0-9]{1,3}|\/\*.*?\*\/)) { return 403; } ## 基础过滤数据库函数和命令执行特征 if ($request_uri ~* (extractvalue\(|updatexml\(|exp\(|floor\(rand\(|group_concat\(|concat_ws\(|char\(|ascii\(|substring\(|mid\(|--[\s]|-{2,}|#$)) { return 403; } ## XSS 特征脚本标签和事件处理器 if ($request_uri ~* (|%3C)[\/]?(script|iframe|object|embed|svg|img|math)[\s\S]*(|%3E)) { return 403; } if ($request_uri ~* (on(load|error|click|mouseover|mouseout|focus|blur|submit|change)[\s]*|javascript:|vbscript:|expression\(|eval\(|document\.cookie|String\.fromCharCode|windows\.open)) { return 403; }配置解读为什么这几条是基础配置第一不允许--和#。这两个符号是 SQL 注释符注入里经常用来把原本的 SQL 后半句注释掉达到截断逻辑的目的。不过#在 URL 里本来就是 fragment 分隔符正常浏览器不会把#后边的内容发给服务器所以标题 URL 参数里出现#大概率是攻击脚本硬拼的。第二不允许/* */块注释。MySQL 还有/*!50000 union select */这类版本化注释很多扫描器拿来混淆。这个正则也没有全防只是把常见形态先兜住。第三${exp\0000}这段是我写错了吧。别在线上抄那是我临时打的废话。实际用exp\(来匹配数学函数exp()它被利用是因为 MySQL 7 版本在某些运算溢出时报错回显但属于小众漏洞可以放也可以不放。保守起见建议放。第四XSS 的正则里[\/]?是为了兼容闭合标签/script。有些扫描器会把整段 payload 原样丢到参数里检测闭合标签能多抓一类。它不完美可挡常规攻击够用。3.2 重定向策略拦截和放行的比例控制回到那套配置有没有发现我没有写return 444那个也是很多加固教程里常见的写法直接断开连接不返回任何内容。return 444对扫描器来说等于石沉大海有时候比 403 更好用因为攻击者不确定目标服务是否存活后续探测节奏会变慢。但反过来看没有响应状态码也意味着我们的日志里没有 response code无法快速判断“这条请求是被刻意丢弃的”。所以我的习惯是对直连接入的扫描流量用return 403对疑似同行反向代理转发的异常请求用return 444。前者方便审计后者让对端代理先怀疑人生。这个细节各位按自己的监控习惯选择不是硬性要求。3.3 POST 请求体的过滤怎么处理开头说过$request_body是原生 Nginx 的老大难。如果直接在普通server配置里读取$request_body会出现两个问题一是读取过一次之后proxy_pass转发到后端时 body 就没了业务接口收到空 body二是大 body 读进内存容易把 Nginx 进程内存吃满。推荐两种落地方式。第一种Nginx 原生条件下在location里使用limit_except先压制请求方法然后对$request_uri做过滤不读 body。POST 请求的 body 过滤交给业务层。这样做只能防一半但能提供基础防护location /api/ { limit_except GET POST { deny all; } if ($request_uri ~* (union[\s]select|sleep\(|benchmark\(|script|onerror|javascript:)) { return 403; } proxy_pass http://backend; }第二种用 OpenResty 的 Lua 模块读取 body 并检查。这段不是原生 Nginx不深入展开但给个思路local body ngx.req.get_body_data() if body and ngx.re.match(body, (union%sselect|sleep%(%d)|script%s*)) then return ngx.exit(403) end多数中小团队其实没必要为了 body 过滤专门引入 Lua因为只要业务层用了参数化查询body 里的注入基本打不穿数据库。反而靠正则硬拦 body 会拦出很多“误杀朋友”。真实场景里POST 表单里有一个字段叫“描述”用户上传一段包含sleep(3)的文本你的规则就把他拦了这体验太差了。3.4 部署和验证的完整流程改完配置先全局检查语法这是老运维都会做的事但新手很容易漏nginx -t输出syntax is ok之后再平滑重载nginx -s reload重载是平滑的不会中断在线请求但保险起见还是建议在低峰期操作。然后验证规则是否生效用 curl 模拟攻击请求# 模拟 SQL 注入 curl -I http://your-server.com/index.php?id1%20union%20select%201,2,3 # 模拟 XSS curl -I http://your-server.com/search?kw%3Cscript%3Ealert(1)%3C/script%3E # 正常请求预期不受影响 curl -I http://your-server.com/index.php?id100前两个请求返回403 Forbidden健康请求返回正常状态码说明规则生效。这一步务必做不然你以为配上了实际上正则写错导致所有请求都 404排查起来更费劲。4. 常见问题与排查技巧实录4.1 误拦截了正常请求怎么办这是上线过滤规则后最多人骂的场景。经典案例是商城站点搜索商品用户在地址栏输入参数kwselect或者某个商品型号里包含sleep5。命中规则403客户直接投诉“商品搜不了了”。我的处理思路是分级降噪。第一层能不写宽泛关键词就不写。比如select这种英文单词除非你的站点是纯技术站点否则建议配合union、from、where同时出现才拦。单纯一个select拦下来误伤概率极高。上面基础配置里select.*?from.*?where已经比裸select强很多。第二层按路径细分。后台管理接口、登录接口、数据库管理页面这些路径严格要求前台商品详情、搜索页这类公开页面只拦明显特征如script、alert(这种不拦select from where组合。具体判断是不是该对参数值里的英文词组放宽得看业务场景。第三层观察一段时间再松动规则。规则上线一周后把 403 的日志翻出来逐个看被拦的 URL凡是带正常业务关键词的把规则改成更精确的变体。比如把select[\s].*?from改成union[\s]select加from.*?where的组合误伤面会小很多。如果不想这么麻烦还有一个投机的做法统一拦截后返回 403再对少量放行的业务 URL 做白名单。比如把需要支持“select 关键词搜索”的页面路径加进location白名单里在里面用allow all和deny all按 IP 或路径放行绕开全局规则。但白名单最好别开太多开了就是给攻击者留后门。4.2 攻击者做了编码变换正则还能拦吗百分号编码、大小写混淆能拦大部分但还有更刁钻的。比如un%69on sele%63t把i和c编码掉普通正则是瞎的。Nginx 的$uri变量是经过 URL 解码的规范化路径但$request_uri是原始未解码串。如果想让匹配更鬼可以改用$uri加$arg_xxx组合因为$arg_xxx变量会自动解码参数值。例如请求/search?kwun%69on%20sele%63t$request_uri里是编码形态把规则改成匹配$arg_kw拿到的就是解码后的union select直接命中if ($arg_kw ~* (union[\s]select|sleep\(|benchmark\(|script|onerror)) { return 403; }这里也要小心$arg_kw只对应名为kw的参数如果攻击者换参数名就成了漏网之鱼。还得配合$request_uri的粗筛。双重编码、三重编码这类变形原生 Nginx 确实防不住别勉强后面还有业务层守着。4.3 用户请求体里带着扫描痕迹被误伤POST 表单里如果有一大段富文本内容比如用户复制了一段包含select from英文的技术文章或者论坛讨论区有人发帖讨论onerror事件写法可能被误杀。这个问题在正文过滤里永远存在防不胜防。我的建议是POST body 的过滤规则只对特定路径开比如登录接口、注册接口、修改密码接口这些对输入内容要求严格的位置对评论、公告、富文本这类“用户生产内容”的路径规则必须关掉或者只保留script这种绝对攻击特征。宁可让少量 payload 进到业务层也不能把正常内容交流全砍了。4.4 日志分析时怎么区分拦截和正常流量加了规则后Nginx 日志默认会记录所有 403 请求。建议在 access log 格式里加一个专用字段把拦截类型标记出来。方法很简单每个if块里先执行set一个变量最后统一在日志里打出来if ($request_uri ~* (union[\s]select)) { set $attack_type sqli-union-select; return 403; } if ($request_uri ~* (script)) { set $attack_type xss-script-tag; return 403; } log_format security $remote_addr [$time_local] $request $status attack_type$attack_type;这样日志里每条 403 请求都能看出是哪种攻击类型被打掉了。平时用grep attack_type就能快速统计今天哪个攻击路径打得多、哪个 IP 在扫端口、哪个特征经常误报。我维护的那台服务器上最长一次持续监控三个月发现大部分自动化流量来自固定的网段最后直接在 Nginx 层写了 IP 黑名单过滤规则的压力小了非常多。4.5 正则性能会不会拖垮 Nginx这是新手最容易担心的问题。其实 Nginx 处理几十条正则的耗时远小于一次后端数据库查询CPU 开销基本可忽略。但有两个性能雷区一是正则里用了灾难性回溯结构比如.*?.*?套娃二是每个请求都执行大量 Lua 脚本。原生正则过滤规则控制在二三十条以内每条不带过深的嵌套完全没问题。我那个配置跑了半年Nginx worker 的 CPU 占用增加不到 1%。5. 我踩过几次坑之后学会的几件事第一件事规则不是越严越好。一开始我为了“安全”把select、script、alert这些关键词全裸放在规则里结果当天就把网站用户搜索“select”关键词的请求全拦了被业务方追着骂了一下午。从那以后我给自己定了一条规矩任何新正则上线前必须在日志里先跑一天观察模式而不是直接开启拦截。Nginx 没有 dry-run 模式就手动把规则里的return 403改成access_log /var/log/nginx/waf.log attack_type$attack_type;只记录不拦截等确认误报率低了再开杀。第二件事正则里一定要写~*不写~。差一个星号大小写就敏感了攻击者把SCRIPT一换就绕过这种低级漏洞不能犯。第三件事别过度依赖反代层过滤把业务代码安全防护也同步更新。反代层规则能替你挡住绝大多数无聊扫描但真正的威胁会精心构造 payload绕开你所有正则。这个过程里业务层参数化查询、输出编码才是最后兜底的生命线。两者配合服务才能睡得着觉。如果你正准备给自己的服务器加这道闸门建议就从本文的基础配置开始先跑几天只记日志不拦截再把误报率降到最低然后切换成拦截模式。遇到奇怪的攻击 payload 可以顺手记下来积累成自己的特征库比网上抄来的大而全规则靠谱得多。
阅读完成 · 觉得有帮助?