Nmap 源码深度解析:nmap_main 函数核心机制详解(二)

本文是 Nmap 源码分析系列的第二篇,将深入剖析 nmap_main 函数的核心执行流程、关键数据结构以及网络地址解析机制。通过本文,你将全面理解 Nmap 如何从命令行参数到完成网络扫描的完整过程。


目录

  1. nmap_main 函数整体执行流程
  2. Targets 容器的存储机制与变化流程
  3. 网络地址解析系统
  4. 路由信息结构体详解
  5. 总结与思考

一、nmap_main 函数整体执行流程

1.1 函数定位与核心目标

nmap_main 是 Nmap 程序的总控入口函数,承担了从"接收用户命令行参数"到"完成扫描、输出结果、释放资源"的全流程管理。其核心目标是:

将用户的扫描指令转化为具体的网络探测行为,并将结果以标准化格式输出

这个函数的设计体现了大型网络工具的典型架构模式:初始化 → 前置处理 → 核心扫描 → 后置收尾

1.2 六阶段执行流程详解

整个 nmap_main 函数的执行流程可以清晰地划分为六个阶段,每个阶段都有明确的目标和核心操作。

阶段 1:系统与环境初始化(基础准备)

核心目标:适配操作系统环境、初始化基础组件,为后续扫描铺路。

关键操作详解

  1. 系统兼容性处理

    // 检测运行环境
    #ifdef WIN32
      // Windows 特定初始化
      win_init();  // 加载 WinPcap 驱动、检查权限
    #elif defined(__linux__)
      // Linux 特定初始化
      if (is_wsl()) {
        // WSL 环境警告
        log_write(LOG_STDOUT, "WARNING: You are running Nmap inside WSL. "
                 "Some features may not work properly.\n");
      }
    #endif
    
    // 初始化时区
    tzset();  // 确保时间戳准确
    

    设计意图:Nmap 需要在多种操作系统上运行,不同平台的网络栈、权限模型、驱动加载方式都不同。通过条件编译和运行时检测,确保程序在各平台都能正常工作。

  2. 参数合法性校验

    // 无参数时显示帮助信息
    if (argc == 1) {
      print_usage(stdout);
      exit(0);
    }
    
    // 解析命令行参数
    parse_options(argc, argv, &o);
    

    parse_options 的作用

    • 将用户输入(如 -sS--script、目标 IP)填充到全局配置对象 o
    • 验证参数组合的合法性(如 -sS-sT 不能同时使用)
    • 设置默认值(如未指定端口时使用常用端口)
  3. 基础组件初始化

    // 初始化日志系统
    log_init(o.debugging, o.verbose);
    
    // 初始化终端(支持实时按键检测)
    tty_init();
    
    // 忽略 SIGPIPE 信号
    signal(SIGPIPE, SIG_IGN);
    

    为什么忽略 SIGPIPE?

    • 当向已关闭的管道写入数据时,系统会发送 SIGPIPE 信号
    • 默认行为是终止程序,但 Nmap 需要优雅处理这种情况
    • 忽略信号后,写入操作会返回错误码,程序可以继续执行
阶段 2:前置业务处理(非核心扫描的辅助操作)

核心目标:处理用户指定的"非扫描类指令",完成扫描前的业务校验。

