目录

前言

ping探测

什么是icmp

ping和icmp的关系

icmp中ping探测的核心字段详解

针对内网主机存活探测的程序设计

为什么这里用到了原始套接字却可以在windows上运行

Go语言的跨平台抽象

Windows的ICMP支持

权限要求较低

部分程序所用知识点解析

并发控制

互斥锁

设置访问截至时间

代码分析

处理输入地址

创建连接并构造数据包

序列化并发送数据

监听并分析响应

源代码

其它


前言

前面已经对六种端口扫描方法进行了讲解,现在开始讲解常见的几种主机探测方法。主机探测在内网攻防中较为常用,探测出的主机数量越多,攻击面就越大,主机探测+端口扫描的有效配合在信息收集的阶段不可或缺。

ping探测

什么是icmp

icmp是控制报文协议

首先要明确一个关键点:ICMP不是用来传输用户数据的(如文件、视频流),而是用来传输网络本身的控制和管理信息的。 它的“数据”是消息本身。

它的工作方式可以类比为邮政系统中的纸条

  • TCP/UDP:像寄送包裹或信件,有明确的发件人、收件人地址(IP),和具体的收件人/房间号(端口),里面装着你要送的东西(应用数据)。
  • ICMP:像是一个邮局工作人员之间传递的内部纸条。这个纸条没有“端口号”,它本身就是全部内容。例如,一张纸条写着:“通往3号街的信件无法投递,请通知发货人”(这就是一个 ICMP Destination Unreachable 报文)。

ICMP 报文的结构(以 Echo Request/Reply 为例):

一个ICMP报文是直接封装在IP数据包里的。它没有TCP/UDP那样的“端口”。

|-----------------------------------------------------------------------------|
|  Ethernet Header |    IP Header    |     ICMP Header    |    ICMP Data      |
| (目标MAC,源MAC)  |(目标IP,源IP,协议=1)|(类型,代码,校验和,ID,序列号)|   (任意数据)     
|-----------------------------------------------------------------------------|

ping和icmp的关系

Ping扫描是ICMP扫描的一种最常见、最具体的表现形式,但ICMP扫描的范围更广

Ping扫描特指使用 ICMP Echo RequestICMP Echo Reply 这两种数据包来探测主机是否在线

特性

Ping扫描

ICMP扫描

关系

子集,是ICMP扫描的一种特定形式。

超集,包含Ping扫描和其他类型的扫描。

使用协议

仅使用 ICMP Echo Request/Reply(类型 8/0)。

使用多种ICMP类型,如Echo、Timestamp、Address Mask等。

目的

探测主机是否存活。

探测主机存活、获取网络信息(如时间、掩码)。

隐蔽性

低,最容易被防火墙和安全策略拦截。

相对较高,可以利用不常见的ICMP类型绕过简单的过滤规则。

常用工具

ping

命令,nmap -sn

(在局域网中)。

nmap

(使用 -PP

, -PM

, -PO

等选项),hping3


icmp中ping探测的核心字段详解

  1. 类型 (Type) 和代码 (Code):定义了ICMP报文的种类。这是ICMP的“语言”。
    • Type=8, Code=0Echo Request (回显请求,即ping请求) - “你好,在吗?”
    • Type=0, Code=0Echo Reply (回显应答,即ping回复) - “我在!”
    • 其他常见类型:Type=3(目标不可达),Type=11(超时)等。
  1. 校验和 (Checksum):用于检查ICMP报文在传输过程中是否出错。
  2. 标识符 (Identifier):通常设置为发送进程的进程ID(PID)。用于在一台主机上同时运行多个ping程序时,区分回响应答应该交给哪个进程。这是匹配请求和回复的第一个关键。
  3. 序列号 (Sequence Number):每发送一个新的Echo Request,这个号码就会增加(通常从0开始)。这是匹配请求和回复的第二个关键。 程序收到一个Reply后,通过查看其中的ID和序列号,就知道它对应的是之前发出的哪个Request。
  4. 数据 (Data):这是可选的负载。在Echo Request中,可以放入任意数据(比如一长串的字母),接收主机必须在Echo Reply中原封不动地返回这些数据。这可以用来测试网络质量和MTU(最大传输单元)。但再次强调,这不是用于传输用户文件的。

