Недавно знакомый попросил помочь с небольшой задачей по проверке внешнего периметра сети компании. Сразу уточню: речь шла об инфраструктуре, на проверку которой было разрешение.
Под внешним периметром обычно понимают всё, что доступно из интернета: публичные 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/