【nmap源码学习】 Nmap网络扫描工具深度解析:从基础参数到核心扫描逻辑

Nmap网络扫描工具深度解析:从基础参数到核心扫描逻辑

前言

Nmap(Network Mapper)作为网络安全领域最著名的网络扫描工具,其强大的功能和灵活的配置使其成为渗透测试、网络管理和安全研究的必备工具。本文将深入剖析Nmap的核心技术实现,从基础参数解析到复杂的扫描算法,帮助读者全面理解这个工具的内部工作机制。


一、网络基础概念解析

1.1 网关与"主机号+1"的关系

在网络配置中,我们经常看到网关地址被设置为"网段+1"的形式,例如在192.168.1.0/24网段中,网关通常是192.168.1.1。这种配置背后的逻辑是什么?

技术原理

网关是连接本地网络与外部网络的桥梁,其IP地址的选择遵循以下原则:

结论 适用场景 与Nmap的关联
网关通常设为主机号+1 家用网络、中小企业网络(99%) 日常--route-dst查询的网关基本都是这个配置
网关可以不设为主机号+1 企业复杂网络、特殊业务配置 --route-dst会如实输出自定义的网关IP
「主机号+1」非技术强制 所有网络 仅为配置习惯,不影响路由功能本身
实际应用

Nmap的--route-dst参数可以显示系统的路由信息,包括网关配置。通过这个参数,我们可以快速了解当前网络的路由结构:

bash 复制代码
nmap --route-dst

输出示例:

复制代码
ROUTE DST: 192.168.1.1

总结 :网关的"主机号+1"是习惯而非规则,就像人们习惯把家门钥匙放在玄关,不是必须放,但最方便;而Nmap的--route-dst就是一个"钥匙位置探测器",不管钥匙放在玄关还是卧室,都会精准告诉你位置。


二、Nmap核心参数详解

2.1 --iflist参数:网络接口信息查询

--iflist是Nmap的命令行触发参数,专门用于打印系统所有网络接口信息,是典型的命令行工具"快捷功能触发"设计。

核心功能

当运行程序时带上--iflist参数,程序会立刻执行"网络接口信息打印"逻辑,完成后直接退出,不会执行程序的其他核心功能

输出内容