“传输”过程:
主机A发送一个ICMP Echo Request(类型8)给主机B -> 主机B的协议栈收到后,认出这是一个Echo Request -> 主机B的协议栈立即构造一个ICMP Echo Reply(类型0),并将Request中的标识符、序列号和数据字段完全复制到Reply中 -> 主机B将这个Reply发回给主机A -> 主机A通过匹配ID和序列号,确认这是一个有效的回复。


针对内网主机存活探测的程序设计

基于以上原理,编写一个高效的内网网段扫描器(例如扫描 192.168.1.1192.168.1.255)的思路如下:

核心逻辑

  1. 创建Socket:创建一个用于发送和接收ICMP报文的原始套接字(Raw Socket)。这通常需要管理员/root权限。
  2. 构造ICMP Echo Request包:准备好ICMP报文头(类型8,代码0),并填充一个你自定义的标识符(ID)和一个起始序列号(Seq)。
  3. 循环发送:遍历目标IP地址列表(如192.168.1.1192.168.1.255)。
    • 对于每个IP,将序列号(Seq)递增。
    • 可选:在数据字段填充一些内容(如时间戳)。
    • 计算校验和并填入报文头。
    • 通过socket的sendto()函数,将这个ICMP包发送给目标IP。
    • 记录:将(标识符, 当前序列号, 目标IP, 发送时间)存入一个字典(Map)中,以便后续匹配回复。
  1. 接收与匹配:进入一个接收循环,使用recvfrom()select()/epoll()等函数监听socket。
    • 收到一个数据包后,先检查它是不是一个IP包,并且IP协议号是1(代表ICMP)。
    • 解析ICMP头部,检查类型是否是0(Echo Reply)
    • 从Reply报文中提取出标识符(ID)序列号(Seq)
    • 用这个(ID, Seq)组合去之前发送记录的字典里查找。如果找到了,就意味着我们收到了对应IP的回复,证明该主机在线
    • 记录该IP为存活主机,并从字典中移除这条记录。
  1. 超时处理:设置一个超时时间(如2秒)。检查发送记录字典,如果某个记录的存在时间超过了超时时间,则认为该主机无响应,标记为离线超时
// 创建 ICMP Echo 消息,这段代码创建了一个完整的ICMP Echo Request消息,用于向目标主机发送"你在吗?"的询问
pid := os.Getpid() & 0xffff //生成唯一ID,会话标识符,用于匹配请求和响应,目标主机回复时会将相同的ID返回
msg := icmp.Message{
		Type: ipv4.ICMPTypeEcho, //指定这是一个ICMP Echo Request(回显请求)
		Code: 0,
		Body: &icmp.Echo{
		ID:   pid,
		Seq:  1,               //包序列标识,用于检测丢包和排序,每次发送递增,回复包包含相同序列号
		Data: []byte("HELLO"), //测试数据,用于验证数据完整性,目标主机应原样返回此数据
		},
}

为什么这里用到了原始套接字却可以在windows上运行

Go语言的跨平台抽象

Go语言的 golang.org/x/net/icmp 包对底层ICMP实现进行了跨平台封装:

// 在Windows上,这个调用会被转换为适当的WinSock API调用
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")

Windows的ICMP支持

Windows系统本身就支持ICMP协议,虽然不像Unix系统那样直接提供原始套接字,但通过WinSock API可以:

  • 创建ICMP套接字:使用 SOCK_RAWIPPROTO_ICMP
  • 发送ICMP Echo请求:系统会处理IP头部的构造
  • 接收ICMP响应:系统过滤并传递ICMP回复

权限要求较低

与真正的原始套接字相比,这个实现需要的权限较低:

// 这个调用在Windows上:
// - 不需要管理员权限(大多数情况下)
// - 使用WinSock的ICMP功能而非真正的原始套接字
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")

部分程序所用知识点解析

并发控制

在该程序中的意思:在当前函数(通常是 goroutine)执行完毕并返回时,释放一个信号量(semaphore)许可,从而允许另一个正在等待的 goroutine 继续执行。

它是一种利用 defer 机制来确保并发控制的代码块一定能被执行的优雅方式。

  1. sem (信号量):
    • 这通常是一个有缓冲的 channel,例如 sem := make(chan struct{}, 10)
    • 这个 channel 的缓冲区大小代表了允许同时执行的 goroutine 的最大数量(即并发度)。
    • 它可以被想象成一个有 N 把钥匙的钥匙盒。要执行任务,必须先拿到一把钥匙。
  1. <-sem:
    • 这是一个接收操作(从 channel 中读取)。
    • 但在这个 defer 上下文中,它扮演的角色是 “释放”或“归还” 一个许可。
    • 为什么是“释放”?因为通常在函数开头会有一个 sem <- struct{}{} 操作来“获取”一个许可(往 channel 里塞一个值,如果 channel 满了就会阻塞,从而限制并发)。函数结束时,就需要通过 <-sem(从 channel 里取走那个值)来腾出一个空位,让其他等待的 goroutine可以获取许可并执行。
