Пишем TCP-сканер портов на Go: goroutine, timeout и CSV-отчёт

от автора

Недавно знакомый попросил помочь с небольшой задачей по проверке внешнего периметра сети компании. Сразу уточню: речь шла об инфраструктуре, на проверку которой было разрешение.

Под внешним периметром обычно понимают всё, что доступно из интернета: публичные IP-адреса, домены, поддомены, облачные или VPS-серверы, а также сервисы, которые слушают внешние порты.

Задача была простой по формулировке, но интересной технически: нужно понять, какие адреса доступны извне и к каким портам можно подключиться.

Что мы будем делать

В данной статье я покажу, как сделать простой TCP port scanner на Go.

Он будет уметь:

  • Читать IP-адреса и домены из файла

  • Проверять диапазон портов

  • Определять открытые порты и добавлять к ним условную оценку риска

  • Сразу реализуем ограничение параллельности через семафор, чтобы обработка портов была быстрее

Структура проекта и сам код

Проект небольшой, поэтому структура получилась простой. Я разделил код на несколько пакетов, чтобы каждая часть отвечала за свою задачу.

cmd/  bin/    main.gointernal/  input/    input.go  report/    csv.go  resolver/    resolver.go  scanner/    scanner.go  services/    services.goperimeter.txtgo.modgo.sum

Коротко пройдёмся по пакетам внутри internal и разберём, за что отвечает каждый из них. Input — отвечает за чтения файла и возвращения массива string с нашими портами:

func ReadTargets(path string) ([]string, error) {file, err := os.Open(path)if err != nil {return nil, err}defer file.Close()return ParseTargets(file)}func ParseTargets(reader io.Reader) ([]string, error) {targets := make([]string, 0)scanner := bufio.NewScanner(reader)for scanner.Scan() {text := strings.TrimSpace(scanner.Text())if text == "" || strings.HasPrefix(text, "#") {continue}targets = append(targets, text)}if err := scanner.Err(); err != nil {return nil, err}return targets, nil}

Здесь всё просто: открываем файл, читаем его построчно через bufio.Scanner, пропускаем пустые строки и комментарии, а остальные значения возвращаем как список целей.

Services — данный пакет отвечает за справочную информацию о сервисах по номеру порта:

package servicestype Info struct {Name stringRisk string}func Lookup(port int) Info {switch port {case 22:return Info{Name: "SSH", Risk: "High"}case 80:return Info{Name: "HTTP", Risk: "Medium"}case 443:return Info{Name: "HTTPS", Risk: "Low"}case 3306:return Info{Name: "MySQL", Risk: "High"}case 3389:return Info{Name: "RDP", Risk: "High"}case 5432:return Info{Name: "PostgreSQL", Risk: "High"}case 6379:return Info{Name: "Redis", Risk: "High"}default:return Info{Name: "Unknown", Risk: "Unknown"}}}

Lookup не делает fingerprint сервиса. Он просто подсказывает наиболее вероятный сервис по номеру порта.

Структура Info — хранит в себе Name — это названия сервиса, например SSH или HTTP. А Risk — это условный уровень риска (Low, Medium, High, Unknown).

Функция Lookup — получает порт смотрит к какому сервису он относиться и возвращает нам нашу структуру. Тоже довольно просто.

Далее нам в пакете Scanner — надо описать структуру Result в которой как у нас будет вся нужная нам информация:

package scannertype Result struct {Target       stringIP           stringPort         intProtocol     stringServiceGuess stringStatus       stringRisk         stringError        string}

Данная структура просто формат ответа: какой домен/IP проверяли, какой порт, открыт он или закрыт, какой сервис, какой риск, была ли ошибка.

Далее по списку нужно сделать функцию которая будем превращать домены в IP адреса и это функция будет лежать у нас в пакете resolver:

package resolverimport "net"func ResolveTarget(target string) ([]string, error) {parsedIP := net.ParseIP(target)if parsedIP != nil {if parsedIP.To4() == nil {return nil, nil}return []string{parsedIP.String()}, nil}ips, err := net.LookupIP(target)if err != nil {return nil, err}targets := make([]string, 0)for _, ip := range ips {if ip.To4() != nil {targets = append(targets, ip.String())}}return targets, nil}

Что здесь происходит, наша функция ResolveTarget принимает наши «Цели» — и смотрит является ли они IP адресами, если нет преобразует в IP адрес и возвращает.

ResolveTarget принимает строку из файла. Если это уже IPv4-адрес, функция сразу возвращает его. Если это домен, она делает DNS-lookup через net.LookupIP и возвращает найденные IPv4-адреса.

Теперь вернемся к нашему пакет Scanner — тут мы должны описать функцию ScanPort, сначала покажу а потом объясню:

func ScanPort(target string, ip string, port int, timeout time.Duration) Result {info := services.Lookup(port)result := Result{Target:       target,IP:           ip,Port:         port,Protocol:     "tcp",ServiceGuess: info.Name,Risk:         info.Risk,}address := net.JoinHostPort(ip, strconv.Itoa(port))conn, err := net.DialTimeout("tcp", address, timeout)if err != nil {if netErr, ok := err.(net.Error); ok && netErr.Timeout() {result.Status = "filtered"result.Error = netErr.Error()return result}result.Status = "closed"result.Error = err.Error()return result}conn.Close()result.Status = "open"return result}

