Пишем свои сетевые инструменты и перестаём плодить одноразовый хлам
Я довольно долго относился к самописным пентест-тулзам одинаково: надо быстро решить задачу - открыл Python, накидал скрипт, прогнал, закрыл, забыл. Один вечер это работает отлично. На втором запуске уже хуже. На третьем выясняется, что скрипт живёт только в той среде, где и был написан, а любое изменение превращается в раскопки.С сетевыми утилитами это особенно быстро всплывает. Сначала нужен простой портсканер под свою задачу. Потом хочется добавить timeout, потому что половина соединений висит дольше, чем вообще имеет смысл ждать. Потом нужен banner grab, потом нормальный вывод, потом ограничение по потокам, потому что “запустил тысячу запросов параллельно” и “получил внятный результат” - вообще не одно и то же. Дальше всё предсказуемо: скрипт уже не маленький, а трогать его по-прежнему неприятно.
Вот в этот момент я и начал смотреть на Go не как на язык, на котором пишут модные offensive тулзы, а как на вполне бытовой способ собирать свои сетевые утилиты без лишнего геморроя. У языка нормальная стандартная библиотека для TCP и HTTP, concurrency встроен без тяжёлой обвязки, а сборка под другую платформу не превращается в отдельный проект. Не случайно на Go сидят Sliver, Chisel, Ligolo-ng, Nuclei и ещё куча security tooling. Тут нет никакой сакральной offensive-магии. Просто для сетевого кода он часто оказывается удобнее, чем всё, что я до этого городил на коленке.
Эта статья не про возможности Go в кибербезе. Мне такой угол сам по себе неинтересен. Гораздо полезнее показать пару утилит, которые реально хочется иметь у себя под рукой: многопоточный TCP-сканер и HTTP path enumerator. Обе кажутся простыми, пока не пишешь их не для демки, а для повторного использования. Вот там и начинается всё интересное: где врут таймауты, зачем нужен worker pool, почему 200 OK часто мусор и почему красивый код без нормальной формы результата бесполезен уже через неделю.
Почему я вообще полез писать это на Go
Обычно разговор про Go начинается с трёх дежурных тезисов: статический бинарник, кросс-компиляция, goroutines. Всё так, но в отрыве от практики это звучит как рекламный проспект.На деле вопрос проще. Большинство самописных утилит в пентесте - это не эксплуатационные фреймворки, а обычные сетевые клиенты. Один открывает TCP-соединения и проверяет, кто жив. Второй дёргает HTTP и ищет интересные пути. Третий стучится в API, снимает заголовки, сравнивает ответы и складывает всё в файл. Для этого не нужен язык, который умеет всё на свете. Нужен язык, на котором такой код не бесит в момент доработки.
Go здесь очень ровный. В net уже лежит всё базовое для TCP, UDP и DNS. В net/http уже есть клиент, таймауты, редиректы, transport и всё, что нужно для нормального HTTP-перебора. Не надо собирать себе мини-стек из пяти библиотек, чтобы просто сделать хороший клиент. Официальная документация Go это и описывает примерно в таком ключе: net - базовый интерфейс для network I/O, net/http - штатный HTTP-стек, а concurrency - часть модели языка, а не внешняя экзотика.
Первый инструмент, который быстро перестаёт быть игрушкой
Если писать себе сетевую утилиту с нуля, портсканер почти всегда оказывается первой остановкой. Не потому что тема новая или невероятно сложная, а потому что на ней очень быстро видно, где у тебя кончается демо и начинается реальный инструмент.На локалке всё красиво. Берёшь net.DialTimeout, пробегаешься по портам, находишь открытые, радуешься. Потом даёшь этому скрипту диапазон побольше, запускаешь не на localhost, а на нормальной сети, и сразу вылезают все старые знакомые:
- часть портов “закрыта” просто потому, что timeout поставлен слишком агрессивно
- половина времени уходит в висящие соединения
- параллелизм сначала кажется спасением, потом превращает вывод в шум
- banner grab то работает, то нет, и быстро становится ясно, что на него нельзя опираться как на основу логики
Ниже тот минимум, который уже можно оставить себе в рабочую папку:
C-подобный:
package main
import (
"bufio"
"flag"
"fmt"
"net"
"sort"
"strings"
"sync"
"time"
)
type Result struct {
Port int
Open bool
Banner string
Err error
}
func scanPort(host string, port int, timeout time.Duration) Result {
address := fmt.Sprintf("%s:%d", host, port)
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return Result{Port: port, Open: false, Err: err}
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
reader := bufio.NewReader(conn)
line, _ := reader.ReadString('\n')
return Result{
Port: port,
Open: true,
Banner: strings.TrimSpace(line),
}
}
func worker(host string, timeout time.Duration, jobs <-chan int, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for port := range jobs {
results <- scanPort(host, port, timeout)
}
}
func main() {
host := flag.String("host", "127.0.0.1", "target host")
start := flag.Int("start", 1, "start port")
end := flag.Int("end", 1024, "end port")
workers := flag.Int("workers", 200, "number of concurrent workers")
timeout := flag.Duration("timeout", 800*time.Millisecond, "dial timeout")
flag.Parse()
jobs := make(chan int, *workers)
results := make(chan Result, *workers)
var wg sync.WaitGroup
for i := 0; i < *workers; i++ {
wg.Add(1)
go worker(*host, *timeout, jobs, results, &wg)
}
go func() {
for port := *start; port <= *end; port++ {
jobs <- port
}
close(jobs)
wg.Wait()
close(results)
}()
var openPorts []Result
for r := range results {
if r.Open {
openPorts = append(openPorts, r)
}
}
sort.Slice(openPorts, func(i, j int) bool {
return openPorts[i].Port < openPorts[j].Port
})
for _, r := range openPorts {
if r.Banner != "" {
fmt.Printf("[+] %d open - banner: %s\n", r.Port, r.Banner)
continue
}
fmt.Printf("[+] %d open\n", r.Port)
}
}
Самый важный из них - timeout. Если ставить его по принципу “ну на глаз вроде нормально”, инструмент очень быстро начинает рисовать красивую, но ложную картину. На локальной сети 200-300 мс могут казаться разумными. На VPN, через WAN, за балансировщиком или просто на тяжёлой машине те же значения уже режут часть реальных открытых портов. Получается не сканирование, а угадайка.
Второй вопрос - воркеры. В какой-то момент почти все проходят одну и ту же стадию: “а что если я просто подниму concurrency ещё выше”. Обычно это даёт не ускорение, а более шумный и менее воспроизводимый результат. Упрёшься не только в таргет, но и в свою машину, в NAT, в лимиты дескрипторов, в поведение firewall. Worker pool я здесь вставил не потому, что это красиво смотрится в статье. Он нужен, чтобы держать тулзу в управляемом диапазоне.
Третий вопрос - banner grab. В демках он смотрится нарядно, в живой сети ведёт себя гораздо хуже. Какие-то сервисы отдают баннер сразу, какие-то молчат до первого клиентского payload, какие-то присылают строку, которая мало что говорит. Поэтому баннер здесь - дополнительный сигнал, а не основа service detection.
Когда свой TCP-сканер уже написан, очень быстро приходит следующий вопрос: где заканчивается удобная самописная утилита и начинается территория Nmap. Мы рассказали, что даёт более глубокое сканирование, чем отличается работа с сервисами, UDP и NSE, и почему свой код и Nmap здесь не конкурируют, а дополняют друг друга. Читать подробнее...
HTTP path enumerator: когда 200 OK почти ничего не значит
С перебором путей у меня долго была одна и та же ошибка: я относился к нему как к тупой механике. Взял словарь, отправил пачку запросов, отфильтровал 200, 301, 403, пробежал глазами по результату. На аккуратном стенде этого хватает. На живом приложении такой подход разваливается почти сразу.Проблема даже не в том, что приложение может вести себя хитро. Проблема в том, что сам класс задачи давно шире старого “dir bruteforce”. В актуальной практике это уже скорее path enumeration или content discovery: ты ищешь не только каталоги, а вообще скрытые маршруты, неочевидные файлы, admin endpoints, backup-артефакты и всё, что не торчит в навигации, но всё ещё доступно по прямому запросу. В OWASP WSTG эта логика никуда не делась: forced browsing, directory and file enumeration, поиск admin-интерфейсов и hidden content по-прежнему живут в нормальной методологии тестирования.
А дальше начинается уже знакомый HTTP-бардак. Один backend честно отдаёт 404. Другой рисует красивую кастомную страницу ошибки и при этом отвечает 200. Третий уводит любой непонятный путь в логин. Четвёртый сначала отвечает нормально, а через минуту начинает душить тебя rate limit'ом, и половина различий в ответах уже говорит не о маршрутах, а о том, что ты сам зажал приложение своим темпом.
После пары таких прогонов быстро перестаёшь думать про directory bruteforce как про поиск “интересных кодов ответа”. Это уже задача другого класса: отличить реальный маршрут от одинакового мусора, не потерять первый редирект, не утонуть в ложноположительных ответах и не превратить инструмент в шумогенератор.
Первая версия почти всегда слишком наивная
Порог входа у такой утилиты низкий до смешного. Можно написать десять строк с http.Get, прикрутить goroutines и получить рабочий результат. Формально - да. По факту это почти гарантированно будет плохой инструмент.Первые проблемы появляются моментально:
- клиент без явного timeout
- автоматическое следование редиректам
- чтение словаря без фильтрации пустых строк и мусора
- вывод только по статус-коду
- отсутствие базовой линии для несуществующего пути
Ниже тот минимум, после которого утилитой уже можно пользоваться без ощущения, что ты сам себе мешаешь. Здесь есть нормальный http.Client, явный timeout, отключённые редиректы и worker pool, который даёт контролируемый темп, а не просто “много goroutines”.
C-подобный:
package main
import (
"bufio"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
)
type Result struct {
Path string
StatusCode int
Length int
Location string
}
func worker(baseURL string, client *http.Client, jobs <-chan string, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for word := range jobs {
word = strings.TrimSpace(word)
if word == "" || strings.HasPrefix(word, "#") {
continue
}
url := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(word, "/")
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "lab-enumerator/1.0")
resp, err := client.Do(req)
if err != nil {
continue
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
results <- Result{
Path: url,
StatusCode: resp.StatusCode,
Length: len(body),
Location: resp.Header.Get("Location"),
}
}
}
func main() {
target := flag.String("url", "http://127.0.0.1:8080", "base url")
wordlist := flag.String("wordlist", "words.txt", "path to wordlist")
workers := flag.Int("workers", 40, "concurrent workers")
timeout := flag.Duration("timeout", 3*time.Second, "request timeout")
flag.Parse()
file, err := os.Open(*wordlist)
if err != nil {
panic(err)
}
defer file.Close()
client := &http.Client{
Timeout: *timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
jobs := make(chan string, *workers)
results := make(chan Result, *workers)
var wg sync.WaitGroup
for i := 0; i < *workers; i++ {
wg.Add(1)
go worker(*target, client, jobs, results, &wg)
}
go func() {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
jobs <- scanner.Text()
}
close(jobs)
wg.Wait()
close(results)
}()
for r := range results {
if r.StatusCode == http.StatusOK ||
r.StatusCode == http.StatusForbidden ||
r.StatusCode == http.StatusUnauthorized ||
(r.StatusCode >= 300 && r.StatusCode < 400) {
fmt.Printf("[%d] %s len=%d location=%s\n", r.StatusCode, r.Path, r.Length, r.Location)
}
}
}
Когда path enumeration перестаёт быть игрой в статус-коды, очень быстро упираешься в фильтры, wordlist’ы и нормальную интерпретацию ответов. Узнайте, как из простого перебора путей вырастает вменяемый HTTP-fuzzing с ffuf, фильтрацией по размеру, регуляркам и более аккуратной работой с шумными ответами. Подробнее здесь...
Почему я почти сразу перестал верить статус-коду
Самая неприятная ловушка у HTTP-перебора - красивые, но ложные находки. У инструмента на экране всё выглядит правдоподобно: куча 200, что-то редиректится, где-то 403. Потом открываешь руками и понимаешь, что половина “успехов” - это одна и та же страница ошибки на разные пути.На этом месте многие начинают усложнять фильтрацию по коду ответа. Это почти бесполезно. Если приложение сознательно маскирует отсутствие маршрута под 200 OK, никакая магия вокруг статус-кода уже не спасёт. Нужна базовая линия.
Смысл простой: перед основным прогоном инструмент стучится в заведомо несуществующий путь и запоминает, как выглядит фоновый мусор. Код ответа, длина тела, при желании - набор заголовков или даже простая сигнатура HTML. После этого уже можно отбрасывать всё, что слишком похоже на baseline.
Вот маленький кусок, который даёт этой тулзе вторую жизнь:
C-подобный:
func baseline(client *http.Client, baseURL string) (int, int, error) {
url := strings.TrimRight(baseURL, "/") + "/this-path-should-not-exist-9f7c1e"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return 0, 0, err
}
resp, err := client.Do(req)
if err != nil {
return 0, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, err
}
return resp.StatusCode, len(body), nil
}
И вот тут как раз начинается нормальная инженерия, а не учебный пример. Потому что в реальном приложении быстро выясняется: код ответа сам по себе слабый сигнал. Длина тела уже полезнее. Location при редиректе ещё полезнее. Шаблонный HTML на кастомной error page тоже даёт материал для фильтрации. Инструмент из “долбилки по словарю” превращается в штуку, которая реально наблюдает поведение приложения.
Редиректы здесь важнее, чем кажутся
Автоматически следовать редиректам - очень плохая привычка для такой утилиты. Браузеру так удобно. Пентестеру - нет.Если /old-admin уводит на /login, это уже факт. Если /backup/ приводит к /backup, это тоже факт. Если непонятный путь отправляют на какой-нибудь catch-all route SPA-фронтенда, мне важнее увидеть именно этот первый шаг, а не итоговую страницу после двух переходов.
Поэтому CheckRedirect я почти всегда выключаю сразу. Не потому что “так правильнее по учебнику”, а потому что иначе инструмент начинает прятать полезный сигнал. Для path enumeration редирект - это не помеха. Это часть ответа сервиса.
Где утилита начинает шуметь сама
Вторая большая проблема - темп. На маленьком словаре и локальном стенде кажется, что чем больше воркеров, тем лучше. На реальном приложении этот подход быстро бьёт по тебе же.Слишком агрессивный перебор начинает сам портить картину. Где-то включается rate limit. Где-то WAF или CDN начинают отвечать по другому профилю. Где-то приложение под нагрузкой меняет задержку и часть путей, которые раньше выглядели одинаково, начинают отличаться только потому, что ты уже зажал ему горло собственным enumerator'ом.
Поэтому у меня такие утилиты почти всегда живут в умеренном режиме. Не “вжарить как можно больше запросов”, а получить воспроизводимый результат. В этом смысле worker pool нужен не для производительности как таковой, а для дисциплины. Он удерживает инструмент в том диапазоне, где наблюдаемое поведение ещё хоть что-то говорит о приложении, а не о твоём собственном напоре.
Что очень быстро хочется добавить после первого нормального прогона
После первой рабочей версии path enumerator почти сам подсказывает, куда его двигать дальше.Сначала хочется сохранить не только код и длину тела, но и заголовки, особенно Location, Content-Type и что-нибудь вроде простого хэша ответа. Потом почти неизбежно появляется baseline для нескольких случайных путей, а не одного. Потом хочется фильтровать похожие ответы не только по длине, но и по сигнатуре тела. Потом приходит мысль про HEAD-запросы, отдельный профиль для API, кастомные заголовки и JSON-вывод, который можно потом скормить другому инструменту.
То есть нормальная эволюция такого кода идёт не в сторону “сделаю ещё один gobuster”. Она идёт в сторону наблюдаемости. Чем лучше инструмент умеет показать, чем один ответ отличается от другого, тем меньше он врёт и тем больше от него пользы.
Кросс-компиляция - момент, когда Go начинает окупаться по-настоящему
У сетевых утилит есть очень бытовой критерий полезности: насколько быстро я могу перекинуть их в другую среду без отдельного цирка с окружением. Пока код живёт только на моей машине, почти любой язык выглядит терпимо. Как только тулзу хочется собрать под Windows-стенд, под Linux-хост или просто под другую архитектуру, часть быстрых решений резко теряет очки.Вот здесь, как мне кажется, Go обычно и начинает нравиться всерьёз. Если инструмент написан на pure Go и не тянет cgo, сборка под другую платформу сводится к очень скучной механике: GOOS, GOARCH, обычный go build. В самой
Ссылка скрыта от гостей
это описано без романтики: при cross-compiling cgo выключен, а если он всё-таки нужен, придётся тащить C cross-compiler под целевую платформу. В документации cmd/cgo то же самое сформулировано ещё прямее: cgo по умолчанию отключён при cross-compiling, его можно принудительно включить через CGO_ENABLED=1, но тогда вы уже заходите в зону, где одной переменной окружения дело не заканчивается.То есть фишка Go здесь очень конкретная. Она работает, пока вы держите инструмент простым и самодостаточным. Один бинарник, один go build, одна целевая платформа за раз. Как только в проект заползает import "C" или что-то, что без cgo не живёт, половина этой красоты кончается. Это не претензия к языку. Это просто граница, после которой удобный security tool на Go превращается в обычный кросс-платформенный софт со всеми его привычными проблемами.
Я делаю это так так:
C-подобный:
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o dist/direnum.exe ./cmd/direnum
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o dist/direnum-linux ./cmd/direnum
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o dist/direnum-macos-arm64 ./cmd/direnum
Но здесь тоже легко впасть в самообман. Собранный бинарник ещё не означает, что инструмент на целевой системе ведёт себя так же, как у тебя локально. На Windows могут иначе всплыть нюансы с сертификатами и firewall. На другом Linux-хосте поедут DNS, IPv6, лимиты дескрипторов или поведение через proxy. На arm64 всё соберётся прекрасно, а потом выяснится, что весь smoke test ты почему-то делал только на одном happy-path. Кросс-компиляция снимает проблему сборки. Она не снимает проблему среды.
Поэтому для меня нормальный рабочий сценарий здесь всегда один: сначала держать тулзу в pure Go, пока это вообще возможно, потом собирать под целевой GOOS/GOARCH, а после сборки делать короткий живой прогон на той платформе, где бинарник реально будет запускаться. Всё остальное быстро превращается в ложное ощущение переносимости.
Где такие утилиты начинают сыпаться уже после первого удачного прогона
Почти у любой самописной сетевой тулзы есть момент, когда автор впервые доволен. Она отсканировала порты, нашла пару путей, вывела что-то похожее на результат, и кажется, что всё - можно пользоваться. Обычно это самая опасная стадия.Потому что сразу после неё начинают вылезать проблемы, которые раньше прятались за эффектом новизны.
Первая - отмена и завершение. Пока прогон короткий, никто не думает про то, как инструмент умрёт. Потом словарь становится больше, сеть медленнее, оператор жмёт Ctrl+C, а половина goroutines ещё сидит в запросах или dial'ах. Внешне процесс почти умер, по факту утилита оставляет после себя рваное состояние. У Go для этого давно есть нормальная механика через context. (Подробнее почитать можете
Ссылка скрыта от гостей
) отмена, дедлайны, каскадное завершение производных контекстов. И в блоге Go, и в документации пакета context это как раз описано как штатный способ раздать сигнал остановки всей связанной работе, а не только одной функции.Когда инструмент доходит до этого места, код обычно начинает выглядеть серьезнее:
C-подобный:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
for i := 0; i < *workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case word, ok := <-jobs:
if !ok {
return
}
_ = word
// обработка задачи
}
}
}()
}
Вторая проблема - таймауты. Они сначала кажутся деталью. Потом становится ясно, что именно они определяют половину правдивости результата. У TCP-сканера слишком агрессивный timeout рисует мнимо закрытые порты. У HTTP-утилиты слишком щедрый timeout делает весь прогон липким и вязким. Если же timeout размазан по коду бессистемно - тут DialTimeout, там Client.Timeout, где-то ещё контекст на запрос, - через пару итераций уже сам не понимаешь, какая граница времени у инструмента вообще считается нормальной.
Третья проблема - скорость, которую часто путают с качеством. У самописных тулзов есть неприятный соблазн мериться количеством воркеров. Это почти всегда тупик. Быстрый инструмент - не тот, который может создать больше goroutines. Быстрый инструмент - тот, который даёт воспроизводимый полезный результат в реальной сети, а не только на localhost. Для этого часто приходится не добавлять concurrency, а наоборот, обрезать её до того уровня, где приложение ещё показывает свой характер, а не реагирует на то, что ты его уже забил запросами.
С HTTP-перебором это видно особенно быстро. Пока ты держишь умеренный темп, приложение ведёт себя относительно честно. Чуть перегнул - включается rate limit, CDN начинает крутить защитную логику, WAF меняет профиль ответов, и ты уже наблюдаешь не структуру путей, а реакцию системы на собственную агрессию. Инструмент в этот момент вредит сам себе.
Четвёртая проблема - формат результата. Это, пожалуй, самый скучный и самый недооценённый кусок. Пока утилита маленькая, хочется просто печатать строки в stdout. На коротком прогоне это выглядит удобно. Через неделю выясняется, что такой вывод невозможно нормально сохранить, перегнать дальше, скормить другому инструменту или просто сравнить между двумя запусками.
В этот момент я почти всегда перевожу такие вещи на JSONL. Не потому что это красиво, а потому что после этого тулза перестаёт быть “терминальной игрушкой”.
Минимальный вывод может быть таким:
C-подобный:
type Finding struct {
Target string `json:"target"`
Path string `json:"path,omitempty"`
Port int `json:"port,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Length int `json:"length,omitempty"`
Banner string `json:"banner,omitempty"`
Error string `json:"error,omitempty"`
}
enc := json.NewEncoder(os.Stdout)
_ = enc.Encode(Finding{
Target: "http://127.0.0.1:8080",
Path: "/admin",
StatusCode: 302,
Length: 0,
})
Пятая проблема - смешивание ошибок и находок. Для оператора connection refused, i/o timeout, 302 /login, 403 /backup и “порт открыт, но баннера нет” - это разные сущности. У плохой тулзы всё это летит в одну трубу и потом одинаково шумит в выводе. У хорошей - ошибка остаётся ошибкой, а находка остаётся находкой. Разница кажется косметической ровно до того момента, пока не начинаешь реально разбирать большой прогон.
Почему стандартная библиотека здесь полезнее, чем кажется
Во всей этой истории мне как раз нравится, что Go не заставляет выносить половину здравого смысла в сторонние зависимости. net даёт нормальную основу для TCP, UDP и DNS. net/http закрывает HTTP-клиент без ощущения, что ты собрал ещё один маленький фреймворк вокруг одной сетевой задачи. Пакет context давно считается штатным способом протащить cancel и deadline через конкурентную работу. Это всё очень приземлённые вещи, но именно они и делают небольшую тулзу удобной для жизни.Мне вообще кажется, что Go особенно хорошо виден не на больших security-проектах, а на маленьких утилитах. На них сразу понятно, помогает язык держать код в руках или мешает. Если после первой рабочей версии сканера или enumerator'а хочется не переписать всё с нуля, а спокойно добавить context, baseline, экспорт и второй профиль таймаутов, значит, материал у языка подходящий.
Что в итоге остаётся в рабочей папке
После всех этих итераций самое полезное открытие оказалось довольно скучным. Ценность своих сетевых утилит вообще не в том, что они “самописные” или “на Go”. Ценность в том, что они решают ровно твою задачу и ведут себя предсказуемо.Сканер нужен не ради самого факта сканирования, а чтобы честно и быстро показать, кто жив, где баннер, где таймаут, а где ты сам переборщил с агрессией. HTTP enumerator нужен не ради галочки “у меня свой gobuster”, а чтобы не верить первому 200 OK, видеть редиректы, различать фон и находки и не терять картину приложения за шумом.
И вот в этом месте Go действительно попадает в свою роль. Не как язык для пафосного offensive storytelling, а как очень практичный материал для маленьких сетевых инструментов, которые не разваливаются после второго запуска, нормально собираются под другие платформы и не заставляют тебя воевать с ними сильнее, чем с самой задачей.
Когда тулза дорастает до такого состояния, её уже не хочется выбрасывать. Она остаётся в рабочей папке. А это для самописного инструмента, пожалуй, и есть самый честный комплимент.
Последнее редактирование: