黑盒扫描器--资产收集模块

2025.4.20 周报

在这个项目中资产收集模块由我负责,目前主要实现的功能为:

  1. IP 主机存活检测

  2. 端口扫描

  3. 指纹识别

  4. 扫描进程展示

  5. 存放数据库

接下来我将一步一步阐述我实现的每一个功能,和我从中学到的知识

一、IP 主机存活检测

通过 go-ping 库检测

原先我是想用 go 语言自带的 go-ping 库来实现通过 ping 去检测主机是否存活,但是很遗憾,出了几点问题:

  • 要用 go-ping 库去发 ICMP 包需要管理员权限,Linux 下需要 root 权限CAP_NET_RAW 能力但经过测试,我发现在 wsl 上用sudo运行也行不通,这就很奇怪了
  • 用 go-ping 库的代码是 ai 写的,我看不懂(bushi

经过询问 ai 得到这样的结果

特性 系统 ping 命令 Go 程序 (go-ping)
实现方式 直接调用内核 ICMP 协议栈 使用原始套接字 (RAW socket)
权限要求 通常有 CAP_NET_RAW 能力 需要明确提权 (sudo/SetPrivileged)
数据包构造 内核自动构造合规 ICMP 包 程序手动构造 ICMP 包
防火墙穿透性 可能被特殊放行 更容易被拦截

还有云服务器的安全策略

  • 许多云服务商(如阿里云/AWS):
    • 允许标准 ICMP Echo(系统 ping
    • 拦截非常规 ICMP 请求(原始套接字构造的包)

因为是 ai 回答,不知准确性,姑且这么认为。

代码实现(因为无法成功,仅仅只是记录)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//func ishostAlive(ip string, count int, timeout time.Duration) bool { //检验主机是否存活,用的是 go-ping 库
// pinger, err := ping.NewPinger(ip)
// if err != nil {
// log.Printf("Error creating pinger: %v", err)
// return false
// }
//
// pinger.SetPrivileged(true)
// pinger.Count = count
// pinger.Timeout = timeout
//
// if err := pinger.Run(); err != nil {
// log.Printf("Ping failed: %v", err)
// return false
// }
// stats := pinger.Statistics()
// return stats.PacketsRecv > 0
//}

调动二进制命令ping检测

这里是直接用 go 来调用ping命令,完完全全可以成功

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func IsHostAlive(ip string) bool { //主机存活检测,用系统 ping 的方式,可以根据不同系统用不同的 ping 命令
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("ping", "-n", "2", "-w", "2000", ip)
default: // Linux/macOS
cmd = exec.Command("ping", "-c", "2", "-W", "2", ip)
}
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("ping %s failed: %v \noutput: %s", ip, err, string(output))
return false
}
return true
}

同时这里做了对不同系统的兼容,匹配了不同系统的 ping 命令语法。

同时,在这里有一个问题:

1
2
3
4
5
case "windows":
cmd = exec.Command("ping", "-n", "2", "-w", "2000", ip)
default: // Linux/macOS
cmd = exec.Command("ping", "-c", "2", "-W", "2", ip)
}

这一部分的 ip由用户输入,是否存在安全问题,是否可以通过这个漏洞进行命令注入?

经过具体尝试,答案是否,实际上这里即使ip由用户控制,但仍然是安全的,原因是:

  • Go 的 exec.Command 不会通过 Shell 执行命令,而是直接调用目标程序(如 ping),并将参数作为字符串传递,而不是解析成 Shell 命令。
  • ping 收到的是 127.0.0.1|ifconfig 这个整体字符串,而不是 127.0.0.1ifconfig 两个命令。
  • 如果代码是 通过 Shell 执行(如 bash -c "ping -c 2 $IP"),那么 |&& 等符号会被解析,导致命令注入。

通过 TCP 连接检测主机是否存活

即使在上面调用二进制命令ping检测已经可行,但要是在某些时候禁用了 ICMP 协议,那就用不了ping命令,这种时候就要用备用方案——TCP 连接

代码如下

1
2
3
4
5
6
7
8
9
10
11
func IsHostAlive_TCP(ip string) bool { //主机存活检测,通过 TCP 连接的方法,可以跨系统运用,同时在对应主机禁止 ICMP 的时候使用
port := []int{80, 22, 443}
for _, port := range port {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, port), 3*time.Second)
if err == nil {
conn.Close()
return true
}
}
return false
}

不必多言(

二、端口扫描

原先在这个项目开始之前我是有写一个简易的扫描器的,但是那时我还没有学习 go 的并行设计,65535 个端口是一个循环一个一个扫的(太愚蠢了),并且当时还是拿127.0.0.1做的测试,扫的很快,我就以为没有什么大不了,等到我尝试去扫描我的服务器 IP 的时候我才知道这有多慢,一个小时仅仅只完成了百分之一……

后来就用一天时间去学习了 go 的并行设计,并主要处理了端口扫描这一部分,现在扫描的就比较迅速,扫描一次需要 48 秒。

我学习的主要是 go Worker 池并行设计,相比于普通 goroutine 的优势:

直接开 goroutine 的问题 Worker 池的优势
瞬间创建数万 goroutine 崩溃 控制并发数量(如500个)
任务分配不均 统一的任务队列,自动均衡负载
资源竞争严重 通过 Channel 实现线程安全通信

和原始代码相比:

原始代码 Worker池改进版 优势体现
单线程循环 多worker并发 速度提升数百倍
直接打印结果 统一结果收集处理 便于后续扩展(如存数据库)
硬编码参数 参数化配置 更灵活易用
无错误控制 内置重试机制 扫描更稳定

(为了方便展示,这里直接使用 ai 提供的表格)

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"
"log"
"net"
"runtime"
"strconv"
"sync"
"time"
)

func ScanWorker(id int, ip string, scanner <-chan scanner, results chan<- scanResult, timeout time.Duration) { //需要进行的任务,这里是进行端口扫描
for scan := range scanner { //这里的 scanner 是通道,里面是有很多的 scan 个体,这里的 scan 才是一个结构体可以引用 scan 结构体的元素
result := scanResult{} //这里是定义一个 scanResult 的结构体
result.address = net.JoinHostPort(ip, strconv.Itoa(scan.port)) //这里是用 ip 和端口一起组成地址
if err := ValidateInput(scan.network, result.address); err != nil { //用 validateInput 检验地址和网络方式是否正确
log.Println(err)
break
}
scan.conn, result.err = net.DialTimeout(scan.network, result.address, timeout) //进行连接
if result.err == nil { //连接成功
result.open = true //open 赋值为 true
result.banner = ReadBanner(scan.conn)
result.service = IdentifyService(result.banner, scan.port)
scan.conn.Close() //连接关闭
} else {
result.open = false
result.errtype = ErrType(result)
}
results <- result
}
}

func Run(ip string, network string) []scanResult {
var (
totalPorts = 65535 // 总端口数
scanned = 0 // 已扫描计数
startTime = time.Now() // 记录开始时间
)
var wg sync.WaitGroup
tasks := make(chan scanner, 1000)
results := make(chan scanResult, 1000)
var openPorts []scanResult
workers := runtime.NumCPU() * 100

for i := 0; i < workers; i++ {//分配工作
wg.Add(1)
go func(id int) {
defer wg.Done()
ScanWorker(id, ip, tasks, results, 2*time.Second)
}(i)
}

go func() {//添加工作
for i := 1; i < 65536; i++ {
tasks <- scanner{network: network, ip: ip, port: i, conn: nil}
}
close(tasks)
}()

go func() {//等待所有任务结束后关闭 worker 池
wg.Wait()
close(results)//关闭 results 通道,不再接受其他数据
}()
errCount := make(map[string]int)
for result := range results {//处理数据
scanned++
if scanned%100 == 0 || scanned == totalPorts {
printProgress(scanned, totalPorts, startTime)
}
if result.open {
//fmt.Printf("[+] %s is open\n", result.address)
openPorts = append(openPorts, result)
} else {
if errCount[result.errtype] < 3 {
fmt.Printf("[-] %s is %s\n", result.address, result.errtype)
}
errCount[result.errtype]++

}
}
fmt.Println()
printOpenPorts(openPorts)

return openPorts
}

这里是并行部分的函数设计,一些操作写在注释中了,不过多赘述。

三、指纹识别

我做到的指纹识别原理是(自我理解):先端口扫描,在端口扫描的时候创建了连接conn,而我需要做的就是通过这个连接获取到 banner 信息,并从 banner信息中提取我所需要的知晓的对应服务。如果从原先的conn中读取不到 banner 信息时,那就主动向服务器那边发送请求,再读取所响应的 banner 信息。

初代码

其实这里的实现是有些莫名其妙的,再这一周中我花了四天的时间去解决这一个问题,但始终没有解决

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
//package main
//
//import (
// "bytes"
// "fmt"
// "io"
// "log"
// "net"
// "strings"
// "time"
//)
//
//func ReadBanner(conn net.Conn) string {
// //timeout := time.Second
// remoteAddr := conn.RemoteAddr().(*net.TCPAddr) //这两行为调试信息
// port := remoteAddr.Port
// buf := make([]byte, 2048)
// //fmt.Printf("[DEBUG] 开始探测端口 %d\n", remoteAddr.Port) // 添加这行
//
// timeout := 3 * time.Second
// switch port {
// case 21:
// timeout = 5 * time.Second // FTP
// case 80, 443, 8080, 81:
// timeout = 5 * time.Second // HTTP/S
// }
// //timeout := 1500 * time.Millisecond
// //if port == 21 {
// // timeout = 3000 * time.Millisecond
// //}
//
// conn.SetReadDeadline(time.Now().Add(timeout))
//
// n, err := conn.Read(buf) //1. 先读取服务端可能主动发送的Banner
// if err != nil {
// log.Printf("read banner failed: %v", err)
// }
// if n > 0 {
// //fmt.Printf("[DEBUG] 端口 %d 主动响应: %q\n", port, string(buf[:n]))
// return strings.TrimSpace(string(buf[:n]))
// }
//
// //2. 如果没有收到Banner,发送通用探测包
// switch conn.RemoteAddr().(*net.TCPAddr).Port {
// case 21: // FTP
// conn.Write([]byte("USER anonymous\r\n"))
// time.Sleep(500 * time.Millisecond)
// n, _ = conn.Read(buf)
// case 22: // SSH
// conn.Write([]byte("SSH-2.0-GoScan\r\n"))
// n, _ = conn.Read(buf)
// case 80, 443, 8080, 888, 81: // HTTP/S
// req := fmt.Sprintf(
// "GET / HTTP/1.1\r\nHost: %s\r\nUser-Agent: Go-Scanner\r\nAccept: */*\r\nConnection: close\r\n\r\n",
// remoteAddr.IP,
// )
// if _, err := conn.Write([]byte(req)); err != nil { //测试
// log.Printf("write banner failed: %v", err)
// return ""
// }
// //time.Sleep(500 * time.Millisecond)
// //n, _ = conn.Read(buf)//原来
// var resp bytes.Buffer //测试
// for {
// n, err := conn.Read(buf)
// if err != nil {
// if err != io.EOF {
// log.Printf("读取 HTTP 响应失败: %v", err)
// }
// break
// }
// resp.Write(buf[:n])
// }
// return resp.String()
// default: // 其他端口保持原样
// conn.Write([]byte("\x01\x02\x03\x04\n")) //魔法数字探测包
// }
// //n, err = conn.Read(buf)
// //if err != nil {
// // //fmt.Printf("[DEBUG] 端口 %d 读取错误: %v\n", port, err)
// // return ""
// //}
// ////fmt.Printf("[DEBUG] 端口 %d 最终响应: %q\n", port, string(buf[:n]))
// //return strings.TrimSpace(string(buf[:n]))
// if n > 0 {
// return strings.TrimSpace(string(buf[:n]))
// }
// return ""
//}
//
//func IdentifyService(banner string, port int) string { //指纹识别函数
//
// if banner == "" {
// switch port {
// case 21:
// return "ftp(无响应)"
// case 80, 443, 8080, 888:
// return "http(无响应)"
// case 22:
// return "ssh(无响应)"
// default:
// return "unknown"
// }
// }
//
// if strings.Contains(banner, "HTTP/1.") { //检测 HTTP 响应头,即使返回 400
// // 提取 Server 头(如 "Server: nginx")
// if serverHeader := ExtractHeader(banner, "Server"); serverHeader != "" {
// return fmt.Sprintf("http | %s", serverHeader)
// }
// return "http" // 默认标识为 http
// }
//
// switch {
// case strings.HasPrefix(banner, "SSH-"):
// return fmt.Sprintf("ssh | %s", FirstLine(banner))
// case strings.HasPrefix(banner, "220") && port == 21: //FTP效应码
// return fmt.Sprintf("ftp | %s", FirstLine(banner))
// case strings.Contains(banner, "HTTP"):
// return fmt.Sprintf("http | %s", FirstLine(banner))
// case bytes.HasPrefix([]byte(banner), []byte("\x16\x03")): // TLS
// return "https(疑似)"
// case strings.Contains(banner, "MySQL"):
// return "mysql"
// //case strings.Contains(banner, "<html>"):
// // return "http"
// default:
// // 端口猜测(保底逻辑)
// switch port {
// case 22:
// return "ssh(疑似)"
// case 80, 443, 8080, 888:
// return "http(疑似)"
// case 3306:
// return "mysql(疑似)"
// default:
// return "unknown"
// }
// }
//}

