使用Go构建网络漏洞扫描器:从端口扫描到漏洞检测

本文详细介绍了如何使用Go语言构建功能完整的网络漏洞扫描器,涵盖端口扫描、多线程优化、服务识别和漏洞检测等核心技术,提供完整的代码实现和实际应用示例。

使用Go构建网络漏洞扫描器

渗透测试使组织能够针对网络中的潜在安全弱点,并在恶意行为者利用漏洞之前提供修复需求。

在本文中,我们将使用Go语言创建一个简单但相当强大的网络漏洞扫描器。Go语言非常适合网络编程,因为它设计时考虑了并发性,并拥有出色的标准库。

1. 项目设置

创建漏洞扫描器

我们希望构建一个简单的CLI工具,能够扫描主机网络,查找开放端口、运行的服务并发现可能的漏洞。扫描器开始时非常简单,但随着我们添加功能将变得越来越强大。

首先,创建一个新的Go项目:

1
2
3
mkdir goscan
cd goscan
go mod init github.com/yourusername/goscan

这将为我们的项目初始化一个新的Go模块,帮助我们管理依赖关系。

配置包和环境

对于我们的扫描器,我们将利用几个Go包:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import (
    "fmt"
    "net"
    "os"
    "strconv"
    "sync"
    "time"
)

func main() {
    fmt.Println("GoScan - 网络漏洞扫描器")
}

这只是我们的初始设置。对于某些初始功能来说已经足够,但我们会根据需要添加更多导入。现在,像net这样的标准库包将处理我们所需的大部分网络功能,sync将处理并发等。

网络扫描的道德考虑和风险

在开始实施之前,我们应该谈谈网络扫描的道德考虑。未经授权的网络扫描或枚举在世界许多地方是非法的,被视为网络攻击的载体。您必须始终遵循这些规则:

  • 权限:只扫描您拥有或明确获得扫描权限的网络和系统
  • 范围:为扫描定义明确的范围,不要超出
  • 时机:不要进行可能导致服务中断或引发安全警报的超速扫描
  • 披露:如果发现漏洞,请负责任地向适当的系统所有者报告
  • 法律合规:了解并遵守管理网络扫描的当地法律

滥用扫描工具可能导致法律诉讼、系统损坏或意外的拒绝服务。我们的扫描器将包括速率限制等保护措施,但最终责任在于用户是否道德地使用它。

2. 简单端口扫描器

漏洞评估基于端口扫描。我们在这些开放端口上寻找的正是可能存在的易受攻击服务的信息。现在,让我们在Go中编写一个简单的端口扫描器。

端口扫描的低级实现

端口扫描:尝试与目标主机上的每个可能端口建立连接。如果连接成功,端口是开放的;如果失败,端口是关闭的或被过滤的。对于这个功能,Go的net包已经为我们提供了支持。

这是我们简单端口扫描器的版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
    "fmt"
    "net"
    "time"
)

func scanPort(host string, port int, timeout time.Duration) bool {
    target := fmt.Sprintf("%s:%d", host, port)
    conn, err := net.DialTimeout("tcp", target, timeout)
    
    if err != nil {
        return false
    }
    
    conn.Close()
    return true
}

func main() {
    host := "localhost" // 更改为您的目标
    timeout := time.Second * 2
    
    fmt.Printf("正在扫描主机: %s\n", host)
    
    // 扫描端口1-1024(知名端口)
    for port := 1; port <= 1024; port++ {
        if scanPort(host, port, timeout) {
            fmt.Printf("端口 %d 开放\n", port)
        }
    }
    
    fmt.Println("扫描完成")
}

使用Net包

上面的代码使用了Go的net包,它提供了网络I/O接口和函数。主要部分包括:

  • net.DialTimeout:此函数尝试连接到TCP网络地址并带有超时。它返回一个连接和一个错误(如果有)
  • 连接处理:如果连接没有问题,我们知道它是开放的,我们立即关闭连接以释放资源
  • 超时参数:我们指定超时以避免在任何被过滤的开放端口上挂起。两秒是一个好的初始值,但可以根据网络条件进行调整

测试我们的第一次扫描