关键操作详解

  1. 路由解析

    if (o.route_dst) {
      struct sockaddr_storage ss;
      size_t sslen = sizeof(ss);
      
      // 解析目标地址
      if (resolve(o.route_dst, 0, &ss, &sslen, o.af()) == 0) {
        // 获取路由信息
        struct route_nfo rn;
        if (get_route_info(&ss, &rn)) {
          // 打印路由信息
          print_route_info(&rn);
        }
      }
      exit(0);  // 仅打印路由,不执行扫描
    }
    

    应用场景:用户想了解访问某个目标需要经过哪些路由节点,而不想执行实际扫描。

  2. 接口列表打印

    if (o.iflist) {
      print_iflist();  // 打印所有网络接口信息
      exit(0);
    }
    

    输出内容示例

    Starting Nmap 7.98 ( https://nmap.org )
    INTERFACE: eth0 (192.168.1.100/255.255.255.0)
      MAC: 00:11:22:33:44:55 (Intel Corporation)
      UP, BROADCAST, RUNNING, MULTICAST
    
  3. FTP 弹跳扫描初始化

    if (o.bounce) {
      // 解析 FTP 服务器地址
      if (resolve(o.bounce, 21, &ftp_ss, &ftp_sslen, o.af()) != 0) {
        fatal("Failed to resolve FTP bounce host: %s", o.bounce);
      }
      
      // 验证 FTP 服务器可达性
      if (!ftp_anon_connect(&ftp_ss)) {
        fatal("FTP bounce host does not support anonymous login");
      }
    }
    

    FTP 弹跳扫描原理

    • 利用 FTP 协议的 PORT 命令,让 FTP 服务器代替 Nmap 发送探测包
    • 可以隐藏 Nmap 的真实 IP 地址
    • 需要支持匿名登录的 FTP 服务器
  4. 资源预分配

    std::vector<Target *> Targets;
    Targets.reserve(100);  // 预分配 100 个元素的内存空间
    

    为什么预分配?

    • std::vector 动态扩容时需要重新分配内存并拷贝元素
    • 预分配可以减少扩容次数,提升性能
    • 100 是经验值,适用于大多数扫描场景
阶段 3:输出框架初始化(结果存储准备)

核心目标:搭建扫描结果的输出框架(尤其是 XML),确保结果结构化、可追溯。

关键操作详解

  1. XML 报告初始化

    if (o.xml_output) {
      if (o.resume) {
        // 恢复扫描:复用已有 XML 框架
        xml_open_log_file(o.xml_filename, true);
      } else {
        // 首次扫描:生成 XML 根节点
        xml_start_document();
        xml_write_start_tag("nmaprun");
        
        // 写入扫描元数据
        xml_write_attribute("scanner", "nmap");
        xml_write_attribute("args", o.cmdline);
        xml_write_attribute("start", time(NULL));
        xml_write_attribute("version", NMAP_VERSION);
        
        // 写入扫描配置
        xml_write_start_tag("scaninfo");
        xml_write_attribute("type", "syn");
        xml_write_attribute("protocol", "tcp");
        xml_write_attribute("services", "1-1024");
        xml_write_end_tag();
      }
    }
    

    XML 输出结构示例

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE nmaprun>
    <?xml-stylesheet href="nmap.xsl" type="text/xsl"?>
    <nmaprun scanner="nmap" args="nmap -sS 192.168.1.1" 
             start="1704528000" version="7.98">
      <scaninfo type="syn" protocol="tcp" services="1-1024"/>
      <host>
        <address addr="192.168.1.1" addrtype="ipv4"/>
        <ports>
          <port protocol="tcp" portid="80">
            <state state="open"/>
          </port>
        </ports>
      </host>
    </nmaprun>
    
  2. 日志输出初始化

    // 写入启动信息到人类可读日志
    log_write(LOG_NORMAL, "Starting Nmap %s ( https://nmap.org )", NMAP_VERSION);
    log_write(LOG_NORMAL, " at %s", ctime(&now));
    
    // 写入启动信息到机器可读日志
    if (o.logfile) {
      log_open(o.logfile, LOG_NORMAL);
      log_write(LOG_NORMAL, "Starting Nmap %s", NMAP_VERSION);
    }
    
阶段 4:扫描配置准备(扫描规则落地)

核心目标:把用户的扫描规则(端口、排除列表、脚本)转化为可执行的扫描配置。

关键操作详解

  1. 端口配置

    // 加载端口→服务名映射关系
    if (o.services_scan) {
      load_services("nmap-services");
    }
    
    // 随机化端口列表
    if (o.randomize_ports) {
      randomize_port_order(&ports);
    }
    

    端口映射示例

    80/tcp    http
    443/tcp   https
    22/tcp    ssh
    3306/tcp  mysql
    

    随机化端口的作用

    • 避免按固定顺序扫描,降低被防火墙检测的风险
    • 常用端口(80、443、22)前置,提升扫描效率
  2. 排除列表加载

    // 解析 --exclude 参数
    if (o.exclude) {
      parse_excludes(o.exclude, &exclude_group);
    }
    
    // 解析 --excludefile 参数
    if (o.excludefile) {
      load_exclude_file(o.excludefile, &exclude_group);
    }
    

    排除列表格式

    192.168.1.0/24
    10.0.0.0/8
    172.16.0.1
    
  3. NSE 脚本初始化

    if (o.script) {
      // 加载脚本数据库
      script_scan_results = get_script_scan_results_obj();
      
      // 初始化 Lua 解释器
      script_init(o.scriptargs, o.scripttimeout);
      
      // 执行预扫描脚本
      script_scan(Targets, SCRIPT_PRE_SCAN);
    }
    

    预扫描脚本的作用

    • 在主机发现之前执行全局信息收集
    • 例如:网络拓扑探测、DNS 枚举、端口扫描前的准备工作
    • 不针对特定主机,而是针对整个扫描任务
阶段 5:核心扫描循环(核心业务执行)

核心目标:按"分组扫描"的思路,批量处理目标主机,执行具体的网络探测并输出结果。

这是函数最核心的阶段,采用"分组→扫描→输出→释放"的循环逻辑。

核心流程图

计算理想分组大小

填充目标分组

分组是否为空?

结束循环

执行端口扫描

执行辅助扫描
OS/路由追踪/脚本

输出单主机扫描结果
XML+日志

释放当前分组内存

关键设计细节详解

  1. 目标分组机制

    do {
      // 计算本次理想扫描的主机数量
      ideal_scan_group_sz = determineScanGroupSize(o.numhosts_scanned, &ports);
      
      // 内层循环:填充 Targets 直到达到理想数量
      while (Targets.size() < ideal_scan_group_sz) {
        // 生成下一个待扫描主机
        currenths = nexthost(&hstate, exclude_group, &ports, o.pingtype);
        if (!currenths) break;
        
        // 过滤逻辑:只有"存活+需要端口扫描"的主机才加入 Targets
        if (currenths->flags & HOST_UP) {
          o.numhosts_up++;
          
          // 设置源地址
          if (o.spoofsource) {
            currenths->setSourceSockAddr(&ss, sslen);
          }
          
          // 加入 Targets
          Targets.push_back(currenths);
        } else {
          // 非存活主机:直接输出+删除
          print_host_basic_info(currenths);
          delete currenths;
          o.numhosts_scanned++;
        }
      }
      
      // 执行扫描...
    } while (o.numhosts_scanned < o.max_ips_to_scan);
    

    分组大小的计算策略

    int determineScanGroupSize(int scanned, PortList *ports) {
      int ideal_size;
      
      // 根据已扫描主机数动态调整
      if (scanned < 10) {
        ideal_size = 10;  // 初始阶段,小批量
      } else if (scanned < 100) {
        ideal_size = 50;  // 中期阶段,中批量
      } else {
        ideal_size = 100; // 后期阶段,大批量
      }
      
      // 考虑端口数量
      if (ports->port_count > 1000) {
        ideal_size = ideal_size / 2;  // 端口多时减少主机数
      }
      
      return ideal_size;
    }
    

    为什么需要分组?

    • 内存管理:避免一次性加载过多主机导致内存溢出
    • 性能优化:批量处理减少系统调用开销
    • 负载均衡:避免短时间内发送过多探测包被防火墙检测
    • 同构性保证:确保分组内主机使用相同的源地址和网卡
  2. 扫描执行

    // TCP SYN 扫描(批量处理)
    if (o.synscan) {
      ultra_scan(Targets, &ports, SYN_SCAN);
    }
    
    // TCP Connect 扫描(批量处理)
    if (o.connectscan) {
      ultra_scan(Targets, &ports, CONNECT_SCAN);
    }
    
    // UDP 扫描(批量处理)
    if (o.udpscan) {
      ultra_scan(Targets, &ports, UDP_SCAN);
    }
    
    // OS 扫描(批量处理)
    if (o.osscan) {
      OSScan os_engine;
      os_engine.os_scan(Targets);
    }
    
    // 脚本扫描(批量处理)
    if (o.script) {
      script_scan(Targets, SCRIPT_SCAN);
    }
    

    ultra_scan 的核心机制

    • 使用原始套接字发送探测包
    • 异步接收响应,非阻塞 I/O
    • 支持多种扫描类型(SYN、ACK、FIN、XMAS、NULL)
    • 自动调整扫描速度(基于网络延迟和丢包率)
  3. 结果输出

    // 遍历 Targets,输出每个主机的扫描结果
    for (targetno = 0; targetno < Targets.size(); targetno++) {
      currenths = Targets[targetno];
      
      // 输出端口状态
      print_port_info(currenths);
      
      // 输出 OS 识别结果
      if (o.osscan) {
        print_os_info(currenths);
      }
      
      // 输出脚本结果
      if (o.script) {
        print_script_result(currenths);
      }
      
      // 输出到 XML
      if (o.xml_output) {
        xml_write_host(currenths);
      }
    }
    

    输出内容示例

    Nmap scan report for 192.168.1.1
    Host is up (0.0023s latency).
    Not shown: 998 closed ports
    PORT   STATE SERVICE
    22/tcp open  ssh
    80/tcp open  http
    MAC Address: 00:11:22:33:44:55 (Intel Corp)
    
    OS CPE: cpe:/o:linux:linux_kernel
    OS details: Linux 4.15 - 5.6
    
  4. 内存释放

    // 释放所有 Target 对象,清空 Targets
    while (!Targets.empty()) {
      currenths = Targets.back();
      delete currenths;  // 释放堆内存
      Targets.pop_back();
    }
    o.numhosts_scanning = 0;
    

    为什么需要手动释放?

    • Targets 存储的是指针,不是对象本身
    • std::vector 析构时只释放指针,不释放指针指向的对象
    • 必须手动 delete 每个对象,否则会导致内存泄漏
阶段 6:后置处理与资源释放(收尾)

核心目标:完成扫描后的收尾工作,确保资源不泄漏、结果完整。

关键操作详解

  1. 脚本后扫描

    if (o.script) {
      script_scan(Targets, SCRIPT_POST_SCAN);
    }
    

    后扫描脚本的作用

    • 在所有主机扫描完成后执行
    • 用于数据分析、报告生成、结果聚合
    • 例如:生成漏洞报告、统计开放端口分布
  2. 资源释放

    // 释放排除列表
    free_excludes(&exclude_group);
    
    // 释放端口列表
    free_port_list(&ports);
    
    // 关闭文件句柄
    if (o.inputfd != -1) {
      close(o.inputfd);
    }
    
    // 关闭以太网套接字
    if (o.rawethsd) {
      eth_close(o.rawethsd);
    }
    
  3. 最终输出

    // 打印扫描摘要
    log_write(LOG_NORMAL, "Nmap done: %d IP addresses (%d hosts up) scanned in %.2f seconds",
              o.numhosts_scanned, o.numhosts_up, 
              (double)(time(NULL) - start_time));
    
    // 打印数据文件路径
    if (o.verbose) {
      log_write(LOG_VERBOSE, "Data files: %s", o.datadir);
    }
    
    // 关闭 XML 输出
    if (o.xml_output) {
      xml_write_end_tag("nmaprun");
      xml_close_log_file();
    }
    

1.3 核心设计思路总结

  1. 模块化与分层

    • 将"环境初始化"“参数解析”“扫描执行”"结果输出"拆分为独立阶段
    • 每个阶段职责单一,逻辑清晰,易于维护和扩展
  2. 效率优先

    • 分组扫描减少系统调用开销,批量探测提升速度
    • 端口随机化、常用端口前置,平衡扫描效率和隐蔽性
    • 预分配内存减少动态扩容开销
  3. 兼容性与鲁棒性

    • 适配 Windows/Linux/WSL 等多平台
    • 处理信号、超时、内存泄漏等异常情况
    • 支持"恢复扫描",避免扫描中断后重新执行
  4. 标准化输出

    • XML 作为核心输出格式,兼顾"机器可解析"和"人类可读"
    • 支撑自动化工具集成和二次开发

二、Targets 容器的存储机制与变化流程

2.1 Targets 的核心存储内容

Targets 是一个 std::vector<Target *> 类型的动态数组,核心存储的是指向 Target 类对象的指针。每个 Target 对象对应一个待执行端口扫描/深度扫描的存活目标主机,包含该主机的全量扫描相关信息。

Target 类的关键属性

class Target {
public:
  // 主机基础信息
  std::string targetipstr() const;  // IP 地址字符串
  std::string HostName() const;     // 主机名
  struct sockaddr_storage targetss; // 套接字地址
  u8 MAC[MAC_ADDR_LEN];            // MAC 地址
  
  // 扫描状态
  u32 flags;                        // 标志位(HOST_UP、HOST_DOWN 等)
  struct timeval starttime;         // 扫描开始时间
  struct timeval endtime;           // 扫描结束时间
  PortList ports;                   // 端口列表(开放/关闭/过滤状态)
  FingerPrintResults FP_results;    // 操作系统识别结果
  
  // 网络配置
  struct sockaddr_storage sourcesock; // 源套接字地址
  char *deviceName;                   // 扫描使用的网卡设备名
  
  // 脚本/路由信息
  ScriptResults scriptResults;        // 脚本扫描结果
  TracerouteResults tracerouteResults; // 路由追踪结果
};

重要说明

  • Targets 是"待扫描存活主机的指针集合"
  • 只有需要执行端口扫描/OS 扫描/脚本扫描的存活主机才会被加入
  • 列表扫描、非存活主机不会进入这个容器

2.2 Targets 的完整变化流程

下面按代码执行阶段拆解 Targets 的变化,核心是"空容器→批量填充→扫描→输出→清空→循环"的闭环。

阶段 1:初始化(函数开头)—— 空容器
std::vector<Target *> Targets;
Targets.reserve(100);

变化说明

  • 创建一个空的 vector 容器
  • 调用 reserve(100) 预留 100 个元素的内存空间
  • 此时状态:Targets.size() = 0,无任何主机指针

为什么预分配 100?

  • 经验值,适用于大多数扫描场景
  • 避免后续频繁扩容(扩容需要重新分配内存并拷贝元素)
  • 100 个主机约占用 100 * sizeof(Target*) = 800 字节(64 位系统)
阶段 2:预扫描脚本阶段 —— 仍为空
if (o.script) {
  script_scan_results = get_script_scan_results_obj();
  script_scan(Targets, SCRIPT_PRE_SCAN);  // 传入空的 Targets
}

变化说明

  • 此时 Targets 依然为空
  • 只是把空容器传给 script_scan 函数
  • 预扫描脚本此时无实际目标可处理

核心原因

  • 预扫描阶段仅做脚本初始化
  • 还没开始主机发现(ping)
  • 自然没有可扫描的主机
阶段 3:核心扫描循环 —— 批量填充 Targets(最关键)

这是 Targets 从空到有、数量增长的核心阶段。

do {
  // 1. 计算本次理想扫描的主机数量
  ideal_scan_group_sz = determineScanGroupSize(o.numhosts_scanned, &ports);
  
  // 2. 内层循环:填充 Targets 直到达到理想数量
  while (Targets.size() < ideal_scan_group_sz) {
    // 2.1 生成下一个待扫描主机
    currenths = nexthost(&hstate, exclude_group, &ports, o.pingtype);
    if (!currenths) break;  // 无更多主机,终止填充
    
    // 2.2 过滤逻辑:只有"存活+需要端口扫描"的主机才加入 Targets
    if (currenths->flags & HOST_UP) {
      o.numhosts_up++;
      
      // ❶ 列表扫描/无需端口扫描:直接输出+删除,不加入 Targets
      if ((o.noportscan && !o.traceroute && !o.script) || o.listscan) {
        print_host_info(currenths);
        delete currenths;
        o.numhosts_scanned++;
        continue;
      }
      
      // ❷ 主机不存活:输出+删除,不加入 Targets
      if (!(currenths->flags & HOST_UP)) {
        print_host_basic_info(currenths);
        delete currenths;
        o.numhosts_scanned++;
        continue;
      }
      
      // ❸ 存活且需要端口扫描:设置源地址,加入 Targets
      if (o.spoofsource) {
        currenths->setSourceSockAddr(&ss, sslen);
      }
      Targets.push_back(currenths);  // 核心操作:将主机指针加入 Targets
    }
  }
  
  // ... 后续扫描逻辑
} while (o.numhosts_scanned < o.max_ips_to_scan);

变化结果

  • Targets 被填充为"一批(理想数量)存活且需端口扫描的主机指针"
  • 关键规则:只有存活(HOST_UP)且需要端口/OS/脚本扫描的主机才会进入 Targets

过滤逻辑详解

情况 处理方式 是否加入 Targets
列表扫描模式 直接输出主机信息,删除对象 ❌ 否
无需端口扫描 直接输出主机信息,删除对象 ❌ 否
主机不存活 输出主机基本信息,删除对象 ❌ 否
存活且需扫描 设置源地址,加入 Targets ✅ 是
阶段 4:扫描执行阶段 —— Targets 内部数据更新

填充完成后,代码对 Targets 中的所有主机执行各类扫描。此时 Targets元素数量不变,但每个指针指向的 Target 对象内部数据被更新。

// TCP/UDP/SCTP 等端口扫描(批量处理 Targets)
if (o.synscan) {
  ultra_scan(Targets, &ports, SYN_SCAN);
}
if (o.udpscan) {
  ultra_scan(Targets, &ports, UDP_SCAN);
}

// OS 扫描(批量处理 Targets)
if (o.osscan) {
  OSScan os_engine;
  os_engine.os_scan(Targets);  // 填充 Target 的 OS 信息
}

// 脚本扫描(批量处理 Targets)
if (o.script) {
  script_scan(Targets, SCRIPT_SCAN);
}

变化说明

  • Targets 中每个 Target 对象的属性被更新
  • 端口状态从"未知"变为"开放/关闭/过滤"
  • 填充 OS 版本、脚本扫描结果等

示例变化

// 扫描前
currenths->ports.hasOpenPorts()  // false
currenths->os_info                // 空

// 扫描后
currenths->ports.hasOpenPorts()  // true
currenths->os_info                // "Linux 4.15"
currenths->ports.port_count       // 5
阶段 5:结果输出 + 清空 Targets(释放内存)

扫描完成后,输出结果并清空 Targets,避免内存泄漏。

// 1. 遍历 Targets,输出每个主机的扫描结果
for (targetno = 0; targetno < Targets.size(); targetno++) {
  currenths = Targets[targetno];
  
  print_port_info(currenths);      // 输出端口状态
  print_os_info(currenths);        // 输出 OS 识别结果
  print_script_result(currenths);  // 输出脚本结果
  
  if (o.xml_output) {
    xml_write_host(currenths);     // 输出到 XML
  }
}

// 2. 核心操作:释放所有 Target 对象,清空 Targets
while (!Targets.empty()) {
  currenths = Targets.back();  // 取最后一个元素
  delete currenths;            // 释放 Target 对象内存(关键!避免泄漏)
  Targets.pop_back();          // 从 vector 中移除指针
}
o.numhosts_scanning = 0;

变化说明

  • 先输出每个主机的扫描结果
  • 逐个 delete 指针指向的 Target 对象(释放堆内存)
  • pop_back 移除指针
  • 最终 Targets.size() = 0,回到空容器状态

为什么从后往前删除?

  • pop_back() 是 O(1) 操作,效率最高
  • 从前往后删除需要移动后续元素,效率较低
阶段 6:循环重复 + 后扫描脚本
// do-while 循环:回到"阶段3",继续填充新的一批主机到 Targets
// 直到扫描完所有目标(达到 o.max_ips_to_scan)

// 后扫描脚本阶段
if (o.script) {
  script_scan(Targets, SCRIPT_POST_SCAN);  // 此时 Targets 已清空
}

变化说明

  • do-while 循环继续填充新的一批主机到 Targets
  • 后扫描脚本阶段:Targets 已清空,仅执行收尾脚本逻辑
阶段 7:函数结束

Targets 是局部变量,函数执行完毕后被自动销毁,无残留。

2.3 内存管理的关键点

  1. 指针与对象的区别

    • Targets 存储的是指针,不是对象本身
    • std::vector 析构时只释放指针,不释放指针指向的对象
    • 必须手动 delete 每个对象,否则会导致内存泄漏
  2. 内存泄漏的后果

    • 长时间运行的 Nmap 会占用大量内存
    • 可能导致系统内存不足,影响其他程序
    • 严重时会导致程序崩溃
  3. 正确的内存管理方式

    // 错误方式:只清空 vector,不释放对象
    Targets.clear();  // ❌ 内存泄漏!
    
    // 正确方式:先释放对象,再清空 vector
    while (!Targets.empty()) {
      delete Targets.back();
      Targets.pop_back();
    }  // ✅ 正确
    

2.4 总结

  1. 存储内容Targets 存储指向 Target 对象的指针,仅包含"存活且需要端口/OS/脚本扫描"的目标主机

  2. 变化流程

    • 空容器初始化
    • 预扫描阶段为空
    • 核心循环批量填充存活主机指针
    • 执行扫描(更新对象内部数据)
    • 输出结果
    • 释放对象 + 清空容器
    • 循环直至扫描完成
  3. 内存管理

    • Targets 存储的是指针
    • 必须手动 delete 每个对象再清空容器
    • 否则会导致严重的内存泄漏

三、网络地址解析系统

Nmap 的网络地址解析系统是其核心基础设施之一,负责将用户输入的主机名或 IP 地址转换为程序可用的套接字地址结构。本节将深入解析这个系统的各个组件。

3.1 resolve 函数:地址解析的简化接口

函数定义
/**
 * @brief 网络地址解析的对外封装函数(简化版接口)
 * 用于将主机名/IP字符串 + 端口号解析为通用的套接字地址结构体
 * 
 * @param hostname 待解析的主机名(如"www.baidu.com")或IP字符串(如"192.168.1.1")
 * @param port 目标端口号(如80、443)
 * @param ss 输出参数:指向sockaddr_storage结构体的指针,存储解析后的完整地址信息
 * @param sslen 输入输出参数:入参时指定ss缓冲区的大小,出参时返回实际填充的地址结构长度
 * @param af 地址族(Address Family),指定解析类型(AF_INET=IPv4,AF_INET6=IPv6)
 * @return 整型返回值:0表示解析成功,非0表示失败(通常是系统错误码)
 */
int resolve(const char *hostname, unsigned short port,
  struct sockaddr_storage *ss, size_t *sslen, int af) {
  
  int flags;
  struct sockaddr_list sl;
  int result;
  
  // 处理 --nodns 选项
  flags = 0;
  if (o.nodns)
      flags |= AI_NUMERICHOST;
  
  // 调用底层解析函数
  result = resolve_internal(hostname, port, &sl, af, flags, 0);
  
  // 将解析结果返回给调用者
  *ss = sl.addr.storage;
  *sslen = sl.addrlen;
  
  return result;
}
参数详解
参数名 类型 作用与特性
hostname const char * 待解析的目标地址:可以是域名(如google.com)、IPv4字符串(1.1.1.1)、IPv6字符串(2001:4860:4860::8888);const 保证函数不会修改传入的字符串内容
port unsigned short 网络端口号,取值范围0-65535(符合TCP/UDP端口的标准范围),无符号短整型刚好适配这个范围
ss struct sockaddr_storage * 输出参数:解析后的地址会写入这个结构体;sockaddr_storage 是通用地址类型,兼容IPv4(sockaddr_in)和IPv6(sockaddr_in6),避免单独处理两种地址类型
sslen size_t * 输入输出参数
- 入参:告诉函数 ss 缓冲区的总大小(如sizeof(struct sockaddr_storage));
- 出参:函数返回时,会把实际填充的地址结构长度(如IPv4是16字节,IPv6是28字节)写回这个指针
af int 指定解析的地址族:AF_INET(强制解析为IPv4)、AF_INET6(强制解析为IPv6),对应你之前问的af()函数的返回值
核心设计意图

resolve 函数的设计目标是:

提供一个简单、安全、统一接口,将用户输入(域名/IP)解析为 socket 可用的地址结构,同时尊重 Nmap 的全局参数(如 --nodns)。

关键特性

  1. 简化接口:隐藏了 resolve_internal 的复杂参数,只暴露核心功能
  2. 参数安全:使用 const 修饰输入参数,防止意外修改
  3. 兼容性:通过 sockaddr_storage 支持 IPv4 和 IPv6
  4. 全局参数集成:自动处理 --nodns 选项
典型使用示例
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    struct sockaddr_storage ss;
    size_t sslen = sizeof(ss);
    const char *hostname = "www.baidu.com";
    unsigned short port = 80;
    
    // 调用 resolve 解析百度 80 端口(IPv4)
    int ret = resolve(hostname, port, &ss, &sslen, AF_INET);
    if (ret == 0) {
        // 转换为 IPv4 地址结构并打印
        struct sockaddr_in *ipv4_addr = (struct sockaddr_in *)&ss;
        char ip_str[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &ipv4_addr->sin_addr, ip_str, INET_ADDRSTRLEN);
        printf("解析成功:%s:%d\n", ip_str, ntohs(ipv4_addr->sin_port));
    } else {
        printf("解析失败,错误码:%d\n", ret);
    }
    return 0;
}

