从漏洞原理到自动化检测:编写高质量Nuclei模板实战指南
2026/9/11 10:05:52 网站建设 项目流程

最近挖了不少漏洞,感触最深的一件事是:很多人用 Nuclei 只会跑现成模板,真遇到一个刚公开的漏洞,想自己写检测规则,却不知道从哪里下手。网上讲模板语法的文章不少,但基本都是照着官方文档翻译一遍,看完了还是不会写。这篇文章我不想重复那些基础语法,而是从漏洞原理开始,带你走一遍完整的分析、设计、编码、验证流程,把一份能稳定复现、误报率低的 Nuclei 模板是怎么炼成的讲清楚。无论你是刚入门的安全工程师,还是已经写过几个模板但总觉得哪里不太对劲的选手,这篇应该都能给你一些启发。

编写高质量的 Nuclei 模板:从漏洞原理到自动化检测脚本

1. 先理解 Nuclei 模板:它不只是一段 YAML,而是一套检测逻辑

1.1 模板驱动扫描器的工作原理

Nuclei 本质上是 ProjectDiscovery 开源的一个"规则驱动扫描器",这句话的重点不在"扫描器",而在"规则驱动"。传统扫描器把检测逻辑写死在代码里,加一个新的检测项就要等版本更新;Nuclei 反过来了,扫描引擎是固定的,所有检测规则都外置成一份份 YAML 模板,引擎只负责加载模板、发请求、比对响应、输出结果。换句话说,模板是"剧本",引擎是"演员",演员不关心演什么戏,你给它什么剧本它就怎么演。

这个设计带来的好处是巨大的:写模板不需要编译代码,不需要懂 Go,只要会写 YAML、理解漏洞原理,就能在几分钟内给一个新漏洞产出检测规则。同一个模板在本地跑是单目标扫描,放到大规模资产收集流程里就是批量巡检,一致性完全由模板保证,不会因为人不同而出现"他测出来了、我测不出来"的情况。

我在实际使用中最大的感受是,Nuclei 模板特别像测试用例——你定义输入、定义期望输出,引擎替你执行并判断是否命中。但问题也出在这:很多人把它当成简单的"发个请求、匹配个关键字"工具,忽略了模板背后其实是漏洞判定逻辑的工程化表达。写模板之前,脑子里必须先把"怎么证明这个漏洞存在"这个问题想清楚。

1.2 一份模板的骨架结构拆解

先看一个最基础的模板长什么样,这里用 Nacos 未授权访问做例子(后面会详细讲):

id: nacos-unauth-access info: name: Nacos Unauthorized Access author: your-name severity: high description: Nacos API endpoints accessible without authentication, may leak configuration data. tags: nacos,unauthorized,exposure http: - method: GET path: - "{{BaseURL}}/nacos/v1/auth/users?pageNo=1&pageSize=9" matchers-condition: and matchers: - type: word part: body words: - "username" - "pageItems" condition: and - type: status status: - 200

一个模板的结构可以拆成四大块:

  • id:模板的唯一标识,命名习惯是vendor-product-vuln,比如nacos-unauth-access。这个 id 会在输出结果、去重、模板管理中反复用到,尽量有意义。
  • info:元信息块,描述漏洞名称、作者、危害等级、参考链接、标签。这部分表面上只是"注释",但实际影响模板的分类和过滤。
  • 协议块:这是核心检测逻辑所在,常见有httpdnsfilesslheadless等。绝大多数 Web 漏洞都走http
  • 匹配与提取:在协议块内部,用matchers定义"什么特征算命中",用extractors定义"要从响应里提取什么数据"。

1.3 从漏洞原理到检测特征的思维模型

我见过太多人写模板的第一步就是查 YAML 语法,这是本末倒置。正确顺序应该是:先回答四个问题,再动笔写文件。

第一,这个漏洞为什么存在?是参数过滤不严、配置错误、还是加密算法强度不够?第二,攻击者怎么触发它?需要什么请求、什么参数、什么前置条件?第三,漏洞被触发后,服务端返回什么特征?是状态码变化、响应体内容变化、响应时间变化,还是后续行为变化?第四,这个特征怎么和其他正常业务区分开?如果随便一个页面都有这个特征,那这个特征就是无效的。