这里是我原先的代码,经过几天的修改可以识别 21端口的 ftp 服务和 22 端口的 ssh 服务,但是面对 http 的服务始终识别不到 banner 信息,经常遇到的错误信息就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GOROOT=D:\go #gosetup
GOPATH=C:\Users\13483\go #gosetup
GOPROXY=https://goproxy.cn,direct #gosetup
D:\go\bin\go.exe build -o C:\Users\13483\AppData\Local\JetBrains\GoLand2024.3\tmp\GoLand\___go_build_golandproject_scan.exe . #gosetup
C:\Users\13483\AppData\Local\JetBrains\GoLand2024.3\tmp\GoLand\___go_build_golandproject_scan.exe #gosetup
Please enter your ip:59.110.163.36
2025/04/18 21:33:00 read banner failed: read tcp 192.168.1.15:17878->59.110.163.36:888: i/o timeout
[-] 59.110.163.36:12 is Timeout
[-] 59.110.163.36:7 is Timeout
[-] 59.110.163.36:222 is Timeout
Scanning: 2800/65535 (4.3%) | Elapsed: 2s2025/04/18 21:33:01 read banner failed: read tcp 192.168.1.15:19329->59.110.163.36:80: i/o timeout
Scanning: 22400/65535 (34.2%) | Elapsed: 16s2025/04/18 21:33:16 read banner failed: read tcp 192.168.1.15:41070->59.110.163.36:23333: i/o timeout
Scanning: 30800/65535 (47.0%) | Elapsed: 22s2025/04/18 21:33:22 read banner failed: read tcp 192.168.1.15:48710->59.110.163.36:30957: i/o timeout
Scanning: 65535/65535 (100.0%) | Elapsed: 48s

=== 开放端口详情 ===
地址 服务类型 Banner信息
59.110.163.36:22 ssh | SSH-2.0-OpenSSH_8.0 SSH-2.0-OpenSSH_8.0
59.110.163.36:21 ftp | 220---------- Welcome to Pure-FTPd [privsep] [TLS] ---------- 220---------- Welcome to Pure-FTPd [privsep] [TLS]...
59.110.163.36:888 http(无响应) [无Banner响应]
59.110.163.36:80 http(无响应) [无Banner响应]
59.110.163.36:23333 unknown [无Banner响应]
59.110.163.36:30957 unknown [无Banner响应]

无论超时时长设置多少都是接收不到 banner 响应。

现代码

后来没有办法交给 ai,在不知道多少次的拷打后,产生如下代码:

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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222

package main

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

// 协议配置
var protocolConfig = map[int]struct {
probe string
timeout time.Duration
identifier func(string) string
}{
21: {"USER anonymous\r\n", 5 * time.Second, identifyFTP},
22: {"SSH-2.0-GoScan\r\n", 3 * time.Second, identifySSH},
80: {"", 8 * time.Second, identifyHTTP},
443: {"", 8 * time.Second, identifyHTTP},
3306: {"", 5 * time.Second, identifyMySQL},
902: {"", 3 * time.Second, identifyVMware},
912: {"", 3 * time.Second, identifyVMware},
}

func ReadBanner(conn net.Conn) string {
remoteAddr := conn.RemoteAddr().(*net.TCPAddr)
port := remoteAddr.Port

// 设置连接参数
setupConnection(conn, port)

// 尝试读取初始banner
if banner := tryReadBanner(conn); banner != "" {
return cleanResponse(banner)
}

// 协议特定探测
if cfg, ok := protocolConfig[port]; ok {
return probeProtocol(conn, port, cfg.probe, cfg.timeout)
}

// 默认探测
return probeDefault(conn)
}

func setupConnection(conn net.Conn, port int) {
timeout := 3 * time.Second
if cfg, ok := protocolConfig[port]; ok {
timeout = cfg.timeout
}
conn.SetReadDeadline(time.Now().Add(timeout))

if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
}
}

func tryReadBanner(conn net.Conn) string {
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err == nil && n > 0 {
return string(buf[:n])
}
return ""
}

func probeProtocol(conn net.Conn, port int, probe string, timeout time.Duration) string {
switch port {
case 80, 443, 8888:
return probeHTTP(conn)
default:
if probe != "" {
conn.Write([]byte(probe))
}
return readWithTimeout(conn, timeout)
}
}