现在让我们对我们的localhost运行简单的扫描器,那里可能运行着一些服务。

  1. 将代码保存到名为main.go的文件中
  2. 使用go run main.go运行它

这将显示哪些本地端口是开放的。在正常的开发机器上,您可能有80(HTTP)、443(HTTPS)或任何数量的数据库端口在使用,具体取决于您运行的服务。

您可能得到的一些示例输出:

1
2
3
4
5
正在扫描主机: localhost
端口 22 开放
端口 80 开放
端口 443 开放
扫描完成

这个基本扫描器可以工作,但它有一些很大的缺点:

  • 速度:由于顺序扫描端口,速度非常慢
  • 信息:只告诉我们端口是否开放,没有服务信息
  • 有限范围:我们只扫描前1024个端口

这些限制使我们的扫描器在实际世界中不实用。

3. 从这里改进:多线程扫描

为什么第一个版本慢

我们的第一个端口扫描器可以工作,但速度慢得无法使用。问题是它的顺序方法——一次探测一个端口。当主机有许多关闭/被过滤的端口时,我们浪费时间等待每个端口的连接超时,然后再移动到另一个端口。

为了展示问题,让我们看看基本扫描器的时间:

  • 扫描前1024个端口的最坏情况最多需要2048秒(超过34分钟),超时时间为2秒
  • 但即使对关闭端口的连接立即失败,由于网络延迟,这种方法也是低效的

这种逐个处理的方法对于任何真正的漏洞扫描工具来说都是一个瓶颈。

添加线程支持

Go在使用goroutine和channel处理并发方面特别出色。因此,我们利用这些功能尝试同时扫描多个端口,从而显著提高性能。

现在,让我们创建一个多线程端口扫描器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package main

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

type Result struct {
    Port  int
    State bool
}

func scanPort(host string, port int, timeout time.Duration) Result {
    target := fmt.Sprintf("%s:%d", host, port)
    conn, err := net.DialTimeout("tcp", target, timeout)
    
    if err != nil {
        return Result{Port: port, State: false}
    }
    
    conn.Close()
    return Result{Port: port, State: true}
}

func scanPorts(host string, start, end int, timeout time.Duration) []Result {
    var results []Result
    var wg sync.WaitGroup
    
    // 创建缓冲通道以收集结果
    resultChan := make(chan Result, end-start+1)
    
    // 创建信号量以限制并发goroutine
    // 这防止我们一次打开太多连接
    semaphore := make(chan struct{}, 100) // 限制为100个并发扫描
    
    // 为每个端口启动goroutine
    for port := start; port <= end; port++ {
        wg.Add(1)
        go func(p int) {
            defer wg.Done()
            
            // 获取信号量
            semaphore <- struct{}{}
            defer func() { <-semaphore }() // 释放信号量
            
            result := scanPort(host, p, timeout)
            resultChan <- result
        }(port)
    }
    
    // 所有goroutine完成时关闭通道
    go func() {
        wg.Wait()
        close(resultChan)
    }()
    
    // 从通道收集结果
    for result := range resultChan {
        if result.State {
            results = append(results, result)
        }
    }
    
    return results
}

func main() {
    host := "localhost" // 更改为您的目标
    startPort := 1
    endPort := 1024
    timeout := time.Millisecond * 500 
    
    fmt.Printf("正在扫描 %s 从端口 %d 到 %d\n", host, startPort, endPort)
    startTime := time.Now()
    
    results := scanPorts(host, startPort, endPort, timeout)
    
    elapsed := time.Since(startTime)
    
    fmt.Printf("\n扫描在 %s 内完成\n", elapsed)
    fmt.Printf("找到 %d 个开放端口:\n", len(results))
    
    for _, result := range results {
        fmt.Printf("端口 %d 开放\n", result.Port)
    }
}

多线程的结果

现在,让我们看看我们添加到改进扫描器中的性能提升和并发机制:

  • Goroutine:为了使扫描高效,我们为需要扫描的每个端口启动一个goroutine,这样在检查一个端口的同时可以同时检查其他端口
  • WaitGroup:当我们启动goroutine时,我们希望等待它们完成。WaitGroup帮助我们跟踪所有运行的goroutine并等待它们完成
  • 结果通道:我们为所有goroutine的结果创建一个缓冲通道
  • 信号量模式:使用通道实现的信号量限制了允许并行运行的扫描数量。这防止我们用太多连接淹没实际目标系统甚至我们自己的机器
  • 减少超时:由于我们以并行方式运行许多扫描,我们使用较低的超时