这四个问题想清楚,模板的核心逻辑就有了。剩下的 YAML 语法只是"把逻辑翻译成机器能理解的格式"。

比如检测一个 SQL 注入,很多人一上来就匹配sql syntax error这类报错关键字,结果目标站点是个报错信息很规范的框架,根本匹配不到。反过来,如果你先分析漏洞原理,发现该框架在参数中拼接了注入 payload 后,错误信息会包含传入语句的关键片段,那么匹配规则就应该是"响应中是否回显了我们传入的独特字符串",而不是"响应中是否包含通用报错关键字"。

这个思维模型,是高质量模板和低质量模板的分水岭。

2. 漏洞原理分析:选目标、找特征、定判定逻辑

2.1 案例一:Nacos 未授权访问漏洞的原理与攻击面

Nacos 是很多微服务架构里都在用的注册中心和配置中心,它在默认部署状态下,部分接口没有做严格的权限校验,导致攻击者可以直接通过 HTTP 请求读取用户列表、配置信息、服务实例列表等敏感数据。这个漏洞的根源主要是两个:一是 Nacos 的部分 API 设计上默认开放,二是在很多生产环境中,运维人员把 Nacos 直接暴露在公网,又没有开启服务端身份验证。

从攻击面来看,最常被利用的几个接口有:

  • /nacos/v1/auth/users?pageNo=1&pageSize=9:未授权获取用户列表,返回 JSON 中包含usernamepassword字段(密码是加密后的哈希)。
  • /nacos/v1/cs/configs?dataId=&group=&tenant=:未授权拉取配置列表,可能包含数据库连接串、密钥等。
  • /nacos/v1/ns/instance/list?serviceName=xxx:查看服务实例列表,泄露内网拓扑。

这个漏洞之所以适合做教学案例,是因为它的检测特征非常明确,而且这些接口在未授权状态下和已授权状态下的返回差异明显。我测试过不少线上 Nacos,只要没开鉴权,/nacos/v1/auth/users基本都会返回 200 和 JSON 数据;而开启鉴权后,返回的是 403 或者类似user not found的提示。

2.2 从原理推导检测请求与响应特征

现在我们用前面的思维模型来推导检测逻辑。

漏洞为什么存在?Nacos 默认不强制鉴权。攻击者怎么触发?直接发一个 GET 请求。服务端返回什么特征?200 状态码,JSON 响应体里出现usernamepageItems等结构字段。怎么区分正常业务?正常业务页面不会返回这种特定结构的 JSON。

所以检测请求就定为:

GET /nacos/v1/auth/users?pageNo=1&pageSize=9 HTTP/1.1 Host: target

检测特征定为:状态码 200,且响应体包含usernamepageItems。我特意没有用password作为必须匹配的关键字,原因有两个:一是新版本 Nacos 对返回字段做了调整,某些版本不直接返回password字段;二是password这个单词太通用,容易在页面脚本里误命中。用usernamepageItems这个组合,既能覆盖多个 Nacos 版本,又能显著降低误报。

这里有个细节很容易踩坑:如果你直接把响应体匹配totalCount,在低版本 Nacos 里也是可行的,但在某个版本之后接口返回结构变了,totalCount字段被移除,模板就漏报了。所以写模板时,字段选择要尽量选那些"跨版本稳定存在"的特征,而不是"当前版本恰好存在"的特征。

2.3 案例二:SSTI 模板注入的检测思路

再看一个稍微绕一点的例子:服务端模板注入(SSTI)。这个漏洞的原理是用户输入被直接拼接进了模板引擎的渲染表达式,常见的模板引擎有 Jinja2、Freemarker、Velocity、Twig 等。攻击者输入{{7*7}},如果服务端真的执行了表达式,响应里就会渲染出49

检测的思路是:发送一个带模板表达式的特殊 payload,然后检查响应中是否回显了表达式计算结果。这里的关键不是"用什么语法配 matcher",而是"选什么 payload 才能既有效又不出误报"。