func probeHTTP(conn net.Conn) string {
//req := buildHTTPRequest(conn.RemoteAddr().(*net.TCPAddr).IP.String())
req := fmt.Sprintf(
"GET / HTTP/1.1\r\n"+
"Host: %s\r\n"+
"User-Agent: Mozilla/5.0 (compatible; GoScanner/1.0)\r\n"+
"Accept: */*\r\n"+
"Connection: close\r\n\r\n",
conn.RemoteAddr().(*net.TCPAddr).IP.String(),
)

conn.Write([]byte(req))

reader := bufio.NewReader(conn)
var resp strings.Builder

for {
line, err := reader.ReadString('\n')
if err != nil || line == "\r\n" {
break
}
resp.WriteString(line)
}

return resp.String()
}

func buildHTTPRequest(host string) string {
return fmt.Sprintf(
"GET / HTTP/1.1\r\n"+
"Host: %s\r\n"+
"User-Agent: Mozilla/5.0\r\n"+
"Connection: close\r\n\r\n",
host,
)
}

func probeDefault(conn net.Conn) string {
conn.Write([]byte("\x01\x02\x03\x04\n"))
return readWithTimeout(conn, 1*time.Second)
}

func readWithTimeout(conn net.Conn, timeout time.Duration) string {
conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 1024)
n, _ := conn.Read(buf)
return string(buf[:n])
}

func cleanResponse(resp string) string {
// 过滤非ASCII字符
resp = strings.Map(func(r rune) rune {
if r >= 32 && r < 127 || r == '\n' || r == '\r' {
return r
}
return -1
}, resp)

// 截断过长的响应
if len(resp) > 200 {
return resp[:200] + "..."
}
return resp
}

func IdentifyService(banner string, port int) string {
banner = cleanResponse(banner)

if cfg, ok := protocolConfig[port]; ok {
return cfg.identifier(banner)
}

switch {
case strings.Contains(banner, "HTTP/"):
return identifyHTTP(banner)
case port == 80 || port == 443 || port == 8080 || port == 8888:
return "http"
default:
return "unknown"
}
}

// 协议识别函数
func identifyHTTP(banner string) string {
// 优先通过指纹库匹配
if server := ExtractHeader(banner, "Server"); server != "" {
// 返回标准化服务名(小写、去除版本号)
switch {
case strings.Contains(server, "nginx"):
return "nginx"
case strings.Contains(server, "Apache"):
return "apache"
case strings.Contains(server, "Microsoft-IIS"):
return "iis"
case strings.Contains(server, "lighttpd"):
return "lighttpd"
case strings.Contains(server, "Caddy"):
return "caddy"
}
}

// 次之通过特征匹配
if strings.Contains(banner, "nginx") {
return "nginx"
}
if strings.Contains(banner, "Apache") {
return "apache"
}

// 最后返回通用标识
return "http-unknown"
}

// func identifyFTP(banner string) string {
// return fmt.Sprintf("ftp | %s", FirstLine(banner))
// }
func identifyFTP(banner string) string { //因为原来的在banner库中识别不到,所以换成通用的ftp
if strings.Contains(banner, "Pure-FTPd") {
return "pure-ftpd" // 精确匹配PureFTPd
}
return "ftp" // 通用FTP服务
}

// func identifySSH(banner string) string {
// return fmt.Sprintf("ssh | %s", FirstLine(banner))
// }
func identifySSH(banner string) string { //因为原来的在banner库中识别不到,所以换成通用的ssh
if strings.Contains(banner, "OpenSSH") {
return "openssh" // 标准化为openssh而非带版本信息
}
return "ssh" // 通用SSH服务
}

func identifyMySQL(banner string) string {
return "mysql"
}

func identifyVMware(banner string) string {
return "vmware-auth"
}
//(这段已是最终的代码,其中做了很多改变)

这段代码不仅仅可以识别出 http 的 banner 信息,还可以识别出相应的服务,如apachenginx(当然,识别出 http 具体的服务是后面才加的功能,同时也对 ssh 服务识别做出了对应的优化)

实际上还是没有很懂为什么这段就行而上面不行。

经过思考,可能是因为对不同的服务有不同的代码去负责,有特定的针对性,但可能只是一个方面的原因

不过这样很成功。(有时可能也会出一点小问题)

运行结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Please enter your ip:59.110.163.36
[-] 59.110.163.36:15 is Timeout
[-] 59.110.163.36:2800 is Timeout
[-] 59.110.163.36:33 is Timeout
Scanning: 65535/65535 (100.0%) | Elapsed: 48s

=== 开放端口详情 ===
地址 服务类型 Banner信息
59.110.163.36:21 pure-ftpd 220---------- Welcome to Pure-FTPd [privsep] [TLS]...
59.110.163.36:22 openssh SSH-2.0-OpenSSH_8.0

59.110.163.36:81 nginx HTTP/1.1 400 Bad Request
Server: nginx
Date: Sun...
59.110.163.36:888 nginx HTTP/1.1 400 Bad Request
Server: nginx
Date: Sun...
59.110.163.36:80 http-unknown [无Banner响应]
59.110.163.36:23333 nginx HTTP/1.1 400 Bad Request
Server: nginx
Date: Sun...
59.110.163.36:30957 unknown [无Banner响应]

四、扫描进程展示

这其实是一段无关紧要的部分,只不过我看没有进度展示的运行感觉难受,再等待的过程中受到不知道自己的代码是否可性,需要多长时间,进度到哪里了,写的代码到底对不对等等问题的烦恼,于是就写了一段用于进度显示的函数

1
2
3
4
5
6
7
8
func printProgress(current, total int, start time.Time) {
percent := float64(current) / float64(total) * 100
elapsed := time.Since(start).Round(time.Second)

// \r 让光标回到行首,实现原地刷新
fmt.Printf("\rScanning: %d/%d (%.1f%%) | Elapsed: %v",
current, total, percent, elapsed)
}