性能差距是显著的。当我们实施这个时,它可以在几分钟内扫描1024个端口,肯定少于半小时。

示例输出:

1
2
3
4
5
6
正在扫描 localhost 从端口 1 到 1024
扫描在 3.2s 内完成
找到 3 个开放端口:
端口 22 开放
端口 80 开放
端口 443 开放

多线程方法对于更大的端口范围和多个主机扩展得很好。信号量模式保证我们不会因为扫描超过一千个端口而耗尽系统资源。

4. 添加服务检测

现在我们有了一个快速、高效的端口扫描器,下一步是知道这些开放端口上运行着什么服务。这通常被称为"服务指纹识别"或"横幅抓取",我们连接到开放端口并检查返回的数据的过程。

横幅抓取的实现

横幅抓取是我们打开一个服务并读取它发送给我们的响应(横幅)。这是识别运行内容的好方法,因为许多服务在这些横幅中标识自己。

现在让我们将横幅抓取添加到我们的扫描器中:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
package main

import (
    "bufio"
    "fmt"
    "net"
    "strings"
    "sync"
    "time"
)

type ScanResult struct {
    Port     int
    State    bool
    Service  string
    Banner   string
    Version  string
}

func grabBanner(host string, port int, timeout time.Duration) (string, error) {
    target := fmt.Sprintf("%s:%d", host, port)
    conn, err := net.DialTimeout("tcp", target, timeout)
    if err != nil {
        return "", err
    }
    defer conn.Close()
    
    conn.SetReadDeadline(time.Now().Add(timeout))
    
    // 某些服务需要触发器来发送数据
    // 为Web服务发送简单的HTTP请求
    if port == 80 || port == 443 || port == 8080 || port == 8443 {
        fmt.Fprintf(conn, "HEAD / HTTP/1.0\r\n\r\n")
    } else {
        // 对于其他服务,只需等待横幅
        // 某些服务可能需要特定的触发器
    }
    
    // 读取响应
    reader := bufio.NewReader(conn)
    banner, err := reader.ReadString('\n')
    if err != nil {
        return "", err
    }
    
    return strings.TrimSpace(banner), nil
}

func identifyService(port int, banner string) (string, string) {
    commonPorts := map[int]string{
        21:    "FTP",
        22:    "SSH",
        23:    "Telnet",
        25:    "SMTP",
        53:    "DNS",
        80:    "HTTP",
        110:   "POP3",
        143:   "IMAP",
        443:   "HTTPS",
        3306:  "MySQL",
        5432:  "PostgreSQL",
        6379:  "Redis",
        8080:  "HTTP-Proxy",
        27017: "MongoDB",
    }
    
    // 尝试从常见端口识别服务
    service := "Unknown"
    if s, exists := commonPorts[port]; exists {
        service = s
    }
    
    version := "Unknown"
    
    lowerBanner := strings.ToLower(banner)
    
    // SSH版本检测
    if strings.Contains(lowerBanner, "ssh") {
        service = "SSH"
        parts := strings.Split(banner, " ")
        if len(parts) >= 2 {
            version = parts[1]
        }
    }
    
    // HTTP服务器检测
    if strings.Contains(lowerBanner, "http") || strings.Contains(lowerBanner, "apache") || 
       strings.Contains(lowerBanner, "nginx") {
        if port == 443 {
            service = "HTTPS"
        } else {
            service = "HTTP"
        }
        
        // 尝试在格式"Server: Apache/2.4.29"中找到服务器信息
        if strings.Contains(banner, "Server:") {
            parts := strings.Split(banner, "Server:")
            if len(parts) >= 2 {
                version = strings.TrimSpace(parts[1])
            }
        }
    }
    
    return service, version
}