很多人刚学写 SSTI 检测模板时会直接匹配49,这在我看来是误报率最高的写法——目标页面里只要正常出现数字 49,就被误报成漏洞。更好的做法是使用带字符串的数学表达式,比如{{7*'7'}},在 Python 的 Jinja2 里结果是7777777,这个字符串基本不可能出现在正常业务响应中。检测特征匹配7777777,误报率就降下来了。

另外要考虑不同模板引擎的语法差异。{{...}}是 Jinja2 和 Twig 的语法,Freemarker 用的是${7*7},而 Velocity 的语法是#set($x=7*7)。所以要覆盖多种模板引擎,通常需要准备一组 payload 来分别探测。这就是后面要讲的 payloads 数组和 fuzzing 的用武之地。

2.4 特征设计的三条原则

综合上面两个案例,我把检测特征的设计原则总结成三条,写模板的时候可以拿来当自查清单:

第一是稳定性。选择的特征必须在目标系统的多数版本、多数部署方式下都稳定存在,不要依赖某个版本独有的字段。如果条件允许,最好能快速搜索一下该漏洞在其他扫描器或公开 PoC 里的响应判断逻辑,看看别人用了哪些稳定特征。

第二是特异性。特征要尽量"只有这个漏洞才会出现",避免和正常业务内容撞车。通用报错信息、通用数字、通用英文单词都属于低特异性特征,能不碰就不碰。可以用组合条件(多个关键字、状态码、响应头)来提升特异性。

第三是可解释性。模板是给别人看的,也是给自己三个月后复盘看的。matcher 的命名、info 里的 description、选择的特征,都要让人一眼看懂"为什么这样匹配"。我自己 review 过的模板里,最痛苦的就是那种 matcher 写得天马行空、毫无注释的模板,出了问题根本没法排查。

3. 把检测逻辑写成 Nuclei 模板:从小白到进阶的落地过程

3.1 第一步:写好模板头部信息,别小看元数据

模板头部就是idinfo块,很多新手觉得这部分随便写写就行,其实不然。id是模板的唯一标识,如果命名不规范,在模板库大规模使用时会出现重复和混乱。我的习惯是遵循组件-漏洞类型的格式,比如nacos-unauth-accessjenkins-script-console-rce

info块里的severity(危害等级)要根据漏洞实际危害来定,不要为了"看起来严重"一律填critical。Nacos 这种可能泄露配置和凭据的未授权访问,我一般定high;如果能直接 RCE 才考虑criticaltags也值得花点心思,它决定了后续扫描时能不能通过-tags参数快速筛选,比如tags: nacos,unauthorized,exposure就比tags: nacos好用得多。

reference字段建议填上漏洞公告或相关 CVE 的官方链接,方便后来人回溯漏洞原始信息。我见过不少模板,info块只写了个名字和 severity,漏洞详情全靠猜,这种模板在正式的漏洞管理流程里非常不友好。

3.2 核心请求块:method、path、body、headers 的选择逻辑

http块的核心是请求定义。最基础的是指定请求方法和路径,但有几个细节值得展开说说。

路径支持数组。比如 Nacos 未授权访问,我不会只测一个用户列表接口,而是同时测几个接口:

path: - "{{BaseURL}}/nacos/v1/auth/users?pageNo=1&pageSize=9" - "{{BaseURL}}/nacos/v1/cs/configs?dataId=&group=&tenant="

引擎会按顺序发这几个请求,任何一个命中都算检测成功。数组的好处是可以在一个模板里覆盖多个相关接口,提升检测面。

{{BaseURL}}这个变量指的是命令行传入的目标地址(包括协议和端口),几乎所有 Web 模板都要用到。如果你写的是绝对地址的 API,不依赖传入目标域名,那可以用self-contained: true让模板完全忽略输入的 BaseURL,直接请求模板里写死的 URL。这个选项在对接某些固定服务时很有用,但多数场景下还是推荐用{{BaseURL}},保证模板的可移植性。

对于 POST 请求,bodyheaders是搭配着来的:

method: POST path: - "{{BaseURL}}/api/check" headers: Content-Type: application/json body: '{"name":"{{payload}}"}'

这个例子里body用单引号包裹整个 JSON 字符串,外层再写{{payload}},就会让 payloads 里的测试值逐个替换进去。这里很容易犯的错是 YAML 引号嵌套混乱,导致模板加载报错。我后面会专门讲编码转义的问题。

