
上周做内网资产梳理时我用nmap -sV --scriptvuln扫了一轮结果干净得让人心虚。实际情况是那台老 nginx 挂在公网一年多没升级TLS 配置也明显过时响应头里连基础的X-Content-Type-Options都没有。通用 NSE 脚本库为什么没抓到因为vuln类脚本对应的是公开 CVE 特征它不知道我们内网自己的安全基线是什么。那时候我就意识到Nmap 脚本引擎NSE)真正值钱的地方不在默认库而在“按需定制”这四个字上。这篇文章就从 NSE 脚本的机械结构讲起带你写两个能直接落地的检测脚本再聊聊调试和排错的完整思路。读完你至少能实现针对一个具体服务、一个具体需求写一条自己的检测规则。如果你还没有 Nmap先去官网装一个 Windows 或 Linux 版本命令行能跑起来就行。NSE 定制化不需要你从头实现 TCP 栈、并发调度这些东西Nmap 已经把端口发现、服务识别、指纹探测都做完了你要做的只是学会写脚本、挂上去、看结果。1. 为什么通用NSE库不够用从一次漏报说起先把这个“漏报”的场景说透。我的目标是一台内部 Web 服务器扫描命令是nmap -sV --scriptvuln -p 80,443 target。输出里只列了服务版本和几个无关紧要的信息没有一条漏洞告警。如果我直接把这个结果交差那这轮巡检基本等于白做。问题出在哪NSE 官方脚本库的逻辑是“按特征匹配”。一个脚本对应一个漏洞类别或检查项比如http-shellshock检测 Shellshockssl-heartbleed检测 Heartbleed。这类脚本对照的是已知 CVE 的指纹特征你的目标是老版本但没有触发特征它就认为是安全的。而安全响应头缺失、TLS 配置弱这类问题根本没有对应的 CVE 编号自然不在vuln类脚本的检测范围内。我自己遇到的定制化需求大概可以分成三类合规基线与安全配置检查比如验证是否缺少 CSP、HSTS、X-Frame-Options 响应头检查 Redis 是否绑定了外网地址。这是业务侧明确要求通用库不会管。私有协议或内部组件的存活与健康检测很多内部组件用的是自定义协议官方 NSE 库根本不认识只能自己写规则。特定补丁的快速验证某个 CVE 公开后很多漏扫工具的插件还没跟上。这时自己写一个十几行的 NSE 脚本挂上--script就能快速摸清影响面。你会说不是有 OpenVAS 这类专业漏扫工具吗为什么还要在 Nmap 这边折腾因为这些工具部署重、依赖插件库更新适合周期性全量扫描。但日常排查、上线前验证、临时响应安全通告需要的是一个轻量的、能随叫随到的检查手段。NSE 定制化恰恰是这个定位——它复用 Nmap 的扫描结论脚本本身只关注判断逻辑输出直接嵌入 Nmap 的报告体系非常适合自动化流程调用。2. NSE脚本是怎么跑起来的目录结构、规则匹配与执行顺序写脚本之前先建立心智模型NSE 脚本是一个 Lua 文件由四个核心部分组成。这四部分如果不理解后续调试会非常痛苦。先看脚本放在哪。官方脚本在 Nmap 安装目录的scripts/下自定义脚本我建议单独建一个目录比如/opt/nse-custom运行时用--script-dir指定。不要把自己写的脚本混进官方目录否则 Nmap 升个版本、官方脚本一更新你的脚本容易丢或者被覆盖。再来看脚本的骨架。每一个 NSE 脚本本质上是一个 Lua 表里面装着元信息、规则函数和动作函数元信息description、author、license、categories。其中categories决定这个脚本在按分类加载时会不会被选中比如标记为safe的脚本用--scriptsafe时会被执行标记为vuln或intrusive则在默认规则下不会被自动执行。规则函数portrule、hostrule、prerule、postrule。这是脚本被调用的“门卫”返回true才会执行真正的动作。动作函数action里面写具体的探测和判断逻辑返回值会成为 Nmap 输出的一部分。依赖声明dependencies声明这个脚本依赖其它哪些脚本先执行Nmap 会自动处理顺序。以portrule为例它收到一个端口表判断这个端口是否值得运行本脚本。比如 Redis 未授权检测脚本可以写成“只匹配 TCP 协议的 6379 端口”或者更灵活一点匹配port.service redis。hostrule则在主机级别判断比如先对存活主机做一次 HTTP 探测再做后续动作。prerule和postrule是更少用的全局钩子分别在扫描前和扫描后执行一次。执行顺序是这样的Nmap 先完成端口扫描和服务识别然后对于每一个开放的端口检查所有脚本的portrule能返回true的脚本就把host和port作为参数传给action执行。这也就是为什么 NSE 脚本能天然复用 Nmap 的探测结果——它压根不需要自己跑一遍端口扫描。参数传递也是一门学问。NSE 脚本通过stdnse.get_script_args(脚本名.参数名)读取--script-args传进来的值。注意命名规范参数名要带脚本名前缀避免和别的脚本冲突。比如脚本叫http-security-headers参数就写成http-security-headers.uri。而在脚本内部Nmap 还提供了一个跨脚本共享数据的registry机制后面调试点会细说。3. 从零写一个检测Web安全头缺失的NSE脚本理论讲得再多不如直接看一个能跑的脚本。我挑的场景很典型检查目标 Web 服务是否在 HTTP 响应中带上常见安全响应头。这类检查不在官方库的 CVE 匹配范围内但又恰恰是很多安全基线要求的硬指标。为什么我优先用 NSE 内置的http库而不是自己撸一个原始 socket因为http.get这个函数已经处理好了连接、超时、重定向、HTTP 解析这些脏活。你只需要传host、port、uri三个参数它返回一个包含status、headers、body等字段的响应表。这不是偷懒是复用——NSE 的生态价值就在这官方库已经替你趟平了大部分网络层的坑。脚本代码如下新建一个http-security-headers.nse文件local shortport require shortport local http require http local stdnse require stdnse description [[ 检查HTTP响应中是否缺失常见安全响应头。 支持的检查项 - X-Frame-Options防点击劫持 - X-Content-Type-Options防MIME类型嗅探 - Content-Security-Policy防XSS与注入 - Strict-Transport-Security强制HTTPS - Referrer-Policy控制Referrer信息泄露 ]] author yourname license Same as Nmap--See https://nmap.org/book/man-legal.html categories {safe, discovery} portrule shortport.http action function(host, port) local uri stdnse.get_script_args(http-security-headers.uri) or / local expect { [X-Frame-Options] 防点击劫持, [X-Content-Type-Options] 防MIME嗅探, [Content-Security-Policy] 防XSS与注入, [Strict-Transport-Security] 强制HTTPS, [Referrer-Policy] 控制Referrer泄露 } local response http.get(host, port, uri) if response nil then return HTTP请求失败无法获取响应头 end local missing {} for hdr, desc in pairs(expect) do if response.headers[hdr] nil then missing[#missing 1] string.format(缺失 %s作用%s, hdr, desc) end end if #missing 0 then return 安全响应头检查发现 .. #missing .. 项缺失\n .. table.concat(missing, \n) end return 安全响应头检查全部通过 end代码里值得注意的几个地方portrule shortport.http是 Nmap 封装好的规则它判定这个端口是 HTTP 服务就返回true。这样脚本不写死在 80 端口也能覆盖 8080、8000 等自定义端口上的 Web 服务。stdnse.get_script_args配合默认值/让脚本可以直接跑也允许通过--script-args覆盖 URI。table.concat把缺失项拼成多行文本Nmap 会把它显示为脚本的结果。字段多的时候比直接返回一张表更直观。categories标成safe意味着它可以进入默认安全扫描的范围不会产生破坏性请求。运行命令也很简单nmap --script ./http-security-headers -p 80,443 target nmap --script ./http-security-headers --script-args http-security-headers.uri/healthz -p 80 target第二条命令适合目标服务主路径需要登录、健康检查路径可以匿名访问的情况。实测中我会把uri参数加到脚本说明里提醒团队其他人注意。如果你想把脚本做得更工程化还可以让它同时检查多个路径、把缺失的头部数量作为 Nmap 的state输出、甚至通过http.pipeline做流水线请求减少握手次数。核心思路都是一样的定义规则、发请求、比对响应头。4. 深入实战定制一个Redis未授权访问检测脚本第二个例子我选了 Redis 未授权访问检测。这个场景非常典型内网 Redis 如果没设密码、又绑定了非本机地址外部就能直接连接并执行命令。传统做法是用 Python 手动连上去发个PING看看返回但如果你要批量检查几百台机器还是得靠扫描工具。检测原理不复杂。Redis 服务端在收到客户端命令后如果配置了requirepass返回内容会带NOAUTH或ERR字样如果没配置密码连接后直接发PING会立刻得到PONG。我们就是要写一个脚本只用一条 PING 来判断目标是否需要认证不做任何写操作保证非侵入。local shortport require shortport local nmap require nmap local stdnse require stdnse description [[ 检测Redis服务是否存在未授权访问风险。 检测原理向目标发送一条PING命令根据响应判断是否需要认证。 注意本脚本仅做存在性判断不执行任何写入类命令。 ]] author yourname license Same as Nmap--See https://nmap.org/book/man-legal.html categories {safe, auth} portrule function(host, port) return port.protocol tcp and (port.number 6379 or port.service redis) end action function(host, port) local socket nmap.new_socket() local status, err socket:connect(host, port) if not status then return string.format(连接失败%s, err) end socket:send(PING\r\n) local response, recv_err socket:receive_lines(1) socket:close() if not response then return string.format(读取响应失败%s, recv_err) end if response:find(PONG) then return Redis无需认证即可执行命令存在未授权访问风险 elseif response:find(NOAUTH) or response:find(ERR) then return Redis需要认证未发现未授权访问 else return string.format(响应异常可能不是Redis服务%s, response) end end这个脚本的写法值得展开讲portrule没有完全写死端口号。只认6379会漏掉把 Redis 迁移到其它端口的场景所以加了port.service redis的条件。Nmap 如果通过服务指纹识别出这个端口跑的是 Redis即使端口是 16379 或者别的脚本也会触发。nmap.new_socket()是 NSE 底层的 socket 封装适合 Redis 这种简单文本协议。不需要引入额外的comm库因为协议交互就一行命令加一行响应。收到响应后判断三段逻辑PONG说明没认证NOAUTH或ERR说明需要认证其它内容说明这端口压根不是 Redis避免误报。categories加上了auth因为在某些场景按分类批量执行时这个标签能帮助自动化流程识别脚本用途。运行方式nmap --script ./redis-unauth-check -p 6379 target nmap --script ./redis-unauth-check --open-only -p 6379 192.168.1.0/24第二条命令专门用在批量网段扫描--open-only会过滤掉关闭端口的主机输出干净很多。这里必须说一句这脚本只适合做“存在性判断”。如果你在客户环境跑扫描务必先拿到授权不要拿这个脚本对不属于你的网络做探测。检测是发一条 PING危害几乎为零但未经授权扫描本身是越界行为这一点比脚本技术本身重要得多。我见过有同事因为批量扫描未授权网段被对方安全团队抓包的情况那体验真是一点都不好。5. 脚本调试--script-trace、debug级别和registry共享数据写完脚本最常遇到的情况不是报错而是“什么都没输出”。脚本加载了但 Nmap 的结果里压根没有这一项。这时候如果直接怀疑代码逻辑方向就错了。正确的排查路径是先确认脚本有没有被加载、有没有被触发。我的习惯是从三条命令入手nmap --script ./redis-unauth-check --script-trace -p 6379 target nmap --script ./redis-unauth-check --debug -p 6379 target nmap --script ./redis-unauth-check --verbose -p 6379 target--script-trace是最有用的一个。它会把 Nmap 与目标之间的原始网络交互全部打出来包含脚本发的每一个包、收的每一个包。比如 Redis 脚本如果不触发你能看到 TCP 连接有没有建立建立之后发了PING但没收到响应那问题多半在超时或目标网络策略上。如果压根没看到PING的发送记录那问题出在portrule没返回true脚本根本没有跑到action这一层。--debug则能看到 NSE 引擎自身的行为包括脚本加载失败时抛出的 Lua 语法错误。有一次我写的脚本里多了一个逗号Nmap 扫描正常跑完但脚本静默不加载全靠--debug才看到attempt to call a nil value这类报错。还有一种比较隐蔽的情况脚本依赖了另一个脚本但那个依赖没装。NSE 的dependencies字段声明之后Nmap 会先执行依赖脚本再执行本脚本。如果依赖缺失本脚本会被直接跳过不会报错。这种问题靠肉眼几乎不可能发现只能靠--script-trace或--debug从日志里找线索。如果你的脚本在action里想输出调试信息可以在代码中加stdnse.print_debug(2, 当前URI%s, uri) stdnse.print_debug(1, HTTP状态码%s, response.status)级别数字越大信息越细。只在--debug开起来的时候才显示。这样写的好处是脚本正常工作时不刷屏排查问题时又不至于两眼一抹黑。registry机制是另一个容易被忽略的进阶能力。先看它的作用Nmap 在一次扫描中可能并发跑多个脚本如果两个脚本都要对同一个目标发 HTTP 请求频繁建连既慢又扰民。通过nmap.registry可以让第一个脚本把探测结果存下来第二个脚本直接复用。举个例子。我写过一组脚本一个负责对 Web 服务发起一次请求并把响应头、响应体存进nmap.registry[custom_http_cache]另一个脚本专门检查响应头安全项第三个脚本检查页面关键字。第三个脚本运行时先查 registry命中就直接用缓存结果不再重新请求。这个机制对大型网段扫描的价值非常明显——减少重复请求降低被安全设备拦截的概率也减少对目标业务的影响。6. 几个我踩过的坑脚本命名、参数规范与团队协作自定义脚本积累到一定数量后比写脚本更难的是“维护脚本”。我在实际维护过程中踩过不少坑这里挑几个最要命的分享。脚本命名不要图省事。Nmap 在按名字加载脚本时如果自定义脚本和官方脚本同名行为不可控。我之前建了一个目录叫custom-scripts里面放了个http-headers.nse结果某次升级 Nmap 后官方库也出现了一个同名脚本加载顺序一乱输出结果完全对不上。现在我的命名规则统一加前缀比如custom-http-security-headers.nse从名字上就和官方库区分开。参数文档化比代码注释更重要。团队协作时别人拿到你的脚本最常问的问题是“这个参数怎么传”。我在每个脚本的description里都会附带完整的用例包括脚本名、可传参数、参数默认说明。这样在--script-help里就能查到不用每次口头解释。脚本要克制别做超出检测范围的事。拿 Redis 脚本举例有人觉得只发 PING 不够想顺便跑个INFO拿更详细的版本信息甚至尝试几个弱密码。我强烈不建议这么干。NSE 脚本的核心价值是“明确验证一件事”而不是把扫描工具做成攻击工具。脚本越克制越容易获得信任在授权范围内跑得越长久。盯紧 Nmap 版本升级带来的 API 变化。我遇到过http.get的返回结构在某个版本后增加了字段、原先直接访问的response.header在某些场景变成nil的情况。所以脚本里对响应结构的访问我都会先判断nil再做后续逻辑。这不仅是为了兼容也是防御性编程。长参数用文件管理。当脚本参数越来越多命令行会越来越难读。Nmap 支持用--script-args-file把参数写进一个文件。nmap --script ./custom-http-security-headers.nse --script-args-file args.txt -p 80,443 targetargs.txt里每行一个键值对长字符串值放在文件里也容易维护扫描历史记录在哪、改了什么参数都清清楚楚。最后分享一点个人体会。NSE 定制化的门槛真的不在 Lua 语法而在于你想清楚“自己到底要验证什么”。如果你连目标服务可能存在的风险点都列不出来那再高级的脚本引擎也帮不上忙。反过来只要你能把一个检查需求描述成“发什么请求、看什么返回、判断什么结论”这件事就已经能写成 NSE 脚本了。先用stdnse.get_script_args把参数留好再用http或 socket 发请求最后把判断结果按 Nmap 的格式返回。这么完整跑通一次后面再写十个别的小脚本都是同一套思路的复制。