goroutineを使って高速TCP ポートスキャン


この記事に書いている事

  • golangでのポートスキャンの方法
  • golangでのgoroutine + channelを使った基本的な並列処理のパターンのサンプル
  • time.Now()のちょっとだけ面白い話
  • スクリプトを書いてみての所感と考えた事

Goでポートスキャンってどうやるの?

net.Dialを使ってコネクションを確立できるかを確認すればいいです。

package main

import (
    "fmt"
    "net"
    "os"
)

func main() {
    host := os.Args[1]
    port := os.Args[2]
    address := fmt.Sprint(host, ":", port)
    _, err := net.Dial("tcp", address)
    if err == nil {
        fmt.Println("Connection successful")
    }
}

$ go run main.go <IP Address> 22  
Connection successful

ちなみに、kubernetesのTCPのProbeもほぼ同じことやってます。
https://github.com/kubernetes/kubernetes/blob/41201e72f7b1ce930288c69ffb20ed1e17551af3/pkg/probe/tcp/tcp.go#L50-L55

well-knownポートを単純なFor Loopを使ってスキャンする

単純に1024回ループさせればOK!

package main

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

func main() {
    var openPorts []int
    host := os.Args[1]
    ports, _ := strconv.Atoi(os.Args[2])

    start := time.Now()
    for i := 0; i <= ports; i++ {
        address := fmt.Sprintf("%s:%d", host, i)
        conn, err := net.DialTimeout("tcp", address, time.Duration(1)*time.Second)
        if err != nil {
            continue
        }
        conn.Close()
        openPorts = append(openPorts, i)
    }
    end := time.Now()

    for _, p := range openPorts {
        fmt.Printf("Opening %d port\n", p)
    }
    fmt.Printf("Done!! Took %f seconds\n", (end.Sub(start)).Seconds())
}
$ go run main.go localhost 1023
Opening 631 port
Opening 1023 port
Done!! Took 16.926570 seconds

約17秒!!!!
goroutineを利用して実行時間を短縮できるかためしてみましょう!

その前に本当にどうでもいい話なんですけど、time.Now()を利用して処理時間を計測(処理終了時間 - 処理開始時間)しているんですけど、
go 1.9以前のバージョンを利用して同じような事を行うと、RTCしか使ってないので、うるう秒の関係で負の値が算出されるイレギュラーなケースががあったよう
で、Cloudflareがそれが原因でちょっとだけDNSの障害になったという....
[障害の詳細]
https://blog.cloudflare.com/how-and-why-the-leap-second-affected-cloudflare-dns/

うるう秒のWikipediaにもCloudflareの障害についてちょっとだけ記述があって、明瞭にtime.Now()って書いてあるのが個人的に面白かった(笑い事ではないけど)
https://en.wikipedia.org/wiki/Leap_second#Examples_of_problems_associated_with_the_leap_second

で、1.9以降のバージョンでは解消されていて、以下のIssueや改修するための議論や提案の流れを追うのも勉強になるかも...
https://go.googlesource.com/proposal/+/master/design/12914-monotonic.md
https://github.com/golang/go/issues/12914

GoroutineでWorker Poolを作成して並列でポートスキャンを実行させる

package main

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

func scanner(buffer chan int, host string, openPorts chan int) {
    // ❺
    for p := range buffer {
        conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, p), time.Duration(1)*time.Second)
        if err != nil {
            continue
        }
        conn.Close()
        openPorts <- p
    }
}

func main() {
    var results []int

    host := os.Args[1]
    maxPort, _ := strconv.Atoi(os.Args[2])
    worker, _ := strconv.Atoi(os.Args[3])

    buffer := make(chan int, worker) // ❶
    openPorts := make(chan int)      // ❷

    var wg sync.WaitGroup
    wg.Add(worker)

    for i := 0; i < worker; i++ {
        go func() {
            scanner(buffer, host, openPorts) // ❸
            wg.Done()
        }()
    }

    go func() {
        wg.Wait()
        close(openPorts) // ❻
    }()

    start := time.Now()
    go func() {
        for p := 0; p <= maxPort; p++ {
            buffer <- p // ❹
        }
        close(buffer)
    }()

    for p := range openPorts {
        results = append(results, p)
    }

    end := time.Now()

    for _, p := range results {
        fmt.Printf("Opening %d port\n", p)
    }

    fmt.Printf("Done!! Took %f seconds\n", (end.Sub(start)).Seconds())
}