3.3 匹配器与提取器的正确打开方式

matchers是模板的灵魂,它的本质是"对响应内容做断言"。Nuclei 支持statuswordregexbinarysizedsl等类型,每一种都有不同的适用场景。

status最常用于配合其他条件,单独使用很容易误报,因为 200 状态码太普通了。word是最常用的文本匹配,适合响应体里的固定关键字。regex适合内容格式不固定但有规律的情况,比如匹配一个 20 位十六进制字符串的 token。dsl是最灵活的方式,可以写复杂的表达式,比如:

matchers: - type: dsl dsl: - "contains(body, 'root') && status_code == 200"

这个表达式同时判断了响应体和状态码,相当于把多个条件写进了一个 matcher。我个人在写复杂判定逻辑时比较偏好 dsl,因为可读性比matchers-condition: and配多个 matcher 更直观。

extractors则是负责"从响应里把数据捞出来",最常见的用途是配合多请求联动,把第一步请求中获取的 token、session 等传给第二步请求。

extractors: - type: json name: token json: - ".data.token" internal: true

这里的internal: true表示提取结果只在模板内部使用,不会输出到扫描结果里。这种设计非常巧妙,可以让模板像流水线一样,一步响应成为下一步的输入。

3.4 多请求联动:用 extractor 把上一步结果传给下一步

有些漏洞不是一个请求就能测出来的,最常见的是要先登录拿 token,再携带 token 去访问敏感接口。Nuclei 的http块天然支持多个请求按顺序执行,通过extractors和变量引用就能串起来。

我写过一个用来检测某系统越权访问的模板,逻辑是:

http: - method: POST path: "{{BaseURL}}/login" headers: Content-Type: application/json body: '{"username":"admin","password":"admin"}' extractors: - type: json name: token json: - ".data.access_token" internal: true - method: GET path: "{{BaseURL}}/api/admin/users" headers: Authorization: "Bearer {{token}}" matchers-condition: and matchers: - type: word part: body words: - "username" - type: status status: - 200

两个请求会按顺序执行,第一个请求提取access_token存入模板变量token,第二个请求通过{{token}}引用。整个过程对使用者完全透明,输出结果里只会看到最终的命中情况。

用这个功能时有三个细节需要特别注意。第一,要确保第一个请求真的返回了能用的 token,否则第二个请求会带一个空值过去,结果不准确。我通常会在第一个请求后面加一个简单 matcher,比如要求 200 和包含token字段,不满足就提前终止。第二,internal: true千万别漏,漏了这个 token 会出现在最终扫描结果里,造成凭据泄露。第三,如果 token 出现在响应头里而不是 JSON 里,可以用type: kval来提取,比如:

extractors: - type: kval kval: - "set_cookie"

3.5 用 payloads 做批量输入测试

payloads机制是 Nuclei 模板里实现"批量输入"的核心,它通过模板变量展开,生成多个请求。拿 SSTI 检测举例:

payloads: ssti: - "{{7*'7'}}" - "${7*7}" - "<%= 7*7 %>" - "#{7*7}" http: - method: GET path: - "{{BaseURL}}/search?q={{ssti}}" matchers: - type: word part: body words: - "7777777" - "49"

这里定义了一个名为ssti的 payload 数组,路径里的{{ssti}}会被逐个替换成数组里的每个值,引擎会生成 4 个请求分别发送。配合 matcher 里的多个关键字,就能覆盖不同模板引擎的回显结果。

payloads 还有一个很方便的用法是通过attack: clusterbomb做多变量笛卡尔积组合,不过这会显著增加请求数量。我之前写一个登录接口的弱口令检测模板时用了两组 payload(用户名和密码),结果请求数瞬间从几十涨到几千。所以用 payloads 一定要有数量意识,测试前先估算请求总量,别把一个扫描模板变成 DoS 工具。

另外,payload 里如果包含{{}}这样的特殊字符,在 YAML 里不会触发 Nuclei 的模板变量解析,引擎只会把它当作普通字符串替换进去。这一点很多刚接触的人容易担心,实际测试下来是完全可用的,因为 Nuclei 变量替换发生在 YAML 解析之后,而 payload 里的花括号只是数据。