package main

import (
	"fmt"
	"net/http"
	"sync"
	"time"
)

func main() {
	urls := []string{"https://www.google.com", "https://www.github.com", "https://www.stackoverflow.com", "https://go.dev"} // ... 很多网址

	// 1. 创建一个缓冲大小为 3 的信号量 channel
	// 这意味着最多允许 3 个并发 goroutine
	maxConcurrency := 3
	sem := make(chan struct{}, maxConcurrency)

	var wg sync.WaitGroup

	for _, url := range urls {
		  wg.Add(1)

		  // 启动一个 goroutine 来处理每个 URL
		  go func(u string) {
    			defer wg.Done() // 确保 WaitGroup 计数器在 goroutine 结束时减一

    			// 2. 获取一个许可(向 channel 发送一个值)
    			// 如果已经有 3 个 goroutine 在运行(channel 已满),这里会阻塞,直到有许可被释放
    			sem <- struct{}{}

    			// 3. 使用 defer 来确保无论函数如何结束,许可都会被释放
    			defer func() {<-sem }()  // 释放许可(从 channel 接收值)

			// 真正的业务逻辑:执行 HTTP 请求
			resp, err := http.Get(u)
			if err != nil {
				fmt.Printf("Error fetching %s: %v\n", u, err)
				return
			}
			defer resp.Body.Close()
			time.Sleep(2 * time.Second) // 模拟耗时操作
			fmt.Printf("Fetched %s, status: %s\n", u, resp.Status)

		}(url)
	}

	// 等待所有 goroutine 完成
	wg.Wait()
	fmt.Println("All requests completed!")
}

互斥锁

核心意思:

var mu sync.Mutex 是声明了一个名为 mu互斥锁(Mutex, Mutual Exclusion)

它的核心目的是:保证在同一时间,只有一个 goroutine 能够访问某一段代码或某一块数据,从而防止多个 goroutine 同时读写共享资源时发生数据竞争(Data Race),确保数据的一致性和正确性。

你可以把它想象成一个房间的钥匙()。这个房间(共享资源)一次只允许一个人(goroutine)进入。一个人进去后会把门锁上(Lock),拿着钥匙。当他处理完事情出来后(Unlock),会把钥匙放回去,下一个人才可以进去。

为什么需要它?:

Go 语言并发模型的核心是“通过通信共享内存”,但有时直接使用共享内存更为简单。当多个 goroutine 同时读写一个变量(如一个计数器、一个切片、一个 map)时,就会发生不可预知的结果。

如何使用:

Mutex 的使用非常简单,它主要提供两个方法:

  1. mu.Lock()获取锁。如果锁已被其他 goroutine 持有,则当前 goroutine 会阻塞,直到锁被释放。
  2. mu.Unlock()释放锁。将锁释放,允许其他正在等待的 goroutine 来获取它。

重要原则: LockUnlock 必须成对出现。通常使用 defer 来调用 Unlock 是一个非常好的实践,因为它能确保即使在函数中途发生 panic,锁也一定能被释放,避免死锁。

package main

import (
    "fmt"
    "sync"
)

func main() {
    count := 0
    var wg sync.WaitGroup
    var mu sync.Mutex // 第 1 步:声明一个互斥锁

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()       // 第 2 步:在访问共享资源前加锁
            defer mu.Unlock() // 第 3 步:使用 defer 确保函数结束时解锁
            count++         // 临界区:现在同一时刻只有一个 goroutine 能执行这行代码
        }()
    }

    wg.Wait()
    fmt.Println("Count:", count) // 结果永远是 1000
}

设置访问截至时间

核心意思:

这行代码的意思是:为这个网络连接(conn)设置一个读操作的绝对截止时间(Deadline)。

  • time.Now().Add(timeout):计算出一个未来的时间点。time.Now() 是当前时间,.Add(timeout) 加上一个超时时间间隔(例如 5 * time.Second),得到的结果就是“从现在起5秒后”。
  • SetReadDeadline:网络连接的方法,它接受一个 time.Time 类型的参数。它告诉系统:“在这个指定的时间点之后,任何读操作(如 Read, ReadFrom)如果还没有完成,就应该自动失败并返回一个超时错误。”