func scanPort(host string, port int, timeout time.Duration) ScanResult {
    target := fmt.Sprintf("%s:%d", host, port)
    conn, err := net.DialTimeout("tcp", target, timeout)
    
    if err != nil {
        return ScanResult{Port: port, State: false}
    }
    
    conn.Close()
    
    banner, err := grabBanner(host, port, timeout)
    
    service := "Unknown"
    version := "Unknown"
    
    if err == nil && banner != "" {
        service, version = identifyService(port, banner)
    }
    
    return ScanResult{
        Port:    port,
        State:   true,
        Service: service,
        Banner:  banner,
        Version: version,
    }
}

func scanPorts(host string, start, end int, timeout time.Duration) []ScanResult {
    var results []ScanResult
    var wg sync.WaitGroup
    
    resultChan := make(chan ScanResult, end-start+1)
    
    semaphore := make(chan struct{}, 100)
    
    for port := start; port <= end; port++ {
        wg.Add(1)
        go func(p int) {
            defer wg.Done()
            
            semaphore <- struct{}{}
            defer func() { <-semaphore }()
            
            result := scanPort(host, p, timeout)
            resultChan <- result
        }(port)
    }
    
    go func() {
        wg.Wait()
        close(resultChan)
    }()
    
    for result := range resultChan {
        if result.State {
            results = append(results, result)
        }
    }
    
    return results
}

func main() {
    host := "localhost"
    startPort := 1
    endPort := 1024
    timeout := time.Millisecond * 800 
    
    fmt.Printf("正在扫描 %s 从端口 %d 到 %d\n", host, startPort, endPort)
    startTime := time.Now()
    
    results := scanPorts(host, startPort, endPort, timeout)
    
    elapsed := time.Since(startTime)
    
    fmt.Printf("\n扫描在 %s 内完成\n", elapsed)
    fmt.Printf("找到 %d 个开放端口:\n\n", len(results))
    
    fmt.Println("端口\t服务\t版本\t横幅")
    fmt.Println("----\t-------\t-------\t------")
    for _, result := range results {
        bannerPreview := ""
        if len(result.Banner) > 30 {
            bannerPreview = result.Banner[:30] + "..."
        } else {
            bannerPreview = result.Banner
        }
        
        fmt.Printf("%d\t%s\t%s\t%s\n", 
            result.Port, 
            result.Service, 
            result.Version, 
            bannerPreview)
    }
}

识别运行的服务

我们使用两种主要策略进行服务检测:

  1. 基于端口的识别:通过映射到常见端口号(例如,端口80是HTTP),我们对服务有一个可能的猜测
  2. 横幅分析:我们获取横幅文本并查找服务标识符和版本信息

第一个函数grabBanner尝试从服务获取第一个响应。某些服务如HTTP需要我们发送请求并接收回复,为此我们添加了特定情况的处理。

基本版本检测

版本检测对于识别漏洞很重要。在可能的情况下,我们的扫描器解析服务横幅以提取版本信息:

  • SSH:通常以"SSH-2.0-OpenSSH_7.4"的形式提供版本信息
  • HTTP服务器:通常在响应头中响应其版本信息,如"Server: Apache/2.4.29"
  • 数据库服务器:可能在其欢迎消息中披露版本信息

现在输出为每个开放端口返回更多信息:

1
2
3
4
5
6
7
8
9
正在扫描 localhost 从端口 1 到 1024
扫描在 5.4s 内完成
找到 3 个开放端口:

端口    服务    版本     横幅
----    ------- ------- ------
22      SSH     2.0     SSH-2.0-OpenSSH_8.4p1 Ubuntu-6
80      HTTP    Apache/2.4.41 Server: Apache/2.4.41 (Ubuntu)
443     HTTPS   Unknown 连接被外部关闭...

这些增强的信息对于漏洞评估更有价值。

5. 漏洞检测实现

现在我们能够枚举运行的服务及其版本,我们将实施漏洞检测。服务信息将被分析并与已知漏洞进行比较。

编写简单漏洞测试

我们将基于常见服务和版本从已知漏洞形成一个数据库。为简单起见,我们将创建一个代码内的漏洞数据库,尽管在真实场景中,扫描器很可能会查询外部漏洞数据库(如CVE或NVD)。

现在,让我们扩展代码以检测漏洞:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import (
    "bufio"
    "fmt"
    "net"
    "strings"
    "sync"
    "time"
)

type ScanResult struct {
    Port         
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计