3.2 resolve_internal 函数:地址解析的核心实现

函数定义
/* 
 * 内部辅助函数:为 resolve 和 resolve_numeric 提供核心地址解析逻辑
 * addl_flags 会按位或到 hints.ai_flags 中,可用于添加 AI_NUMERICHOST 等标志
 * AI_NUMERICHOST:强制将hostname当作数字IP解析(如192.168.1.1),跳过DNS查询
 */
static int resolve_internal(const char *hostname, unsigned short port,
  struct sockaddr_storage *ss, size_t *sslen, int af, int addl_flags) {
  
  // 用于给 getaddrinfo 传递"解析提示"的结构体
  struct addrinfo hints;
  // 存储 getaddrinfo 返回的解析结果链表头
  struct addrinfo *result;
  // 临时缓冲区:将数字端口转为字符串(getaddrinfo 要求端口是字符串格式)
  char portbuf[16];
  // 指向端口字符串的指针(传给 getaddrinfo 的 servname 参数)
  char *servname = NULL;
  // 函数返回码/临时状态码
  int rc;

  // 调试阶段断言:确保输入参数非空,避免空指针访问
  assert(hostname);  // 主机名/IP不能为空
  assert(ss);        // 输出地址结构体指针不能为空
  assert(sslen);     // 输出地址长度指针不能为空

  // 初始化 hints 结构体为全0,避免脏数据影响解析
  memset(&hints, 0, sizeof(hints));
  hints.ai_family = af;          // 指定解析的地址族(AF_INET=IPv4,AF_INET6=IPv6)
  hints.ai_socktype = SOCK_DGRAM;// 指定套接字类型为UDP(数据报)
  hints.ai_flags |= addl_flags;  // 合并附加标志(如AI_NUMERICHOST)

  /* 将数字端口转换为字符串:getaddrinfo 的 servname 参数要求是字符串格式 */
  if (port != 0) {  // 仅当端口非0时处理(端口0通常表示不指定)
    // 安全格式化端口号到 portbuf(Snprintf 是安全版 snprintf,避免缓冲区溢出)
    rc = Snprintf(portbuf, sizeof(portbuf), "%hu", port);
    // 断言:格式化成功,且结果长度未超出缓冲区
    assert(rc >= 0 && (size_t) rc < sizeof(portbuf));
    servname = portbuf;  // 让 servname 指向转换后的端口字符串
  }

  // 核心调用:通过 getaddrinfo 解析主机名/IP + 端口,获取套接字地址信息
  rc = getaddrinfo(hostname, servname, &hints, &result);
  if (rc != 0)  // 解析失败:直接返回 getaddrinfo 的错误码(如EAI_NONAME=域名不存在)
    return rc;
  if (result == NULL)  // 解析无结果:返回EAI_NONAME(无匹配名称)
    return EAI_NONAME;
  
  // 断言:解析结果的地址长度有效(大于0且不超过 sockaddr_storage 的大小)
  assert(result->ai_addrlen > 0 && result->ai_addrlen <= (int) sizeof(struct sockaddr_storage));
  
  // 将解析结果的长度和地址拷贝到输出参数中
  *sslen = result->ai_addrlen;                // 输出实际的地址结构长度
  memcpy(ss, result->ai_addr, *sslen);        // 拷贝地址数据到 ss 缓冲区

  // 释放 getaddrinfo 分配的内存(必须调用,否则内存泄漏)
  freeaddrinfo(result);

  return 0;  // 解析成功,返回0
}
核心逻辑分步拆解