运用是在上述端口扫描部分所展示的 run 函数部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for result := range results { //处理数据
scanned++
if scanned%100 == 0 || scanned == totalPorts {
printProgress(scanned, totalPorts, startTime)//在这里
}
if result.open {
//fmt.Printf("[+] %s is open\n", result.address)
openPorts = append(openPorts, result)
} else {
if errCount[result.errtype] < 3 {
fmt.Printf("[-] %s is %s\n", result.address, result.errtype)
}
errCount[result.errtype]++

}
}

五、存放数据库

连接云sql,将扫描结果存放在 scan_results 表里。

后来要创建一个 banner 指纹信息库,和该库中的 banner 信息匹配后,才将扫描的结果存放在 scan_results 库中

代码展现

实现存入 sql 数据库的代码如下:

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
package main

import (
"database/sql"
"fmt"
"net"
"strconv"
"strings"
"time"
)

func InitDB() (*sql.DB, error) {
db, err := sql.Open("mysql", "root:mysqlyd0ngAlicloud@tcp(47.113.206.220:3306)/ASM?parseTime=true")
if err != nil {
return nil, fmt.Errorf("数据库连接失败: %v", err)
}

// 设置连接池参数
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)

if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
}

return db, nil
}

// 修改saveResult函数
func SaveResult(db *sql.DB, result scanResult) error {
if !result.open || result.service == "unknown" {
return nil // 只存储开放端口
}

ip, portStr, err := net.SplitHostPort(result.address)
if err != nil {
return fmt.Errorf("解析地址失败: %v", err)
}

serviceType := MatchFingerprint(db, result.banner)
if serviceType == "" {
serviceType = strings.ToLower(result.service)
if strings.HasPrefix(serviceType, "http") {
serviceType = "http-unknown"
}
}
if len(serviceType) > 255 {
serviceType = serviceType[:255]
}

port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("端口转换失败: %v", err)
}

// 限制banner长度以避免溢出
banner := result.banner
if len(banner) > 65535 {
banner = banner[:65535]
}

//a, _ := db.Exec(`select * from scan_results `)
//fmt.Println(a)

_, err = db.Exec(`
INSERT INTO scan_results
(ip, port, service_id, service_type)
VALUES (?, ?,
(SELECT id FROM banner WHERE service_name = ? LIMIT 1),
?)
ON DUPLICATE KEY UPDATE
service_id = VALUES(service_id),
service_type = VALUES(service_type)`,
ip, port, serviceType, serviceType,
)

//b, _ := db.Exec(`select * from scan_results `)
//fmt.Println(b)

return err
}

func MatchFingerprint(db *sql.DB, banner string) string {
var serviceName string
db.QueryRow(`
SELECT service_name FROM service_fingerprints
WHERE ? LIKE CONCAT('%', banner_pattern, '%')
ORDER BY LENGTH(banner_pattern) DESC
LIMIT 1`, banner).Scan(&serviceName)
return serviceName
}

SQL 数据库的展现

所建造的 banner 库如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+----+--------------+-----------------------------------+--------------------------+
| id | service_name | banner_pattern | description |
+----+--------------+-----------------------------------+--------------------------+
| 1 | ssh | SSH-2.0% | OpenSSH服务 |
| 2 | ssh | SSH-1.99% | 旧版OpenSSH |
| 5 | mysql | mysql_native_password% | MySQL认证 |
| 7 | unknown | | 未识别服务 |
| 8 | apache | Server: Apache/% | Apache网页服务器 |
| 9 | nginx | Server: nginx[/s][d.]* | Nginx网页服务器 |
| 10 | iis | Server: Microsoft-IIS/% | IIS网页服务器 |
| 11 | openssh | SSH-2.0-OpenSSH% | OpenSSH服务 |
| 12 | dropbear | SSH-2.0-dropbear% | Dropbear SSH服务 |
| 13 | pure-ftpd | 220--- Welcome to Pure-FTPd% | Pure-FTPd服务 |
| 14 | vmware-ftpd | 220 VMware Authentication Daemon% | VMware认证守护进程 |
| 15 | mysql | \x0a[0-9]\.\d+\.\d+-MySQL | MySQL数据库 |
| 16 | redis | \+REDIS\d+ | Redis数据库 |
| 17 | vmware-auth | 220 VMware Authentication Daemon% | VMware认证服务 |
| 18 | openssh | SSH-2.0-OpenSSH[_.d]+% | OpenSSH服务 |
| 19 | pure-ftpd | 220[-]+ Welcome to Pure-FTPd% | Pure-FTPd服务 |
| 20 | http-service | ^HTTP/d.d | 基础HTTP服务 |
+----+--------------+-----------------------------------+--------------------------+

这是一次的扫描结果:

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
Please enter your ip:59.110.163.36
[-] 59.110.163.36:2800 is Timeout
[-] 59.110.163.36:56 is Timeout
[-] 59.110.163.36:68 is Timeout
Scanning: 65535/65535 (100.0%) | Elapsed: 48s

=== 开放端口详情 ===
地址 服务类型 Banner信息
59.110.163.36:21 pure-ftpd 220---------- Welcome to Pure-FTPd [privsep] [TLS]...
59.110.163.36:22 openssh SSH-2.0-OpenSSH_8.0

59.110.163.36:81 nginx HTTP/1.1 400 Bad Request
Server: nginx
Date: Sun...
59.110.163.36:888 nginx HTTP/1.1 400 Bad Request
Server: nginx
Date: Sun...
59.110.163.36:80 http-unknown [无Banner响应]
59.110.163.36:23333 nginx HTTP/1.1 400 Bad Request
Server: nginx
Date: Sun...
59.110.163.36:30957 unknown [无Banner响应]
2025/04/20 22:32:27 成功储存 59.110.163.36:21
2025/04/20 22:32:27 成功储存 59.110.163.36:22
2025/04/20 22:32:27 成功储存 59.110.163.36:81
2025/04/20 22:32:28 成功储存 59.110.163.36:888
2025/04/20 22:32:28 成功储存 59.110.163.36:80
2025/04/20 22:32:28 成功储存 59.110.163.36:23333
2025/04/20 22:32:28 成功储存 59.110.163.36:30957