触发该参数后,程序会打印系统中所有网络接口的关键信息:

  • 接口名称 :如Linux下的eth0lo,Windows下的以太网WLAN
  • IP地址:接口配置的IPv4地址(未配置则显示"未配置")
  • MAC地址 :网卡的物理地址(格式为xx:xx:xx:xx:xx:xx
  • MTU值:接口的最大传输单元(默认以太网为1500,回环接口lo为65536)
  • 接口状态UP(启用/已连接)或DOWN(禁用/未连接)
执行逻辑
c 复制代码
if (delayed_options.iflist) {  // 检测到--iflist参数被传入
    print_iflist();            // 调用打印函数输出接口信息
    exit(0);                   // 打印完成后立即退出程序,不走后续逻辑
}
输出示例

Linux系统下的输出效果:

复制代码
==================== 系统网络接口列表 ====================
接口名称    IP地址           MAC地址             MTU    状态    
---------------------------------------------------------
lo          127.0.0.1        00:00:00:00:00:00   65536  UP      
eth0        192.168.1.100    00:11:22:33:44:55   1500   UP      
wlan0       192.168.2.101    66:77:88:99:aa:bb   1500   DOWN    
========================================================

Windows系统下的输出效果:

复制代码
==================== 系统网络接口列表 ====================
接口名称              IP地址           MAC地址             MTU    状态    
---------------------------------------------------------
以太网                192.168.1.200    08:00:27:xx:xx:xx   1500   UP      
WLAN                  192.168.2.201    fc:xx:xx:xx:xx:xx   1500   UP      
本地连接* 1           未配置           00:00:00:00:00:00   1500   DOWN    
========================================================
使用场景
  1. 快速排查网络问题:确认某个网卡是否启用、IP/MAC是否配置正确
  2. 脚本自动化:在批处理/Shell脚本中调用程序,仅获取接口信息用于后续分析
  3. 调试程序:开发阶段验证网络接口识别逻辑是否正确

2.2 -s系列参数:扫描类型选择

Nmap的-s(全称Scan)是所有"扫描类型"参数的统一前缀,后接不同字母会对应完全不同的扫描逻辑,是Nmap最核心的参数组之一。

常用扫描类型对比
参数 全称/类型 核心作用 & 特点
-sS TCP SYN扫描(半开放扫描) 🔹 最常用、速度最快、隐蔽性最好 🔹 不完成TCP三次握手,仅发送SYN包判断端口开放 🔹 需要root/管理员权限
-sT TCP连接扫描 🔹 完成完整TCP三次握手,易被防火墙/IDS检测 🔹 普通用户即可运行,无需特殊权限 🔹 速度比-sS
-sn Ping扫描(原-sP 🔹 仅检测主机是否存活(在线),不扫描端口 🔹 适合快速排查网段内存活主机
-sU UDP扫描 🔹 专门扫描UDP端口(如DNS 53、DHCP 67) 🔹 UDP无连接,扫描速度极慢 🔹 通过ICMP响应判断端口状态
-sV 版本探测 🔹 识别端口上运行的服务版本(如nginx 1.21.6OpenSSH 9.0p1) 🔹 常和其他扫描参数配合使用
-sC 默认脚本扫描 🔹 运行nmap内置的NSE脚本,检测漏洞/弱口令/服务详情 🔹 渗透测试中最常用的参数之一
实用示例
bash 复制代码
# 仅检测主机是否存活(不扫端口)
nmap -sn 192.168.1.1

# 快速扫描端口 + 识别服务版本(推荐组合)
nmap -sS -sV 192.168.1.1

# 扫描UDP端口(指定53/DNS、161/SNMP)
nmap -sU 192.168.1.1 -p 53,161

# 扫描端口 + 运行默认脚本 + 版本探测(渗透测试常用)
nmap -sS -sV -sC 192.168.1.1
权限差异
  • -sS:需要root/管理员权限,因为需要构造原始TCP数据包
  • -sT:普通用户即可运行,使用系统socket建立完整TCP连接
  • -sn:普通用户即可运行,仅进行主机存活检测

三、高级扫描技术解析

3.1 FTP弹跳扫描(FTP Bounce Scan)

FTP弹跳扫描是Nmap的一项高级功能,利用FTP服务器的PORT命令,让FTP服务器替扫描器发送探测数据包,以此绕过目标主机的防火墙/访问控制。

初始化逻辑
c 复制代码
// ========== FTP弹跳扫描初始化 ==========
/* 如果用户启用FTP弹跳扫描(-b参数),必须确保FTP服务器可达 */
if (o.bouncescan) {  // o是程序参数结构体,bouncescan为1表示用户通过-b参数启用了FTP弹跳扫描
    // 解析FTP弹跳服务器地址(仅IPv4,AF_INET)
    // resolve函数:将域名/IP字符串(ftp.server_name)解析为网络通信可用的sockaddr结构体
    int rc = resolve(ftp.server_name, 0, &ss, &sslen, AF_INET);
    
    // 解析失败:终止程序
    if (rc != 0)
        fatal("Failed to resolve FTP bounce proxy hostname/IP: %s",
            ftp.server_name);
    
    // 将解析后的FTP服务器IP(sockaddr_in格式)拷贝到ftp.server(in_addr类型)
    memcpy(&ftp.server, &((sockaddr_in*)&ss)->sin_addr, 4);
    
    // 详细模式:打印FTP服务器解析结果
    if (o.verbose) {
        log_write(LOG_STDOUT, "Resolved FTP bounce attack proxy to %s (%s).\n",
            ftp.server_name, inet_ntoa(ftp.server));
    }
}
核心原理
  1. 地址解析:首先解析FTP服务器地址,确保其可达
  2. 连接建立:匿名连接到FTP服务器
  3. PORT命令:通过FTP的PORT命令指定目标主机和端口
  4. 数据包转发:FTP服务器向目标发送探测数据包
  5. 结果返回:FTP服务器将响应返回给扫描器
使用场景
  • 绕过防火墙:当目标主机的防火墙阻止直接扫描时
  • 隐藏扫描源:扫描流量来自FTP服务器,而非真实扫描源
  • 测试FTP服务器安全性:检测FTP服务器是否被滥用
执行流程图







用户启动程序并传入-b参数
o.bouncescan是否为1?
跳过FTP弹跳扫描初始化,执行其他逻辑
调用resolve解析FTP服务器地址
解析是否成功(rc==0?)
调用fatal输出错误,终止程序
将解析后的IP拷贝到ftp.server
o.verbose是否为1?
打印解析结果到终端
不打印
完成初始化,进入FTP弹跳扫描核心逻辑


3.2 resolve()函数:地址解析核心

resolve()是Nmap中自定义的地址解析函数,功能对标Linux/Windows系统的getaddrinfo()inet_aton(),核心作用是将人类可读的地址字符串转换为网络编程中可直接使用的二进制网络地址结构体。

函数原型
c 复制代码
int resolve(const char *addr_str, int port, struct sockaddr *ss, 
            size_t *sslen, int af);
参数说明
参数位置 参数值 含义
1 ftp.server_name 输入参数:要解析的FTP服务器地址(字符串类型,可是域名或IPv4)
2 0 输入参数:端口号(此处设为0,因为仅解析地址,暂不指定FTP端口)
3 &ss 输出参数:指向struct sockaddr的指针,存储解析后的二进制网络地址
4 &sslen 输出参数:指向size_t的指针,存储ss结构体的实际长度(IPv4固定为16)
5 AF_INET 输入参数:地址族,强制解析为IPv4(拒绝IPv6,符合FTP弹跳扫描仅支持IPv4的需求)
核心功能
  • 输入:域名/IPv4字符串、端口号、地址族(如IPv4)
  • 处理
    • 若输入是IPv4字符串 (如192.168.1.1):直接转换为二进制格式(in_addr
    • 若输入是域名 (如ftp.example.com):调用系统DNS解析接口,获取对应的IPv4地址
  • 输出 :填充好的sockaddr结构体(存储二进制地址)、结构体长度
  • 返回值0表示解析成功,非0表示失败(如域名解析失败、地址格式错误)
简化实现示例
c 复制代码
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

// 自定义resolve函数:解析地址字符串为sockaddr结构体(仅支持IPv4)
int resolve(const char *addr_str, int port, struct sockaddr *ss, 
            size_t *sslen, int af) {
    // 仅支持IPv4(AF_INET),其他地址族直接失败
    if (af != AF_INET) {
        return -1;
    }

    struct sockaddr_in *sin = (struct sockaddr_in *)ss;
    memset(sin, 0, sizeof(struct sockaddr_in));
    sin->sin_family = AF_INET;
    sin->sin_port = htons(port);  // 端口转换为网络字节序

    // 第一步:尝试直接解析为IPv4地址(如192.168.1.1)
    if (inet_pton(AF_INET, addr_str, &sin->sin_addr) == 1) {
        *sslen = sizeof(struct sockaddr_in);
        return 0;  // 解析成功
    }

    // 第二步:若不是IPv4字符串,尝试解析为域名(如ftp.example.com)
    struct hostent *host = gethostbyname(addr_str);
    if (host == NULL) {
        return -2;  // 域名解析失败
    }

    // 将域名解析结果拷贝到sockaddr_in
    memcpy(&sin->sin_addr, host->h_addr_list[0], host->h_length);
    *sslen = sizeof(struct sockaddr_in);
    return 0;  // 域名解析成功
}
与系统原生函数的关联

resolve()本质是对系统原生解析函数的封装,核心依赖以下系统函数:

  • inet_pton():将IPv4字符串转换为二进制格式(in_addr
  • gethostbyname():解析域名,获取对应的IPv4地址(已被getaddrinfo()替代,但老程序仍常用)
  • getaddrinfo():更通用的现代解析函数(支持IPv6、端口),resolve()也可基于此实现

封装的目的

  • 简化程序调用(无需重复处理解析逻辑)
  • 适配程序的参数结构(直接返回sssslen
  • 强制限制IPv4(符合FTP弹跳扫描的需求)

四、扫描初始化与配置

4.1 扫描时间与XML报告初始化

Nmap在扫描开始前会进行一系列初始化操作,包括时间记录、XML报告生成和日志输出。

核心代码解析
c 复制代码
// ========== 扫描时间与XML报告初始化 ==========
timep = time(NULL); // 获取扫描启动时间戳(time_t类型,秒级)
// 将时间戳转换为可读字符串(如"Thu Jan 29 10:00:00 2026")
err = n_ctime(mytime, sizeof(mytime), &timep);
if (err) {
    // 时间格式化失败:终止程序(时间是扫描报告的核心元数据,必须成功)
    fatal("n_ctime failed: %s", strerror(err));
}
chomp(mytime); // 去除mytime末尾的换行符

// 非恢复扫描(首次扫描):生成完整XML报告头部
if (!o.resuming) {
    /* 记录扫描基础信息,避免用户忘记扫描参数 */
    char* xslfname = o.XSLStyleSheet(); // 获取XML样式表路径
    xml_start_document("nmaprun");     // 初始化XML文档,定义根标签为<nmaprun>

    if (xslfname) { // 如果指定了XSL样式表:添加XML处理指令
        xml_open_pi("xml-stylesheet"); // 打开处理指令:<?xml-stylesheet
        xml_attribute("href", "%s", xslfname); // 设置样式表路径
        xml_attribute("type", "text/xsl");     // 设置样式表类型为text/xsl
        xml_close_pi(); // 关闭处理指令:?>
        xml_newline();  // 换行,保持XML代码整洁
    }

    // 添加XML注释:记录扫描启动的核心上下文
    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();
    xml_newline();

    // 打开<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_attribute("startstr", "%s", mytime);   // 扫描启动可读时间
    xml_attribute("version", "%s", NMAP_VERSION); // Nmap版本号
    xml_attribute("xmloutputversion", NMAP_XMLOUTPUTVERSION); // XML输出版本
    xml_close_start_tag(); // 关闭<nmaprun>开始标签
    xml_newline();

    // 输出XML格式的扫描配置信息
    output_xml_scaninfo_records(&ports);

    // 输出<verbose>和<debugging>标签
    xml_open_start_tag("verbose");
    xml_attribute("level", "%d", o.verbose);
    xml_close_empty_tag();
    xml_newline();
    
    xml_open_start_tag("debugging");
    xml_attribute("level", "%d", o.debugging);
    xml_close_empty_tag();
    xml_newline();
}
else {
    // 恢复扫描(--resume参数):仅打开<nmaprun>标签,不重新生成头部
    xml_start_tag("nmaprun", false);
}

// 输出扫描启动信息到普通日志和机器可读日志
log_write(LOG_NORMAL | LOG_MACHINE, "# ");
log_write(LOG_NORMAL | LOG_MACHINE, "%s %s scan initiated %s as: %s", 
          NMAP_NAME, NMAP_VERSION, mytime, join_quoted(argv, argc).c_str());
log_write(LOG_NORMAL | LOG_MACHINE, "\n");

/* 端口列表输出:随机化前先输出,避免随机后顺序混乱 */
if (o.verbose)
    output_ports_to_machine_parseable_output(&ports);
关键函数说明
关键元素 作用说明
timep time_t类型变量,存储扫描启动的时间戳(秒级),是扫描时间的"数字凭证"
n_ctime() 自定义时间格式化函数(安全封装系统ctime),将时间戳转为可读字符串,避免缓冲区溢出
chomp() 字符串处理函数,去除字符串末尾的换行符,清理格式
o.resuming 布尔型变量,标记是否为"恢复扫描"(--resume参数)
xml_*()系列函数 XML报告构建的封装函数,用于生成规范的XML标签、属性、注释
join_quoted() argv(命令行参数数组)拼接为带引号的完整字符串,保留扫描的所有参数
log_write() 日志输出函数,支持多日志类型:LOG_NORMAL(普通人类可读日志)、LOG_MACHINE(机器可解析日志)
执行流程图



否(首次扫描)
是(恢复扫描)


开始初始化
获取扫描启动时间戳
调用n_ctime格式化时间字符串
格式化成功?
调用fatal终止程序
chomp去除换行符
是否恢复扫描?
生成完整XML头部:样式表+注释+属性+扫描配置+verbose/debug标签
仅打开根标签,不生成头部
输出扫描启动信息到普通/机器可读日志
是否启用详细模式?
输出待扫描端口列表到机器可读日志
跳过端口列表输出
初始化完成,进入扫描核心逻辑


4.2 端口列表初始化与随机化

Nmap在扫描前会对端口列表进行初始化和随机化处理,这是提升扫描效率和隐蔽性的关键步骤。

端口列表初始化
c 复制代码
// ========== 端口列表初始化 ==========
/* 随机化端口前,初始化PortList类(加载端口→服务名映射) */
if (o.ipprotscan)
    // IP协议扫描(-sO):初始化IP协议映射
    PortList::initializePortMap(IPPROTO_IP, ports.prots, ports.prot_count);
if (o.TCPScan())
    // TCP扫描(如-sS/-sT):初始化TCP端口映射
    PortList::initializePortMap(IPPROTO_TCP, ports.tcp_ports, ports.tcp_count);
if (o.UDPScan())
    // UDP扫描(-sU):初始化UDP端口映射
    PortList::initializePortMap(IPPROTO_UDP, ports.udp_ports, ports.udp_count);
if (o.SCTPScan())
    // SCTP扫描(-sY/-sZ):初始化SCTP端口映射
    PortList::initializePortMap(IPPROTO_SCTP, ports.sctp_ports, ports.sctp_count);

核心作用:提前加载"端口号/协议号 → 服务名"的映射关系(比如80→http、443→https、6→TCP协议),这样扫描后能直接显示"80/tcp open http",而非仅显示"80/tcp open",提升扫描结果的可读性。

端口随机化
c 复制代码
// ========== 端口随机化 ==========
if (o.randomize_ports) { // 启用端口随机化(--randomize-ports)
    if (ports.tcp_count) { // 有TCP端口待扫描
        shortfry(ports.tcp_ports, ports.tcp_count); // 随机打乱TCP端口列表
        random_port_cheat(ports.tcp_ports, ports.tcp_count);
        // 常用端口(如80/443)移到列表前部,提升扫描效率
    }
    if (ports.udp_count) // 随机打乱UDP端口列表
        shortfry(ports.udp_ports, ports.udp_count);
    if (ports.sctp_count) // 随机打乱SCTP端口列表
        shortfry(ports.sctp_ports, ports.sctp_count);
    if (ports.prot_count) // 随机打乱IP协议列表
        shortfry(ports.prots, ports.prot_count);
}

核心作用

  1. 隐蔽性:固定端口扫描顺序(如1→65535)容易被防火墙/IDS检测到,随机化后扫描顺序无规律,降低被拦截的概率
  2. 效率random_port_cheat把常用端口(80、443、22、21等)移到随机列表前部,这些端口更可能开放,先扫能更快得到有效结果
排除列表加载
c 复制代码
// ========== 排除列表加载 ==========
exclude_group = addrset_new(); // 创建空的排除地址集合

/* 加载排除列表(--exclude/--excludefile参数) */
if (o.excludefd != NULL) { // 有排除文件:加载文件中的排除地址
    load_exclude_file(exclude_group, o.excludefd);
    fclose(o.excludefd); // 关闭排除文件句柄
}
if (o.exclude_spec != NULL) { // 有排除字符串:加载字符串中的排除地址
    load_exclude_string(exclude_group, o.exclude_spec);
}

核心作用:精准控制扫描范围,加载用户指定的"不扫描的地址/网段",避免扫描网关、本机、重要服务器等不需要扫描的目标,减少无效扫描和误报。


4.3 Lua NSE脚本引擎初始化

NSE(Nmap Scripting Engine)是Nmap的核心扩展功能,基于Lua脚本实现服务探测、漏洞检测、网络枚举等高级功能。

初始化逻辑
c 复制代码
// ========== Lua NSE脚本初始化 ==========
#ifndef NOLUA  // 编译宏:未定义NOLUA时,启用Lua/NSE功能
    if (o.scriptupdatedb) {
        // 脚本数据库更新(--script-updatedb):禁用扫描,仅更新数据库
        o.max_ips_to_scan = o.numhosts_scanned;
    }
    if (o.servicescan)
        // 启用服务版本扫描:自动开启脚本版本检测
        o.scriptversion = true;
    if (o.scriptversion || o.script || o.scriptupdatedb)
        // 打开NSE脚本环境(加载脚本、初始化Lua解释器)
        open_nse();

    /* 执行脚本预扫描阶段 */
    if (o.script) {
        // 获取脚本扫描结果存储对象
        script_scan_results = get_script_scan_results_obj();
        // 执行预扫描脚本(SCRIPT_PRE_SCAN)
        script_scan(Targets, SCRIPT_PRE_SCAN);
        // 打印预扫描脚本结果
        printscriptresults(script_scan_results, SCRIPT_PRE_SCAN);
        // 释放脚本结果内存(避免内存泄漏)
        for (ScriptResults::iterator it = script_scan_results->begin();
            it != script_scan_results->end(); it++) {
            delete (*it);
        }
        script_scan_results->clear();
    }
#endif
关键概念
关键元素 作用说明
o.scriptupdatedb 对应--script-updatedb参数:仅更新NSE脚本的元数据库,不执行扫描
o.servicescan 对应-sV参数:启用服务版本扫描(检测端口上运行的服务版本)
o.script 对应--script参数:指定要执行的NSE脚本(单个/多个/分类)
ScriptResults 存储NSE脚本执行结果的容器类,支持迭代、增删、清空
SCRIPT_PRE_SCAN 脚本执行阶段枚举值:预扫描阶段(扫描开始前)
open_nse() NSE引擎入口函数,封装Lua解释器初始化、脚本加载、环境配置
脚本执行阶段

NSE脚本分为三个执行阶段:

  1. SCRIPT_PRE_SCAN:预扫描阶段,在扫描正式开始前执行,用于目标预处理、环境检查
  2. SCRIPT_SCAN:扫描阶段,在端口扫描后执行,用于服务探测、漏洞检测
  3. SCRIPT_POST_SCAN:后扫描阶段,在扫描结束后执行,用于结果汇总、资源清理

五、主机分组与扫描执行

5.1 主机分组参数初始化

Nmap采用"分组扫描"策略,将待扫描主机分成多个批次,每批处理一定数量的主机,以平衡扫描效率和系统资源。

初始化代码
cpp 复制代码
// 确保ping分组大小不小于最小阈值(避免分组过小降低效率)
if (o.ping_group_sz < o.minHostGroupSz())
    o.ping_group_sz = o.minHostGroupSz();

// 初始化主机分组状态对象:管理目标迭代、分组大小、随机化等
HostGroupState hstate(o.ping_group_sz, o.randomize_hosts,
    o.generate_random_ips, o.max_ips_to_scan, argc, (const char**)argv);
HostGroupState构造函数
cpp 复制代码
HostGroupState::HostGroupState(int lookahead, int rnd, bool gen_rand, 
                               unsigned long num_random, int argc, 
                               const char **argv) {
  assert(lookahead > 0);
  this->argc = argc;
  this->argv = argv;
  hostbatch = (Target **) safe_zalloc(sizeof(Target *) * lookahead);
  defer_buffer = std::list<Target *>();
  undeferred = std::list<Target *>();
  max_batch_sz = lookahead;
  current_batch_sz = 0;
  next_batch_no = 0;
  randomize = rnd;
  if (gen_rand) {
    current_group.generate_random_ips(num_random);
  }
}
参数说明
参数名 对应调用参数 实际含义
lookahead o.ping_group_sz 单批次最大扫描主机数(分组大小)
rnd o.randomize_hosts 是否随机化扫描主机的顺序(0/1布尔值)
gen_rand o.generate_random_ips 是否开启"随机生成IP"模式
num_random o.max_ips_to_scan 随机生成IP时的最大数量限制
argc/argv 命令行参数个数/参数数组 用于后续解析用户输入的扫描目标
设计亮点
  1. 预取-扫描流水线模式 :通过预取lookahead个主机,实现"预取目标"和"执行扫描"的并行化,减少扫描等待时间

  2. 延迟缓冲区设计:当分组内主机需要同构(如同一网卡/源地址)时,不同构的主机需要延迟到下一组处理

  3. 安全内存分配 :使用safe_zalloc而非malloc,确保内存被初始化为0,避免野指针


5.2 核心扫描循环

Nmap的核心扫描循环实现了从"动态凑齐扫描分组"到"执行各类扫描"再到"输出结果"的完整流程。

整体流程
cpp 复制代码
do {
    // 动态计算最优分组大小
    ideal_scan_group_sz = determineScanGroupSize(o.numhosts_scanned, &ports);

    // 填充目标列表:直至达到理想分组大小
    while (Targets.size() < ideal_scan_group_sz) {
        // ... 主机筛选/处理逻辑 ...
    }

    // ... 执行各类扫描 ...
} while (!o.max_ips_to_scan || o.max_ips_to_scan > o.numhosts_scanned);
执行流程图





开始循环
动态计算最优分组大小
填充Targets列表
Targets是否为空?
退出核心循环
扫描前准备
执行各类扫描
批量高速扫描ultra_scan
单目标特殊扫描
辅助扫描服务/OS/路由/脚本
输出扫描结果
释放当前分组内存
是否达到最大扫描数?

批量高速扫描
cpp 复制代码
if (!o.noportscan) { // 未禁用端口扫描
    if (o.synscan)
        ultra_scan(Targets, &ports, SYN_SCAN);      // SYN扫描(半开放,最常用)
    if (o.ackscan)
        ultra_scan(Targets, &ports, ACK_SCAN);      // ACK扫描(检测防火墙规则)
    if (o.windowscan)
        ultra_scan(Targets, &ports, WINDOW_SCAN);   // 窗口扫描(精准判断端口状态)
    if (o.finscan)
        ultra_scan(Targets, &ports, FIN_SCAN);      // FIN扫描(隐蔽,绕过TCP握手检测)
    if (o.xmasscan)
        ultra_scan(Targets, &ports, XMAS_SCAN);     // XMAS扫描(FIN+URG+PSH)
    if (o.nullscan)
        ultra_scan(Targets, &ports, NULL_SCAN);     // NULL扫描(无TCP标志位)
    if (o.maimonscan)
        ultra_scan(Targets, &ports, MAIMON_SCAN);   // Maimon扫描(FIN+ACK)
    if (o.udpscan)
        ultra_scan(Targets, &ports, UDP_SCAN);      // UDP扫描(检测UDP端口)
    if (o.connectscan)
        ultra_scan(Targets, &ports, CONNECT_SCAN);  // TCP全连接扫描(无权限时用)
    if (o.sctpinitscan)
        ultra_scan(Targets, &ports, SCTP_INIT_SCAN);// SCTP INIT扫描
    if (o.sctpcookieechoscan)
        ultra_scan(Targets, &ports, SCTP_COOKIE_ECHO_SCAN);
    if (o.ipprotscan)
        ultra_scan(Targets, &ports, IPPROT_SCAN);   // IP协议扫描(-sO)
}

ultra_scan函数:Nmap最核心的批量扫描函数,专为"高并发、批量处理"设计,能同时扫描一组主机的指定端口,效率远高于单目标扫描。

单目标特殊扫描
cpp 复制代码
// 空闲扫描(僵尸机扫描,-sI):依赖僵尸机的IP ID,无法批量
if (o.idlescan) { 
    for (targetno = 0; targetno < Targets.size(); targetno++) {
        o.current_scantype = IDLE_SCAN;
        keyWasPressed(); // 检查用户是否按回车/空格,实时打印扫描状态
        idle_scan(Targets[targetno], ports.tcp_ports, ports.tcp_count, 
                  o.idleProxy, &ports);
    }
}
// FTP弹跳扫描(-b):依赖FTP服务器代理,无法批量
if (o.bouncescan) { 
    for (targetno = 0; targetno < Targets.size(); targetno++) {
        o.current_scantype = BOUNCE_SCAN;
        keyWasPressed();
        if (ftp.sd <= 0)
            ftp_anon_connect(&ftp); // 匿名连接FTP弹跳服务器
        if (ftp.sd > 0)
            bounce_scan(Targets[targetno], ports.tcp_ports, 
                        ports.tcp_count, &ftp);
    }
}

设计原因:空闲扫描、FTP弹跳扫描依赖"僵尸机/FTP服务器"的专属状态,无法批量处理多主机,因此必须循环逐个扫描。

辅助扫描
cpp 复制代码
// 服务版本扫描(-sV):检测端口上的服务(如 80→nginx/1.21)
if (o.servicescan) {
    o.current_scantype = SERVICE_SCAN;
    service_scan(Targets);
}

// OS检测(-O):基于TCP/IP指纹识别操作系统
if (o.osscan) {
    OSScan os_engine;
    os_engine.os_scan(Targets);
}

// 路由追踪(--traceroute):追踪到目标的路由路径
if (o.traceroute)
    traceroute(Targets);

// 脚本扫描(--script):执行Lua脚本(如漏洞检测、信息收集)
#ifndef NOLUA
if (o.script || o.scriptversion) {
    script_scan(Targets, SCRIPT_SCAN);
}
#endif

5.3 主机筛选与处理

在填充Targets列表的过程中,Nmap会对每个主机进行筛选和处理,只保留符合条件的主机进行扫描。

四大场景处理
cpp 复制代码
while (Targets.size() < ideal_scan_group_sz) {
    o.current_scantype = HOST_DISCOVERY; // 标记当前阶段为「主机发现」
    // 从hstate管理的目标队列中取下一个待扫描主机
    currenths = nexthost(&hstate, exclude_group, &ports, o.pingtype);
    if (!currenths) // 无更多目标:退出填充循环
        break;

    // 统计存活主机数(仅主机存活+非列表扫描时)
    if (currenths->flags & HOST_UP && !o.listscan)
        o.numhosts_up++;

    // 场景1:仅主机发现/列表扫描 → 直接输出+释放(不扫端口)
    if ((o.noportscan && !o.traceroute && !o.script) || o.listscan) {
        // 满足输出条件则打印主机基础信息(IP/MAC/时间)
        if (currenths->flags & HOST_UP || (o.verbose && !o.openOnly())) {
            xml_start_tag("host");
            write_host_header(currenths); // 输出IP/主机名
            printmacinfo(currenths);      // 输出MAC地址
            printtimes(currenths);        // 输出扫描时间
            xml_end_tag();
            log_flush_all();              // 立即刷新日志
        }
        delete currenths; // 释放内存(无需后续扫描)
        o.numhosts_scanned++; // 已扫描数+1
        // 未达最大扫描数则继续,否则退出
        if (!o.max_ips_to_scan || o.max_ips_to_scan > o.numhosts_scanned + Targets.size())
            continue;
        else
            break;
    }

    // 场景2:源地址伪造(--spoof-source)→ 绑定伪造地址到主机
    if (o.spoofsource) {
        o.SourceSockAddr(&ss, &sslen); // 读取用户配置的伪造源地址
        currenths->setSourceSockAddr(&ss, sslen); // 绑定到当前主机
    }

    // 场景3:主机未存活 → 输出基础信息+释放
    if (!(currenths->flags & HOST_UP)) {
        if (o.verbose && (!o.openOnly() || currenths->ports.hasOpenPorts())) {
            xml_start_tag("host");
            write_host_header(currenths);
            xml_end_tag();
        }
        delete currenths; // 释放内存
        o.numhosts_scanned++;
        // 边界检查:未达上限则继续
        if (!o.max_ips_to_scan || o.max_ips_to_scan > o.numhosts_scanned + Targets.size())
            continue;
        else
            break;
    }

    // 场景4:原始扫描(如SYN扫描)→ 配置源地址/网卡/诱饵
    if (o.RawScan()) {
        // 主机未配置源地址:自动补全
        if (currenths->SourceSockAddr(NULL, NULL) != 0) {
            if (o.SourceSockAddr(&ss, &sslen) == 0) {
                currenths->setSourceSockAddr(&ss, sslen);
            } else {
                // 解析本地主机名失败则终止程序
                if (gethostname(myname, FQDN_LEN) || 
                    resolve(myname, 0, &ss, &sslen, o.af()) != 0)
                    fatal("Cannot get hostname!  Try using -S <my_IP_address>...\n");
                o.setSourceSockAddr(&ss, sslen);
                currenths->setSourceSockAddr(&ss, sslen);
                // 仅一次输出源地址猜测警告
                if (!sourceaddrwarning) {
                    error("WARNING: We guessed source address %s, use -S to specify...", 
                          inet_socktop(&ss));
                    sourceaddrwarning = 1;
                }
            }
        }

        // 原始扫描必须指定网卡,无网卡则终止
        if (!currenths->deviceName())
            fatal("Do not have appropriate device name for target");

        // 检查分组内主机是否同构(同一网卡/源地址),不同构则放回队列
        if (Targets.size() && 
            target_needs_new_hostgroup(&Targets[0], Targets.size(), currenths)) {
            returnhost(&hstate); // 主机放回待处理队列
            o.numhosts_up--;     // 存活数回退
            break;               // 退出填充,处理当前组
        }

        // 配置诱饵地址(隐藏真实扫描源)
        o.decoys[o.decoyturn] = currenths->source();
    }

    // 符合条件的主机加入待扫描列表
    Targets.push_back(currenths);
}
核心概念

currenths->flagsTarget结构体的位掩码成员,存储当前主机的所有状态标记。

标志位宏 含义
HOST_UP 主机存活(通过ping/主机发现阶段检测到主机在线)
HOST_DOWN 主机下线(未检测到存活)
HOST_EXCLUDED 主机被排除扫描(用户指定/系统筛选,跳过该主机)
HAS_MAC 该主机已获取到MAC地址(通过ARP扫描/二层探测)
HOST_TIMED_OUT 主机扫描超时(未在指定时间内完成检测)
HOST_UNKNOWN 主机状态未知(无法判断存活/下线)
SCANNED 该主机已完成端口扫描(无需重复扫描)

判断主机是否存活

cpp 复制代码
if (currenths->flags & HOST_UP && !o.listscan)
    o.numhosts_up++;

设置主机状态

cpp 复制代码
// 标记主机为存活
currenths->flags |= HOST_UP;  // 位或运算:将HOST_UP对应的位设为1

// 取消主机存活标记
currenths->flags &= ~HOST_UP; // 位与非运算:将HOST_UP对应的位设为0

// 判断主机是否有MAC地址
if (currenths->flags & HAS_MAC) {
    printmacinfo(currenths); // 输出MAC信息
}

5.4 RawScan原始扫描

RawScan是Nmap的扫描模式标识,代表需要发送"原始IP数据包"的扫描类型,核心是绕开系统TCP/IP栈限制,直接构造、发送底层数据包。

触发场景

当使用需要原始数据包的扫描类型时,o.RawScan()会返回true

  • SYN扫描(-sS)、ACK扫描(-sA)、FIN/XMAS/NULL扫描(-sF/-sX/-sN
  • UDP扫描(-sU)、SCTP扫描(-sY/-sZ
  • 其他需要直接操作数据包的扫描(如Idle扫描、IP协议扫描)

反之 ,CONNECT扫描-sT是普通扫描,不触发RawScan,因为它依赖系统套接字建立完整TCP连接。

RawScan块的核心逻辑
cpp 复制代码
if (o.RawScan()) {
    // 1. 设置源IP地址
    if (currenths->SourceSockAddr(NULL, NULL) != 0) {
        if (o.SourceSockAddr(&ss, &sslen) == 0) {
            currenths->setSourceSockAddr(&ss, sslen);
        } else {
            // 获取主机名,再解析主机名得到IP地址
            if (gethostname(myname, FQDN_LEN) ||
                resolve(myname, 0, &ss, &sslen, o.af()) != 0)
                fatal("Cannot get hostname!  Try using -S <my_IP_address> or -e <interface to scan through>\n");

            // 解析成功后,把地址同步到配置o和当前主机状态
            o.setSourceSockAddr(&ss, sslen);
            currenths->setSourceSockAddr(&ss, sslen);
            // 首次出现该问题时,输出警告
            if (! sourceaddrwarning) {
                error("WARNING: We could not determine for sure which interface to use, so we are guessing %s .  If this is wrong, use -S <my_IP_address>.",
                      inet_socktop(&ss));
                sourceaddrwarning = 1;
            }
        }
    }

    // 2. 验证扫描设备名称:无有效设备名则终止扫描
    if (!currenths->deviceName())
        fatal("Do not have appropriate device name for target");

    // 3. 主机组同构性检查:同一组的主机需满足同构性
    if (Targets.size() && target_needs_new_hostgroup(&Targets[0], Targets.size(), currenths)) {
        returnhost(&hstate);  // 归还当前主机状态资源
        o.numhosts_up--;      // 减少"在线主机数"统计
        break;                // 跳出逻辑,不将当前主机加入Targets
    }

    // 4. 设置诱饵地址:将当前主机源地址写入诱饵列表
    o.decoys[o.decoyturn] = currenths->source();
}
RawScan的核心价值
  1. 更隐蔽 :比如SYN扫描(-sS)仅发送SYN包,不建立完整TCP连接,目标主机日志不会记录完整连接,难以检测
  2. 支持更多高级扫描:系统套接字无法实现FIN/XMAS等扫描,原始数据包可自定义TCP标志位
  3. 权限要求:RawScan需要root/管理员权限(普通用户无法构造原始数据包)
与普通扫描的对比
维度 RawScan(原始扫描) 普通扫描(如CONNECT扫描)
数据包构造 手动构造底层IP/TCP/UDP数据包 系统套接字自动封装数据包
权限 需要root/管理员 普通用户即可
隐蔽性 高(半开连接/无完整连接) 低(完整TCP连接,易被记录)
支持的扫描类型 SYN/UDP/FIN等 仅CONNECT扫描

六、设备类型识别

Nmap提供了两种独立的设备类型识别机制:OS分类(Device_Type)和服务识别(devicetype)。

6.1 OS分类(Device_Type)

Device_Type是OS指纹匹配后生成的设备分类(如router/switch/ip camera),仅由OS探测模块触发 ,核心参数是-O

核心触发参数
参数 作用 关键说明
-O 启用OS探测(必选) 只有开启-O,才会生成OS分类的Device_Type,无-O则完全无此信息
--osscan-guess 模糊匹配OS/设备类型(可选) 对指纹匹配度不高的目标,推测最可能的Device_Type,提升识别概率
--osscan-limit 仅对"大概率存活+有开放/关闭端口"的主机做OS探测(可选) 减少无效探测,加快扫描速度,不影响Device_Type的生成逻辑
-A 综合扫描(包含-O 一键触发OS探测+服务探测,同时生成两类设备类型
实战指令示例
bash 复制代码
# 基础:仅开启OS探测,生成Device_Type
nmap -O 192.168.1.1

# 优化:模糊匹配,提升设备类型识别概率
nmap -O --osscan-guess 192.168.1.1

# 高效:仅对有效主机做OS探测
nmap -O --osscan-limit 192.168.1.0/24
输出示例
复制代码
Device type: broadband router/gateway  # 核心的 Device_Type 输出
Running: Huawei embedded
OS CPE: cpe:/h:huawei:hg8245h
OS details: Huawei HG8245H GPON Terminal (Router)

6.2 服务识别(devicetype)

devicetype是版本探测根据nmap-service-probes规则生成的设备类型(如printer/voip phone/ip camera),仅由服务/版本探测模块触发 ,核心参数是-sV

核心触发参数
参数 作用 关键说明
-sV 启用服务/版本探测(必选) 只有开启-sV,才会生成服务识别的devicetype,无-sV则完全无此信息
--version-intensity <0-9> 调整版本探测强度(可选,默认7) 强度越高,探测包越多,devicetype识别越精准(如9=全探测,0=仅端口扫描)
--version-light 轻量版本探测(等价于--version-intensity 2,可选) 扫描更快,适合批量扫描,识别精度略降
--version-all 全强度版本探测(等价于--version-intensity 9,可选) 识别最精准,适合单目标深度扫描
-A 综合扫描(包含-sV 一键触发服务探测+OS探测,同时生成两类设备类型
实战指令示例
bash 复制代码
# 基础:仅开启服务/版本探测,生成devicetype
nmap -sV 192.168.1.1

# 精准:全强度探测,提升devicetype识别精度
nmap -sV --version-all 192.168.1.1

# 高效:轻量探测,批量扫描
nmap -sV --version-light 192.168.1.0/24
输出示例
复制代码
PORT   STATE SERVICE VERSION
80/tcp open  http    Huawei HG8245H GPON Terminal httpd (router)  # 括号内是 devicetype
53/tcp open  dns     dnsmasq 2.80 (embedded) (router)

6.3 关键对比与实战组合

需求 对应参数组合 输出结果
仅要OS分类的Device_Type -O 仅显示Device type: xxx
仅要服务识别的devicetype -sV 仅在端口行显示(devicetype)
同时获取两类设备类型(推荐) -A(等价于-O -sV -sC 同时显示Device type和端口级devicetype
组合指令示例
bash 复制代码
# 综合扫描:一键触发OS探测+服务探测,同时生成两类设备类型
nmap -A 192.168.1.1
输出示例(两类设备类型同时存在)
复制代码
MAC Address: 00:11:22:33:44:55 (Huawei Technologies)
Device type: broadband router/gateway  # OS 分类的 Device_Type
Running: Huawei embedded
PORT   STATE SERVICE VERSION
80/tcp open  http    Huawei HG8245H GPON Terminal httpd (router)  # 服务识别的 devicetype

七、核心函数深度解析

7.1 nexthost函数

nexthost是Nmap中获取下一个待扫描主机的核心函数,采用「批次加载(batch)」的设计。

函数签名
c 复制代码
Target *nexthost(HostGroupState *hs, struct addrset *exclude_group,
                 const struct scan_lists *ports, int pingtype);
参数说明
参数名 类型 核心含义
hs HostGroupState* 主机组状态结构体指针,保存「当前主机批次、批次大小、下一个要取的主机索引」等状态
exclude_group struct addrset* 需要排除的主机集合(如已扫描过、用户指定排除的主机)
ports const scan_lists* 扫描端口配置(主机发现/端口扫描的端口列表)
pingtype int 主机发现类型(如ICMP ping、TCP SYN ping等)
返回值 Target* 待扫描主机对象指针(有可用主机则非NULL,无则返回NULL)
核心逻辑
c 复制代码
// 第一步:检查当前批次是否已取完
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++];
执行示例

假设初始状态:

  • hs->hostbatch = [主机A, 主机B, 主机C](当前批次有3台主机)
  • hs->current_batch_sz = 3
  • hs->next_batch_no = 0
调用次数 执行逻辑 返回值 索引变化(next_batch_no)
第1次 0 < 3 → 不刷新,直接取hostbatch[0] 主机A 0 → 1
第2次 1 < 3 → 取hostbatch[1] 主机B 1 → 2
第3次 2 < 3 → 取hostbatch[2] 主机C 2 → 3
第4次 3 ≥ 3 → 调用refresh_hostbatch,加载新批次[主机D, 主机E](sz=2,索引重置为0); 0 < 2 → 取hostbatch[0] 主机D 0 → 1
第N次 待扫描主机池空 → refresh_hostbatch后sz=0; 0 ≥ 0 → 返回NULL NULL
设计亮点
  1. 分批加载:不一次性加载所有待扫描主机,而是分批(batch)获取,避免扫描大量主机时占用过多内存
  2. 惰性刷新:仅当当前批次取完时才刷新,减少不必要的IO/计算开销
  3. 状态维护 :通过HostGroupState保存批次状态,保证每次调用能按顺序取主机,逻辑连贯

7.2 n_ctime函数

n_ctime是Nmap源代码中的时间戳处理函数,是标准C库ctime()的定制封装,用于将时间戳转换为人类可读的字符串格式。

函数原型
c 复制代码
const char n_ctime(const struct timeval tv);
典型实现
c 复制代码
const char n_ctime(const struct timeval tv) {
    static char buf[26];
    time_t t;

    if (tv == NULL)
        return "(no time)";
    t = tv->tv_sec;
    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", localtime(&t));
    return buf;
}
关键特性
  • 输入struct timeval类型的高精度时间戳(包含秒和微秒)
  • 输出:格式化的字符串(如2026-01-30 14:30:45)
  • 线程安全性:使用静态缓冲区,非线程安全(Nmap中通过单线程日志输出避免问题)
设计初衷
标准ctime() n_ctime()
格式不适合机器解析 采用ISO 8601风格的YYYY-MM-DD HH:MM:SS格式,便于日志分析工具处理
包含冗余的星期信息 仅保留关键的日期和时间信息,减少日志体积
无微秒精度支持 可扩展支持微秒级时间戳(部分实现中包含)
线程不安全 明确的非线程安全设计,配合单线程日志输出使用
典型应用场景
  1. 扫描日志输出
c 复制代码
// 记录扫描开始时间
struct timeval start_time;
gettimeofday(&start_time, NULL);
logwrite(LOGPLAIN, "Scan started at %s\n", nctime(&starttime));
  1. 主机扫描时间戳
c 复制代码
// 记录主机扫描的开始和结束时间
HostScanState *hss = malloc(sizeof(HostScanState));
gettimeofday(&hss->start_time, NULL);

// 执行扫描...

gettimeofday(&hss->end_time, NULL);
logwrite(LOGMACHINE, "Host: %s\tStart: %s\tEnd: %s\n",
          host->ipstr, nctime(&hss->starttime), nctime(&hss->endtime));
  1. 端口扫描时间戳
c 复制代码
// 记录端口扫描的响应时间
PortScanResult *psr = malloc(sizeof(PortScanResult));
gettimeofday(&psr->response_time, NULL);

printf("Port %d/tcp open\tResponse time: %s\n",
       psr->port, nctime(&psr->responsetime));

八、总结

Nmap作为网络扫描领域的标杆工具,其设计充分体现了"效率、兼容性、正确性"的平衡:

核心设计思想

  1. 分组扫描:不一次性加载所有目标,而是凑够一组、扫描一组,避免内存过载,同时保证扫描效率
  2. 批量处理 :大部分扫描用ultra_scan批量处理,特殊扫描(空闲/FTP)才逐个处理,平衡效率和兼容性
  3. 延迟加载 :通过delayed_options机制,把依赖主机状态的动态配置推迟到「凑组阶段确认主机有效后」应用
  4. 状态封装HostGroupState封装所有目标分组状态,Targets封装待扫描主机,避免参数散落在代码中

技术亮点

  1. 高效并发 :动态计算分组大小 + 批量ultra_scan保证扫描效率
  2. 内存安全:无效主机立即释放,所有动态分配的对象都在作用域内销毁,符合RAII原则
  3. 标准化输出:统一输出XML格式(带时间戳、状态标记),兼顾机器解析和人类阅读
  4. 模块化设计ultra_scan/service_scan/os_scan等函数各司其职,新增扫描类型只需加分支

实战建议

  1. 新手优先掌握-sn(存活检测)、-sS(快速端口扫描)、-sV(版本探测)
  2. 渗透测试常用-A(综合扫描,等价于-O -sV -sC
  3. 权限差异-sS需要root/管理员权限,-sT/-sn普通用户即可运行
  4. 设备识别 :想获取OS维度的设备类型,加-O;想获取服务维度的设备类型,加-sV;想两者都要,加-A

通过深入理解Nmap的内部工作机制,我们不仅能更好地使用这个工具,还能学习到优秀的软件设计思想和网络编程技巧。希望本文能帮助读者全面掌握Nmap的核心技术,在实际工作中发挥更大的价值。


参考资料


作者注:本文基于Nmap 7.98版本源代码分析,部分实现细节可能随版本更新而变化。建议读者结合实际源代码进行学习,以获得最准确的理解。

相关推荐
肉包_5112 小时前
两个数据库互锁,用全局变量互锁会偶发软件卡死
开发语言·数据库·c++
霖霖总总2 小时前
[小技巧64]深入解析 MySQL InnoDB 的 Checkpoint 机制:原理、类型与调优
数据库·mysql
Trouvaille ~2 小时前
【Linux】UDP Socket编程实战(一):Echo Server从零到一
linux·运维·服务器·网络·c++·websocket·udp
咖丨喱3 小时前
IP校验和算法解析与实现
网络·tcp/ip·算法
此刻你3 小时前
常用的 SQL 语句
数据库·sql·oracle
それども3 小时前
分库分表的事务问题 - 怎么实现事务
java·数据库·mysql
那就回到过去3 小时前
交换机特性
网络·hcip·ensp·交换机
·云扬·3 小时前
MySQL Binlog 配置指南与核心作用解析
数据库·mysql·adb
盐焗西兰花4 小时前
鸿蒙学习实战之路-Reader Kit构建阅读器最佳实践
学习·华为·harmonyos