失败

成功

参数断言校验

初始化hints结构体

端口号转字符串(非0时)

调用getaddrinfo解析地址

解析成功?

返回错误码

校验结果地址长度

拷贝地址数据到输出参数

释放getaddrinfo内存

返回0(成功)

关键技术点
  1. getaddrinfo 的使用

    • getaddrinfo 是 POSIX 标准的地址解析函数
    • 替代了老旧的 gethostbynameinet_addr
    • 支持 IPv4/IPv6 兼容、域名解析、端口转换
    • 返回标准化的错误码(如 EAI_NONAMEEAI_AGAIN
  2. hints 结构体的作用

    struct addrinfo {
      int ai_flags;      // AI_NUMERICHOST、AI_PASSIVE 等标志
      int ai_family;     // AF_INET、AF_INET6、AF_UNSPEC
      int ai_socktype;   // SOCK_STREAM、SOCK_DGRAM
      int ai_protocol;   // IPPROTO_TCP、IPPROTO_UDP
      size_t ai_addrlen; // 地址长度
      struct sockaddr *ai_addr;  // 地址指针
      char *ai_canonname;        // 规范名称
      struct addrinfo *ai_next;  // 链表下一个节点
    };
    
  3. 端口转换的必要性

    • getaddrinfoservname 参数要求是字符串(如 "80"
    • 需要将数字端口(unsigned short)转为字符串
    • 使用 Snprintf 安全格式化,避免缓冲区溢出
  4. 内存管理

    • getaddrinfo 会动态分配内存,必须调用 freeaddrinfo 释放
    • 否则会导致内存泄漏
典型调用场景
// 示例:强制解析数字IP(192.168.1.1)的80端口,跳过DNS查询
struct sockaddr_storage ss;
size_t sslen = sizeof(ss);
// 附加标志传 AI_NUMERICHOST,强制数字IP解析
int ret = resolve_internal("192.168.1.1", 80, &ss, &sslen, AF_INET, AI_NUMERICHOST);
if (ret == 0) {
    printf("解析成功,地址长度:%zu字节\n", sslen);
} else {
    printf("解析失败:%s\n", gai_strerror(ret)); // 转换错误码为可读字符串
}

3.3 升级版 resolve_internal:支持多地址返回

函数定义
/* 
 * 内部辅助函数:为 resolve 和 resolve_numeric 提供核心地址解析逻辑(支持多地址返回)
 * 关键特性:
 * 1. addl_flags 按位或到 hints.ai_flags,可添加 AI_NUMERICHOST 等标志(强制数字IP解析)
 * 2. sl 指向 sockaddr_list 链表的首个元素(静态分配),后续节点动态分配
 * 3. multiple_addrs 控制是否返回多个地址:false 仅返回第一个,true 返回所有解析结果
 * 
 * @param hostname      待解析的主机名/数字IP(非空)
 * @param port          目标端口号(需转为字符串传给 getaddrinfo)
 * @param sl            输出参数:指向 sockaddr_list 链表首节点的指针,存储解析后的地址
 * @param af            地址族(AF_INET=IPv4,AF_INET6=IPv6)
 * @param addl_flags    附加标志(如 AI_NUMERICHOST),合并到 hints.ai_flags
 * @param multiple_addrs 是否返回多个解析地址:false=仅第一个,true=所有
 * @return              0=成功,非0=错误码(如 EAI_NONAME=域名不存在)
 */
static int resolve_internal(const char *hostname, unsigned short port,
    struct sockaddr_list *sl, int af, int addl_flags, int multiple_addrs)
{
    // 用于给 getaddrinfo 传递解析提示的结构体
    struct addrinfo hints;
    // 存储 getaddrinfo 返回的解析结果链表头
    struct addrinfo *result;
    // 遍历 getaddrinfo 结果链表的临时指针
    struct addrinfo *next;
    // 指向 sockaddr_list 链表节点指针的指针(用于构建链表)
    struct sockaddr_list **item_ptr = &sl;
    // 指向当前 sockaddr_list 节点的指针
    struct sockaddr_list *new_item;
    // 临时缓冲区:将数字端口转为字符串(getaddrinfo 要求端口是字符串)
    char portbuf[16];
    // 函数返回码/临时状态码
    int rc;

    // 断言:确保主机名非空
    ncat_assert(hostname != NULL);

    // 初始化 hints 结构体为全0,避免脏数据影响解析
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = af;          // 指定解析的地址族(IPv4/IPv6)
    hints.ai_socktype = SOCK_DGRAM;// 指定套接字类型为UDP(数据报)
    hints.ai_flags |= addl_flags;  // 合并附加标志(如 AI_NUMERICHOST)

    /* 将数字端口转换为字符串:getaddrinfo 的 servname 参数要求是字符串格式 */
    rc = Snprintf(portbuf, sizeof(portbuf), "%hu", port);
    // 断言:格式化成功且未超出缓冲区
    ncat_assert(rc >= 0 && (size_t) rc < sizeof(portbuf));

    // 核心调用:解析主机名+端口,获取地址信息链表
    rc = getaddrinfo(hostname, portbuf, &hints, &result);
    if (rc != 0)  // 解析失败:返回 getaddrinfo 错误码
        return rc;
    if (result == NULL)  // 解析无结果:返回 EAI_NONAME(无匹配名称)
        return EAI_NONAME;
    // 断言:解析结果的地址长度有效(大于0且不超过 sockaddr_storage 大小)
    ncat_assert(result->ai_addrlen > 0 && result->ai_addrlen <= (int) sizeof(struct sockaddr_storage));

    // 遍历 getaddrinfo 返回的所有地址结果
    for (next = result; next != NULL; next = next->ai_next) {
        // 如果当前链表节点为空,动态分配新节点
        if (*item_ptr == NULL)
        {
            *item_ptr = (struct sockaddr_list *)safe_malloc(sizeof(struct sockaddr_list));
            (**item_ptr).next = NULL;  // 初始化新节点的 next 指针为 NULL
        }
        // 指向当前要填充的链表节点
        new_item = *item_ptr;
        // 填充地址长度
        new_item->addrlen = next->ai_addrlen;
        // 拷贝地址数据到节点的通用地址存储区(sockaddr_storage)
        memcpy(&new_item->addr.storage, next->ai_addr, next->ai_addrlen);
        
        // 如果不需要返回多个地址,跳出循环(仅保留第一个地址)
        if (!multiple_addrs)
            break;
        // 否则,移动指针到下一个节点,准备存储下一个地址
        item_ptr = &new_item->next;
    }

    // 释放 getaddrinfo 分配的内存(必须调用,避免内存泄漏)
    freeaddrinfo(result);

    return 0;  // 解析成功,返回0
}
核心新增特性
特性 旧版 resolve_internal 新版 resolve_internal
地址存储 单个 sockaddr_storage 结构体 sockaddr_list 链表(支持多个地址)
多地址返回 不支持(仅返回第一个) 支持(multiple_addrs 控制)
内存分配 无动态分配(仅拷贝) 动态分配 sockaddr_list 节点(safe_malloc)
sockaddr_list 结构体
// 存储套接字地址的链表节点结构体
struct sockaddr_list {
    size_t addrlen;                  // 地址结构体的实际长度
    union {
        struct sockaddr_storage storage;  // 通用地址存储(兼容IPv4/IPv6)
        struct sockaddr sa;
        struct sockaddr_in sin;      // IPv4 地址
        struct sockaddr_in6 sin6;    // IPv6 地址
    } addr;
    struct sockaddr_list *next;      // 指向下一个节点的指针
};
二级指针的巧妙使用

二级指针 item_ptr 是构建动态链表的关键:

// 初始状态:sl 是链表头(NULL),item_ptr = &sl
item_ptr = &sl; 

// 第一次循环:*item_ptr 为 NULL,分配新节点后 *item_ptr 指向节点A
*item_ptr = safe_malloc(...); 

// 如果 multiple_addrs=true,item_ptr 指向节点A的next指针
item_ptr = &new_item->next;

// 第二次循环:*item_ptr 为 NULL,分配新节点后 *item_ptr 指向节点B,节点A的next=节点B

优势

  • 无需额外维护"尾指针"
  • 直接通过二级指针遍历并扩展链表
  • 代码更简洁,逻辑更清晰
典型使用场景
// 示例:解析百度域名,返回所有IPv4地址
struct sockaddr_list sl = {0}; // 初始化链表头
int ret = resolve_internal(
    "www.baidu.com", 80,    // 主机名+端口
    &sl,                    // 输出链表
    AF_INET,                // 仅IPv4
    0,                      // 无附加标志
    1                       // 返回多个地址
);
if (ret == 0) {
    // 遍历链表打印所有IP
    struct sockaddr_list *cur = &sl;
    while (cur != NULL) {
        const char *ip = inet_ntop_ez(&cur->addr.storage, cur->addrlen);
        printf("解析到IP:%s\n", ip);
        cur = cur->next;
    }
}

3.4 inet_ntop_ez 函数:简化的 IP 地址转换

函数定义
/* 
 * 简化版的 inet_ntop 函数:无需手动传入目标缓冲区,降低调用复杂度
 * 特性:
 * 1. 返回静态缓冲区指针,可直接使用(但再次调用会覆盖内容)
 * 2. 自动识别 IPv4/IPv6 地址族,无需手动指定
 * 3. 长度校验:若 sslen 过小(不足以容纳对应地址结构体),返回 NULL
 * 4. 兼容禁用网卡的异常场景(这类场景会返回 NULL)
 * 
 * @param ss    指向通用套接字地址结构体的指针(存储 IPv4/IPv6 地址)
 * @param sslen ss 结构体的实际长度(用于校验有效性)
 * @return      成功返回指向静态缓冲区的 IP 字符串指针;失败/异常返回 NULL
 */
const char *inet_ntop_ez(const struct sockaddr_storage *ss, size_t sslen) {

  // 强制转换为 IPv4 地址结构体指针(先假设是 IPv4,后续判断地址族)
  const struct sockaddr_in *sin = (struct sockaddr_in *) ss;
  // 静态缓冲区:存储转换后的 IP 字符串,大小兼容 IPv6(INET6_ADDRSTRLEN=46)
  // 注意:静态缓冲区会被多次调用复用,线程不安全
  static char str[INET6_ADDRSTRLEN];
#if HAVE_IPV6  // 条件编译:仅当系统支持 IPv6 时定义 IPv6 相关变量
  const struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *) ss;
#endif

  // 初始化缓冲区为空字符串,避免脏数据干扰
  str[0] = '\0';

  // 处理 IPv4 地址
  if (sin->sin_family == AF_INET) {
    // 校验 sslen:必须至少容纳 IPv4 地址结构体,否则返回 NULL(防止越界)
    if (sslen < sizeof(struct sockaddr_in))
      return NULL;
    // 调用标准 inet_ntop 转换 IPv4 地址到静态缓冲区,返回缓冲区指针
    return inet_ntop(AF_INET, &sin->sin_addr, str, sizeof(str));
  }
#if HAVE_IPV6  // 系统支持 IPv6 时处理 IPv6 地址
  else if(sin->sin_family == AF_INET6) {
    // 校验 sslen:必须至少容纳 IPv6 地址结构体
    if (sslen < sizeof(struct sockaddr_in6))
      return NULL;
    // 调用标准 inet_ntop 转换 IPv6 地址到静态缓冲区
    return inet_ntop(AF_INET6, &sin6->sin6_addr, str, sizeof(str));
  }
#endif
  // 注释说明:部分笔记本的禁用无线网卡会上报空的 IP/地址族,因此会走到这里
  // 其他未知地址族也会走到这里,返回 NULL
  return NULL;
}
核心设计目标

标准 inet_ntop 要求调用者手动分配缓冲区并传入,使用繁琐且容易因缓冲区大小不足导致问题;inet_ntop_ez 做了两层简化:

  1. 内置静态缓冲区,无需调用者管理内存
  2. 自动识别地址族(IPv4/IPv6),无需手动指定 AF_INET/AF_INET6
静态缓冲区的"便利"与"坑"

便利

// 简化调用示例
struct sockaddr_storage ss;
size_t sslen = sizeof(ss);
resolve("www.baidu.com", 80, &ss, &sslen, AF_INET);
const char *ip = inet_ntop_ez(&ss, sslen);
if (ip) printf("IP: %s\n", ip); // 直接使用返回的字符串

风险

  1. 线程不安全:多线程同时调用会覆盖缓冲区,导致返回的 IP 字符串错乱
  2. 内容覆盖:连续调用会覆盖前一次结果
    const char *ip1 = inet_ntop_ez(&ss1, sslen1); // ip1指向"192.168.1.1"
    const char *ip2 = inet_ntop_ez(&ss2, sslen2); // ip1和ip2都指向新值(如"8.8.8.8")
    
  3. 解决办法:若需保存结果,需立即将返回的字符串拷贝到自己的缓冲区(如 strcpy/strdup
与标准 inet_ntop 的对比
特性 标准 inet_ntop inet_ntop_ez
缓冲区管理 调用者手动分配、传入 内置静态缓冲区,无需管理
地址族指定 需手动传入 AF_INET/AF_INET6 自动识别地址族
线程安全 线程安全(缓冲区由调用者管理) 线程不安全(静态缓冲区复用)
调用复杂度 高(需处理缓冲区、地址族) 低(仅传 ss 和 sslen)
异常容错 无长度校验,需调用者自行处理 内置长度校验、禁用网卡容错

四、路由信息结构体详解

4.1 route_nfo 结构体定义

/**
 * @brief 路由信息结构体
 * 用于存储与目标网络/主机通信时的核心路由相关信息,包括接口、直连状态、源地址、下一跳等
 */
struct route_nfo {
    /**
     * 关联的网络接口信息
     * interface_info 是另一个预定义的结构体(通常包含接口名、接口IP、MAC地址等)
     * 存储当前路由对应的出口网络接口的基础信息
     */
    struct interface_info ii;

    /**
     * 直连标志位(布尔型)
     * - 非0(true):目标主机/网络与本机直连,无需经过路由器转发
     * - 0(false):目标非直连,需要通过路由转发才能到达
     */
    int direct_connect;

    /**
     * 数据包发送时应使用的源IP地址
     * 注意:此字段可能与 ii.addr(接口本身的IP)不同
     * 典型场景:使用本地回环接口(localhost)扫描本机另一块网卡的IP时,源地址需指定为对应网卡的IP
     * sockaddr_storage 是通用地址结构体,兼容IPv4(sockaddr_in)和IPv6(sockaddr_in6)
     */
    struct sockaddr_storage srcaddr;

    /**
     * 下一跳(网关)地址
     * 仅当 direct_connect = 0(非直连)时有效,存储前往目标的第一个路由节点(网关)地址
     * 若 direct_connect = 1(直连),此字段无实际意义(通常置空或未初始化)
     */
    struct sockaddr_storage nexthop;
};

4.2 逐成员详细解释

1. struct interface_info ii

这是一个嵌套的结构体,interface_info 是网络编程中常见的"接口信息结构体",通常包含:

struct interface_info {
    char devname[32];           // 接口名称(如 "eth0"、"wlan0")
    struct sockaddr_storage addr; // 接口的IP地址
    u8 mac[MAC_ADDR_LEN];       // 接口的MAC地址
    int netmask_bits;           // 子网掩码位数(如 24 表示 255.255.255.0)
    int is_up;                  // 接口是否启用
};

作用:绑定当前路由对应的出口网络接口,告诉程序"要访问目标,需要从哪个网卡出去"。

2. int direct_connect

本质是布尔型标志(C语言无原生bool,用int替代),核心作用是判断目标是否在本机的直连网段内。

举例

  • 你的电脑和路由器直连(direct_connect=1
  • 但访问外网服务器时需要经过路由器转发(direct_connect=0

判断逻辑

// 伪代码:判断是否直连
if ((target_ip & netmask) == (interface_ip & netmask)) {
    direct_connect = 1;  // 在同一网段,直连
} else {
    direct_connect = 0;  // 不在同一网段,需要路由
}
3. struct sockaddr_storage srcaddr

sockaddr_storage 是 POSIX 标准的通用地址存储结构,解决了 sockaddr(IPv4)和 sockaddr_in6(IPv6)的兼容性问题,能存储任意类型的IP地址。

作用:指定发送数据包时的源IP——比如本机有多个网卡(192.168.1.100、10.0.0.5),访问目标时需要明确用哪个IP作为数据包的"源地址",而非默认的接口IP。

典型场景

// 场景1:多网卡环境
// eth0: 192.168.1.100
// eth1: 10.0.0.5
// 访问 192.168.1.1 时,srcaddr 应设置为 192.168.1.100
// 访问 10.0.0.1 时,srcaddr 应设置为 10.0.0.5

// 场景2:IP 欺骗
// 用户指定 --spoof-source 1.2.3.4
// srcaddr 应设置为 1.2.3.4(即使本机没有这个IP)
4. struct sockaddr_storage nexthop

仅在非直连场景下有效,存储"下一跳网关"的IP地址。

举例

  • 你要访问 8.8.8.8(谷歌DNS)
  • 本机路由表显示下一跳是 192.168.1.1(路由器IP)
  • nexthop 就存储 192.168.1.1

路由表查询流程

// 伪代码:查询路由表
route_entry = lookup_route_table(target_ip);
if (route_entry) {
    if (route_entry->gateway != 0) {
        nexthop = route_entry->gateway;  // 有网关,使用网关地址
        direct_connect = 0;
    } else {
        nexthop = target_ip;  // 无网关,直接访问目标
        direct_connect = 1;
    }
}

4.3 实际应用示例

// 示例:获取访问 8.8.8.8 的路由信息
struct sockaddr_storage target_ss;
size_t target_sslen = sizeof(target_ss);
resolve("8.8.8.8", 0, &target_ss, &target_sslen, AF_INET);

struct route_nfo rn;
if (get_route_info(&target_ss, &rn)) {
    printf("接口名称:%s\n", rn.ii.devname);
    printf("接口IP:%s\n", inet_ntop_ez(&rn.ii.addr, sizeof(rn.ii.addr)));
    printf("源地址:%s\n", inet_ntop_ez(&rn.srcaddr, sizeof(rn.srcaddr)));
    
    if (rn.direct_connect) {
        printf("直连:是\n");
    } else {
        printf("直连:否\n");
        printf("下一跳:%s\n", inet_ntop_ez(&rn.nexthop, sizeof(rn.nexthop)));
    }
}

输出示例

接口名称:eth0
接口IP:192.168.1.100
源地址:192.168.1.100
直连:否
下一跳:192.168.1.1

4.4 总结

  1. route_nfo 结构体的核心作用是封装访问目标网络/主机的全量路由上下文,整合了接口、直连状态、源地址、下一跳四大核心信息。

  2. direct_connect 是核心标志位,决定了 nexthop 字段是否有效,是区分"直连通信"和"路由转发通信"的关键。

  3. sockaddr_storage 的使用让结构体兼容 IPv4 和 IPv6,是网络编程中处理多协议的通用做法。


五、总结与思考

5.1 核心要点回顾

通过本文的深入分析,我们全面理解了 Nmap 的核心机制:

  1. nmap_main 函数的六阶段执行流程

    • 系统与环境初始化
    • 前置业务处理
    • 输出框架初始化
    • 扫描配置准备
    • 核心扫描循环
    • 后置处理与资源释放
  2. Targets 容器的存储机制

    • 存储指向 Target 对象的指针
    • 仅包含"存活且需要扫描"的主机
    • 遵循"空容器→批量填充→扫描→输出→清空→循环"的闭环
  3. 网络地址解析系统

    • resolve:简化接口,处理全局参数
    • resolve_internal:核心实现,支持多地址返回
    • inet_ntop_ez:简化的 IP 地址转换
  4. 路由信息结构体

    • 封装接口、直连状态、源地址、下一跳
    • 支持直连和路由转发两种通信模式
    • 兼容 IPv4 和 IPv6

5.2 设计思想与最佳实践

  1. 模块化设计

    • 将复杂功能拆分为独立的函数和模块
    • 每个模块职责单一,易于维护和扩展
  2. 内存管理

    • 使用智能指针或 RAII 模式(C++)
    • 手动管理时,确保配对分配和释放
    • 使用 assert 和断言进行调试校验
  3. 错误处理

    • 使用标准化的错误码
    • 提供详细的错误信息
    • 优雅处理异常情况
  4. 跨平台兼容

    • 使用条件编译处理平台差异
    • 使用标准 API(如 getaddrinfo
    • 提供统一的接口隐藏平台细节

5.3 学习建议

  1. 深入理解网络编程基础

    • 套接字编程
    • 地址解析(DNS)
    • 路由和网关
  2. 阅读源码的方法

    • 从核心函数入手(如 nmap_main
    • 理解数据结构和算法
    • 关注内存管理和错误处理
  3. 实践与实验

    • 使用调试工具(如 GDB)
    • 添加日志输出
    • 编写测试用例

5.4 后续学习方向

  1. 扫描引擎实现

    • ultra_scan 函数的详细机制
    • 原始套接字的使用
    • 异步 I/O 和事件驱动
  2. 操作系统识别

    • osscan2.cc 的实现
    • 指纹匹配算法
    • 主动和被动探测
  3. NSE 脚本引擎

    • Lua 解释器的集成
    • 脚本执行流程
    • 脚本库和 API

参考资源


作者注:本文基于 Nmap 7.98 版本源码进行分析,部分实现细节可能在不同版本中有所差异。建议读者结合实际源码进行学习和实践。

版权声明:本文遵循 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