这是扫描结束后存放 scan_results 库的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+----+---------------+-------+------------+--------------+---------------------+
| id | ip | port | service_id | service_type | scan_time |
+----+---------------+-------+------------+--------------+---------------------+
| 1 | 127.0.0.1 | 3306 | 5 | mysql | 2025-04-19 15:19:54 |
| 2 | 127.0.0.1 | 902 | NULL | vmware-auth | 2025-04-19 15:19:55 |
| 3 | 127.0.0.1 | 912 | NULL | vmware-auth | 2025-04-19 15:19:55 |
| 4 | 127.0.0.1 | 8888 | 8 | apache | 2025-04-19 15:19:55 |
| 5 | 127.0.0.1 | 9210 | NULL | http-unknown | 2025-04-19 15:19:55 |
| 6 | 127.0.0.1 | 49743 | NULL | http-unknown | 2025-04-19 15:19:55 |
| 7 | 127.0.0.1 | 80 | NULL | http-unknown | 2025-04-19 15:19:56 |
| 15 | 59.110.163.36 | 81 | 9 | nginx | 2025-04-20 04:38:04 |
| 16 | 59.110.163.36 | 888 | 9 | nginx | 2025-04-20 04:38:04 |
| 17 | 59.110.163.36 | 80 | NULL | http-unknown | 2025-04-20 04:38:04 |
| 18 | 59.110.163.36 | 23333 | 9 | nginx | 2025-04-20 04:38:04 |
| 19 | 59.110.163.36 | 22 | 11 | openssh | 2025-04-20 04:53:04 |
| 20 | 59.110.163.36 | 21 | 13 | pure-ftpd | 2025-04-20 04:53:04 |
+----+---------------+-------+------------+--------------+---------------------+

其中 service_id 是识别 banner 库中的结果,如果 banner 信息存于 banner 库中,那么 service_id

则会显示所对应的行数,如果为 NULL,那就是没有接收到 banner 信息或者是 banner 不在 banner 表中,而上表所展示的59.110.163.36的80端口服务信息为 http-unknown ,这就说明了没有接受到 banner 信息,之所以是认定为 http 服务是由端口判断,这些代码逻辑都在上述的指纹识别的代码中。

本周总结

上述基本上都为本周我所完成的结果和学习到的内容,本周学习到的知识很多,之前一直都是去看 go 语言的语法而不是真正的去写代码,实际证明,真正去用 go 语言来写这样的一个项目后,我对 go 语言的的认知才变的更加深刻,上手写代码才更熟练。

同时本周我也学到的更多的网络知识,帮助我完成这样的扫描器。

不过真的很多内容都要有 ai 辅助学习啊,不然根本不会不理解,去一一生啃那些枯燥难懂的内容效率真的很慢。不过这样也导致我在一些方面还没有搞的透彻,还需要加强学习。

接下来要迎接的是下周的内容。

2025.4.27 周报

在这一周中我完成的内容为:

  • 子域名收集(目前只完成了crt.sh和搜索引擎方式收集)
  • 子域名收集功能和端口扫描功能分离选择
  • 子域名与其对应的 IP 和网站标题入库

一、子域名收集

在这个模块中,ai 出力出的更多(悲),先丢个代码

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
package main

import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
"strings"
"time"
)

type CertRecord struct { //证书记录结构
ID uint64 `json:"id"` //证书在 crt.sh 数据库中的唯一标识符
LoggedAt string `json:"entry_timestamp"` //证书被记录到透明日志的时间(格式:2006-01-02T15:04:05)
NotBefore string `json:"not_before"` //证书生效的开始日期
NotAfter string `json:"not_after"` //证书过期时间
CommonName string `json:"common_name"` //证书的主域名(CN字段),可能是通配符如 *.example.com
NameValue string `json:"name_value"` //含所有主题备用名称(SANs),多个域名用换行符(\n)分隔
MatchingIPs []net.IP //非API字段,程序自行填充的匹配IP列表
IsWildcard bool //非API字段,标记是否为通配符证书(根据CommonName或NameValue)
}

type CollectResult struct {
Subdomain string
IPs []net.IP
FirstSeen time.Time
Sources []string
}

type CRTshCollector struct{}

func (c *CRTshCollector) Collect(domain string, timeout time.Duration) ([]CollectResult, error) {
return CollectSubdomains_crtsh(domain, timeout)
}

// CollectSubdomains_crtsh 主收集函数
func CollectSubdomains_crtsh(domain string, timeout time.Duration) ([]CollectResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

// 使用更稳定的API端点
apiURL := fmt.Sprintf("https://crt.sh/?q=%s&output=json", domain)

// 调试输出
log.Printf("正在查询: %s", apiURL)

records, err := fetchCertRecords(ctx, apiURL)
if err != nil {
return nil, fmt.Errorf("fetchCertRecords失败: %w", err)
}

if len(records) == 0 {
return nil, fmt.Errorf("未找到任何证书记录")
}

// 处理结果
var results []CollectResult
for _, rec := range records {
select {
case <-ctx.Done():
return results, nil
default:
res := processRecord(rec, domain)
if res.Subdomain != "" {
results = append(results, res)
}
}
}

return results, nil
}

// fetchCertRecords 获取证书记录
func fetchCertRecords(ctx context.Context, apiURL string) ([]CertRecord, error) {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}

req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MyScanner/1.0)")
req.Header.Set("Accept", "application/json")

client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()

// 检查状态码
if resp.StatusCode != http.StatusOK {
body, _ := ioutil.ReadAll(resp.Body)
return nil, fmt.Errorf("API返回错误: %s\n响应: %s", resp.Status, string(body))
}

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}

// 调试输出
log.Printf("API响应: %s", string(body[:min(200, len(body))]))

var records []CertRecord
if err := json.Unmarshal(body, &records); err != nil {
return nil, fmt.Errorf("JSON解析失败: %w\n响应: %s", err, string(body[:200]))
}