4. 模板质量保障:测试、调试、防误报的实战经验

4.1 本地调试:别上来就跑全量,先用 debug 模式看响应

写好的模板直接对真实目标跑,这是最危险也最没效率的做法。我现在的习惯是先用一个本地起好的测试环境验证,没有测试环境就先用-debug模式在单个目标上跑,务求看清每一个请求的完整内容和响应原文。

调试最常用的几个命令参数:

  • -debug:打印每个请求的完整请求头和响应头,这是定位"为什么没匹配到"的第一工具。
  • -v:输出更多过程信息,比-debug轻量,适合大致观察运行情况。
  • -stats:实时显示已发送请求数、匹配数等统计信息,适合观察模板是否产生大量请求。
  • -silent:只输出命中结果,适合已经稳定的模板跑批。

我调试时最常见的操作是:

nuclei -t template.yaml -u http://127.0.0.1:8080 -debug

看请求是否如期发出、响应体是否包含预期关键字、matcher 的 part 是否指对了位置。大部分"匹配不上"的问题,在-debug输出里一眼就能看出来——要么是响应体里关键字确实不存在,要么是part写错了位置,关键字明明在 header 里却去 match body。

4.2 误报与漏报如何平衡:宁可少报,也不要满屏假阳性

模板的误报和漏报是一对矛盾,关键在于场景取舍。在做漏洞管理流程时,误报的代价不仅仅是"多处理几条告警",更严重的是会让安全团队对扫描结果失去信任,最终发现真正漏洞时反而被忽视。所以我的原则是:宁可漏报,也要把误报压到极低。

实际操作中有几个有效的降误报手段。第一是组合条件,不要只靠一个关键字,加一个状态码、加一个路径上下文特征都行。Nacos 那个模板就是一个例子,usernamepageItems加 200,三重确认后才算命中。第二是选择高特异性 payload,SSTI 检测用7777777而不是49,就是这个道理。第三是通过matchers-condition: and把多个维度的特征用"与"组合起来,让误报的概率成倍下降。

漏报的问题相对不那么致命,但也不能太离谱。常见漏报原因是单点特征闭门造车,只测了一个接口、一个版本。缓解办法是在写模板时多看几个可信参考源,把不同版本的响应差异考虑进去。比如 Nacos 用户列表接口在某个版本返回结构变化了,如果我没看别人提交的模板,可能就会漏掉那个版本。

4.3 模板性能与扫描节奏控制

模板写得再准,如果发出去的请求量太大,实际操作中也会被目标设备的防护策略拦掉,或者直接把目标打挂了。控制请求节奏有几个关键参数:

  • stop-at-first-match: true:第一个请求命中后就停止后续请求,这在 path 是数组时能显著减少请求数。
  • max-redirects:限制重定向跟随次数,避免陷入重定向循环。
  • read-all: false:只读取部分响应体,对超大响应体可以明显减轻内存压力。
  • pre-condition:在发送正式请求前先发一个探测请求,只有探测通过才继续,可以过滤掉大量无关目标。

pre-condition是我觉得很多模板忽视的一个好功能。比如检测某个管理后台的漏洞时,先做一个轻量的路径探测,如果目标根本没有这个后台,就没必要发出完整攻击载荷。

4.4 编码、转义与大小写:最容易翻车的隐藏细节

最后这部分是实战中踩坑最多的,几乎每个模板都会遇到。YAML 里的特殊字符处理是第一个坑。比如 matcher 的关键字里包含单引号,必须注意 YAML 的引号嵌套。我写 payload 和 matcher 时习惯统一用双引号包裹 YAML 字符串,然后在需要的时候用反斜杠转义内部引号。

第二个坑是 URL 编码。Nuclei 模板里如果要把查询参数写成 URL 编码后的形式,可以直接在 path 里写编码后的字符串,也可以使用 helper 函数:

path: - "{{BaseURL}}/search?q={{url_encode('../../etc/passwd')}}"

这里的url_encode是 Nuclei 内置的 helper 函数,会在运行时对括号里的内容做 URL 编码。类似的 helper 还有base64md5sha1randstr,写模板时遇到需要动态生成的字符串,优先用这些内置函数,而不是自己在 payload 里写死。