やっていること

  1. goroutineにポート番号を渡すためのchannelの作成
  2. ポートスキャンした結果をgoroutineから受け取るためのchannelの作成
  3. workerの数だけgoroutineを起動して、channelからポート番号が送信されるの待つ
  4. ポート番号をchannelに送信
  5. workerがchannelからポート番号を受け取ってポートスキャン, 結果をchannelに送信(ここがgoroutineで並列に処理しているところ)
  6. 最後のポート番号の結果までchannelに送信されたらchannelをcloseする

並列数を変更しながら実行する

# goroutineを1個起動して実行(worker = 1)
$ go run main.go localhost 1023 1
Opening 631 port
Opening 1023 port
Done!! Took 15.974225 seconds

# goroutineを10個起動して再実行(worker = 10)
$ go run main.go localhost 1023 10
Opening 631 port
Opening 1023 port
Done!! Took 13.226174 seconds

# goroutineを100個起動して再実行(worker = 100)
$ go run main.go localhost 1023 100
Opening 631 port
Opening 1023 port
Done!! Took 6.061187 seconds

# goroutineを1000個起動して再実行(worker = 1000)
$ go run main.go localhost 1023 1000
Opening 631 port
Opening 1023 port
Done!! Took 2.007343 seconds

goroutineを1000個まで増やす事で2秒まで実行時間を短縮することができました。
ただし、localhostに対して実行しているのでネットワークIOにかかる時間が限りなく短い状態で計測しています。
そのため、実際にサーバ -> サーバでスキャンを実行する場合は、1 groutineで実行した場合と1000 groutinesで実行した場合の差は大きくなると予測されます。

所感と考えた事

今回、golangで簡単なスクリプトを書くだけで高速にポートスキャンする事ができました。検証では、約2秒でサーバ1台分のwell-knownポートをスキャンすることが確認できました。localhostに対してのポートスキャンなので数値はあまり信用できませんが、1台のサーバの全ポートに対してのポートスキャンは1分程度で完了できるでしょう。これはサーバ管理者にとっては、意図しないポートが空いている事を確認するいい方法と言えます。ただし、その逆で、悪意のある攻撃者にとってもこれはいいことです。グローバルIPのリストは、もはや公開されているといってもいい情報なので、悪意のある攻撃者は、すぐにサーバの空いているポートを見つけます。なので本当に基本的なことですが, 不要なポートは空けておかない、Firewall,セキュリティグループで慎重に管理する必要性を改めて認識できると思います。

最後に、10000台以上のサーバに対してポートスキャンするならどうやって実行したらいいかを考えてみました。
Core数が多い1台のサーバで、多くのgoroutineを起動してポートスキャンをするのも1つの手だと思いますが、個人的にはCloudの力を使いたい。
例えばAWSで実行するのであれば以下の構成とかがいいなと思いました。

❶Lambda -> SQS -> ❷Lmabda -> ❸SNS -> Lmabda

❶スキャン対象のサーバのリストを取得して数台のリストに細かく分けてSQSに送信する
❷SQSからスキャンするサーバのリストを取得してサンプルのコードのようにgoroutineで並列化してポートスキャンを実行する。意図しないポートが空いていればSNSにサーバのアドレスとポートを送信する
❸SNSからEmail, Slackに通知させてLambdaを実行させる
❹Lambdaから何かしらの対応をする(悪意のあるスクリプトはダメ絶対)

特に❷のLambdaでfanoutさせて、さらにgoroutineで並列実行させるという事がCloudnativeな構成だと簡単に実現できるのでいいかなと思いました!
また、実行が失敗した場合の再実行やエラー処理もある程度、AWSのマネージドサービスに任せられることもメリットです。
1台のサーバでスクリプトを実行して、数千台へポートスキャンをしていて、途中で処理がストップして再実行とかやってられないよ...

って感じでまとめにしたいと思います! 最後まで読んでいただきありがとうございました!