return records, nil
}

// processRecord 处理单条记录
func processRecord(rec CertRecord, baseDomain string) CollectResult {
rec.IsWildcard = strings.HasPrefix(rec.CommonName, "*.") ||
strings.Contains(rec.NameValue, "*.")

names := append(strings.Split(rec.NameValue, "\n"), rec.CommonName)
var validSubdomains []string

for _, name := range names {
name = strings.ToLower(strings.TrimSpace(name))
if name != "" && isValidSubdomain(name, baseDomain) {
validSubdomains = append(validSubdomains, name)
}
}

if len(validSubdomains) == 0 {
return CollectResult{}
}

// 只处理第一个有效子域名
subdomain := validSubdomains[0]
var ips []net.IP
if !rec.IsWildcard {
if resolvedIPs, err := net.LookupIP(subdomain); err == nil {
ips = resolvedIPs
}
}

var firstSeen time.Time
if rec.LoggedAt != "" {
firstSeen, _ = time.Parse("2006-01-02T15:04:05", rec.LoggedAt)
}

return CollectResult{
Subdomain: subdomain,
IPs: ips,
FirstSeen: firstSeen,
Sources: []string{"crt.sh"},
}
}

// isValidSubdomain 验证子域名
func isValidSubdomain(name, baseDomain string) bool {
name = strings.TrimSuffix(name, ".")
baseDomain = strings.TrimSuffix(baseDomain, ".")
return name == baseDomain || strings.HasSuffix(name, "."+baseDomain)
}

这是在证书透明收集的代码实现,经检验有效果

下面是在main.go中整合的函数

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
func collectSubdomains(db *sql.DB, scan scanner) { //收集子域名函数
var domain string

fmt.Print("please enter your domain: ")
fmt.Scan(&domain)

var results []CollectResult

if crtResults, err := (&CRTshCollector{}).Collect(domain, 30*time.Second); err == nil {
results = append(results, crtResults...)
}

if searchResults, err := (&SearchEngineCollector{}).Collect(domain, 30*time.Second); err == nil {
fmt.Printf("[DEBUG] 搜索引擎结果数量: %d\n", len(searchResults)) // 调试输出,添加这行
results = append(results, searchResults...)
} else {
log.Printf("搜索引擎收集错误: %v", err) // 确保错误可见
}

uniqueIPs := make(map[string]bool) //用于取独特的一个 IP,没有重复
var ipsToScan []string

for _, res := range results {
fmt.Printf("[%s] %s (IPs: %v)\n", //打印收集到的子域名结果
res.FirstSeen.Format("2006-01-02"),
res.Subdomain,
res.IPs)

domainID, err := SaveDomainInfo(db, domain, res.Subdomain, //存入 sql 数据库
strings.HasPrefix(res.Subdomain, "*."),
"",
strings.Join(res.Sources, ","), // ✅ 动态来源
)

if err != nil {
log.Printf("保存子域名失败 %s: %v", res.Subdomain, err)
continue
}

for _, ip := range res.IPs {
ipStr := ip.String()
if !uniqueIPs[ipStr] && ip.To4() != nil {
uniqueIPs[ipStr] = true
ipsToScan = append(ipsToScan, ipStr)

if err := SaveDomainIP(db, domainID, ipStr, res.Subdomain, nil); err != nil {
log.Printf("保存IP关联失败 %s: %v", ipStr, err)
}
}
}
}

var answer string
fmt.Print("Subdomain collecting is done. Do the domains need to scan?(y/N): ")
fmt.Scan(&answer)

if answer == "y" {
for _, scan.ip = range ipsToScan {
domainScan(scan, db)
}
} else if answer == "n" {
fmt.Print("The task is over.")
}
}

为了将域名扫描的结果和端口扫描的结果分开,我又将这些整合到不同的函数中

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
func domainScan(scan scanner, db *sql.DB) {         //这是域名ip扫描的函数
fmt.Printf("\n=== 开始域名扫描 %s ===\n", scan.ip)

if IsHostAlive(scan.ip) {
//Run(scan.ip, scan.network) //测试用
openPorts := Run(scan.ip, scan.network) //真正利用,存储数据库
// 3. 保存结果(存储逻辑)
if err := SaveDomainScanResult(db, scan.ip, openPorts); err != nil {
log.Printf("保存扫描结果失败: %v", err)
}
} else {
fmt.Println("Can't ping")
if IsHostAlive_TCP(scan.ip) {
//Run(scan.ip, scan.network) //测试用
openPorts := Run(scan.ip, scan.network) //真正利用,存储数据库
// 3. 保存结果(存储逻辑)
if err := SaveDomainScanResult(db, scan.ip, openPorts); err != nil {
log.Printf("保存扫描结果失败: %v", err)
}
} else {
log.Print("没有进入TCP连接")
fmt.Printf("%s is not alive\n", scan.ip)
}
}
}
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
func portScan(scan scanner, db *sql.DB) { //这是把之前的 端口扫描 和 主机检测 利用整合到一个函数里面,方便调用

if IsHostAlive(scan.ip) {
//Run(scan.ip, scan.network) //这一行是用于测试,结果不进入 sql 数据库
openPorts := Run(scan.ip, scan.network) //这一段是真正利用,结果进入 sql 数据库
for _, result := range openPorts {
if err := SaveResult(db, result); err != nil {
log.Printf("存储失败 %s: %v", result.address, err)
} else {
log.Printf("成功储存 %s", result.address)
}
}
} else {
fmt.Println("Can't ping")
if IsHostAlive_TCP(scan.ip) {
//Run(scan.ip, scan.network) //这一行是用于测试,结果不进入 sql 数据库
openPorts := Run(scan.ip, scan.network) //这一段是真正利用,结果进入 sql 数据库
for _, result := range openPorts {
if err := SaveResult(db, result); err != nil {
log.Printf("存储失败 %s: %v", result.address, err)
} else {
log.Printf("成功储存 %s", result.address)
}
}
} else {
log.Print("没有进入TCP连接")
fmt.Printf("%s is not alive\n", scan.ip)
}
}
}

