ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Nmap源码深度解析:nmap_main核心机制与二次开发实战

Nmap源码深度解析:nmap_main核心机制与二次开发实战 Nmap 的主函数我前前后后读过很多遍每次都觉得这函数不是用来读的是用来泡的。上一篇我们从入口聊到整体框架把 nmap_main 的调用链拉了一遍。但说实话那种读法只能让你知道哦这里做了什么离我能改它、我能二次开发还差着十万八千里。这篇我打算换个角度专门盯住 nmap_main 里真正决定扫描行为的那些核心机制参数怎么变成机器能懂的结构目标列表是怎么从一行文本变成一台台主机扫描引擎在什么条件下被触发回来之后又怎么把结果交代清楚。如果你正在做基于 Nmap 的扫描器二次开发或者打算写一个自己的网络探测工具这篇文章应该能给你省下不少啃源码的时间。Nmap 源码深度解析nmap_main 函数核心机制详解二1. 参数到内部选项nmap_main 如何用 Options 结构体盘活全局状态1.1 Options 结构体半个全局变量的集合我刚开始读 Nmap 源码时有个很直观的感受这个程序的全局状态管理基本就靠一个Options结构体在撑。你可以在nmap.h里找到它的定义那是一个非常庞大的结构体里面几乎囊括了从扫描类型、端口范围、时序模板、输出方式到调试级别的一切运行时配置。nmap_main 在正式干任何活之前第一件事就是把 argv 里的字符串翻译成这个结构体的字段。这一点和很多现代 C/C 项目不太一样。不少新项目倾向于搞一堆 getter/setter或者把配置拆到不同的 manager 类里Nmap 则很老派地选择把全局配置塞进一个结构体实例。好处是直观调试的时候打印一个o变量就能看到全部状态坏处是耦合度高你改一个字段就要小心有没有别的地方在读它。如果你想在 Nmap 源码上做二次开发第一课一定是把Options结构体从头到尾捋一遍不然后面看代码会频繁卡壳。1.2 getopt_long 主循环一个一夫当关的参数分发器nmap_main 里处理命令行参数的核心是围绕getopt_long()构建的一个大循环。你可以把它想象成一个只有一个入口的分发器所有的-sS、-p 1-1000、--top-ports 100这些参数全部在这里被逐个解析然后落到Options的对应字段里。伪代码的逻辑大致是这样static int nmap_main(int argc, char *argv[]) { struct nmap_opt *opt; int optval; while ((optval getopt_long(argc, argv, sS:sT:sU:..., long_options, opt)) ! -1) { switch (optval) { case s: // 设置扫描类型 o.scanflags | opt-flag; break; case p: // 解析端口列表 parse_ports_string(optarg, o); break; case O: o.osscan true; break; case T: set_timing_level(optarg); break; // ... 大量分支 } } }实际代码当然比我这个骨架复杂得多但主线就是这样一个 switch 大钞。我在读的时候统计过nmap_main 里跟参数解析相关的分支至少有几十个有些参数之间还会互相影响。比如-sS表示 SYN 扫描但如果你同时给了-sTNmap 会根据你是否具有 root 权限来自动选择这种联动逻辑不会发生在解析阶段而是发生在参数全部解析完之后由一段配置归一化代码来做。这里我的建议是二次开发时尽量不要模仿这种巨型 switch而是给每个参数一个清晰的 handler 函数。Nmap 是历史包袱重不代表你也要这么做。但读懂这个循环仍然很有价值因为你会在里面看到很多实际工程中参数互相牵扯的经典解法。1.3 端口字符串解析是参数解析里最深的一块-p参数看起来人畜无害无非就是22、1-1000、U:53,T:80这类表达。但 Nmap 的端口解析函数相当讲究它需要支持单一端口-p 80连续范围-p 1-1000步进范围-p 1-1000:5协议前缀-p T:80,U:53端口的名字映射-p http,https这些规则在parse_ports_string()里逐条实现。我第一次读的时候觉得不过是个字符串拆分后来自己动手写端口解析器才发现坑很多1-65535和一个一个枚举性能差很多Nmap 内部采用了一个类似 range 压缩的数组结构来保存端口列表避免了为每个端口单独开内存。你如果看 nmap 使用教程可能只注意到哦-p可以指定范围但读源码你才会理解为什么它可以承载数万端口的扫描而不崩。另外有个细节很多人忽略parse_ports_string()跑完之后Nmap 会把端口列表排序去重。别小看这步如果你要扫描的端口集合里既有80又有80-90不去重就会导致后续扫描阶段对同一个端口发起两次探测既浪费流量又拖慢整体速度。源码里的PortList类就是为了解决这个问题而存在的。2. 目标列表的展开、去重与分组扫描前最容易被忽略的工程点2.1 输入来源与归一化域名、IP、CIDR、文件全都要变成同一种东西命令行的扫描目标可能长得很随意nmap scanme.nmap.org、nmap 192.168.1.0/24、nmap -iL targets.txt。但 namp 真正开始扫描前必须把所有输入统一成一个个具体的 IP 地址。这一步发生在参数解析完成后的目标构建阶段。我记得nmap_main里大致会调用类似get_targets()的函数把参数解析出来的目标字符串交给一个目标工厂。它会判断输入是域名就去做 DNS 解析是 CIDR 就拆成 IP 范围是文件就逐行读取并递归处理。这里有两个容易踩的坑第一个是 DNS 解析的并发性。如果你喂进来的域名有几千个逐个串行解析会非常慢。Nmap 源码里用了自己的异步解析引擎配合并发上限来避免卡死。二次开发时如果你只是简单地调gethostbyname()那性能和稳定性都会差一个量级。第二个是 hostname 去重的时机。同一个域名解析出多个 IP 地址或者不同域名解析到同一个 IPNmap 会合并处理。因为在扫描阶段IP 才是最小单位如果不合并同一台主机会被反复扫描既浪费时间又让输出变得混乱。刚开始我不理解为什么要做这么重的归一化直到自己写批量扫描脚本时发现输出里同一台机器出现三次才幡然醒悟。2.2 目标分组grouping)它决定了你的扫描快慢Nmap 在真正发送探测包之前还会对目标做一个分组操作。这个分组逻辑是隐藏在nmap_main调用链里的但它的意义几乎是所有扫描参数里最容易被低估的一个。让我用大白话解释一下分组是干嘛的。假设你要扫 10000 台主机Nmap 不会傻乎乎地同时发 10000 份探测包——那会让本机网络栈直接崩溃也违反了 TCP/IP 协议栈的拥塞控制原则。它会按一定规则把目标分成若干批比如一次同时处理 32 台或 64 台主机每批内部再按端口和时序模板来调度发包。这个批次大小不是写死的Nmap 会根据-T时序等级、网络往返时间RTT和丢包率动态调整。源码里的group_targets()干的事就是在真正扫描前把目标列表切分好形成一个个小组交给扫描引擎循环处理。我在读这块时最大的感受是Nmap 的性能调优并不是靠某个魔法参数而是靠这套分组、并发控制和动态调整机制组合出来的。如果你在二次开发时只想保留扫描器核心部分我建议优先把目标分组逻辑抄下来因为它直接决定了你的工具能不能在快和稳之间找到平衡点。我自己就曾经因为省略分组逻辑导致写出来的扫描工具一跑大网段就疯狂丢包。2.3 排除规则从目标列表里精确挖掉不扫的主机--exclude参数在 nmap 使用教程里常常一笔带过但它在源码里的实现却是个独立模块并且处理起来相当细致。原因是排除规则的优先级必须在目标展开之后、但在分组之前生效。具体来说如果你同时给了192.168.1.0/24 --exclude 192.168.1.10Nmap 会先把整个 C 段展开成 256 个 IP再把 192.168.1.10 从列表里移除最后才做分组。这个顺序很关键如果先分组再排除那排除操作要修改多个小组的状态逻辑会乱套而先展开再排除就只需要在单个列表上做删除操作简单又高效。源码里我看到的做法是维护一个排除列表扫描目标展开后逐项比对。这个比对不是简单的字符串相等而是支持 IP 范围匹配和 CIDR 匹配。也就是说你完全可以用--exclude 192.168.1.0/28这种写法Nmap 也能正确地把 16 个 IP 全部挑出来去掉。如果你自己写目标过滤器我建议参考这种做法统一把表达式编译成范围对象再与目标做范围碰撞检测。不要用字符串前缀匹配去处理那样会漏掉很多边界情况。3. 在权限、输出、定时器都就位之后扫描引擎的调用边界3.1 权限检查为什么放在这一步Nmap 有很多扫描技术是需要 root 权限的比如 SYN 扫描需要发送原始数据包操作系统能力检测需要读取某些系统接口。nmap_main 会在调用扫描引擎之前做一次权限检查但这并不是第一步——前面参数解析和目标展开都不需要特权真正需要特权的时刻是开始发包之前。这个能晚则晚的原则我认为是 Nmap 源码里很有工程智慧的设计。如果它在启动第一秒就检查权限然后直接拒绝运行那用户连--help都看不了而把权限检查放到接近发包的地方既保证了合法扫描的灵活性普通用户也能跑 TCP connect 扫描也保证了真正需要特权的功能不会运行到一半才崩溃。我调试 Nmap 源码时曾经故意用普通用户身份去跑-sS发现它在输出里明确提示SYN scan may require root privileges之后并不会直接退出而是降级到 TCP connect 扫描继续跑。这个行为就是由这一段权限检查降级逻辑控制的。它在源码里的实现不复杂先检查geteuid() 0如果不是就把o.scanflags里的 SYN 标志位替换成 CONNECT 标志位。但就是这几行代码决定了一个普通用户体验的好坏。3.2 输出模块的初始化什么时机开始写文件Nmap 支持三种输出格式普通文本normal、XML、grepable 格式。这三者的初始化时机在 nmap_main 里也很有讲究。它不是等扫描跑完才打开文件而是在扫描开始前就把输出文件建好并且把头部信息先写进去。这样做的原因有两个一是如果文件权限不对比如你想写到 /var/log 但没权限Nmap 会尽早报错而不是扫了一晚上才发现文件写不了二是扫描过程中 Nmap 会边扫边写如果等全部扫描完再一次性写几万条结果同时往文件里灌内存压力会非常大。我在实际使用 Nmap API 做二次开发时也刻意模仿了这种设计——先建文件、写头部再边扫边追加结果。刚开始我觉得边扫边写很多余因为批量扫描本来就在后台跑直到有次程序意外崩溃发现之前扫描了几小时的结果全丢了才体会到流式写入的真正价值即使工具中途挂掉前面扫到的数据也不会白费。3.3 scan_engine 调用点整条主线的临界移交如果你用调试器跟踪 Nmap会发现 nmap_main 里有一个高度集中的拐点从这之前程序是在做准备工作从这之后控制权就交给了扫描引擎。这个拐点就是调用scan_engine()现在源码里也叫scan_engine()的那一行。我当初为了找到这个调用点在函数列表里翻了好久。它不像参数解析那样有非常直观的名字而是一个接收目标组、输出对象、端口列表等一堆参数的复杂调用。大致长这样if (scan_engine(targets, o, portspec, output, ...) ! 0) { // 处理扫描引擎异常 }这里我特别想提醒二次开发者的一个点是scan_engine()返回后目标列表和端口列表可能已经被修改过了。扫描引擎内部会消费掉部分状态比如标记哪些主机已经探测完毕。所以不要把targets当成一个只读的输入参数更不要尝试在扫描结束后直接复用这个列表来发起第二次扫描。想复扫的话一定要在调用前深度拷贝一份。4. 从 scan_engine 返回后的收尾结果整理、退出码与二次开发 hook 点4.1 扫描结果的回填与输出刷新当scan_engine()返回时绝大部分的网络探测活动已经结束但 nmap_main 的收尾工作也不少。最明显的一块是结果整理扫描引擎在运行过程中会不断更新每个 host 的状态比如端口是否开放、操作系统指纹识别到哪个阶段、检测到的服务版本是什么。这些数据存放在目标对象的内部字段里但输出模块需要以某种格式把它们呈现出来。这块我推荐你去读一下 Nmap 的输出模块。它不是简单地把数据 print 到 stdout而是维护了一个输出表达式系统同一份扫描数据可以按不同格式渲染成文本或 XML。nmap_main 在收尾阶段要做的就是遍历目标列表把每个 host 的最终结果依次交给输出模块落盘。我在二次开发自己的扫描工具时就借鉴了这套思路扫描引擎只负责产生结果不负责格式化格式化、持久化全部交给外层。这样业务逻辑和表现逻辑彻底分离后面想加 JSON 输出或数据库存储根本不用碰扫描核心代码。4.2 退出码的语义0、1、2 分别代表什么Nmap 的退出码不是简单的 0 成功、非 0 失败。源码里有一套约定我在完成整个调用链阅读之后又专门回来看了一遍发现很多使用者包括早期我自己都误读了退出码退出码含义典型场景0执行成功扫描完成且没有任何任务失败型错误1发生运行时错误无法解析目标、系统调用失败等2参数错误传入不存在的选项、参数格式写错注意这个约定跟操作系统里通用的0 成功、非 0 失败略有差异——Nmap 把参数错误单独拆成 2把运行时错误拆成 1。如果你写脚本去检查 Nmap 的执行结果一定要分辨清楚到底是参数写错了还是运行时出了问题否则排错方向会完全跑偏。我在一个自动巡检脚本里曾经直接把 Nmap 退出码当作布尔值用结果-p 1-1000写错成-p 1-时脚本只报失败完全没提示是参数问题排查了很久才回到命令行里发现语法错误。源码里实现退出码的方式也很直白nmap_main 返回一个整数值main 函数再把它直接作为return值交给操作系统。你可以在代码里搜E2BIG、EXIT_FAILURE这类常量就能看到不同错误路径对应的返回码。4.3 在 nmap_main 里做二次开发三个值得落地的 hook 点读这么长时间的 nmap_main如果不聊聊我到底能在哪里下手改代码那前面这些分析就显得太书卷气了。以我个人给几个内部巡检工具做过定制的经验nm_app 里最适合插入自定义逻辑的是这三个位置第一参数解析完成后。此时Options已经就位目标列表也建好了但你还没开始碰网络栈。如果你想加一个自定义参数比如--my-timeout这里是最理想的解析位置。你只需要在 long_options 数组里增加一项然后在 switch 里加一个 case 分支即可完全不干扰后续流程。第二scan_engine 调用之前。这个位置适合做扫描前置动作比如记录扫描开始时间、初始化自己的告警计数器、加载外部指纹库。因为目标列表、端口列表都已经准备完毕你可以拿到所有的扫描范围信息做一些涉及全局的决策。第三scan_engine 返回之后、结果输出之前。这应该是改动价值最高的 hook 点。扫描结果已经全部产生你可以在这一层做二次加工过滤掉自己不关心的端口、把结果格式化成内部统一的 JSON、把数据写入消息队列。而且此时扫描引擎已退出你再怎么折腾输出代码都不会影响扫描过程的稳定性。我自己做的其中一个工具就是在第三个 hook 点加入了去重逻辑——内网里经常出现同一台机器通过多个 IP 被发现的情况我在这个位置根据 MAC 地址或者 OS 指纹把重复主机合并最终输出里就干净多了。说句真心话nmap_main 这个函数体量实在不小想一口气全部读懂是不可能的。我的经验是每隔一段时间就回来重读一遍每读一次都会注意到上次忽略的分支。尤其是那些处在错误路径上的代码——平时根本执行不到但一旦你的运行环境稍微特殊一点它们就是救命的稻草。读源码不要求快要求深要求带着问题去读。这篇把核心机制拆完下一篇我打算深入讲 Nmap 的定时器系统那是控制整个扫描节奏的大脑也是很多性能问题真正的根源所在。
返回列表