LOADING

正在加载

简易TCP端口扫描器

壹 TCP扫描本质

该工具是自己在看《Go黑帽子-渗透测试编程之道》学习写工具时编写的,该工具只进行了单个IP的全端口扫描!

要想做TCP端口扫描器,我们需要了解TCP的三次握手以及对方机器的ip:port。回顾一下TCP的三次握手:

  • 第一次握手:客户端发送syn包,表示通信开始
  • 第二次握手:服务端回复syn-ack作为相应,提示客户端以ack结束
  • 第三次握手:客户端发送ack,通信开始

050c3bbb53095299fb19fec7effaaf9a.png

贰 TCP连接的情况

  • 正常握手

连接成功的话,流程如下:
0b6255ae3f4c57417b9cf27f3eae128f.png

  • 连接失败

如果端口被关闭,则服务器会发送一个rst数据包而不是syn-ack进行响应,流程如下:
0dd65771d30b8a307b901f0534caacef.png

  • 如果有防火墙

如果流量被防火墙过滤,那么客户端不会从服务器收到任何响应,流程如下:
155aa61b290a0802f78c56bf51cc8401.png

叁 代码实现的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多个协程的,但是我们已经可以进行线程大小约束了。假设现在有这样的去求,有100ip,需要扫描每个ip开放的端口,如果采用简单粗暴开线程的方式。那就是100+65535=6552300600w多个线程,还是比较消耗内存的,可能系统就会崩溃,如果采用线程池方式。将线程池控制在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黑帽子-渗透测试编程之道》
avatar
小C&天天

修学储能 先博后渊


今日诗句