而这里就是我的main函数实现

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
func main() {
db, err := InitDB() //连接数据库
if err != nil {
log.Fatal(err)
}
defer db.Close()

var scan scanner
scan.network = "tcp"

var command string
fmt.Print("please enter a command(domain/scan): ")
fmt.Scan(&command)

if command == "scan" {
fmt.Print("Please enter your ip:")
fmt.Scan(&scan.ip)

portScan(scan, db) //进行扫描
} else if command == "domain" {
collectSubdomains(db, scan)
} else {
fmt.Println("please enter a true command.")
}
}

二、对应结果入库

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
package main

import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net"
"regexp"
"strconv"
"strings"
"time"
)

func InitDB() (*sql.DB, error) {
db, err := sql.Open("mysql", "root:mysqlyd0ngAlicloud@tcp(47.113.206.220:3306)/ASM?parseTime=true")
if err != nil {
return nil, fmt.Errorf("数据库连接失败: %v", err)
}

// 设置连接池参数
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)

if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
}

return db, nil
}

// 修改saveResult函数
func SaveResult(db *sql.DB, result scanResult) error { //用于存放 scan_results 库
if !result.open || result.service == "unknown" {
return nil // 只存储开放端口
}

ip, portStr, err := net.SplitHostPort(result.address)
if err != nil {
return fmt.Errorf("解析地址失败: %v", err)
}

port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("端口转换失败: %v", err)
}

serviceType := MatchFingerprint(db, result.banner, port)

if serviceType == "" {
serviceType = strings.ToLower(result.service)
if strings.HasPrefix(serviceType, "http") {
serviceType = "http-unknown"
}
}
if len(serviceType) > 255 {
serviceType = serviceType[:255]
}

// 限制banner长度以避免溢出
banner := result.banner
if len(banner) > 65535 {
banner = banner[:65535]
}

//a, _ := db.Exec(`select * from scan_results `)
//fmt.Println(a)

_, err = db.Exec(`
INSERT INTO scan_results
(ip, port, service_id, service_type)
VALUES (?, ?,
(SELECT id FROM banner WHERE service_name = ? LIMIT 1),
?)
ON DUPLICATE KEY UPDATE
service_id = VALUES(service_id),
service_type = VALUES(service_type)`,
ip, port, serviceType, serviceType,
)

//b, _ := db.Exec(`select * from scan_results `)
//fmt.Println(b)

return err
}

//func MatchFingerprint(db *sql.DB, banner string) string { //和 banner 库进行匹配
// var serviceName string
// db.QueryRow(`
// SELECT service_name FROM service_fingerprints
// WHERE ? REGEXP banner_pattern -- 改用REGEXP
// ORDER BY
// LENGTH(banner_pattern) DESC, -- 优先匹配更长模式
// CASE WHEN banner_pattern LIKE '^%' THEN 0 ELSE 1 END -- 优先匹配开头模式
// LIMIT 1`, banner).Scan(&serviceName)
// return serviceName
//}

func MatchFingerprint(db *sql.DB, banner string, port int) string {
var serviceName string

err := db.QueryRow(`
SELECT description FROM finger_print
WHERE match_type = 'regex'
AND ? REGEXP keyword
AND protocol_type = 'TCP'
ORDER BY
CASE
WHEN description IN ('ssh', 'ftp', 'mysql', 'http', 'nginx', 'apache', 'iis') THEN 0
ELSE 1
END,
LENGTH(keyword) DESC
LIMIT 1`, banner).Scan(&serviceName)

if err == nil && serviceName != "" {
return serviceName
}

if strings.Contains(banner, "HTTP/") { //先匹配 http 服务
err := db.QueryRow(`
SELECT service_name FROM finger_print
WHERE match_type = 'regex'
AND ? REGEXP keyword
AND service_name IN ('nginx','apache','iis','http')
ORDER BY LENGTH(keyword) DESC
LIMIT 1`,
banner).Scan(&serviceName)

if err == nil && serviceName != "" {
log.Printf("DEBUG - HTTP服务匹配成功: %s", serviceName)
return serviceName
}
}

// 1. 优先尝试精确的banner匹配
err = db.QueryRow(`
SELECT service_name FROM finger_print
WHERE match_type = 'regex'
AND ? REGEXP keyword
AND (protocol_type = 'TCP' OR protocol_type IS NULL)
ORDER BY
CASE WHEN service_name IN ('ssh', 'ftp','mysql') THEN 0 ELSE 1 END, -- 优先匹配常见服务
LENGTH(keyword) DESC -- 其次匹配更长/更精确的模式
LIMIT 1`,
banner).Scan(&serviceName)

if err == nil && serviceName != "" {
return serviceName
}

// 次之尝试端口匹配
err = db.QueryRow(`
SELECT service_name FROM finger_print
WHERE match_type = 'port'
AND keyword = ?
LIMIT 1`,
strconv.Itoa(port)).Scan(&serviceName)

if err == nil && serviceName != "" {
return serviceName
}

// 3. 最后尝试模糊匹配
err = db.QueryRow(`
SELECT service_name FROM finger_print
WHERE match_type = 'regex'
AND ? LIKE CONCAT('%', keyword, '%')
AND (protocol_type = 'TCP' OR protocol_type IS NULL)
ORDER BY LENGTH(keyword) DESC
LIMIT 1`,
banner).Scan(&serviceName)

if err == nil && serviceName != "" {
return serviceName
}

return ""
}

在这段代码中和 finger_print 库匹配的代码中还是有一些问题的,关乎指纹识别方面,等着来解决。

本周总结

这一周完成的成果不如上一周的成果多,可能也与这周的事情相对较多,这篇文章也写的迟,写的潦草,总之拖拖拖拖到现在


黑盒扫描器--资产收集模块
http://example.com/2025/04/20/黑盒扫描器-资产收集模块/
作者
yuhua
发布于
2025年4月20日
许可协议