简单比喻: 就像给读操作设置了一个闹钟。闹钟一响(截止时间一到),无论书读到哪一页,都必须合上书并站起来(操作失败,返回超时错误)。

各种超时:

  • SetReadDeadline:只控制操作。
  • SetWriteDeadline:只控制操作。
  • SetDeadline:同时控制读和写操作。

为什么需要它?:

在网络编程中,I/O操作(读/写)是阻塞的。这意味着一个 conn.Read() 调用会一直等待,直到:

  1. 成功读取到数据。
  2. 连接被对方关闭。
  3. 发生错误。

如果没有设置超时,你的程序可能会因为一个缓慢或恶意的对端而永远等待下去,导致 goroutine 被挂起(** goroutine 泄漏)或程序失去响应。设置读超时是保证程序健壮性可靠性**的关键手段。

循环读:每次读之前重置超时

在一个需要持续读取数据的循环中,你通常需要在每次读操作之前重新设置超时。否则,第一次超时后,后续所有的读操作都会立即超时。

func readLoop(conn net.Conn) {
    buffer := make([]byte, 256)
    readTimeout := 10 * time.Second // 每次读操作的超时时间

    for {
        // !!! 在每次循环开始时重置读超时 !!!
        conn.SetReadDeadline(time.Now().Add(readTimeout))

        n, err := conn.Read(buffer)
        if err != nil {
            // 使用 net.Error 接口的 Timeout() 方法来判断是否是超时错误
            if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
                fmt.Println("读超时,可能对方无响应,但连接保持...")
                continue // 或者 break,取决于你的需求
            }
            fmt.Println("连接出错或关闭:", err)
            break
        }
        fmt.Printf("收到消息: %s", buffer[:n])
        // ... 处理收到的数据
    }
}

代码分析

处理输入地址

//处理ip地址
host_ip := host.IP.String()                    //把ip地址转为string类型,这里host变量为输入的ip
arr := strings.Split(host_ip, ".")             //根据'.'将字符串分割成数组
arr[3] = fmt.Sprintf("%d", j)                  //把int类型转为string类型再赋值
arr1 := strings.Join(arr, ".")                 //用'.'重新连接数字每一项
host_use, err := net.ResolveIPAddr("ip", arr1) //将字符串格式的IP地址解析为网络地址结构体
if err != nil {
    return
}

创建连接并构造数据包

//创建icmp连接
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
    return
}
defer conn.Close()

// 创建 ICMP Echo 消息,这段代码创建了一个完整的ICMP Echo Request消息,用于向目标主机发送"你在吗?"的询问
pid := os.Getpid() & 0xffff //生成唯一ID,会话标识符,用于匹配请求和响应,目标主机回复时会将相同的ID返回
msg := icmp.Message{
    Type: ipv4.ICMPTypeEcho, //指定这是一个ICMP Echo Request(回显请求)
    Code: 0,
    Body: &icmp.Echo{
       ID:   pid,
       Seq:  j,               //包序列标识,用于检测丢包和排序,每次发送递增,回复包包含相同序列号
       Data: []byte("HELLO"), //测试数据,用于验证数据完整性,目标主机应原样返回此数据
    },
}

Type: ipv4.ICMPTypeEcho  // 值 = 8

作用:标识这是一个 ICMP Echo Request(回显请求)

完整的ICMP类型表

// IPv4 ICMP类型常量定义
const (
    ICMPTypeEchoReply              = 0   // 回显应答
    ICMPTypeDestinationUnreachable = 3   // 目标不可达
    ICMPTypeSourceQuench           = 4   // 源站抑制
    ICMPTypeRedirect               = 5   // 重定向
    ICMPTypeEcho                   = 8   // 回显请求
    ICMPTypeRouterAdvertisement    = 9   // 路由器通告
    ICMPTypeRouterSolicitation     = 10  // 路由器请求
    ICMPTypeTimeExceeded           = 11  // 超时
    ICMPTypeParameterProblem       = 12  // 参数问题
    ICMPTypeTimestamp              = 13  // 时间戳请求
    ICMPTypeTimestampReply         = 14  // 时间戳应答
)
Code: 0  // 对于Echo Request必须为0

作用:对Type字段的进一步细分,提供更精确的错误或状态信息。

对于Echo Request/Reply (Type=8/0)

Code: 0  // 唯一有效值,无其他含义

