ICMP PING 实现主机探测(包含完整实现代码)
本文介绍了ICMP协议原理及基于Go语言的Ping探测实现。主要内容包括:1. ICMP协议基础:ICMP是网络控制协议,不传输用户数据,直接封装在IP包中;2. Ping探测原理:使用ICMP Echo Request/Reply报文进行主机存活检测,核心字段包括类型/代码、校验和、标识符和序列号;3. Go实现方案:利用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 Request 和 ICMP Echo Reply 这两种数据包来探测主机是否在线
|
特性 |
Ping扫描 |
ICMP扫描 |
|
关系 |
子集,是ICMP扫描的一种特定形式。 |
超集,包含Ping扫描和其他类型的扫描。 |
|
使用协议 |
仅使用 ICMP Echo Request/Reply(类型 8/0)。 |
使用多种ICMP类型,如Echo、Timestamp、Address Mask等。 |
|
目的 |
探测主机是否存活。 |
探测主机存活、获取网络信息(如时间、掩码)。 |
|
隐蔽性 |
低,最容易被防火墙和安全策略拦截。 |
相对较高,可以利用不常见的ICMP类型绕过简单的过滤规则。 |
|
常用工具 |
命令, (在局域网中)。 |
(使用 , , 等选项), 。 |
icmp中ping探测的核心字段详解
- 类型 (Type) 和代码 (Code):定义了ICMP报文的种类。这是ICMP的“语言”。
-
- Type=8, Code=0: Echo Request (回显请求,即ping请求) - “你好,在吗?”
- Type=0, Code=0: Echo Reply (回显应答,即ping回复) - “我在!”
- 其他常见类型:
Type=3(目标不可达),Type=11(超时)等。
- 校验和 (Checksum):用于检查ICMP报文在传输过程中是否出错。
- 标识符 (Identifier):通常设置为发送进程的进程ID(PID)。用于在一台主机上同时运行多个ping程序时,区分回响应答应该交给哪个进程。这是匹配请求和回复的第一个关键。
- 序列号 (Sequence Number):每发送一个新的Echo Request,这个号码就会增加(通常从0开始)。这是匹配请求和回复的第二个关键。 程序收到一个Reply后,通过查看其中的ID和序列号,就知道它对应的是之前发出的哪个Request。
- 数据 (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.1 到 192.168.1.255)的思路如下:
核心逻辑
- 创建Socket:创建一个用于发送和接收ICMP报文的原始套接字(Raw Socket)。这通常需要管理员/root权限。
- 构造ICMP Echo Request包:准备好ICMP报文头(类型8,代码0),并填充一个你自定义的标识符(ID)和一个起始序列号(Seq)。
- 循环发送:遍历目标IP地址列表(如
192.168.1.1到192.168.1.255)。
-
- 对于每个IP,将序列号(Seq)递增。
- 可选:在数据字段填充一些内容(如时间戳)。
- 计算校验和并填入报文头。
- 通过socket的
sendto()函数,将这个ICMP包发送给目标IP。 - 记录:将
(标识符, 当前序列号, 目标IP, 发送时间)存入一个字典(Map)中,以便后续匹配回复。
- 接收与匹配:进入一个接收循环,使用
recvfrom()或select()/epoll()等函数监听socket。
-
- 收到一个数据包后,先检查它是不是一个IP包,并且IP协议号是1(代表ICMP)。
- 解析ICMP头部,检查类型是否是0(Echo Reply)。
- 从Reply报文中提取出标识符(ID) 和序列号(Seq)。
- 用这个
(ID, Seq)组合去之前发送记录的字典里查找。如果找到了,就意味着我们收到了对应IP的回复,证明该主机在线。 - 记录该IP为存活主机,并从字典中移除这条记录。
- 超时处理:设置一个超时时间(如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_RAW和IPPROTO_ICMP - 发送ICMP Echo请求:系统会处理IP头部的构造
- 接收ICMP响应:系统过滤并传递ICMP回复
权限要求较低
与真正的原始套接字相比,这个实现需要的权限较低:
// 这个调用在Windows上:
// - 不需要管理员权限(大多数情况下)
// - 使用WinSock的ICMP功能而非真正的原始套接字
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
部分程序所用知识点解析
并发控制
在该程序中的意思:在当前函数(通常是 goroutine)执行完毕并返回时,释放一个信号量(semaphore)许可,从而允许另一个正在等待的 goroutine 继续执行。
它是一种利用 defer 机制来确保并发控制的代码块一定能被执行的优雅方式。
sem(信号量):
-
- 这通常是一个有缓冲的 channel,例如
sem := make(chan struct{}, 10)。 - 这个 channel 的缓冲区大小代表了允许同时执行的 goroutine 的最大数量(即并发度)。
- 它可以被想象成一个有 N 把钥匙的钥匙盒。要执行任务,必须先拿到一把钥匙。
- 这通常是一个有缓冲的 channel,例如
<-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 的使用非常简单,它主要提供两个方法:
mu.Lock():获取锁。如果锁已被其他 goroutine 持有,则当前 goroutine 会阻塞,直到锁被释放。mu.Unlock():释放锁。将锁释放,允许其他正在等待的 goroutine 来获取它。
重要原则: Lock 和 Unlock 必须成对出现。通常使用 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() 调用会一直等待,直到:
- 成功读取到数据。
- 连接被对方关闭。
- 发生错误。
如果没有设置超时,你的程序可能会因为一个缓慢或恶意的对端而永远等待下去,导致 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
其它
在我写完针对多协议端口扫描和主机探测的工具后,希望通过文章整理用到的知识点,非常欢迎各位大佬指正文章内容的错误和工具的问题。
更多推荐



所有评论(0)