Nmap 核心技术深度解析:从源码到实战
本文深入解析 Nmap 网络扫描工具的核心技术原理,涵盖网络编程基础、扫描算法、源码实现等多个维度,帮助读者全面理解 Nmap 的工作机制。
目录
- 网络编程核心概念解析
- [Nmap 恢复扫描机制](#Nmap 恢复扫描机制)
- 端口映射与随机化算法
- 扫描分组与目标管理
- [SYN 扫描技术详解](#SYN 扫描技术详解)
- [Nmap 主函数完整流程](#Nmap 主函数完整流程)
网络编程核心概念解析
1. spoofss 是什么?
核心定义
spoofss 是 spoof(欺骗) + ss(sockaddr_storage,存储IP地址的通用结构体)的缩写,翻译为**"伪造的源地址"**。本质是:你手动指定一个IP地址,强制让程序发数据包时用这个IP当"发送方地址",而不是系统默认的源地址。
实际场景
- 正常情况 :你网卡
eth0的IP是192.168.1.100,给192.168.1.50发包,源地址默认是192.168.1.100 - 用
spoofss:你指定spoofss = 192.168.1.200,发包时源IP就变成192.168.1.200(假装成另一台设备)
代码里的作用
c
if(spoofss!=NULL){
memcpy(&rnfo->srcaddr, spoofss, sizeof(rnfo->srcaddr)); // 用伪造的IP当源地址
assert(device!=NULL && device[0]!='\0'); // 伪造IP必须指定出口网卡(比如eth0)
}
关键注意 :
spoofss只是修改数据包里的源IP字段,不是真的改本机IP;公网伪造会被路由器丢弃,仅局域网有效。
2. sockaddr_in6 是什么?
核心定义
sockaddr_in6 是专门存储IPv6地址 的结构体(对应IPv4的sockaddr_in),是系统网络编程的标准结构体,定义在<netinet/in.h>里。
简化版结构(新手关注核心字段)
c
struct sockaddr_in6 {
sa_family_t sin6_family; // 地址族,固定为AF_INET6(标识这是IPv6)
in_port_t sin6_port; // 端口号(比如80、443)
uint32_t sin6_flowinfo; // 流信息(忽略,新手用不到)
struct in6_addr sin6_addr; // 核心:存储IPv6地址(比如fe80::1)
uint32_t sin6_scope_id; // 作用域ID(IPv6本地地址绑定的网卡索引,比如eth0对应2)
};
对比理解
| 结构体类型 | 存储内容 | 示例 |
|---|---|---|
sockaddr_in |
IPv4地址 | 192.168.1.100 |
sockaddr_in6 |
IPv6地址 | fe80::1%eth0 |
sockaddr_storage |
通用结构体 | 兼容IPv4/IPv6 |
3. 类型强制转换:const struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *) dst;
核心作用
这是类型强制转换 ,把通用的struct sockaddr_storage *dst(能存IPv4/IPv6)转成struct sockaddr_in6 *(仅存IPv6),目的是读取IPv6地址的专属字段(比如sin6_scope_id)。
分步拆解
dst:类型是const struct sockaddr_storage *,是个"通用IP容器",可能装IPv4也可能装IPv6- 代码先判断 :
dst->ss_family == AF_INET6(确认是IPv6地址),再转换 (struct sockaddr_in6 *) dst:告诉编译器"把dst指向的内存,按照sockaddr_in6的结构来解析"const struct sockaddr_in6 *sin6:定义一个只读指针,指向转换后的IPv6结构体,后续能通过sin6->sin6_scope_id读取网卡索引
例子
如果dst里存的是IPv6地址fe80::1%eth0,转换后:
sin6->sin6_addr:拿到fe80::1sin6->sin6_scope_id:拿到eth0对应的数字索引(比如2)
4. getInterfaceByName
核心定义
这是自定义的工具函数 (非系统标准函数),作用是:根据网卡名称(比如eth0)+ 地址族(IPv4/IPv6),返回该网卡的详细信息(存在interface_info结构体里)。
输入/输出
输入参数:
| 参数 | 作用 |
|---|---|
| device | 网卡名称(比如"eth0") |
| family | 地址族(AF_INET=IPv4,AF_INET6=IPv6) |
输出:
| 返回值 | 作用 |
|---|---|
| 指向interface_info的指针 | 包含网卡IP、子网掩码、是否启用、索引等 |
| 失败返回NULL | 没找到指定的网卡 |
代码里的作用
c
iface = getInterfaceByName(device, dst->ss_family);
if (!iface)
netutil_fatal("Could not find interface %s", device);
例子 :传入
device="eth0"、family=AF_INET,函数返回eth0的IPv4信息(比如IP=192.168.1.100,子网掩码=24);找不到eth0就直接终止程序。
5. getsysroutes
核心定义
这是跨平台的自定义函数 ,作用是:读取系统的路由表,返回所有路由规则(存在sys_route数组里)。
输入/输出
输入参数:
| 参数 | 作用 |
|---|---|
| numroutes | 输出参数,返回路由表条目数 |
| errstr | 输出参数,存储错误信息(比如"读取路由表失败") |
| errstr_len | errstr的缓冲区长度 |
输出:
| 返回值 | 作用 |
|---|---|
| 指向sys_route数组的指针 | 每个sys_route包含:目标网段、子网掩码、网关、出口网卡等 |
| 失败返回NULL | 读取路由表失败 |
关键特性
- 跨平台 :Linux读
/proc/net/route,Windows读注册表/WMI,macOS读route -n的输出 - 排序 :返回的路由表已按"精确路由优先"排序(比如
192.168.1.50/32优先于192.168.1.0/24)
代码里的作用
c
routes=getsysroutes(&numroutes, errstr, sizeof(errstr));
例子 :函数返回的
routes里会有一条规则:目标192.168.1.0/24,网关0.0.0.0,出口网卡eth0。
6. getinterfaces
核心定义
这是跨平台的自定义函数 ,作用是:读取本机所有网卡的信息,返回网卡列表(存在interface_info数组里)。
输入/输出
输入参数:
| 参数 | 作用 |
|---|---|
| numifaces | 输出参数,返回网卡总数 |
| errstr | 输出参数,存储错误信息 |
| errstr_len | errstr的缓冲区长度 |
输出:
| 返回值 | 作用 |
|---|---|
| 指向interface_info数组的指针 | 每个元素包含:网卡名称、IP、子网掩码、是否启用、是否回环等 |
| 失败返回NULL | 读取网卡信息失败 |
代码里的作用
c
ifaces=getinterfaces(&numifaces, errstr, sizeof(errstr));
例子 :函数返回的
ifaces里会有eth0的信息:名称=eth0,IP=192.168.1.100,子网掩码=24,状态=启用,类型=以太网。
7. find_loopback_iface
核心定义
这是自定义工具函数 ,作用是:从网卡列表(interface_info数组)里,找到"回环网卡"(lo/lo0)。
输入/输出
输入参数:
| 参数 | 作用 |
|---|---|
| ifaces | 网卡列表(getinterfaces返回的数组) |
| numifaces | 网卡总数 |
输出:
| 返回值 | 作用 |
|---|---|
| 指向回环网卡的interface_info指针 | 比如lo的信息 |
| 失败返回NULL | 系统里没有回环网卡(极少出现) |
代码里的作用
c
loopback = find_loopback_iface(ifaces, numifaces);
例子:函数会从网卡列表里找到lo网卡,返回它的信息(IP=127.0.0.1,类型=回环,状态=启用)。
总结:7个核心概念速查表
| 名称 | 一句话核心作用 |
|---|---|
| spoofss | 伪造发包的源IP,不用系统默认的 |
| sockaddr_in6 | 存储IPv6地址的标准结构体 |
| dst强制转换为sin6 | 从通用IP容器里提取IPv6专属字段(比如scope_id) |
| getInterfaceByName | 根据网卡名查网卡详细信息 |
| getsysroutes | 读取系统路由表,返回路由规则列表 |
| getinterfaces | 读取本机所有网卡,返回网卡信息列表 |
| find_loopback_iface | 从网卡列表里找回环网卡(lo/lo0) |
Nmap 恢复扫描机制
一句话讲明白
恢复扫描 = 继续上次没扫完、被中断、被暂停的扫描任务,接着往下扫,而不是从头重新开始。
为什么需要恢复扫描?
Nmap 经常用来扫大量IP、大网段、深度扫描(-A、-O、全端口),这些扫描非常慢,可能出现:
- 电脑死机 / 断电
- SSH 断开
- 网络断了
- 手动按 Ctrl+C 暂停
- 扫到一半超时
如果没有恢复功能 ,你就要从头再扫一遍 ,非常浪费时间。所以 Nmap 提供了 断点续扫(恢复扫描)。
Nmap 恢复扫描的核心:--resume
恢复扫描只有一个关键参数:
bash
nmap --resume 上次的输出文件.gnmap
它是怎么工作的?
- 你上一次扫描 必须用了 -oN/-oG/-oA 输出日志(特别是 -oG(grepable) 或 -oA)
- Nmap 会把已经扫完的 IP、端口、状态记录进去
- 恢复时,Nmap 读取这个日志
- 跳过已经扫完的主机
- 只继续扫没扫完、中断的、失败的主机
最简单的例子(一看就懂)
第1次扫描(正常扫,保存日志)
bash
nmap -A -p- 192.168.1.0/24 -oA scan_result
会生成 3 个文件:
scan_result.gnmap← 恢复必须用这个scan_result.nmapscan_result.xml
扫到一半你按 Ctrl+C 中断了
现在想继续扫,不重头来:
bash
nmap --resume scan_result.gnmap
Nmap 就会:
- 读取上次进度
- 跳过已经完成的 IP
- 继续扫剩下的 IP
恢复扫描的关键限制(必须知道)
- 必须使用 -oA 或 -oG 输出的 .gnmap 文件
- 只能恢复同一条命令、同一段目标
- 不能改参数(不能中途换端口、换扫描类型)
- 只恢复未完成的主机,不会重复扫已完成的
超通俗比喻
- 正常扫描 = 看电影从头看到尾
- 中断 = 电影看到一半突然关掉
- 恢复扫描 = 继续从上次关掉的地方继续播放,不从头开始
总结(最精简版)
Nmap 恢复扫描 = 断点续扫
- 用
--resume 文件.gnmap - 接着上次中断的地方继续扫
- 不重复扫已完成的主机
- 节省大量时间
端口映射与随机化算法
1. initializePortMap:端口映射初始化
代码整体功能
这段代码的核心作用是:为指定的网络协议(如TCP、UDP)初始化两个端口映射数组(正向映射和反向映射),建立"端口号 ↔ 索引值"的双向映射关系,方便后续快速查找。它要求传入的端口列表是已排序的,且每个协议只能初始化一次。
核心意义:用"空间换时间"
这个函数的核心意义是用"空间换时间"的设计思路,为端口操作提供极致的查询效率,这在网络编程(尤其是高性能、高频次端口处理的场景,比如网络扫描、防火墙、协议分析等)中至关重要。
解决"线性查找"的性能瓶颈
如果没有这个映射表,要判断一个端口是否在目标列表中、或找到它的位置,只能用循环遍历端口列表(线性查找):
cpp
// 低效的线性查找:时间复杂度 O(n)
int findPortIndex(u16 target_port, u16* ports, int portcount) {
for(int i=0; i<portcount; i++) {
if(ports[i] == target_port) return i;
}
return -1;
}
这种方式在端口数量多、查询频繁时(比如每秒数万次端口检查),性能会急剧下降(时间复杂度 O ( n ) O(n) O(n))。
而通过 initializePortMap 构建的映射表:
- 正向查询 (端口号 → 索引):直接通过数组下标访问
port_map[proto][port],时间复杂度 O ( 1 ) O(1) O(1) - 反向查询 (索引 → 端口号):直接访问
port_map_rev[proto][index],时间复杂度也是 O ( 1 ) O(1) O(1)
这是网络高性能编程中典型的"空间换时间"策略------虽然多占用了一点内存(比如TCP协议要分配 65536 个 u16,约 128KB),但换来的是查询效率的数量级提升。
核心实现逻辑
cpp
void PortList::initializePortMap(int protocol, u16 *ports, int portcount) {
int i;
int ports_max = (protocol == IPPROTO_IP) ? MAX_IPPROTONUM + 1 : 65536;
int proto = INPROTO2PORTLISTPROTO(protocol);
// 重复初始化检查
if (port_map[proto] != NULL || port_map_rev[proto] != NULL)
fatal("%s: portmap for protocol %i already initialized", __func__, protocol);
// 断言校验
assert(port_list_count[proto]==0);
// 内存分配
port_map[proto] = (u16 *) safe_zalloc(sizeof(u16) * ports_max);
port_map_rev[proto] = (u16 *) safe_zalloc(sizeof(u16) * portcount);
// 记录端口数量
port_list_count[proto] = portcount;
// 构建双向映射关系
for(i=0; i < portcount; i++) {
port_map[proto][ports[i]] = i;
port_map_rev[proto][i] = ports[i];
}
}
映射结构示例
若传入的端口是 [2,4,6],则:
port_map[proto][2] = 0、port_map[proto][4] = 1、port_map[proto][6] = 2port_map_rev[proto][0] = 2、port_map_rev[proto][1] = 4、port_map_rev[proto][2] = 6
2. shortfry:Fisher-Yates 洗牌算法
代码整体功能
这段函数实现了经典的 Fisher-Yates 洗牌算法(也叫 Knuth 洗牌算法) ,核心作用是:在原地(in-place)随机打乱一个 unsigned short 类型的数组,保证每个元素出现在任意位置的概率均等,且时间复杂度为 O(n),空间复杂度为 O(1)。
核心实现
c
void shortfry(unsigned short *arr, int num_elem) {
int num;
unsigned short tmp;
int i;
if (num_elem < 2)
return;
for (i = num_elem - 1; i > 0 ; i--) {
num = get_random_ushort() % (i + 1);
if (i == num)
continue;
tmp = arr[i];
arr[i] = arr[num];
arr[num] = tmp;
}
}
算法特点
| 特性 | 说明 |
|---|---|
| 公平性 | 每个元素被放到任意位置的概率完全相等(无偏洗牌) |
| 效率高 | 时间复杂度 O ( n ) O(n) O(n)(仅遍历数组一次),空间复杂度 O ( 1 ) O(1) O(1) |
| 无额外内存 | 不需要创建新数组,直接修改原数组,节省内存 |
执行示例
假设输入数组:arr = [1, 2, 3, 4],num_elem = 4
- 第一次循环 (i=3):随机数范围
[0,3],假设num=1→ 交换arr[3]和arr[1]→arr = [1,4,3,2] - 第二次循环 (i=2):随机数范围
[0,2],假设num=0→ 交换arr[2]和arr[0]→arr = [3,4,1,2] - 第三次循环 (i=1):随机数范围
[0,1],假设num=1→ 跳过交换 - 最终结果 :
[3,4,1,2](每次运行结果随机,但概率均等)
3. random_port_cheat:热门端口优先
代码整体功能
这个函数的核心作用是:在已完成端口随机化的基础上,把预定义的"热门TCP端口"优先移动到端口列表的最前面。这样做的目的是让网络扫描工具先扫描这些更可能开放的端口,从而加快扫描速度、提前获取有效结果,同时又不会让端口顺序完全固定(因为先做了随机化)。
核心实现
c
void random_port_cheat(u16 *ports, int portcount) {
int allportidx = 0;
int popportidx = 0;
int earlyreplidx = 0;
// 定义热门端口列表
u16 pop_ports[] = {
80, 23, 443, 21, 22, 25, 3389, 110, 445, 139,
143, 53, 135, 3306, 8080, 1723, 111, 995, 993, 5900,
1025, 587, 8888, 199, 1720,
113, 554, 256
};
int num_pop_ports = sizeof(pop_ports) / sizeof(u16);
// 核心遍历与交换逻辑
for(allportidx = 0; allportidx < portcount; allportidx++) {
for(popportidx = 0; popportidx < num_pop_ports; popportidx++) {
if (ports[allportidx] == pop_ports[popportidx]) {
if (allportidx != earlyreplidx) {
ports[allportidx] = ports[earlyreplidx];
ports[earlyreplidx] = pop_ports[popportidx];
}
earlyreplidx++;
break;
}
}
}
}
执行示例
假设调用前的端口列表(已随机化):
ports = [1000, 80, 2000, 22, 3000, 443, 4000]
portcount = 7
执行过程:
allportidx=1(端口80):匹配热门端口,earlyreplidx=0→ 交换 →ports = [80, 1000, 2000, 22, 3000, 443, 4000]allportidx=3(端口22):匹配热门端口,earlyreplidx=1→ 交换 →ports = [80, 22, 2000, 1000, 3000, 443, 4000]allportidx=5(端口443):匹配热门端口,earlyreplidx=2→ 交换 →ports = [80, 22, 443, 1000, 3000, 2000, 4000]
最终结果:热门端口80、22、443被移到列表前三位,其余端口顺序保留随机化后的状态。
设计目的
| 目的 | 说明 |
|---|---|
| 扫描效率优化 | 先扫描热门端口(如80、443、22),这些端口更可能开放,能更快得到扫描结果 |
| 兼顾随机性 | 要求调用前先做端口随机化,避免热门端口的顺序完全固定 |
| 无额外内存 | 原地修改数组,不占用额外内存空间 |
扫描分组与目标管理
1. load_exclude_file:排除列表加载
代码整体功能
这个函数的核心是:逐行读取排除列表文件中的地址/网段规则(如IP、CIDR、主机名等),验证规则的合法性后,将其添加到 excludelist 这个地址集合中,后续扫描时会跳过这些被排除的目标。
核心实现
c
int load_exclude_file(struct addrset *excludelist, FILE *fp) {
char host_spec[1024];
size_t n;
while ((n = read_host_from_file(fp, host_spec, sizeof(host_spec))) > 0) {
if (n >= sizeof(host_spec))
fatal("One of your exclude file specifications was too long to read (>= %u chars)", (unsigned int) sizeof(host_spec));
if (!addrset_add_spec(excludelist, host_spec, o.af(), 1)) {
fatal("Invalid address specification:");
}
}
return 1;
}
执行示例
假设排除列表文件 exclude.txt 内容:
# 注释行(会被 read_host_from_file 跳过)
192.168.1.1 # 单个IP
10.0.0.0/24 # CIDR网段
172.16.0.1-172.16.0.10 # 地址范围
example.com # 主机名
函数执行过程:
read_host_from_file跳过注释行,读取第一行有效内容192.168.1.1到host_spec- 检查长度(远小于1024),通过
addrset_add_spec解析192.168.1.1为IPv4地址,添加到excludelist- 依次读取并解析剩余行,全部添加到排除集合
- 文件读取完毕,返回1
2. determineScanGroupSize:动态计算扫描组大小
代码整体功能
该函数的核心作用是:结合扫描类型(TCP/UDP/SCTP)、已扫描主机数、端口数量、计时级别等因素,动态计算单次扫描的主机组大小,同时保证组大小在配置的最小/最大值范围内,且不超过剩余可扫描的主机数量。
核心实现
c
int determineScanGroupSize(int hosts_scanned_so_far,
const struct scan_lists *ports) {
int groupsize = 16; // 默认扫描组大小
if (o.UDPScan())
groupsize = 128; // UDP扫描的扫描组大小
else if (o.SCTPScan())
groupsize = 128; // SCTP扫描的扫描组大小
else if (o.TCPScan()) {
groupsize = MAX(1024 / (ports->tcp_count ? ports->tcp_count : 1), 64);
if (ports->tcp_count > 1000 && o.timing_level <= 4) {
int quickgroupsz = 4;
if (o.timing_level == 4)
quickgroupsz = 8;
if (hosts_scanned_so_far == 0)
groupsize = quickgroupsz;
else if (hosts_scanned_so_far == quickgroupsz &&
groupsize > quickgroupsz * 2)
groupsize -= quickgroupsz;
}
}
groupsize = box(o.minHostGroupSz(), o.maxHostGroupSz(), groupsize);
if (o.max_ips_to_scan && (o.max_ips_to_scan - hosts_scanned_so_far) < (unsigned int)groupsize)
groupsize = o.max_ips_to_scan - hosts_scanned_so_far;
return groupsize;
}
执行示例
示例1:TCP扫描,端口数=500,计时级别=3,已扫描数=0,最小组=64,最大组=1024,最大IP数=1000
- 默认组大小=16 → 进入TCP分支
- 基础计算:
1024/500=2→ MAX(2,64)=64 - 端口数500≤1000,不触发精细化调整
box(64,1024,64)→ 64- 剩余IP数=1000-0=1000 >64 → 不调整
- 返回64
示例2:TCP扫描,端口数=2000,计时级别=4,已扫描数=0,最小组=4,最大组=1024,最大IP数=1000
- 默认组大小=16 → 进入TCP分支
- 基础计算:
1024/2000=0→ MAX(0,64)=64 - 端口数>1000且计时级别≤4 → 触发精细化调整:
quickgroupsz=8(计时级别4)- 已扫描数=0 → 组大小=8
box(4,1024,8)→ 8- 剩余IP数=1000>8 → 不调整
- 返回8(首次扫描快速返回结果)
3. nexthost:按批次获取下一个目标
代码整体功能
该函数的核心作用是:从当前的主机扫描批次(hostbatch)中取出下一个待扫描的 Target 目标 ;如果当前批次的目标已全部取完,则先调用 refresh_hostbatch 刷新/填充新的批次,若刷新后仍无目标则返回 NULL,否则返回新批次的第一个目标。
核心实现
c
Target *nexthost(HostGroupState *hs, struct addrset *exclude_group,
const struct scan_lists *ports, int pingtype) {
if (hs->next_batch_no >= hs->current_batch_sz)
refresh_hostbatch(hs, exclude_group, ports, pingtype);
if (hs->next_batch_no >= hs->current_batch_sz)
return NULL;
return hs->hostbatch[hs->next_batch_no++];
}
执行流程示例
假设:
- 初始状态:
hostbatch=NULL,current_batch_sz=0,next_batch_no=0 determineScanGroupSize计算的批次大小=64- 排除集合
exclude_group包含 192.168.1.1 - 待扫描网段:192.168.1.0/24(共256个IP)
执行步骤:
-
第一次调用 nexthost:
next_batch_no(0) ≥ current_batch_sz(0)→ 触发refresh_hostbatchrefresh_hostbatch生成 64 个待扫描IP,填充到hostbatch- 设置
current_batch_sz=64,next_batch_no=0 - 返回
hostbatch[0],next_batch_no=1
-
第2~64次调用 nexthost:
- 依次返回
hostbatch[1]~hostbatch[63] - 第64次调用后,
next_batch_no=64
- 依次返回
-
第65次调用 nexthost:
next_batch_no(64) ≥ current_batch_sz(64)→ 触发refresh_hostbatch- 生成下一批 64 个IP
- 重置
current_batch_sz=64,next_batch_no=0 - 返回
hostbatch[0](新批次第一个目标)
4. next_batch_no:批次游标详解
核心定义
next_batch_no 直译是"下一个批次序号",更准确的含义是:当前扫描批次中,下一个要取出的目标在 hostbatch 数组中的下标(游标/指针)。
它的本质是一个整数计数器,核心作用是:
- 标记"已经取到了批次中的第几个目标"
- 控制从
hostbatch数组中有序、不重复地取出目标 - 作为"批次是否耗尽"的判断依据
关键属性
| 特性 | 说明 |
|---|---|
| 数据类型 | 通常为 int(整数) |
| 初始值 | 新批次生成后,会被重置为 0(指向批次第一个目标) |
| 递增规则 | 每成功取出一个目标,自增 1(next_batch_no++) |
| 核心判断逻辑 | next_batch_no >= current_batch_sz → 当前批次已耗尽,需要刷新新批次 |
| 归属 | 属于 HostGroupState 结构体,是扫描批次的核心状态变量 |
完整生命周期
调用 nexthost 次数 |
next_batch_no 取值 |
取出的目标 | 操作后 next_batch_no |
说明 |
|---|---|---|---|---|
| 1 | 0 | hostbatch[0] |
1 | 取第一个目标,游标+1 |
| 2 | 1 | hostbatch[1] |
2 | 取第二个目标,游标+1 |
| ... | ... | ... | ... | 依次取目标,游标持续递增 |
| 64 | 63 | hostbatch[63] |
64 | 取最后一个目标,游标+1 |
第65次调用 nexthost 时:
next_batch_no = 64,current_batch_sz = 64- 满足
next_batch_no >= current_batch_sz→ 触发refresh_hostbatch生成新批次 - 新批次生成后,
next_batch_no再次被重置为 0
超通俗比喻
简单来说,next_batch_no 就像你看书时夹的书签------标记"下一次要读哪一页",读完当前章节(批次)后,书签会重置到新章节(新批次)的第一页,直到整本书(所有目标)读完。
SYN 扫描技术详解
基本原理(TCP 三次握手简化版)
正常 TCP 连接:
- 客户端 → 服务器:SYN(请求连接)
- 服务器 → 客户端:SYN+ACK(同意连接)
- 客户端 → 服务器:ACK(确认,连接建立)
SYN 扫描只做前两步,不发第三步 ACK:
- Nmap 发:SYN 包到目标端口
- 目标回应:
- 端口开放 :回 SYN+ACK
- 端口关闭 :回 RST(重置)
- Nmap 收到后直接发 RST 断开,不建立完整会话
因此叫半开放,不会留下完整连接日志,比全连接扫描更隐蔽。
Nmap 中怎么用
默认情况下,root/管理员权限 执行 Nmap 时,自动使用 SYN 扫描:
bash
nmap 目标IP/域名
显式指定 SYN 扫描(推荐,明确意图):
bash
nmap -sS 目标IP
-sS= SYN scan(半开放扫描)
SYN 扫描的特点
优点
- 速度快:不建立完整连接,开销小
- 隐蔽:多数防火墙/IDS 只记录完整连接,不易告警
- 准确:能清晰区分开放/关闭/过滤端口
- 跨平台稳定:支持几乎所有 TCP 端口扫描场景
缺点
- 需要管理员/root 权限(Windows/Linux 都要)
- 无法绕过严格状态防火墙(会拦截异常 SYN)
- 不能用于 UDP 端口(UDP 无 SYN 机制)
与 TCP 全连接扫描(-sT)对比
| 方式 | 命令 | 特点 | 隐蔽性 | 权限 |
|---|---|---|---|---|
| SYN 扫描 | -sS |
半开放,只握手前两步,不建完整连接 | 高 | 需要 |
| 全连接扫描 | -sT |
完整三次握手,真正建立再断开 | 低 | 无需 |
适用场景
- 快速扫描大量端口
- 希望降低被防火墙/日志发现的概率
- 标准 TCP 端口探测(80、443、22、3389 等)
- 渗透测试中首选默认扫描方式
简单示例
扫描 192.168.1.1 的常见端口,使用 SYN 扫描:
bash
sudo nmap -sS 192.168.1.1
总结
SYN 扫描 = 不建立真实连接的 TCP 端口探测,是 Nmap 最标准、最实用的扫描方式。
Nmap 主函数完整流程
整体功能总结
nmap_main 是 Nmap 程序的唯一入口,负责统筹整个扫描生命周期:
- 环境初始化(系统检测、时区、终端、权限)
- 命令行参数解析(扫描类型、端口、目标、排除列表等)
- 扫描前准备(端口映射、随机化、排除列表加载、XML报告初始化)
- 核心扫描逻辑(主机分组、存活探测、端口扫描、OS/服务/脚本检测)
- 结果输出(XML/日志/终端)
- 资源释放(内存、文件句柄、套接字)
执行流程与模块拆解
整个函数按「初始化 → 准备 → 扫描 → 输出 → 收尾」分为 8 个核心阶段:
阶段1:环境与系统初始化
核心作用:适配不同操作系统,初始化基础运行环境,避免兼容性问题。
cpp
#ifdef LINUX
// 检测WSL环境并警告(WSL下网络栈不完整,扫描可能异常)
struct utsname uts;
if (!uname(&uts)) {
if (strstr(uts.release, "Microsoft") != NULL) {
error("Warning: %s may not work correctly on Windows Subsystem for Linux...", NMAP_NAME);
}
}
#endif
tzset(); // 初始化时区(保证时间显示正确)
now = time(NULL); // 获取当前时间戳
err = n_localtime(&now, &local_time); // 转换为本地时间(全局变量)
if (err) fatal("n_localtime failed: %s", strerror(err)); // 时间初始化失败则终止
if (argc < 2) { printusage(); exit(-1); } // 无参数 → 打印帮助并退出
Targets.reserve(100); // 预分配目标列表内存(减少扩容开销)
#ifdef WIN32
win_pre_init(); // Windows初始化(加载WinPcap、网卡适配)
#endif
parse_options(argc, argv); // 解析命令行参数(核心!填充全局配置对象o)
关键对象 :o 是全局配置对象,存储所有扫描参数(如 -sS/-p80/--exclude 等)。
阶段2:辅助功能预处理
核心作用:处理非核心但必要的前置任务(路由解析、接口列表、FTP弹跳)。
cpp
// --route-dst参数:解析目标路由信息(出口网卡、下一跳、源地址)
for (unsigned int i = 0; i < route_dst_hosts.size(); i++) {
rc = resolve(dst, 0, &ss, &sslen, o.af()); // 解析目标IP
if (!route_dst(&ss, &rnfo, o.device, o.SourceSockAddr())) {
printf("Can't route %s (%s).", dst, inet_ntop_ez(&ss, sslen));
}
}
if (delayed_options.iflist) { print_iflist(); exit(0); } // --iflist:打印网卡列表并退出
if (o.bouncescan) { // -b参数:FTP弹跳扫描,提前解析FTP服务器地址
rc = resolve(ftp.server_name, 0, &ss, &sslen, AF_INET);
if (rc != 0) fatal("Failed to resolve FTP bounce proxy...");
}
核心逻辑:优先处理「一次性辅助功能」(路由解析/网卡列表/FTP弹跳),避免干扰核心扫描流程。
阶段3:报告初始化
核心作用:初始化 XML 报告(Nmap 主要输出格式),记录扫描元信息(命令行、启动时间、版本)。
cpp
timep = time(NULL);
err = n_ctime(mytime, sizeof(mytime), &timep); // 格式化启动时间
if (!o.resuming) { // 非恢复扫描:生成完整XML头部
xml_start_document("nmaprun"); // 根标签 <nmaprun>
if (xslfname) { // 添加XSL样式表(让XML在浏览器中格式化显示)
xml_open_pi("xml-stylesheet");
xml_attribute("href", "%s", xslfname);
xml_close_pi();
}
// 记录扫描命令行(避免用户忘记参数)
xml_start_comment();
xml_write_escaped(" %s %s scan initiated %s as: %s ", NMAP_NAME, NMAP_VERSION, mytime, join_quoted(argv, argc).c_str());
xml_end_comment();
// 填充<nmaprun>核心属性(版本、启动时间、参数)
xml_open_start_tag("nmaprun");
xml_attribute("scanner", "nmap");
xml_attribute("args", "%s", join_quoted(argv, argc).c_str());
xml_attribute("start", "%lu", (unsigned long)timep);
xml_close_start_tag();
}
关键设计:区分「首次扫描」和「恢复扫描(--resume)」,恢复扫描仅打开标签,不重复生成头部。
阶段4:扫描参数校验与调试
核心作用:校验系统资源(套接字数)、输出调试信息、初始化端口映射。
cpp
// 信号处理:忽略SIGPIPE(避免写入关闭的套接字导致程序崩溃)
#if defined(HAVE_SIGNAL) && defined(SIGPIPE)
signal(SIGPIPE, SIG_IGN);
#endif
// 校验最大并行套接字数:系统限制 < 配置值 → 警告
if (o.max_parallelism && (i = max_sd()) > 0 && i < o.max_parallelism) {
error("WARNING: max_parallelism is %d, but your system says it might only give us %d sockets...", o.max_parallelism, i);
}
// 调试模式:打印时序参数(方便排查扫描速度/超时问题)
if (o.debugging) {
log_write(LOG_PLAIN, "--------------- Timing report ---------------\n");
log_write(LOG_PLAIN, " hostgroups: min %d, max %d\n", o.minHostGroupSz(), o.maxHostGroupSz());
log_write(LOG_PLAIN, " rtt-timeouts: init %d, min %d, max %d\n", o.initialRttTimeout(), o.minRttTimeout(), o.maxRttTimeout());
}
// 初始化端口→服务名映射(如80→http)
if (o.TCPScan()) PortList::initializePortMap(IPPROTO_TCP, ports.tcp_ports, ports.tcp_count);
if (o.UDPScan()) PortList::initializePortMap(IPPROTO_UDP, ports.udp_ports, ports.udp_count);
// 端口随机化(--randomize-ports):打乱端口顺序,提升扫描隐蔽性
if (o.randomize_ports) {
if (ports.tcp_count) shortfry(ports.tcp_ports, ports.tcp_count); // 随机打乱
if (ports.tcp_count) random_port_cheat(ports.tcp_ports, ports.tcp_count); // 常用端口前置
}
关键优化 :random_port_cheat 是 Nmap 性能优化点------常用端口(80/443/22)移到列表前部,优先扫描,提升效率。
阶段5:排除列表加载
核心作用 :加载 --exclude/--excludefile 指定的排除地址,避免扫描这些目标。
cpp
exclude_group = addrset_new(); // 创建空的排除地址集合
if (o.excludefd != NULL) { // 加载排除文件
load_exclude_file(exclude_group, o.excludefd);
fclose(o.excludefd);
}
if (o.exclude_spec != NULL) { // 加载排除字符串(如 --exclude 192.168.1.1)
load_exclude_string(exclude_group, o.exclude_spec);
}
if (o.debugging > 3) dumpExclude(exclude_group); // 调试:打印排除列表
核心数据结构 :addrset 是 Nmap 封装的地址集合,支持IP/网段快速匹配,避免扫描排除目标。
阶段6:Lua NSE脚本初始化
核心作用:初始化脚本环境,执行预扫描脚本。
cpp
#ifndef NOLUA
if (o.scriptupdatedb) {
o.max_ips_to_scan = o.numhosts_scanned; // --script-updatedb:仅更新脚本数据库,禁用扫描
}
if (o.servicescan) o.scriptversion = true; // 服务扫描自动开启脚本版本检测
if (o.scriptversion || o.script || o.scriptupdatedb) open_nse(); // 打开Lua解释器
if (o.script) {
script_scan_results = get_script_scan_results_obj();
script_scan(Targets, SCRIPT_PRE_SCAN); // 执行预扫描脚本
printscriptresults(script_scan_results, SCRIPT_PRE_SCAN); // 打印结果
}
#endif
关键逻辑 :--script-updatedb 会禁用扫描,仅更新脚本数据库,避免无效操作。
阶段7:核心扫描循环
核心作用:Nmap 最核心的逻辑------分组获取目标、执行扫描、输出结果。
cpp
// 初始化主机分组状态(控制批次大小、随机化)
HostGroupState hstate(o.ping_group_sz, o.randomize_hosts, o.generate_random_ips, o.max_ips_to_scan, argc, (const char**)argv);
do {
// 动态计算最优分组大小(根据已扫描数、端口配置)
ideal_scan_group_sz = determineScanGroupSize(o.numhosts_scanned, &ports);
// 填充目标列表:凑够ideal_scan_group_sz个目标
while (Targets.size() < ideal_scan_group_sz) {
currenths = nexthost(&hstate, exclude_group, &ports, o.pingtype); // 获取下一个目标
if (!currenths) break;
// 场景1:仅主机发现/列表扫描 → 直接输出,不加入扫描列表
if ((o.noportscan && !o.traceroute && !o.script) || o.listscan) {
// 输出主机信息 → 释放内存 → 继续
}
// 场景2:源地址伪造 → 绑定伪造IP
if (o.spoofsource) {
o.SourceSockAddr(&ss, &sslen);
currenths->setSourceSockAddr(&ss, sslen);
}
// 场景3:主机未存活 → 输出基础信息,释放内存
if (!(currenths->flags & HOST_UP)) { /* ... */ }
// 场景4:原始扫描(SYN/ACK等)→ 配置源地址/网卡
if (o.RawScan()) { /* ... */ }
Targets.push_back(currenths); // 加入待扫描列表
}
if (Targets.size() == 0) break; // 无目标 → 退出循环
// 执行端口扫描:不同扫描类型调用不同函数
if (!o.noportscan) {
if (o.synscan) ultra_scan(Targets, &ports, SYN_SCAN); // SYN扫描(核心!)
if (o.ackscan) ultra_scan(Targets, &ports, ACK_SCAN);
if (o.udpscan) ultra_scan(Targets, &ports, UDP_SCAN);
if (o.connectscan) ultra_scan(Targets, &ports, CONNECT_SCAN);
// 特殊扫描(空闲/FTP弹跳):仅支持单目标,需循环执行
if (o.idlescan) {
for (targetno = 0; targetno < Targets.size(); targetno++) {
idle_scan(Targets[targetno], ports.tcp_ports, ports.tcp_count, o.idleProxy, &ports);
}
}
if (o.servicescan) service_scan(Targets); // 服务版本扫描
}
if (o.osscan) { // OS检测
OSScan os_engine;
os_engine.os_scan(Targets);
}
if (o.traceroute) traceroute(Targets); // 路由追踪
// 输出扫描结果:遍历目标,输出XML/日志
for (targetno = 0; targetno < Targets.size(); targetno++) {
currenths = Targets[targetno];
if (currenths->timedOut(NULL)) { // 超时 → 标记并输出
xml_open_start_tag("host");
xml_attribute("timedout", "true");
// ... 输出基础信息
} else {
// 输出完整结果(IP、端口、OS、服务、脚本、路由)
xml_open_start_tag("host");
write_host_header(currenths); // IP/主机名
printportoutput(currenths, ¤ths->ports); // 端口
printosscanoutput(currenths); // OS
printserviceinfooutput(currenths); // 服务
// ... 其他信息
xml_end_tag();
}
}
// 释放当前分组内存,避免泄漏
while (!Targets.empty()) {
currenths = Targets.back();
delete currenths;
Targets.pop_back();
}
} while (!o.max_ips_to_scan || o.max_ips_to_scan > o.numhosts_scanned); // 循环至无目标/达上限
核心设计:
- 分组扫描:避免一次性扫描所有目标导致资源耗尽,动态计算分组大小
- 场景化处理:不同扫描类型(SYN/ACK/UDP)、不同目标状态(存活/未存活)做差异化处理
- 内存安全:每轮扫描后释放目标内存,避免内存泄漏
- 高性能 :
ultra_scan是批量高速扫描函数,支持大部分扫描类型
阶段8:扫描收尾与资源释放
核心作用:执行后扫描脚本,释放所有资源,输出最终摘要。
cpp
#ifndef NOLUA
if (o.script) {
script_scan(Targets, SCRIPT_POST_SCAN); // 执行后扫描脚本
printscriptresults(script_scan_results, SCRIPT_POST_SCAN);
}
#endif
// 释放资源
addrset_free(exclude_group); // 排除列表
if (o.inputfd != NULL) fclose(o.inputfd); // 输入文件
printdatafilepaths(); // 打印数据文件路径(如nmap-services)
printfinaloutput(); // 打印最终摘要(存活主机数、开放端口数)
free_scan_lists(&ports); // 端口列表
eth_close_cached(); // 以太网套接字
if (o.release_memory) nmap_free_mem(); // 全局内存
return 0; // 正常退出
关键收尾 :printfinaloutput() 是用户最直观的输出------汇总扫描结果(如 Nmap scan report for 192.168.1.1 (192.168.1.1))。
总结
核心流程
nmap_main 遵循「初始化 → 预处理 → 准备 → 扫描 → 输出 → 收尾」的全生命周期,每个阶段职责明确,兼顾兼容性、性能和可维护性。
关键设计
- 分组扫描:动态计算批次大小,平衡效率与资源占用
- 场景化处理:不同扫描类型(SYN/ACK/UDP)、目标状态(存活/未存活)做差异化逻辑
- 资源安全:每轮扫描后释放内存,忽略SIGPIPE信号,避免崩溃
- 可调试性:调试模式输出时序/排除列表等信息,方便问题排查
核心对象
o:全局配置对象,存储所有扫描参数Targets:当前批次待扫描目标列表HostGroupState:管理目标迭代和分组状态PortList:端口→服务名映射,支撑端口扫描结果解析
这段代码是 Nmap 架构的核心体现,既保证了高性能(批量扫描、端口随机化),又兼顾了灵活性(适配不同扫描类型、操作系统)和易用性(详细的日志/XML输出)。
结语
通过本文的深度解析,我们全面了解了 Nmap 的核心技术原理,从底层的网络编程概念到高层的扫描算法实现,从单个函数的细节到整个系统的架构设计。这些知识不仅帮助我们更好地使用 Nmap,也为理解其他网络扫描工具和开发自己的网络工具奠定了坚实的基础。
希望本文能够为读者提供有价值的参考,如有任何问题或建议,欢迎交流讨论。