Функция выглядит по сложней чем другие, но уверяю вас тут все легко.

ScanPort получает цель, IP, порт и timeout. Сначала мы получаем информацию о предполагаемом сервисе через services.Lookup. Затем собираем адрес через net.JoinHostPort — это безопаснее, чем склеивать ip + «:» + port вручную.

После этого вызываем net.DialTimeout. Если соединение удалось, считаем порт открытым. Если произошла ошибка, считаем порт закрытым. Если ошибка связана с timeout, помечаем статус как filtered.

Статус filtered здесь условный: я использую его для случаев, когда соединение не было явно отклонено, а завершилось по timeout.

Ну и если ошибки не было просто говорим что статус = открыто и возвращаем на результат.

Последний технический кусок — пакет report. Он отвечает за сохранение результатов в CSV-файл.

package reportimport ("encoding/csv""io""os""perimeter-audit/internal/scanner""strconv")func WriteCSV(results []scanner.Result, path string) error {file, err := os.Create(path)if err != nil {return err}defer file.Close()return WriteCSVWriter(file, results)}func WriteCSVWriter(writer io.Writer, results []scanner.Result) error {csvWriter := csv.NewWriter(writer)err := csvWriter.Write([]string{"target", "ip", "port", "protocol", "service_guess", "status", "risk", "error"})if err != nil {return err}for _, result := range results {if err := csvWriter.Write([]string{result.Target, result.IP, strconv.Itoa(result.Port), result.Protocol, result.ServiceGuess, result.Status, result.Risk, result.Error}); err != nil {return err}}csvWriter.Flush()if err := csvWriter.Error(); err != nil {return err}return nil}

Тут у нас report сохраняет результаты сканирования в CSV-файл: создает файл, записывает заголовки колонок и добавляет по строке на каждый результат.

Теперь осталось связать все части в main.go: прочитать путь к файлу через флаг -input, загрузить цели, просканировать их и сохранить результат в CSV.

package mainvar defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 143, 443, 445, 1433, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9200, 27017}func main() {inputFlag := flag.String("input", "", "path to targets file")outputFlag := flag.String("output", "report.csv", "path to CSV report")flag.Parse()if *inputFlag == "" {fmt.Fprintln(os.Stderr, "input flag is required")os.Exit(1)}targets, err := input.ReadTargets(*inputFlag)if err != nil {fmt.Fprintf(os.Stderr, "Error reading targets: %v\n", err)os.Exit(1)}results := scanTargets(targets, defaultPorts, 2*time.Second, 50)if err := report.WriteCSV(results, *outputFlag); err != nil {fmt.Fprintf(os.Stderr, "Error writing report: %v\n", err)os.Exit(1)}fmt.Printf("Results written to %s\n", *outputFlag)}func scanTargets(targets []string, ports []int, timeout time.Duration, maxConcurrency int) []scanner.Result {resultCh := make(chan scanner.Result)var wg sync.WaitGroupsem := make(chan struct{}, maxConcurrency)results := make([]scanner.Result, 0)for _, target := range targets {ips, err := resolver.ResolveTarget(target)if err != nil {results = append(results, scanner.Result{Target: target,Status: "error",Error:  err.Error(),})continue}for _, ip := range ips {for _, port := range ports {wg.Add(1)go func(target string, ip string, port int) {defer wg.Done()sem <- struct{}{}defer func() {<-sem}()resultCh <- scanner.ScanPort(target, ip, port, timeout)}(target, ip, port)}}}go func() {wg.Wait()close(resultCh)}()for result := range resultCh {results = append(results, result)}sort.Slice(results, func(i int, j int) bool {if results[i].Target != results[j].Target {return results[i].Target < results[j].Target}if results[i].IP != results[j].IP {return results[i].IP < results[j].IP}return results[i].Port < results[j].Port})return results}

В main программа просто управляет всем процессом: берет путь к файлу с целями, читает эти цели, запускает сканирование, а потом сохраняет результат в CSV-файл.

Если по шагам, то получается так: сначала проверяем, что пользователь передал -input, потом читаем список доменов или IP из файла, дальше для каждой цели получаем IP- адреса, проверяем нужные порты и в конце записываем все найденное в отчет.

Семафор здесь нужен как ограничитель. Мы запускаем много проверок портов параллельно, но не хотим, чтобы их одновременно было слишком много. Поэтому семафором говорим: “одновременно можно выполнять максимум 50 проверок”. Когда одна проверка закончилась, она освобождает место, и запускается следующая.

Отдельно я разобрал работу семафора на схеме и пошаговом примере — ссылку оставлю в конце статьи.

По сути, семафор защищает программу от ситуации, когда она сама себя перегрузит слишком большим количеством сетевых подключений.

В итоге получился небольшой TCP-сканер, который читает список целей из файла, резолвит домены в IP-адреса, проверяет набор портов с ограничением параллельности и сохраняет результат в CSV. Проект небольшой, но на нём хорошо видно, как в Go можно работать с сетью, timeout, goroutine, WaitGroup и семафором.

Ещё раз: такой инструмент стоит использовать только для своей инфраструктуры или с разрешения владельца.

Дополнительно: схема работы семафора на примере этой программы — https://t.me/walkerinit

ссылка на оригинал статьи https://habr.com/ru/articles/1046217/