对于Destination Unreachable (Type=3)

// 目标不可达的各种原因
Code: 0  // Net unreachable - 网络不可达
Code: 1  // Host unreachable - 主机不可达
Code: 2  // Protocol unreachable - 协议不可达
Code: 3  // Port unreachable - 端口不可达(重要的扫描结果!)
Code: 4  // Fragmentation needed - 需要分片但DF位已设置
Code: 5  // Source route failed - 源站路由失败

对于Redirect (Type=5)

// 重定向类型
Code: 0  // Redirect datagram for the network
Code: 1  // Redirect datagram for the host
Code: 2  // Redirect datagram for the TOS and network
Code: 3  // Redirect datagram for the TOS and host

对于Time Exceeded (Type=11)

// 超时原因
Code: 0  // TTL expired in transit - TTL超时
Code: 1  // Fragment reassembly time exceeded - 分片重组超时

序列化并发送数据

//序列化信息,将 ICMP 消息结构序列化为字节切片
wb, err := msg.Marshal(nil)
if err != nil {
    return
}

//发送ping
_, err = conn.WriteTo(wb, host_use)
if err != nil {
    return
}

wb, err := msg.Marshal(nil)
  • msg.Marshal():ICMP消息的序列化方法
  • nil:可选的缓冲区参数(传入nil表示自动创建缓冲区)
  • wb:返回的字节切片(wire bytes - 网络字节)
  • err:错误信息(序列化失败时返回)

生成的字节切片结构

wb = []byte{
    0x08,       // Type = 8 (Echo Request)
    0x00,       // Code = 0
    0xAB, 0xCD, // Checksum (计算得出)
    0x12, 0x34, // Identifier (pid)
    0x00, 0x01, // Sequence (j)
    'H', 'E', 'L', 'L', 'O', // Data
}

Marshal() 方法会自动计算ICMP校验和:

// 在Marshal过程中:
1. 临时将校验和字段设为0
2. 对整个ICMP消息计算16位补码和
3. 将结果存入校验和字段

监听并分析响应

deadline := time.Now().Add(3 * time.Second)

for {
				//设置读取超时防止阻塞
				conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))

				//设置总超时,for循环时间到了直接退出
				if time.Now().After(deadline) {
					return
				}

				//读取响应
				rb := make([]byte, 1500) //rb存储读取的内容
				n, _, err := conn.ReadFrom(rb)
				if err != nil {
					return
				}

				//响应解析和验证
				rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n]) //告诉解析器这是ICMP协议的数据,解析出完整的ICMP消息结构
				if err != nil {
					return
				}
				if rm.Type != ipv4.ICMPTypeEchoReply { //检查消息类型,只处理ICMPTypeEchoReply(类型0,回显应答)
					return
				}
				echo, ok := rm.Body.(*icmp.Echo) //类型断言检查 Body 是否为 *icmp.Echo。echo:如果类型断言成功,返回转换后的 *icmp.Echo 对象;如果失败,返回该类型的零值(nil)。ok:true 表示类型断言成功,false 表示失败
				if ok {
					if echo.ID == pid && echo.Seq == seq {

						result := PINGResult{
							IP:       Ip,
							HostInfo: "",
							State:    "up",
							Reason:   "收到响应",
						}

						mu.Lock()
						results_ping = append(results_ping, result)
						mu.Unlock()
					}
				}
			}

分析响应

//响应解析和验证
rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n]) //告诉解析器这是ICMP协议的数据,解析出完整的ICMP消息结构
if err != nil {
    return
}

if rm.Type != ipv4.ICMPTypeEchoReply { //检查消息类型,只处理ICMPTypeEchoReply(类型0,回显应答)
    return
}

上面的代码是让程序按照icmp的形式解析数据,下面的代码才是检查消息类型

// 示例
ipv4.ICMPTypeEchoReply.Protocol() // 返回 1 (ICMP协议号)
ipv4.ICMPTypeEchoReply            // 返回 0 (Echo Reply类型值)

源代码

因为后续代码有所改动,笔记没有更新改动后的,所以这里给出最终的代码链接

https://github.com/yty0v0/ReconQuiver/blob/main/internal/discovery/icmp_host/ping.go

其它

在我写完针对多协议端口扫描和主机探测的工具后,希望通过文章整理用到的知识点,非常欢迎各位大佬指正文章内容的错误和工具的问题。

这里附上工具链接 https://github.com/yty0v0/ReconQuiver

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新

更多推荐