壹 TCP扫描本质
该工具是自己在看《Go黑帽子-渗透测试编程之道》学习写工具时编写的,该工具只进行了单个
IP
的全端口扫描!
要想做TCP端口扫描器,我们需要了解TCP
的三次握手以及对方机器的ip:port
。回顾一下TCP
的三次握手:
- 第一次握手:客户端发送
syn
包,表示通信开始 - 第二次握手:服务端回复
syn-ack
作为相应,提示客户端以ack
结束 - 第三次握手:客户端发送
ack
,通信开始
贰 TCP连接的情况
- 正常握手
连接成功的话,流程如下:
- 连接失败
如果端口被关闭,则服务器会发送一个rst
数据包而不是syn-ack
进行响应,流程如下:
- 如果有防火墙
如果流量被防火墙过滤,那么客户端不会从服务器收到任何响应,流程如下:
叁 代码实现的TCP端口扫描器
在Go中,我们通常使用net.Dial
进行TCP连接,该函数可以创建UNIX
套接字、UDP
和第4层协议的连接,需要两个参数:
- 第一个为我们使用的连接类型
- 第二个参数为我们的连接地址和端口,
如何判断连接是否成功,分两种情况:
- 成功:返回
conn
- 失败:
err != nil
3.1 简易版
写工具的第一步,先完成基本功能:
package main
import (
"fmt"
"net"
)
func main() {
// 设置IP:端口
IP := "192.168.239.142:22"
// 创建TCP连接
conn, err := net.Dial("tcp", IP)
// 判断端口是否打开
if err != nil {
fmt.Println("[\033[31;1m-\033[0m]", IP, "端口是关闭的!")
// 这里不需要关闭连接,因为端口关闭,没有连接资源占用
} else {
fmt.Println("[\033[32;1m+\033[0m]", IP, "端口是打开的!")
// 关闭连接,减少资源占用
conn.Close()
}
}
// [+] 192.168.239.142:22端口是打开的!
3.2 批量化
上面是对单个端口的扫描,但是在实际情况下,我们要对多个端口进行扫描,例如我们需要对1-65535
个端口扫描,我们可以使用for
循环:
package main
import (
"fmt"
"net"
)
func main() {
// 设置IP:端口
IP := "192.168.239.142"
// 创建TCP连接
for i := 1; i <= 65535; i++ {
// 使用fmt包中的Sprintf函数将端口进行拼接
address := fmt.Sprintf("%s:%d", IP, i)
conn, err := net.Dial("tcp", address)
// 判断端口是否打开
if err != nil {
// fmt.Printf("[\033[31;1m-\033[0m] %d 端口是关闭的!\n", i)
// 该端口关闭,然后跳过该端口连接
continue
}
// 关闭连接,减少资源占用
conn.Close()
// 输出端口信息
fmt.Printf("[\033[32;1m+\033[0m] %d 端口是打开的!\n", i)
}
}
// [+] 22 端口是打开的!
// [+] 25 端口是打开的!
3.3 并发扫描
前面的批量版,我们可以看出,net.Dial
如果连接的是未开放的端口,会非常慢,这时候我们就需要使用多线程扫描,即并发扫描,在Go
语言中,通过goroutine
设置多线程扫描:
package main
import (
"fmt"
"net"
"sync"
"time"
)
func main() {
// 设置全局创建同步计数器wg
var wg sync.WaitGroup
// 设置IP:端口
IP := "192.168.239.142"
// 设置开始时间
var begin = time.Now()
// 创建TCP连接
for i := 1; i <= 65535; i++ {
// 计数器加一
wg.Add(1)
// 使用goroutine创建多线程
go func(j int) {
// 在函数执行结束时计数器减一
defer wg.Done()
// 使用fmt包中的Sprintf函数将端口进行拼接
address := fmt.Sprintf("%s:%d", IP, j)
conn, err := net.Dial("tcp", address)
// 判断端口是否打开
if err != nil {
// fmt.Printf("[\033[31;1m-\033[0m] %d 端口是关闭的!\n", j)
// 该端口关闭,然后跳过该端口的连接关闭
return
}
// 关闭连接,减少资源占用
conn.Close()
// 输出端口信息
fmt.Printf("[\033[32;1m+\033[0m] %d 端口是打开的!\n", j)
}(i)
}
// 阻塞 等待计数器归零
wg.Wait()
// 结束时间
elapseTime := time.Now().Sub(begin)
fmt.Println("耗时:", elapseTime)
}
// [+] 110 端口是打开的!
// [+] 25 端口是打开的!
// [+] 22 端口是打开的!
// 耗时: 15.161702s
上面代码中我们创建了同步扫描器,然后每当for
循环循环一次,我们给我们的同步计数器使用Add
函数加1
,然后在我们的子线程的函数中,我们使用了defer
在函数结束时,通过Done()
函数,将我们的计数器减1
,代表一个端口扫描已经完成了。然后回到我们的主函数,这里是最关键的,我们使用了Wait()
将主函数阻塞,只有当计数器为0
时,该函数才会解除阻塞,也就是说,只有当我们的扫描1024
个端口的函数全部执行完成,计数器归0
时,阻塞才会停止,我们的主函数也就运行结束,程序执行完成。其实是同时开启了6W
多个线程,去扫描每个ip:port
,所以耗时最长的线程结束的时间,就是程序结束的时间。
3.4 线程池版
上面我们我们直接打开了6W
多个线程,虽然在Go
中,理论上开个几十万个都没问题,但是实际上是非常占用资源的,这时候我们可以采用线程池方式进行优化。我们使用的Go
的线程池包:gohive
,官方地址:传送门
gohive-master.zip
gohive
简单介绍:包gohive为Go实现了一个简单易用的goroutine池
- 可以根据需求创建具有特定大小的池
- 通过实现同步提供高效的性能,其中的工人在不使用时自动回收
- 实现一个任务队列,如果提交的任务超过池容量,该任务队列可以保持等待中的剩余任务
- 实现
PoolService
类型,它作为一个易于使用的API
,具有与gohive
交互的简单方法- 优雅地处理紧急情况并防止应用程序崩溃或陷入死锁
- 提供如下函数:
AvailableWorkers()
,ActiveWorkers()
和Close()
等。
package main
import (
"fmt"
"net"
"sync"
"time"
"github.com/loveleshsharma/gohive"
)
// 设置全局创建同步计数器wg
var wg sync.WaitGroup
// 地址管道,100容量
var addressChan = make(chan string, 100)
// 管道函数
func worker() {
// 在函数执行结束时计数器减一
defer wg.Done()
for {
address, ok := <-addressChan
if !ok {
break
}
conn, err := net.Dial("tcp", address)
// 判断端口是否打开
if err != nil {
// fmt.Printf("[\033[31;1m-\033[0m] %s 端口是关闭的!\n", address)
// 该端口关闭,然后跳过该端口连接
return
}
// 关闭连接,减少资源占用
conn.Close()
// 输出端口信息
fmt.Printf("[\033[32;1m+\033[0m] %s 端口是打开的!\n", address)
}
}
func main() {
// 设置IP:端口
IP := "192.168.239.142"
//线程池大小
var pool_size = 70000
var pool = gohive.NewFixedSizePool(pool_size)
// 设置开始时间
begin := time.Now()
// 启动一个线程,用于生成ip:port,并且存放到地址管道种
go func() {
// 创建TCP连接
for i := 1; i <= 65535; i++ {
// 使用fmt包中的Sprintf函数将端口进行拼接
address := fmt.Sprintf("%s:%d", IP, i)
//将address添加到地址管道
//fmt.Println("<-:",address)
addressChan <- address
}
//发送完关闭 addressChan 管道
close(addressChan)
}()
//启动pool_size工人,处理addressChan种的每个地址
for work := 0; work < pool_size; work++ {
// 计数器加一
wg.Add(1)
pool.Submit(worker)
}
// 阻塞 等待计数器归零
wg.Wait()
// 结束时间
fmt.Println("耗时:", time.Now().Sub(begin))
}
// [+] 192.168.239.142:25 端口是打开的!
// [+] 192.168.239.142:110 端口是打开的!
// [+] 192.168.239.142:22 端口是打开的!
// 耗时: 15.2146358s
我设置的线程池大小是7w
个,所以也是一下子开启6w
多个协程的,但是我们已经可以进行线程大小约束了。假设现在有这样的去求,有100
个ip
,需要扫描每个ip
开放的端口,如果采用简单粗暴开线程的方式。那就是100+65535=6552300
,600w
多个线程,还是比较消耗内存的,可能系统就会崩溃,如果采用线程池方式。将线程池控制在50w
个,或许情况就会好很多。但是有一点的是,在Go
中,线程池通常需要配合chan
使用,可能需要不错的基础。
3.5 通过通道控制扫描数量
除了使用线程池的方法控制扫描数量,我们也可以使用通道进行控制。
package main
import (
"fmt"
"net"
"sync"
"time"
)
// 端口扫描函数
func Portscan(ports chan int, IP string, wg *sync.WaitGroup) {
// 根据通道内的数量进行循环扫描,所以刚开始是没有缓冲值的,需要给通道缓冲赋值
for p := range ports {
// 使用fmt包中的Sprintf函数将端口进行拼接
address := fmt.Sprintf("%s:%d", IP, p)
conn, err := net.Dial("tcp", address)
// 判断端口是否打开
if err != nil {
// fmt.Printf("[\033[31;1m-\033[0m] %d 端口是关闭的!\n", j)
// 需要注意存在两种情况,一种是端口没有打开,一种是打开打开了,所以需要两次wg.Done()
// 端口关闭的情况下计数器减一
wg.Done()
// 该端口关闭,然后跳过该端口的连接关闭
continue
}
// 关闭连接,减少资源占用
conn.Close()
// 输出端口信息
fmt.Printf("[\033[32;1m+\033[0m] %d 端口是打开的!\n", p)
// 端口打开的情况下计数器减一
wg.Done()
}
}
func main() {
// IP地址
IP := "192.168.239.142"
// 创建5000个通道缓冲
ports := make(chan int, 5000)
// 设置全局创建同步计数器wg
var wg sync.WaitGroup
// 设置开始时间
var begin = time.Now()
// 获取通道缓冲并创建对应数量通道线程,注意这里还不会进入扫描,只是会创建这些数量通道线程
for i := 0; i < cap(ports); i++ {
// 使用goroutine创建多线程
go Portscan(ports, IP, &wg)
}
// 循环65535个端口,并添加通道缓冲值
for i := 1; i <= 65535; i++ {
// 计数器加一
wg.Add(1)
// 给通道缓冲赋值
ports <- i
}
// 阻塞 等待计数器归零
wg.Wait()
// 关闭通道
close(ports)
// 结束时间
elapseTime := time.Now().Sub(begin)
fmt.Println("耗时:", elapseTime)
}
这个方法不需要引入别的库,但是我们可以发现存在两个问题:
- 端口输出的顺序比较乱
Portscan
函数不能返回值到主函数
3.5 通过通道控制扫描数量进阶
由于上一节存在的问题,这时候我们可以追加一个通道,用于存储端口扫描返回值状态,并且用一个列表存储扫描成功后的端口。
package main
import (
"fmt"
"net"
"sort"
"time"
)
// 端口扫描函数
func Portscan(ports, results chan int, IP string) {
// 根据通道内的数量进行循环扫描,所以刚开始是没有缓冲值的,需要给通道缓冲赋值
for p := range ports {
// 使用fmt包中的Sprintf函数将端口进行拼接
address := fmt.Sprintf("%s:%d", IP, p)
conn, err := net.Dial("tcp", address)
// 判断端口是否打开,如果关闭给results传递一个0,否则传递端口
// 由于results通道是1,所以如果通道已满会进行堵塞,使得线程也进行堵塞,到达了对sync.WaitGroup的依赖
if err != nil {
results <- 0
// 该端口关闭,然后跳过该端口的连接关闭
continue
}
// 关闭连接,减少资源占用
conn.Close()
results <- p
}
}
func main() {
// IP地址
IP := "192.168.239.1"
// 需要扫描多少端口
portsnum := 65535
// 创建5000个通道缓冲
ports := make(chan int, 5000)
// 用于存储端口扫描返回值状态
results := make(chan int)
// 存储扫描成功后的端口
var openports []int
// 设置开始时间
var begin = time.Now()
// 获取通道缓冲并创建对应数量通道线程,注意这里还不会进入扫描,只是会创建这些数量通道线程
for i := 0; i < cap(ports); i++ {
// 使用goroutine创建多线程
go Portscan(ports, results, IP)
}
// 循环65535个端口,并添加通道缓冲值
// 之所以使用goroutine创建线程,防止该循环堵塞,而不进行端口赋值
go func() {
for i := 1; i <= portsnum; i++ {
ports <- i
}
}()
// 将端口赋值给列表中
for i := 0; i < portsnum; i++ {
port := <-results
// 判断返回值是否为0,否则添加到列表里
if port != 0 {
openports = append(openports, port)
}
}
// 关闭通道
close(ports)
close(results)
// 对端口列表排序
sort.Ints(openports)
// 输出端口
for _, port := range openports {
fmt.Printf("[\033[32;1m+\033[0m] %d 端口是打开的!\n", port)
}
// 结束时间
elapseTime := time.Now().Sub(begin)
fmt.Println("耗时:", elapseTime)
}
根据上面的方法,我们可以看到不但解决了上一节的两个问题,还消除了对sync.WaitGroup
的依赖。
肆 自写简易TCP端口扫描器工具
package main
import (
"flag"
"fmt"
"net"
"os"
"sort"
"time"
)
// 标记
func tagPrint() {
fmt.Println("\033[31;1m====================================================\033[0m\033[34;1m")
fmt.Println(" ______ ____\033[31;1m_\033[0m\033[34;1m__\033[31;1m___\033[0m\033[34;1m_____\033[31;1m___")
fmt.Println(" /\033[0m\033[34;1m || 。 \033[33;1m。\033[0m\033[34;1m / T00ls:端口扫描")
fmt.Println(" / _ \033[31;1m|\033[0m\033[34;1m|____\033[31;1m__\033[0m\033[34;1m_ _ _/")
fmt.Println(" \033[31;1m/\033[0m\033[34;1m \033[31;1m/ |\033[0m\033[34;1m | / /\033[31;1m( )\033[0m\033[34;1m")
fmt.Println(" / \033[31;1m/_\033[0m\033[34;1m_| | \033[31;1m/\033[0m\033[34;1m / ( ) ")
fmt.Println(" / ____ \033[31;1m|\033[0m\033[34;1m \033[35;1m<-—+—++—+--}\033[0m\033[34;1m\033[31;1m( )\033[0m\033[34;1m____\033[31;1m/|\033[0m\033[34;1m")
fmt.Println(" \033[31;1m/\033[0m\033[34;1m / | | / / ( \033[33;1m.\033[0m\033[34;1m . \033[31;1m)\033[0m\033[34;1m")
fmt.Println(" /\033[31;1m_\033[0m\033[34;1m_/ |_\033[31;1m__|\033[0m\033[34;1m \033[31;1m/_\033[0m\033[34;1m___/ (\033[31;1m__\033[0m\033[34;1m__=___) \033[31;1m❤\033[0m")
fmt.Println("\033[0m\033[31;1m====================================================\033[0m")
}
// 端口扫描函数
func portScanner(ports, results chan int, IP string) {
// 根据通道内的数量进行循环扫描,所以刚开始是没有缓冲值的,需要给通道缓冲赋值
for p := range ports {
// 使用fmt包中的Sprintf函数将端口进行拼接
address := fmt.Sprintf("%s:%d", IP, p)
conn, err := net.Dial("tcp", address)
// 判断端口是否打开,如果关闭给results传递一个0,否则传递端口
// 由于results通道是1,所以如果通道已满会进行堵塞,使得线程也进行堵塞,到达了对sync.WaitGroup的依赖
if err != nil {
results <- 0
// 该端口关闭,然后跳过该端口的连接关闭
continue
}
// 关闭连接,减少资源占用
conn.Close()
results <- p
}
}
func main() {
// 输出tag
tagPrint()
// 设置查看进度的开关
top := true
// 输入IP
var IP string
flag.StringVar(&IP, "ip", "", "IP地址")
// 解析命令行参数
flag.Parse()
if IP == "" {
fmt.Println("[\033[31;1m-\033[0m] 没有输入IP!")
flag.Usage()
os.Exit(0)
}
// 需要扫描多少端口
portsnum := 65535
// 创建30000个通道缓冲
ports := make(chan int, 30000)
// 用于存储端口扫描返回值状态
results := make(chan int)
// 存储扫描成功后的端口
var openports []int
// 设置开始时间
var begin = time.Now()
fmt.Println("[\033[33;1m*\033[0m] 扫描开始!")
// 用于查看进度,表示还在扫描
go func() {
for top {
time.Sleep(100 * time.Millisecond)
fmt.Printf("\r[\033[34;1m—\033[0m] 扫描中,请耐心等待。。。")
time.Sleep(100 * time.Millisecond)
fmt.Printf("\r[\033[34;1m\\\033[0m] 扫描中,请耐心等待。。。")
time.Sleep(100 * time.Millisecond)
fmt.Printf("\r[\033[34;1m|\033[0m] 扫描中,请耐心等待。。。")
time.Sleep(100 * time.Millisecond)
fmt.Printf("\r[\033[34;1m/\033[0m] 扫描中,请耐心等待。。。")
}
fmt.Println()
}()
// 获取通道缓冲并创建对应数量通道线程,注意这里还不会进入扫描,只是会创建这些数量通道线程
for i := 0; i < cap(ports); i++ {
// 使用goroutine创建多线程
go portScanner(ports, results, IP)
}
// 循环65535个端口,并添加通道缓冲值
// 之所以使用goroutine创建线程,防止该循环堵塞,而不进行端口赋值
go func() {
for i := 1; i <= portsnum; i++ {
ports <- i
}
}()
// 将端口赋值给列表中
for i := 0; i < portsnum; i++ {
port := <-results
// 判断返回值是否为0,否则添加到列表里
if port != 0 {
openports = append(openports, port)
}
}
// 关闭通道
close(ports)
close(results)
// 对端口列表排序
sort.Ints(openports)
top = false
fmt.Printf("\r")
// 结束时间
elapseTime := time.Now().Sub(begin)
fmt.Println("[\033[33;1m*\033[0m]", IP, "扫描结束,耗时:", elapseTime)
// 输出端口
for _, port := range openports {
fmt.Printf("[\033[32;1m+\033[0m] %d 端口是打开的!\n", port)
}
}
伍 总结
对于该工具的编写主要使用四种方式来实现功能:
- 正常:没有并发,速度很慢
- 多协程:并发,性能很高,但是协程太多可能会崩溃
- 线程池:并发,性能高,协程数量可控
- 通道:并发,性能高,协程数量可控,不需要引入外部库,消除了对
sync.WaitGroup
的依赖
当然其实还可以通过net.DialTimeout
连接ip:port
这个可以设置超时时间,比如超时5s
就判定端口未开放,通常情况下,如果基础可以,更推荐使用通道方式,加上上面程序是单个IP
的扫描,我们也可以使用多个IP
。
陆 参考
- 书籍:《Go黑帽子-渗透测试编程之道》