Nmap OS扫描核心技术深度解析:从循环迭代器到随机数生成
目录
- 循环迭代器:高效遍历未完成主机列表
- 执行与状态分离:工业级设计模式
- 全局静态变量:性能统计的核心
- 拥塞控制结构体:扫描速率的智能调节器
- cwnd翻倍机制:慢启动阶段的核心特征
- 初始化方法:动态配置拥塞控制参数
- TCP拥塞控制四大算法:网络传输的基石
- MSS概念:TCP报文的最大数据长度
- 时间计算函数:扫描耗时的精确计量
- 抓包器初始化:构建高性能抓包环境
- 字符串安全拼接:Snprintf详解
- 跨平台接口名转换:Windows平台的特殊处理
- 随机数生成:加密级安全随机源
1. 循环迭代器:高效遍历未完成主机列表
1.1 核心概念
在Nmap的OS扫描代码中,std::list<HostOsScanInfo *>::iterator nextI 这个"循环迭代器"并非C++标准库的原生概念,而是基于标准库迭代器实现的业务逻辑层面的功能。
本质特征:
nextI是std::list的普通迭代器,用于遍历incompleteHosts链表容器- "循环"是指:当迭代器遍历到链表末尾(
end())时,会自动回到链表开头(begin()),实现循环遍历未完成扫描的主机列表
1.2 核心方法实现
cpp
// 重置迭代器到链表开头
void OsScanInfo::resetHostIterator() {
nextI = incompleteHosts.begin();
}
// 获取下一个未完成主机(核心:循环遍历)
HostOsScanInfo *OsScanInfo::nextIncompleteHost() {
// 如果链表为空,直接返回空
if (incompleteHosts.empty()) {
return nullptr;
}
// 保存当前迭代器指向的主机
HostOsScanInfo *host = *nextI;
// 迭代器后移
++nextI;
// 关键:如果迭代器到末尾,回到开头(实现"循环")
if (nextI == incompleteHosts.end()) {
nextI = incompleteHosts.begin();
}
return host;
}
1.3 使用场景与价值
在OS扫描类中,循环迭代器的核心价值体现在:
- 轮询未完成主机:扫描任务通常需要轮询未完成的主机(比如多线程扫描时,每个线程从列表中取一个主机扫描)
- 避免重复遍历:避免每次遍历都从头开始,而是"接力式"遍历,提升扫描效率
- 动态适应 :即使链表中的主机被移除(
removeCompletedHosts()),迭代器仍能通过重置/重新遍历保持逻辑正确
1.4 设计优势总结
| 特性 | 优势 |
|---|---|
| 私有化nextI | 避免外部直接修改迭代器,保证遍历逻辑的安全性和一致性 |
| 循环机制 | 实现对未完成主机的循环、无遗漏遍历 |
| 重置能力 | 通过resetHostIterator()保证遍历的起点可控 |
2. 执行与状态分离:工业级设计模式
2.1 设计理念对比
| 不好的设计(状态耦合) | 好的设计(执行与状态分离) |
|---|---|
| 类 = 扫描逻辑 + 扫描状态 | 类 = 纯扫描逻辑;状态单独存在 HostOsScanStats 中 |
一个 HostOsScan 实例只能处理一台主机的扫描 |
一个 HostOsScan 实例可以复用处理多台主机的扫描 |
| 状态存在类成员里,多线程/多主机场景易冲突 | 状态通过参数传递,每台主机的状态独立隔离 |
2.2 核心优势详解
2.2.1 无状态化设计,支持实例复用
不好的设计:
cpp
class HostOsScan {
private:
std::vector<Probe> probes; // 每个实例只能存一台主机的探测包
std::string targetIp; // 只绑一个IP
public:
HostOsScan(std::string ip) { targetIp = ip; }
void sendProbe() {
send_tcp_probe(probes[0], targetIp);
}
};
// 扫描100台主机,要创建100个实例,浪费!
HostOsScan scan1("192.168.1.1");
scan1.sendProbe();
HostOsScan scan2("192.168.1.2");
scan2.sendProbe();
好的设计:
cpp
// 专门存状态的类:只装数据,不干活
class HostOsScanStats {
public:
std::vector<Probe> probes; // 某台主机的探测包
std::string targetIp; // 某台主机的IP
};
// 通用引擎类:只干活,不存具体主机的状态
class HostOsScan {
public:
void sendProbe(HostOsScanStats *hss) {
send_tcp_probe(hss->probes[0], hss->targetIp);
}
};
// 只创建1个引擎,扫100台主机只需100个状态对象
HostOsScan scanEngine; // 通用引擎,只创建一次
HostOsScanStats host1;
host1.targetIp = "192.168.1.1";
host1.probes = {probe1, probe2};
scanEngine.sendProbe(&host1); // 扫主机1
HostOsScanStats host2;
host2.targetIp = "192.168.1.2";
host2.probes = {probe3, probe4};
scanEngine.sendProbe(&host2); // 扫主机2
2.2.2 状态隔离,避免并发问题
OS扫描通常是多线程/多进程的(同时扫描多台主机),状态耦合的设计会导致严重问题:
- 不好的设计 :如果多个线程共用一个
HostOsScan实例,会同时修改类内的probes成员,导致数据错乱 - 好的设计 :每台主机的状态(
HostOsScanStats)是独立的,即使多线程调用同一个HostOsScan实例的sendProbe(),只要传入不同的hss指针,状态就不会互相干扰
2.2.3 职责单一,代码易维护/扩展
软件工程的"单一职责原则"要求:一个类只做一件事。
- 不好的设计 :
HostOsScan既负责"怎么扫描(构建包、发送、处理响应)",又负责"存扫描数据(probes、响应结果)" - 好的设计 :
HostOsScan只专注于"扫描逻辑的执行"HostOsScanStats只专注于"存储单主机的扫描状态"
2.2.4 状态可灵活管理(持久化/恢复)
扫描过程中可能需要暂停、恢复、保存扫描进度:
- 不好的设计 :状态和执行逻辑绑死在
HostOsScan实例里,要保存进度需要从类的各个成员中提取数据 - 好的设计 :所有状态都集中在
HostOsScanStats中,只需序列化/反序列化这个对象,就能轻松实现扫描进度的保存和恢复
2.3 生活化类比
把 HostOsScan(扫描执行引擎)和"状态"的关系,类比成 "外卖骑手"和"订单信息":
HostOsScan= 外卖骑手(核心能力是"会送外卖")- 扫描状态(probes/响应/速率等)= 订单信息(收货地址、菜品、配送时间、备注等)
- 多台待扫描主机 = 多个外卖订单
不好的设计 :给每个订单配一个专属骑手,且这个骑手脑子里只记这一个订单的信息
好的设计:一个骑手可以送多个订单,每个订单的信息写在"订单小票"(HostOsScanStats)上,骑手送哪个订单就拿对应的小票
3. 全局静态变量:性能统计的核心
3.1 代码解析
cpp
static struct scan_performance_vars perf;
| 关键字/标识符 | 含义 |
|---|---|
static |
静态变量,限制变量的作用域 (只在当前文件可见),且生命周期贯穿整个程序运行期 |
struct scan_performance_vars |
自定义的结构体类型,专门用来封装"扫描性能相关的所有数据" |
perf |
变量名(performance 的缩写),是这个结构体的实例 |
简单说:这行代码定义了一个只在当前文件可见、全程存在的"扫描性能统计账本"。
3.2 结构体定义
cpp
struct scan_performance_vars {
// 速率相关
int total_probes_sent; // 累计发送的探测包总数
int total_probes_recv; // 累计接收的响应包总数
float send_rate; // 当前发送速率(包/秒)
float recv_rate; // 当前接收速率(包/秒)
// 丢包/重试相关
int lost_probes; // 丢失的探测包数(发了没回应)
int retry_count; // 重试次数
float loss_rate; // 丢包率(lost/total_sent)
// 耗时相关
float avg_round_trip_time; // 平均往返时延(RTT)
int max_concurrent_probes; // 最大并发探测数
// 状态标记
bool is_rate_limited; // 是否触发了速率限制
time_t last_update_time; // 最后一次更新统计的时间
};
3.3 核心作用
3.3.1 统一收集"全量扫描"的性能数据(全局视角)
HostOsScanStats是单主机的状态(比如"给192.168.1.1发了5个包")perf是整个扫描任务的全局统计(比如"总共扫了100台主机,发了1000个包,丢了20个,平均速率50包/秒")
生活化类比:
HostOsScanStats= 每个外卖订单的配送记录("订单A送了30分钟,距离2公里")perf= 骑手一天的整体业绩("今天送了50单,总里程80公里,平均配送时间25分钟,超时2单")
3.3.2 static关键字的关键作用
- 不加static:这个变量是"全局可见"的,其他文件如果也定义了同名变量会冲突,且容易被误修改
- 加static :
- ✅ 作用域只在当前
.cpp文件,其他文件看不到,避免命名冲突 - ✅ 生命周期是"程序启动到退出",统计数据不会中途丢失
- ✅ 作用域只在当前
3.4 实际使用示例
cpp
// 发送探测包时,更新统计
void send_tcp_probe(...) {
// 发送逻辑...
// 更新全局性能统计
perf.total_probes_sent++; // 发送数+1
perf.send_rate = calc_send_rate(); // 重新计算发送速率
perf.last_update_time = time(NULL);// 更新时间戳
}
// 接收响应时,更新统计
void processResp(...) {
// 响应处理逻辑...
perf.total_probes_recv++; // 接收数+1
perf.recv_rate = calc_recv_rate(); // 重新计算接收速率
perf.loss_rate = (float)perf.lost_probes / perf.total_probes_sent; // 计算丢包率
}
// 提供读取统计的接口(给外部用,比如打印扫描报告)
struct scan_performance_vars get_scan_perf_stats() {
return perf; // 返回当前的性能统计结果
}
4. 拥塞控制结构体:扫描速率的智能调节器
4.1 核心定位
scan_performance_vars 结构体不是单纯的"统计账本" ,而是 OS 扫描中拥塞控制(Congestion Control)的核心配置/状态结构体 ------ 本质是"扫描速率的智能调节器",用来控制探测包发送的快慢、并发量,避免网络拥塞或被目标主机拉黑。
4.2 拥塞窗口(cwnd)核心概念
拥塞窗口(cwnd):简单说就是"当前允许同时发出去但还没收到响应的探测包最大数量" ------ cwnd 越大,并发越高、扫描越快;cwnd 越小,并发越低、越保守。
4.3 逐字段解释
4.3.1 拥塞窗口(cwnd)的核心阈值(基础配置)
| 字段名 | 中文含义 | 通俗解释 + 扫描场景作用 |
|---|---|---|
low_cwnd |
最小拥塞窗口 | 哪怕网络再差,也至少允许同时发这么多探测包(比如设为 2),保证扫描不会完全停摆 |
host_initial_cwnd |
单主机初始拥塞窗口 | 对每台新主机开始扫描时,初始允许并发的探测包数(比如设为 5),相当于"给单主机的初始油门" |
group_initial_cwnd |
全局初始拥塞窗口 | 对所有扫描主机的总并发上限(比如设为 100),避免整体发太多包压垮网络 |
max_cwnd |
最大拥塞窗口 | 无论网络多好,单主机/全局的 cwnd 都不能超过这个数(比如设为 20),防止过度并发 |
4.3.2 cwnd 的增长规则("踩油门"的节奏)
| 字段名 | 中文含义 | 通俗解释 + 扫描场景作用 |
|---|---|---|
slow_incr |
慢启动模式增量 | 「慢启动」是扫描初期的模式,每收到一个响应,cwnd 就加这么多(比如设为 1)------ 初期慢慢提速,避免一开始就发太多 |
ca_incr |
拥塞避免模式增量 | 当 cwnd 超过阈值后进入「拥塞避免」模式,每经过一个 RTT(往返时延),cwnd 加这么多(比如设为 0.5)------ 平稳提速,防止拥塞 |
cc_scale_max |
cwnd 增量的最大缩放因子 | 限制 cwnd 增长的"上限倍数"(比如设为 4),避免某些极端情况下 cwnd 疯长 |
initial_ssthresh |
初始慢启动阈值 | cwnd 超过这个值,就从「慢启动」切换到「拥塞避免」模式(比如设为 10)------ 相当于"提速到一定程度就换平稳模式" |
4.3.3 cwnd 的下降规则("踩刹车"的节奏)
| 字段名 | 中文含义 | 通俗解释 + 扫描场景作用 |
|---|---|---|
group_drop_cwnd_divisor |
全局丢包时 cwnd 除数 | 只要有任何一个包丢了,全局 cwnd 就除以这个数(比如设为 2)------ 全局降速,比如 cwnd=100 → 丢包后变成 50 |
group_drop_ssthresh_divisor |
全局丢包时慢启动阈值除数 | 同时降低全局的慢启动阈值(比如设为 2),让后续更难进入"快提速"的慢启动模式,更保守 |
host_drop_ssthresh_divisor |
单主机丢包时慢启动阈值除数 | 某台主机丢包时,单独降低这台主机的慢启动阈值(比如设为 2)------ 只针对有问题的主机降速,不影响其他主机 |
4.4 实际扫描示例
假设配置:host_initial_cwnd=5、slow_incr=1、initial_ssthresh=10、ca_incr=0.5、group_drop_cwnd_divisor=2
扫描流程:
- 开始扫描某台主机 → 初始 cwnd=5(host_initial_cwnd),进入「慢启动」模式
- 每收到1个响应 → cwnd +=1(slow_incr)→ 5→6→7→...→10
- cwnd 到 10(initial_ssthresh)→ 切换到「拥塞避免」模式
- 每经过1个 RTT → cwnd +=0.5(ca_incr)→ 10→10.5→11→...→max_cwnd(20)就不再涨
- 如果出现丢包 → 全局 cwnd = 全局 cwnd / 2(group_drop_cwnd_divisor)→ 比如从 100 降到 50,整体降速
- 丢包后再恢复 → 从 low_cwnd(2)重新慢慢涨,避免再次拥塞
5. cwnd翻倍机制:慢启动阶段的核心特征
5.1 核心结论
cwnd 只在「慢启动阶段」才会快速翻倍,进入「拥塞避免阶段」后就不再翻倍,而是缓慢增长。
5.2 慢启动阶段定义
慢启动是 TCP 拥塞控制(也是扫描工具借鉴的核心逻辑)的第一个阶段,核心特点是:
网络状态未知时,cwnd 以"指数级"增长(比如 1→2→4→8→16...),快速试探网络的承载能力,直到触发"慢启动阈值(ssthresh)"。
5.3 cwnd 翻倍的具体条件
5.3.1 标准 TCP 慢启动:每收到一个 ACK,cwnd 加 1 → 一轮 RTT 后翻倍
TCP 场景:
- 初始 cwnd=1,发送 1 个报文段 → 收到 1 个 ACK(响应)→ cwnd 变成 2
- 接着发送 2 个报文段 → 收到 2 个 ACK → cwnd 变成 4
- 一轮 RTT(往返时延)内,发出去的包都收到响应,cwnd 就从 1→2→4→8... 指数级翻倍
扫描场景(适配探测包):
- 初始 cwnd =
host_initial_cwnd(比如设为 2) - 每收到 1 个探测包的响应 → cwnd +=
slow_incr(通常设为 1) - 一轮 RTT 内,所有发出去的探测包都收到响应 → cwnd 翻倍(比如 2→4→8...)
5.3.2 扫描工具的优化:按"响应数/时间"翻倍(更灵活)
| 触发条件 | 例子(结合结构体) |
|---|---|
| 累计收到 N 个响应 | N = 当前 cwnd 值 → 比如 cwnd=5,收到 5 个响应 → cwnd 翻倍到 10(slow_incr=1 时刚好触发) |
| 一轮 RTT 无丢包 | 只要一轮 RTT 内发出去的探测包都收到响应,不管数量多少,直接翻倍(简化逻辑) |
| 未触发速率限制 | 只要 perf.is_rate_limited 为 false(无拥塞),每秒钟检查一次,cwnd 翻倍(扫描特有的时间驱动逻辑) |
5.4 代码实现逻辑
cpp
// 单主机的拥塞窗口状态(存在 HostOsScanStats 里)
struct HostOsScanStats {
int current_cwnd; // 当前cwnd
int ssthresh; // 该主机的慢启动阈值(初始=perf.initial_ssthresh)
int resp_received; // 本轮已收到的响应数
};
// 收到一个探测包响应时,更新cwnd
void on_probe_response(HostOsScanStats *hss) {
// 1. 只在慢启动阶段(current_cwnd < ssthresh)才会触发翻倍逻辑
if (hss->current_cwnd < hss->ssthresh) {
// 2. 每收到1个响应,cwnd += slow_incr(=1)
hss->current_cwnd += perf.slow_incr;
hss->resp_received++;
// 3. 本轮响应数 == 当前cwnd → 触发翻倍(简化逻辑)
if (hss->resp_received == hss->current_cwnd) {
hss->current_cwnd *= 2; // cwnd翻倍
hss->resp_received = 0; // 重置响应计数
printf("cwnd 翻倍:%d → %d\n", hss->current_cwnd/2, hss->current_cwnd);
}
// 4. 不能超过max_cwnd(上限)
if (hss->current_cwnd > perf.max_cwnd) {
hss->current_cwnd = perf.max_cwnd;
}
} else {
// 拥塞避免阶段:不再翻倍,每次只加 ca_incr(比如0.5)
hss->current_cwnd += perf.ca_incr;
}
}
5.5 cwnd 停止翻倍的条件
翻倍不是无限的,满足以下任一条件就会停止:
- 达到慢启动阈值 :
current_cwnd >= ssthresh(initial_ssthresh)→ 切换到拥塞避免阶段,不再翻倍 - 达到最大cwnd :
current_cwnd >= max_cwnd→ 无论如何都不涨了 - 出现丢包/拥塞 :只要有探测包丢包 → 触发
group_drop_cwnd_divisor/host_drop_ssthresh_divisor,cwnd 直接减半(比如8→4),重新从慢启动开始 - 触发速率限制 :
is_rate_limited = true→ 强制停止增长,甚至降低cwnd
6. 初始化方法:动态配置拥塞控制参数
6.1 核心前置知识
在看代码前,先搞懂两个关键变量:
o:全局的NmapOps结构体实例,存储用户输入的扫描配置(比如-T4时序等级、--min-parallelism最小并发、--max-parallelism最大并发)o.timing_level:Nmap 的时序等级(-T0~-T5),数字越大扫描越激进(越快),是这段代码中最核心的控制变量
6.2 逐行拆解代码
cpp
void scan_performance_vars::init() {
/* TODO: 后续要优化这些值,至少要受 -T 时序参数影响 */
// 1. 初始化最小拥塞窗口 low_cwnd
low_cwnd = o.min_parallelism ? o.min_parallelism : 1;
/* 逻辑:
- 如果用户通过 --min-parallelism 设置了最小并发数 → low_cwnd 用这个值;
- 否则默认 1(哪怕网络再差,至少允许1个并发探测包);
- 作用:保证扫描不会完全停摆,是速率的"底线"。
*/
// 2. 初始化最大拥塞窗口 max_cwnd
max_cwnd = MAX(low_cwnd, o.max_parallelism ? o.max_parallelism : 300);
/* 逻辑:
- MAX() 是取最大值的宏;
- 优先用用户设置的 --max-parallelism,否则默认 300;
- 但必须 ≥ low_cwnd(比如用户设 min=5、max=3 → 最终 max_cwnd=5,避免矛盾);
- 作用:限制并发的"上限",防止过度发包导致网络拥塞。
*/
// 3. 初始化全局初始拥塞窗口 group_initial_cwnd
group_initial_cwnd = box(low_cwnd, max_cwnd, 10);
/* 逻辑:
- box(a, b, c) 是 Nmap 自定义宏 → 把 c 限制在 [a, b] 区间内(即 max(a, min(b, c)));
- 这里是把初始全局 cwnd 设为 10,但不能小于 low_cwnd、不能大于 max_cwnd;
- 例子:如果 low_cwnd=5、max_cwnd=20 → group_initial_cwnd=10;
如果 low_cwnd=15、max_cwnd=20 → group_initial_cwnd=15(不能小于low);
- 作用:给所有主机的总并发设置一个"初始值",平衡保守和激进。
*/
// 4. 初始化单主机初始拥塞窗口 host_initial_cwnd
host_initial_cwnd = group_initial_cwnd;
/* 逻辑:
- 单主机的初始 cwnd 和全局初始值一致(简化设计);
- 后续可根据单主机的丢包/响应情况单独调整,不影响全局;
- 作用:单主机扫描的"初始油门",和全局保持一致避免局部过载。
*/
// 5. 初始化慢启动增量 slow_incr
slow_incr = 1;
/* 逻辑:
- 慢启动阶段,每收到1个响应,cwnd 加1(固定值);
- 这是标准 TCP 慢启动的经典配置,保证指数级增长(翻倍)的基础;
- 作用:控制慢启动的"提速步长"。
*/
// 6. 初始化拥塞避免增量 ca_incr(受时序等级影响)
/* 拥塞窗口在激进时序下增长更快 */
if (o.timing_level < 4)
ca_incr = 1;
else
ca_incr = 2;
/* 逻辑:
- o.timing_level(-T0~-T5):<4 是保守/普通模式(-T0~T3),≥4 是激进模式(-T4/T5);
- 保守模式:拥塞避免阶段 cwnd 每次加1(平稳增长);
- 激进模式:每次加2(更快增长,适配快速扫描需求);
- 作用:时序越激进,拥塞避免阶段的提速越快。
*/
// 7. 初始化 cwnd 增量最大缩放因子 cc_scale_max
cc_scale_max = 50;
/* 逻辑:
- 限制 cwnd 增量的"最大倍数"(比如本来该加1,缩放后最多加50);
- 防止极端情况下 cwnd 疯长(比如网络突然变通畅);
- 作用:给增量加"天花板",避免失控。
*/
// 8. 初始化初始慢启动阈值 initial_ssthresh
initial_ssthresh = 75;
/* 逻辑:
- cwnd 超过75就从慢启动(翻倍增长)切换到拥塞避免(缓慢增长);
- 固定值,后续可根据丢包动态调整;
- 作用:慢启动和拥塞避免的"切换临界点"。
*/
// 9. 初始化全局丢包时 cwnd 除数 group_drop_cwnd_divisor
group_drop_cwnd_divisor = 2.0;
/* 逻辑:
- 只要有丢包,全局 cwnd 直接除以2(减半);
- 标准 TCP 拥塞控制的"快速恢复"策略,快速降速避免进一步拥塞;
- 作用:丢包时的"刹车力度"(全局)。
*/
// 10. 初始化 ssthresh 除数(受时序等级影响)
/* 根据时序等级调整 ssthresh 的下降幅度 */
double ssthresh_divisor;
if (o.timing_level <= 3)
ssthresh_divisor = (3.0 / 2.0); // 1.5
else if (o.timing_level <= 4)
ssthresh_divisor = (4.0 / 3.0); // ~1.333
else
ssthresh_divisor = (5.0 / 4.0); // 1.25
group_drop_ssthresh_divisor = ssthresh_divisor;
host_drop_ssthresh_divisor = ssthresh_divisor;
/* 逻辑:
- 时序越保守(≤3),ssthresh 下降越多(除以1.5)→ 更谨慎,后续难进入慢启动;
- 时序越激进(≥5),ssthresh 下降越少(除以1.25)→ 更大胆,快速恢复提速;
- 例子:ssthresh=75,保守模式丢包后 → 75/1.5=50;激进模式 → 75/1.25=60;
- 作用:丢包后调整"慢启动阈值",时序越激进,恢复越快。
*/
}
6.3 实际场景示例
假设用户执行扫描命令:nmap -T4 --min-parallelism=2 --max-parallelism=200 192.168.1.0/24
o.timing_level=4、o.min_parallelism=2、o.max_parallelism=200- 初始化后关键参数值:
| 参数名 | 计算过程 | 最终值 |
|---|---|---|
| low_cwnd | o.min_parallelism=2 | 2 |
| max_cwnd | MAX(2, 200) | 200 |
| group_initial_cwnd | box(2, 200, 10) | 10 |
| host_initial_cwnd | = group_initial_cwnd | 10 |
| slow_incr | 固定1 | 1 |
| ca_incr | o.timing_level=4 → 2 | 2 |
| initial_ssthresh | 固定75 | 75 |
| group_drop_cwnd_divisor | 固定2.0 | 2.0 |
| ssthresh_divisor | o.timing_level=4 → 4/3 | ~1.333 |
扫描时的表现:
- 初始全局 cwnd=10,单主机 cwnd=10
- 慢启动阶段:每收到1个响应,cwnd+1 → 快速翻倍到75
- 拥塞避免阶段:cwnd 每次+2(比保守模式快)
- 丢包时:全局 cwnd 减半(10→5),ssthresh 从75→56.25(75/1.333),恢复更快
7. TCP拥塞控制四大算法:网络传输的基石
7.1 慢启动 Slow Start
- 刚建连时 :
cwnd = 1 MSS - 规则 :每收到一个 ACK,cwnd += 1
- 效果 :一个 RTT 后 cwnd 翻倍 ,呈指数增长
- 直到 :
cwnd >= ssthresh,进入拥塞避免
目的:慢慢试探网络,不一开始就打满。
7.2 拥塞避免 Congestion Avoidance
- 规则 :每个 RTT,cwnd 只 +1
- 效果 :线性缓慢增长
- 一直增长到 :出现丢包/超时
加法增大 AI(Additive Increase)
7.3 快速重传 Fast Retransmit
- 收到 3 个重复 ACK → 判定丢包
- 不等超时,直接重传丢失的包
- 比超时重传更快、更平滑
7.4 快速恢复 Fast Recovery(配合快速重传)
收到 3 个重复 ACK 时:
ssthresh = cwnd / 2cwnd = cwnd / 2- 直接进入拥塞避免,不再回到 cwnd=1
乘法减小 MD(Multiplicative Decrease)
7.5 超时重传(最严厉)
一旦定时器超时:
ssthresh = cwnd / 2cwnd = 1- 重新走慢启动
网络拥塞严重,直接"跪到底"。
7.6 一句话串起整个流程
- 刚开始:慢启动(指数涨)
- 过阈值:拥塞避免(线性涨)
- 丢包但收到 3 个重复 ACK:快速重传 + 快速恢复
- 直接超时:窗口砍半 + 重置为1,重走慢启动
7.7 最经典口诀
慢启动翻倍,拥塞避免加一
三次ACK快重传,快速恢复不回一
一旦超时全重来,窗口直接降到一
8. MSS概念:TCP报文的最大数据长度
8.1 一句话定义
MSS = Maximum Segment Size
= 一个 TCP 段里,纯数据部分的最大字节数。
公式:
MSS ≈ MTU - IP头(20) - TCP头(20)
日常以太网里:
- MTU = 1500
- MSS ≈ 1460 字节
8.2 用最通俗的比喻
- 一辆货车:整个 IP 包
- 车头:IP 头 + TCP 头
- 车厢里的货:TCP 数据
MSS = 车厢最多能装多少货
8.3 和拥塞控制的关系
在 TCP 拥塞控制里:
- cwnd(拥塞窗口)的单位经常就是 多少个 MSS
- 比如:
- cwnd = 1 → 一次最多发 1 个 MSS 的数据
- cwnd = 8 → 一次最多发 8 个 MSS 的数据
所以文章里说:
慢启动 cwnd 从 1 开始指数增长
这里的 1,就是 1 个 MSS。
8.4 简单记忆
MSS = TCP 一次能发的"最大纯数据长度"
cwnd = 一次能发几个 MSS
9. 时间计算函数:扫描耗时的精确计量
9.1 核心作用
这个函数的核心功能是:计算从 Nmap 扫描启动到"当前时刻"的耗时,返回以"秒"为单位的浮点值(比如 10.5 秒、0.23 秒),是扫描过程中"计时、速率控制、超时判断"的基础工具函数。
9.2 代码实现
cpp
// ===================== 计算扫描启动后的耗时(秒) =====================
// now: 可选参数,传入当前时间(避免重复调用gettimeofday)
float NmapOps::TimeSinceStart(const struct timeval *now) {
struct timeval tv;
if (!now) // 未传入当前时间,则获取系统当前时间
gettimeofday(&tv, NULL);
else tv = *now;
// 计算当前时间与启动时间的差值(浮点秒数)
return TIMEVAL_FSEC_SUBTRACT(tv, start_time);
}
9.3 关键数据类型
| 标识符/类型 | 含义 |
|---|---|
struct timeval |
Linux/Unix 系统的"高精度时间结构体",存储秒+微秒,精度达 1 微秒(1e-6 秒) |
start_time |
NmapOps 类的成员变量,记录扫描启动时的时间 |
now |
可选入参:如果外部已经获取过当前时间,直接传进来复用,避免重复系统调用 |
gettimeofday(&tv, NULL) |
系统调用:获取当前系统的高精度时间 |
9.4 核心宏 TIMEVAL_FSEC_SUBTRACT
cpp
// 自定义宏:计算两个 timeval 的差值(t1 - t0),返回浮点秒数
#define TIMEVAL_FSEC_SUBTRACT(t1, t0) \
((t1.tv_sec - t0.tv_sec) + (t1.tv_usec - t0.tv_usec) / 1000000.0)
拆解计算逻辑:
t1.tv_sec - t0.tv_sec:计算"秒数差值"(t1.tv_usec - t0.tv_usec) / 1000000.0:计算"微秒差值"并转换成秒- 两者相加:得到高精度浮点秒数
9.5 实际使用场景
场景1:打印扫描耗时
cpp
float elapsed = o.TimeSinceStart(NULL);
printf("扫描耗时:%.2f 秒\n", elapsed); // 输出:扫描耗时:10.50 秒
场景2:速率控制
cpp
float elapsed = o.TimeSinceStart(¤t_tv);
int sent_count = 150;
float send_rate = sent_count / elapsed;
if (send_rate > 100) {
usleep(10000); // 速率超限,休眠10毫秒
}
场景3:超时判断
cpp
float host_elapsed = o.TimeSinceStart(NULL);
if (host_elapsed > 30.0) {
printf("主机扫描超时(耗时%.2f秒),放弃\n", host_elapsed);
return;
}
9.6 关键设计细节
- 可选参数
now的价值 :避免重复调用gettimeofday(系统调用比普通函数慢) - 浮点秒数的优势:保留了微秒精度,适合高精度的速率控制、超时判断
- 鲁棒性 :即使
now为NULL,也能自动兜底获取当前时间
10. 抓包器初始化:构建高性能抓包环境
10.1 核心目标
这个函数的唯一目的:
为
HostOsScan(单主机 OS 扫描引擎)初始化高性能的抓包器 ------ 打开指定网卡的 pcap 抓包句柄,并设置精准的 BPF 过滤器,只抓取"和当前 OS 扫描相关的数据包"。
10.2 关键前置知识
- pcap:Linux/Unix 下的抓包库(WinPcap/Npcap 是Windows版本)
- BPF 过滤器:Berkeley Packet Filter,是内核态的数据包过滤规则,只把符合规则的包交给用户程序,能大幅减少用户态处理的数据包数量
10.3 完整执行流程
阶段1:初始化变量 + 决定过滤策略
cpp
char pcap_filter[2048]; // 存完整BPF过滤规则
char dst_hosts[1200]; // 存"源地址列表"子串
int filterlen = 0, len;
unsigned int targetno;
// 核心判断:目标数≤20时,用"精确地址过滤";否则用"模糊过滤"
bool doIndividual = Targets.size() <= 20;
pcap_filter[0] = '\0';
阶段2:构建源地址过滤子串(仅目标数≤20时)
cpp
if (doIndividual) {
for (targetno = 0; targetno < Targets.size(); targetno++) {
len = Snprintf(dst_hosts + filterlen, sizeof(dst_hosts) - filterlen,
"%ssrc host %s", (targetno==0)?"":" or ",
Targets[targetno]->targetipstr());
if (len < 0 || len + filterlen >= (int) sizeof(dst_hosts))
fatal("ran out of space in dst_hosts");
filterlen += len;
}
len = Snprintf(dst_hosts + filterlen, sizeof(dst_hosts) - filterlen, ")))");
if (len < 0 || len + filterlen >= (int) sizeof(dst_hosts))
fatal("ran out of space in dst_hosts");
}
例子 :若目标是 192.168.1.1、192.168.1.2,dst_hosts 最终会是:
src host 192.168.1.1 or src host 192.168.1.2)))
阶段3:打开pcap抓包句柄
cpp
HOS->pd = my_pcap_open_live(
Targets[0]->deviceName(), // 网卡名(如eth0)
8192, // snaplen:只抓8KB包头
o.spoofsource ? 1 : 0, // 混杂模式
pcap_selectable_fd_valid() ? 200 : 2 // 超时时间
);
if (HOS->pd == NULL)
fatal("%s", PCAP_OPEN_ERRMSG);
阶段4:构建完整BPF过滤器 + 生效
cpp
struct sockaddr_storage ss = Targets[0]->source();
if (ss.ss_family == AF_INET) {
if (doIndividual) { // 目标少:精确过滤
len = Snprintf(pcap_filter, sizeof(pcap_filter),
"dst host %s and (icmp or (tcp and (%s",
inet_ntoa(((struct sockaddr_in *)&ss)->sin_addr), dst_hosts);
} else { // 目标多:模糊过滤
len = Snprintf(pcap_filter, sizeof(pcap_filter),
"dst host %s and (icmp or tcp)",
inet_ntoa(((struct sockaddr_in *)&ss)->sin_addr));
}
if (len < 0 || len >= (int) sizeof(pcap_filter))
fatal("ran out of space in pcap filter");
if (o.debugging)
log_write(LOG_PLAIN, "Packet capture filter (device %s): %s\n",
Targets[0]->deviceFullName(), pcap_filter);
set_pcap_filter(Targets[0]->deviceFullName(), HOS->pd, pcap_filter);
}
完整过滤器例子 (目标少+本机IP=192.168.1.100):
dst host 192.168.1.100 and (icmp or (tcp and (src host 192.168.1.1 or src host 192.168.1.2))))
过滤器含义:只抓取"目的地址是本机、协议是ICMP,或协议是TCP且源地址是扫描目标"的包
10.4 核心设计亮点
-
性能优先:
- 内核态BPF过滤:减少用户态处理的数据包数量
- 分策略过滤:目标少则精确过滤,目标多则模糊过滤
- 只抓包头(snaplen=8192):节省内存和CPU
-
安全可靠:
- 所有字符串拼接用
Snprintf+ 越界检查 - 抓包句柄打开失败直接终止
- 括号配对严格检查
- 所有字符串拼接用
-
兼容性/鲁棒性:
- 仅处理IPv4,IPv6分支留空
- 调试模式打印过滤器
- 依赖封装的
my_pcap_open_live
11. 字符串安全拼接:Snprintf详解
11.1 核心作用
这行代码的唯一目的是:把当前遍历到的目标IP,以"src host IP"的格式(首个IP不加前缀,后续IP加" or "前缀),安全地追加到 dst_hosts 缓冲区的指定位置。
11.2 完整代码
cpp
len = Snprintf(
dst_hosts + filterlen, // 参数1:写入的起始地址
sizeof(dst_hosts) - filterlen, // 参数2:最大可写入的字符数
"%ssrc host %s", // 参数3:格式化模板
(targetno == 0)? "" : " or ", // 参数4:第一个占位符的取值(前缀)
Targets[targetno]->targetipstr() // 参数5:第二个占位符的取值(目标IP)
);
11.3 逐个参数拆解
参数1:dst_hosts + filterlen ------ 决定"从缓冲区的哪个位置开始写"
- 本质:这是一个指针偏移操作
- 第一次循环(targetno=0):
filterlen=0→dst_hosts + 0= 缓冲区起始地址 - 第二次循环(targetno=1):假设第一次写入了16个字符,
filterlen已更新为16 →dst_hosts + 16= 缓冲区第17个字符的位置 - 核心意义:实现"追加写入",而不是每次都从头覆盖
参数2:sizeof(dst_hosts) - filterlen ------ 决定"最多能写多少字符"
- 本质:计算缓冲区的剩余可用空间
Snprintf的关键特性:会严格遵守这个数值,最多只写「该数值-1」个字符(留1个位置给\0)- 核心意义:从根源避免"缓冲区溢出"
参数3:"%ssrc host %s" ------ 决定"写入的内容格式"
- 本质:这是格式化模板字符串 ,里面的
%s是"占位符" - 模板拆解:
- 第一个
%s:替换成"前缀"(要么是空字符串,要么是" or ") src host:固定字符串(BPF过滤器的语法)- 第二个
%s:替换成目标IP字符串
- 第一个
- 核心意义:统一所有目标IP的格式,符合BPF过滤器的语法要求
参数4:(targetno == 0)? "" : " or " ------ 决定"是否加'or'前缀"
- 本质:这是三元运算符
- 第一次循环(targetno=0):前缀是空字符串 → 直接写"src host 192.168.1.1"
- 第二次循环(targetno=1):前缀是" or " → 写" or src host 192.168.1.2"
- 核心意义:保证拼接后的字符串语法正确
参数5:Targets[targetno]->targetipstr() ------ 提供"要拼接的目标IP字符串"
- 本质:调用
Target类的成员函数targetipstr(),返回当前遍历到的目标主机的IP地址字符串 - 核心意义:动态获取每个目标的IP,不用硬编码
11.4 核心设计亮点
- 安全优先 :用
Snprintf+ 剩余空间计算,彻底杜绝缓冲区溢出 - 动态追加:通过指针偏移和已用长度记录,实现多个IP的无缝拼接
- 语法合规:三元运算符控制"or"前缀,保证BPF过滤器能识别
- 灵活性:适配任意数量的目标(≤20)
12. 跨平台接口名转换:Windows平台的特殊处理
12.1 函数整体说明
c
int DnetName2PcapName(const char *dnetdev, char *pcapdev, int pcapdevlen)
功能:
- 输入:
dnetdev(dnet 库使用的网卡名) - 输出:
pcapdev(对应的 pcap 格式设备名) - 返回值:
1:成功找到并写入pcapdev0:找不到对应的 pcap 设备名
此转换 仅在 Windows 上需要,因为 Windows 下 dnet 和 pcap 使用的接口命名格式不同。
12.2 静态缓存结构
c
static struct NameCorrelationCache {
char dnetd[64];
char pcapd[128];
} *NCC = NULL;
static int NCCsz = 0;
static int NCCcapacity = 0;
NCC:已成功转换的映射缓存(dnet → pcap)NCCsz:当前缓存数量NCCcapacity:当前缓存容量
c
static struct NameNotFoundCache {
char dnetd[64];
} *NNFC = NULL;
static int NNFCsz = 0;
static int NNFCcapacity = 0;
NNFC:记录 查过但找不到对应 pcap 名称 的 dnet 设备名,避免重复查询
12.3 初始化缓存
c
if (!NCC) {
NCCcapacity = 5;
NCC = (struct NameCorrelationCache *) safe_zalloc(NCCcapacity * sizeof(*NCC));
NCCsz = 0;
}
if (!NNFC) {
NNFCcapacity = 5;
NNFC = (struct NameNotFoundCache *) safe_zalloc(NNFCcapacity * sizeof(*NNFC));
NNFCsz = 0;
}
- 第一次调用时分配初始缓存空间(5 条)
- 使用
safe_zalloc,表示分配并清零,防止脏数据
12.4 查询缓存
第一步:查成功缓存
c
for (i = 0; i < NCCsz; i++) {
if (strcmp(NCC[i].dnetd, dnetdev) == 0) {
Strncpy(pcapdev, NCC[i].pcapd, pcapdevlen);
return 1;
}
}
如果该 dnetdev 已经转换过,直接从缓存取值,避免再次调用系统接口。
第二步:查失败缓存
c
for (i = 0; i < NNFCsz; i++) {
if (strcmp(NNFC[i].dnetd, dnetdev) == 0) {
return 0;
}
}
如果之前已经确认这个设备名 找不到对应的 pcap 名称,直接返回失败。
12.5 调用系统接口转换
c
if (intf_get_pcap_devname(dnetdev, tmpdev, sizeof(tmpdev)) != 0) {
// 转换失败:将该dnet名称加入"未找到"缓存
if (NNFCsz >= NNFCcapacity) {
NNFCcapacity <<= 2; // 容量乘 4
NNFC = (struct NameNotFoundCache *) safe_realloc(NNFC,
NNFCcapacity * sizeof(*NNFC));
}
Strncpy(NNFC[NNFCsz].dnetd, dnetdev, sizeof(NNFC[0].dnetd));
NNFCsz++;
return 0;
}
12.6 转换成功处理
c
if (NCCsz >= NCCcapacity) {
NCCcapacity <<= 2;
NCC = (struct NameCorrelationCache *) safe_realloc(NCC,
NCCcapacity * sizeof(*NCC));
}
Strncpy(NCC[NCCsz].dnetd, dnetdev, sizeof(NCC[0].dnetd));
Strncpy(NCC[NCCsz].pcapd, tmpdev, sizeof(NCC[0].pcapd));
NCCsz++;
Strncpy(pcapdev, tmpdev, pcapdevlen);
return 1;
12.7 总结
这段代码实现了:
在 Windows 下,将 dnet 接口名转换为 pcap 接口名,并通过双缓存(成功缓存 + 失败缓存)机制提高性能,避免重复查询系统接口。
13. 随机数生成:加密级安全随机源
13.1 get_random_u32() 函数
cpp
u32 get_random_u32() {
u32 i;
get_random_bytes(&i, sizeof(i));
return i;
}
核心作用:
- 通过系统安全随机源生成高质量的32位无符号随机数
- 依赖
get_random_bytes()接口,保证了随机数的不可预测性 - 函数返回的随机数直接用于设置 TCP 序列号、ACK 值等核心扫描参数
13.2 nrand_init() 函数
13.2.1 数据结构
cpp
struct nrand_handle {
u8 i, j; // RC4 算法的两个索引变量(核心状态)
u8 s[256]; // RC4 的 S 盒(256字节置换表,算法核心)
u8 *tmp; // 临时缓存指针
int tmplen; // 临时缓存长度
};
13.2.2 完整实现
cpp
void nrand_init(nrand_h *r) {
u8 seed[256];
int i;
/* 第一步:跨平台收集高熵种子 */
#ifdef WIN32
// Windows 平台:使用系统加密API获取安全随机种子
HCRYPTPROV hcrypt = 0;
CryptAcquireContext(&hcrypt, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT);
CryptGenRandom(hcrypt, sizeof(seed), seed);
CryptReleaseContext(hcrypt, 0);
#else
// Linux/Unix 平台:混合多源熵值 + 读取系统随机设备
struct timeval *tv = (struct timeval *)seed;
int *pid = (int *)(seed + sizeof(*tv));
int fd;
gettimeofday(tv, NULL);
*pid = getpid();
if ((fd = open("/dev/urandom", O_RDONLY)) != -1 ||
(fd = open("/dev/arandom", O_RDONLY)) != -1) {
ssize_t n;
do {
errno = 0;
n = read(fd, seed + sizeof(*tv) + sizeof(*pid),
sizeof(seed) - sizeof(*tv) - sizeof(*pid));
} while (n < 0 && errno == EINTR);
close(fd);
}
#endif
/* 第二步:初始化 RC4 的 S 盒 */
for (i = 0; i < 256; i++) { r->s[i] = i; };
r->i = r->j = 0;
/* 第三步:用种子打乱 S 盒(密钥调度算法 KSA) */
nrand_addrandom(r, seed, 128);
nrand_addrandom(r, seed + 128, 128);
/* 第四步:初始化临时缓存 */
r->tmp = NULL;
r->tmplen = 0;
/* 第五步:丢弃前 1KB 随机数据(消除 RC4 初始偏置) */
nrand_get(r, seed, 256); nrand_get(r, seed, 256);
nrand_get(r, seed, 256); nrand_get(r, seed, 256);
}
13.3 HCRYPTPROV 类型
HCRYPTPROV 本质上是 Windows CryptoAPI 中的一个句柄类型,你可以把它理解成:
- 一个"加密服务上下文的身份证",用来唯一标识一个打开的、与加密服务提供程序(CSP) 的连接
- 类比生活中的例子:你去银行办理业务,银行会给你一个排号单(句柄),凭这个排号单才能调用银行的服务
从代码层面,它的底层定义在 wincrypt.h 中:
cpp
typedef unsigned long HCRYPTPROV;
#define NULL_PROV_HANDLE ((HCRYPTPROV)0)
13.4 CryptAcquireContext 函数
cpp
CryptAcquireContext(&hcrypt, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT);
逐参数拆解:
| 参数 | 含义 | 作用 |
|---|---|---|
&hcrypt |
输出:加密上下文句柄 | 函数执行成功后,会把有效的加密上下文句柄写入 hcrypt 变量中 |
NULL |
密钥容器名称 | 指定"密钥容器"的名称,传NULL表示使用默认容器 |
NULL |
CSP名称 | 指定要使用的加密服务提供程序(CSP)的名称,传NULL表示使用系统默认的CSP |
PROV_RSA_FULL |
CSP类型 | 指定要使用的CSP类型,支持RSA加密/解密、数字签名/验证、哈希计算,同时也支持生成安全随机数 |
CRYPT_VERIFYCONTEXT |
操作标志 | 无需用户权限、无需访问密钥容器、无需交互式登录(比如服务/后台程序也能调用) |
13.5 跨平台熵源设计思路
| 平台 | 熵源组成 | 安全性 |
|---|---|---|
| Windows | 系统加密API(CryptGenRandom) | 最高(底层混合硬件/系统事件熵值) |
| Linux | 时间戳 + 进程ID + /dev/urandom | 高(/dev/urandom 是系统熵池的非阻塞接口) |
13.6 为什么要丢弃前 1KB 数据?
RC4 算法存在初始输出偏置问题:前几百字节的随机数分布不够均匀,容易被破解。
通过调用 4 次 nrand_get 生成 1024 字节并丢弃,让 RC4 算法运行到稳定状态,后续生成的随机数才符合加密安全要求。
13.7 与上层函数的完整链路
HostOsScan::reInitScanSystem()
↓
get_random_u32() → get_random_bytes()
↓
nrand_get()(基于 RC4 PRGA 生成随机字节)
↓
nrand_init()(初始化 RC4 状态,保证 nrand_get 有安全的随机源)
总结
本文档深入解析了Nmap OS扫描的核心技术,涵盖了从循环迭代器设计到随机数生成的完整技术栈。主要知识点包括:
- 循环迭代器:实现高效遍历未完成主机列表的机制
- 执行与状态分离:工业级设计模式,支持实例复用和并发安全
- 全局静态变量:性能统计的核心,统一收集全量扫描数据
- 拥塞控制结构体:扫描速率的智能调节器,动态调节探测包并发量
- cwnd翻倍机制:慢启动阶段的核心特征,实现指数级增长
- 初始化方法:动态配置拥塞控制参数,适配用户需求
- TCP拥塞控制四大算法:网络传输的基石,保证网络稳定性
- MSS概念:TCP报文的最大数据长度,拥塞控制的基础单位
- 时间计算函数:扫描耗时的精确计量,支持速率控制和超时判断
- 抓包器初始化:构建高性能抓包环境,通过BPF过滤器精准过滤
- 字符串安全拼接:Snprintf详解,避免缓冲区溢出
- 跨平台接口名转换:Windows平台的特殊处理,通过缓存机制提升性能
- 随机数生成:加密级安全随机源,基于RC4算法实现
这些技术共同构成了Nmap OS扫描的核心能力,使其成为工业级网络扫描工具的典范。通过深入理解这些技术,开发者可以更好地掌握网络扫描的核心原理,并将其应用到实际项目中。