第三个坑是大小写问题。HTTP 响应体的大小写是不可控的,如果你匹配的关键字是username,但目标返回的是UserName,那就漏报了。拿不准的时候可以在响应体里搜索前先用to_lower之类的转换,或者准备多个大小写变体。Nuclei 的wordmatcher 是大小写敏感的,这点要特别留意。

第四个坑是响应体里的转义字符。JSON 响应里的中文和特殊字符经常被转义成 Unicode 形式,比如\u5bc6,如果你匹配中文关键字会匹配不上。我的做法是尽量匹配 ASCII 的字段名,避免匹配中文内容。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

我把平时写模板和帮同事排查时遇到的高频问题整理成一个速查表,照着查能省不少时间。

症状可能原因解决办法
模板加载报 YAML 解析错误缩进不正确、引号嵌套错误用 VSCode 的 YAML 插件检查,确认列表项对齐
请求已发出但总是 no matchpart指向了错误位置-debug查看响应,确认关键字在 body 还是 header
关键字匹配不上且响应为 JSONJSON 内容被转义匹配 ASCII 字段名,避免匹配中文或转义字符
提取的变量为空正则或 JSON 路径写错先用curl拿到响应,单独验证提取表达式
请求数量过多导致扫描很慢payloads 展开成笛卡尔积stop-at-first-match,精简 payload 数组
目标出现大量 403 被拦截请求特征明显,触发了防护模拟真实浏览器 UA,合理调节并发和延时

5.2 版本兼容与模板迁移的踩坑记录

Nuclei 版本迭代比较快,模板语法也有过几次不兼容升级。我印象最深的是早期http块用request关键字,后来统一改成直接用methodpath平铺的方式。如果你在 GitHub 上看到一些比较老的模板,直接往新版本 Nuclei 里扔会报错。

建议长期维护模板库的话,指定 Nuclei 版本再配合 CI 做模板校验。我自己的做法是写了一个简单的模板校验脚本,在提交模板前跑一遍nuclei -validate -t template.yaml,确保语法兼容。以后更新 Nuclei 大版本时,先跑一遍全量校验,能筛出很多兼容性问题。

另外,官方模板库(nuclei-templates)的内容质量参差不齐,引用的时候不能盲目相信。我见过一些社区模板为了凑热度,匹配逻辑写得很草率,误报率极高。用别人的模板没问题,但要在自己的环境里测试验证过再上线,尤其是关键业务的目标环境。

5.3 从单模板到自动化漏扫闭环

模板写出来不是终点,真正体现价值的是把它纳入自动化漏洞管理流程。我目前的使用方式是这样组织的:

本地维护一个模板目录,按资产类型或业务线划分,比如web/api/cloud/,每个模板都经过测试和 review 后才进入正式目录。扫描任务通过配置文件指定模板目录和目标域名,输出格式选 JSON,方便后续脚本做汇总和去重。

nuclei -t ./custom-templates -l targets.txt -json -o results.json

再配合定时任务,每天晚上自动跑一轮核心资产巡检,命中结果推送到团队协作工具。这里有一个经验:自动化扫描不需要把模板跑得越全越好,而是要分层。第一层用轻量指纹模板,确认目标用了什么组件、什么版本;第二层基于指纹结果只跑相关的漏洞模板,这样可以大幅减少无效请求和误报。整个过程其实不需要太复杂的平台,一个定时任务加一个消息推送就能跑起来,重点是模板质量要过关。

最后再分享一点个人经验

写模板这几年,我最深的体会是:模板写得好的前提,永远是对漏洞本身的理解足够深。语法只是载体,逻辑才是核心。同样的漏洞,有人写的模板只匹配一个静态关键字,换台服务器就失灵;有人写的模板能从响应结构到状态码到字段内容层层校验,跑了大半年依然稳定。差别不在写 YAML 的速度,而在分析漏洞时愿不愿意多想几步。

最后分享一个对新手最有用的小技巧:写模板前,先不要碰编辑器,用 curl 或 Postman 把检测请求手工打一遍,看真实响应内容,把响应保存下来。写 matcher 的时候对着真实响应写,而不是凭想象写。这一步虽然简单,但能帮你避掉至少一半的调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询