В роли атакующего вы должны понимать принцип работы 𝗧𝗖𝗣, чтобы справляться с разработкой пригодных вариантов его конструкций, позволяющих определять открытые/закрытые порты, распознавать такие потенциально ошибочные результаты, как ложные срабатывания — например, 𝗦𝗬𝗡-флуд защиты, — и обходить ограничения на исходящий трафик посредством переадресации портов. В этой главе вы изучите основы 𝗧𝗖𝗣-коммуникаций в 𝗚𝗼, реализуете многопоточный правильно отрегулированный сканер портов, создадите 𝗧𝗖𝗣-прокси, который можно использовать для переадресации портов, а также воссоздадите 𝗡𝗲𝘁𝗰𝗮𝘁-функцию «зияющая дыра в безопасности».
В интернете куча информации которые раскрывают каждый нюанс 𝗧𝗖𝗣, включая такие темы, как потоки и структура пакетов, надежность, повторная сборка сегментов и многие другие. Настолько подробная детализация выходит за рамки этой темы, поэтому я рекомендую глубже изучить эту тему, после прочтения статьи.
TCP Handshaking:
В качестве напоминания мы начнем с основ. снизу будет показано, как 𝗧𝗖𝗣 при запросе порта использует процесс рукопожатия (𝗵𝗮𝗻𝗱𝘀𝗵𝗮𝗸𝗶𝗻𝗴), определяя, открыт порт, закрыт или фильтруется.
Если порт открыт, рукопожатие осуществляется в три этапа. Сначала клиент отправляет пакет 𝘀𝘆𝗻, определяющий начало сеанса связи. В ответ на это сервер отправляет 𝘀𝘆𝗻-𝗮𝗰𝗸, иначе говоря, подтверждение получения пакета 𝘀𝘆𝗻, предлагая клиенту завершить сеанс установки связи отправкой сигнала 𝗮𝗰𝗸, то есть встречного подтверждения получения ответа сервера. После этого может начаться обмен данными. Если же порт будет закрыт, сервер ответит пакетом 𝗿𝘀𝘁, а не 𝘀𝘆𝗻-𝗮𝗰𝗸. В случае, когда трафик фильтруется межсетевым экраном (брандмауэром), клиент обычно не получает от сервера ответа.
При написании сетевых протоколов важно понимать принцип работы этих пакетов. Соответствие выходных данных создаваемых вами инструментов этим низкоуровневым потокам пакетов поможет убедиться в правильной установке сетевого соединения и устранить потенциальные проблемы. Как вы увидите чуть позже, в коде можно легко допустить ошибки, не реализовав полный цикл рукопожатия при соединении «клиент — сервер», что приведет к неточным или вводящим в заблуждение результатам.
Обход брандмауэра с помощью переадресации портов:
Иногда в системах с целью ограничения доступа клиента к конкретным серверам и портам устанавливаются брандмауэры. В некоторых случаях эти ограничения можно обойти, используя промежуточную систему для проксирования в обход или через такой брандмауэр. Эта техника называется переадресацией портов.
Многие корпоративные сети ограничивают возможность подключения своих внутренних ресурсов к вредоносным сайтам. В качестве примера представьте такой сайт под названием 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺. Если сотрудник компании попытается подключиться к нему напрямую, брандмауэр заблокирует его запрос. Но если у сотрудника есть собственная внешняя система, доступная через брандмауэр (например, 𝘀𝘁𝗮𝗰𝗸𝘁𝗶𝘁𝗮𝗻.𝗰𝗼𝗺), то он может задействовать ее для установки связи с 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺. Этот принцип отражен снизу.
Клиент подключается к удаленному хосту 𝘀𝘁𝗮𝗰𝗸𝘁𝗶𝘁𝗮𝗻.𝗰𝗼𝗺 через брандмауэр. Этот хост настроен на перенаправление соединений к хосту 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺. Несмотря на то что брандмауэр запрещает прямые подключения к 𝗲𝘃𝗶𝗹.𝗰𝗼𝗺, описанная конфигурация позволяет клиенту обойти этот механизм защиты.
Переадресацию портов можно использовать для эксплуатации нескольких запрещающих сетевых конфигураций. Например, можно перенаправить трафик через инсталляционный сервер для доступа к сегментированной сети или обращения к портам, привязанным к ограниченным интерфейсам.
Написание TCP-сканера:
Один из эффективных способов концептуализировать понимание взаимодействия 𝗧𝗖𝗣-портов — это реализация их сканера. В процессе его создания вы увидите все шаги обмена рукопожатиями в 𝗧𝗖𝗣, а также эффекты от возникающих изменений состояний, которые позволяют определить, является ли порт доступным, закрытым или отфильтровывается.
Написав базовый сканер портов, вы перейдете к созданию его ускоренной версии. Базово эта программа способна сканировать множество портов, используя один непрерывный метод, но если вам потребуется выполнить сканирование всех 𝟲𝟱 𝟱𝟯𝟱 портов, это может занять слишком много времени. Поэтому мы научим вас с помощью многопоточности делать малоэффективный сканер более подходящим для выполнения задач расширенного сканирования. Освоенные в этой статье шаблоны параллельности вы сможете применять и во многих других сценариях.
Тестирование портов на доступность:
Первый шаг в создании сканера портов — понять процесс инициирования соединения от клиента к серверу. В рассматриваемом примере вы будете подключаться к 𝘀𝗰𝗮𝗻𝗺𝗲.𝗻𝗺𝗮𝗽.𝗼𝗿𝗴 — сервису проекта 𝗡𝗺𝗮𝗽𝟭 и сканировать его. Для этого мы с вами задействуем пакет 𝗚𝗼 𝗻𝗲𝘁: 𝗻𝗲𝘁.𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴).
Первый аргумент — это строка, определяющая тип инициируемого соединения. Дело в том, что 𝗗𝗶𝗮𝗹 используется не только для 𝗧𝗖𝗣, но и для создания соединений, задействующих сокеты 𝗨𝗻𝗶𝘅, 𝗨𝗗𝗣 и протоколы 𝟰-го уровня, которые мы оставим в стороне, так как на основе всего нашего опыта будет достаточно просто сказать, что 𝗧𝗖𝗣 очень хорош. В этот аргумент можно передать несколько вариантов строк, но для краткости будем использовать строку 𝘁𝗰𝗽.
Второй аргумент указывает 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) на хост, к которому вы хотите подключиться. Обратите внимание на то, что это одна строка, а не 𝘀𝘁𝗿𝗶𝗻𝗴 и 𝗶𝗻𝘁. Для соединений 𝗜𝗣𝘃𝟰/𝗧𝗖𝗣 она будет принимать форму 𝗵𝗼𝘀𝘁:𝗽𝗼𝗿𝘁. Например, если вам нужно подключиться к 𝘀𝗰𝗮𝗻𝗺𝗲.𝗻𝗺𝗮𝗽.𝗼𝗿𝗴 через 𝗧𝗖𝗣-порт 𝟴𝟬, то нужно указать 𝘀𝗰𝗮𝗻𝗺𝗲.𝗻𝗺𝗮𝗽.𝗼𝗿𝗴:𝟴𝟬.
Теперь вы знаете, как создать соединение, но как понять, что оно было успешным? Для этого выполняется проверка на ошибки: 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) возвращает Conn и error. При этом error будет nil, если соединение установлено успешно. Так что для проверки вам просто нужно убедиться, что error равна nil. Вот теперь у вас есть все необходимые элементы для построения сканера портов, хотя и не особо корректного. В Основах рукопожатия в TCP показано, как все это объединить.
Простой сканер портов, сканирующий только один порт /dial/main.go
Выполнив этот код, вы должны увидеть сообщение 𝗖𝗼𝗻𝗻𝗲𝗰𝘁𝗶𝗼𝗻 𝘀𝘂𝗰𝗰𝗲𝘀𝘀𝗳𝘂𝗹 при условии наличия у вас доступа к великой информационной супермагистрали
Выполнение однопоточного сканирования:
Сканирование по одному порту за раз не особо полезно и малоэффективно, так как диапазон 𝗧𝗖𝗣-портов — от 𝟭 до 𝟲𝟱 𝟱𝟯𝟱. В целях же тестирования давайте пока просканируем порты от 𝟭 до 𝟭𝟬𝟮𝟰. Для этого можно использовать цикл 𝗳𝗼𝗿:
Теперь у вас есть 𝗶𝗻𝘁, но нужно помнить, что в качестве второго аргумента для 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) требуется строка. Есть по меньшей мере два способа преобразования целого числа в строку. Первый — использовать пакет преобразования строк 𝘀𝘁𝗿𝗰𝗼𝗻𝘃. Второй — применить функцию 𝗦𝗽𝗿𝗶𝗻𝘁𝗳(𝗳𝗼𝗿𝗺𝗮𝘁 𝘀𝘁𝗿𝗶𝗻𝗴, 𝗮 ...𝗶𝗻𝘁𝗲𝗿𝗳𝗮𝗰𝗲{}) из пакета 𝗳𝗺𝘁, которая (аналогично своему собрату в 𝗖) возвращает 𝘀𝘁𝗿𝗶𝗻𝗴, сгенерированную из строки формата (𝗳𝗼𝗿𝗺𝗮𝘁 𝘀𝘁𝗿𝗶𝗻𝗴).
Создайте файл с кодом из листинга 𝟮.𝟮 и убедитесь в работоспособности цикла и функции генерации строки. Выполнение этого кода должно вывести 𝟭𝟬𝟮𝟰 строки, но утруждать себя их подсчетом не обязательно.
Сканирование 1024 портов scanme.nmap.org /tcp-scanner-slow/main.go
Теперь осталось только подставить переменную адреса из предыдущего кода в 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) и протестировать доступность портов, реализовав такую же проверку ошибок, как в предыдущем разделе. Помимо этого, чтобы не оставлять успешные соединения открытыми, следует добавить логику их закрытия. Завершение соединений — это жест вежливости. Для этого вам нужно выполнить в 𝗖𝗼𝗻𝗻 вызов 𝗖𝗹𝗼𝘀𝗲(). Снизу показана полноценная реализация сканера портов.
Завершенный сканер портов /tcp-scanner-slow/main.go
Скомпилируйте и выполните этот код для выполнения легкого сканирования цели. Вы должны обнаружить пару открытых портов.
Параллельное сканирование:
Предыдущий сканер сканировал серию портов в один заход. Но вашей целью является проверка множества портов параллельно, что существенно ускорит его работу. Для этого мы воспользуемся горутинами. 𝗚𝗼 позволяет создавать столько горутин, сколько способна обработать ваша система, ограничиваясь только объемом доступной памяти.
Слишком быстрая версия сканера:
Самым прямолинейным способом создания параллельного сканера будет обернуть вызов 𝗗𝗶𝗮𝗹(𝗻𝗲𝘁𝘄𝗼𝗿𝗸, 𝗮𝗱𝗱𝗿𝗲𝘀𝘀 𝘀𝘁𝗿𝗶𝗻𝗴) в горутину. Чтобы собственными глазами увидеть последствия этого, создайте файл 𝘀𝗰𝗮𝗻-𝘁𝗼𝗼-𝗳𝗮𝘀𝘁.𝗴𝗼 с кодом который снизу.
Сканер, работающий слишком быстро /tcp-scanner-too-fast/main.go
При выполнении кода вы заметите, что программа завершается практически мгновенно:
Этот код запускает по одной горутине для каждого соединения, а основная горутина не знает, что нужно ждать окончания его установки. В связи с этим выполнение кода завершается, как только цикл 𝗳𝗼𝗿 заканчивает перебор, что происходит быстрее, чем сеть успевает осуществить обмен пакетами между кодом и всеми целевыми портами. Поэтому вы не получите точных результатов для портов, чьи пакеты находились в процессе обмена.
Исправить это можно несколькими способами. Первый — использовать 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 из пакета 𝘀𝘆𝗻𝗰, предоставляющий потокобезопасный способ управления параллельным выполнением. 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 — это тип структуры, который создается так:
Создав 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽, вы можете вызвать для этой структуры несколько методов. Первый метод — 𝗔𝗱𝗱(𝗶𝗻𝘁), увеличивающий внутренний счетчик согласно переданному числу. Следующий — метод 𝗗𝗼𝗻𝗲(), уменьшающий счетчик на 𝟭. И наконец, метод 𝗪𝗮𝗶𝘁(), блокирующий выполнение горутины, в которой вызывается, запрещая дальнейЭтот вариант кода по большому счету остался неизменным. Тем не менее здесь мы добавили код, явно отслеживающий оставшуюся работу. В этой версии программышее выполнение, пока внутренний счетчик не достигнет нуля. Эти вызовы можно совмещать, гарантируя, что основная горутина дождется завершения всех соединений.
Синхронизированное сканирование с помощью WaitGroup:
Синхронизированный сканер, использующий WaitGroup /tcp-scanner-wg-too-fast/main.go
Этот вариант кода по большому счету остался неизменным. Тем не менее здесь мы добавили код, явно отслеживающий оставшуюся работу. В этой версии программы
создаем 𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽, выступающую в качестве синхронизированного счетчика. Мы увеличиваем этот счетчик через 𝘄𝗴.𝗔𝗱𝗱(𝟭) при каждом создании горутины для сканирования порта. При этом отложенный вызов 𝘄𝗴.𝗗𝗼𝗻𝗲() уменьшает этот счетчик при завершении каждой единицы работы. Функция 𝗺𝗮𝗶𝗻() вызывает 𝘄𝗴.𝗪𝗮𝗶𝘁(), который блокирует выполнение, пока не будет выполнена вся работа и счетчик не достигнет нуля.
Эта версия программы уже лучше, но по-прежнему имеет недостатки. Если запустить ее несколько раз для разных хостов, можно получить несогласованные результаты. Одновременное сканирование чрезмерного количества хостов или портов может привести к тому, что ограничения системы или сети исказят результаты. Попробуйте изменить в коде значение 𝟭𝟬𝟮𝟰 на 𝟲𝟱𝟱𝟯𝟱 и укажите адрес целевого сервера как 𝟭𝟮𝟳.𝟬.𝟬.𝟭. При желании можете использовать 𝗪𝗶𝗿𝗲𝘀𝗵𝗮𝗿𝗸 или 𝘁𝗰𝗽𝗱𝘂𝗺𝗽, чтобы увидеть, насколько быстро открываются эти соединения.
Сканирование портов с помощью пула воркеров:
Чтобы избежать несогласованности, можно задействовать для управления параллельным выполнением пул горутин. С помощью цикла 𝗳𝗼𝗿 вы создаете определенное количество воркеров горутин в качестве пула. Затем в потоке 𝗺𝗮𝗶𝗻() с помощью канала обеспечиваете работу.
Для начала создайте новую программу, которая использует канал 𝗶𝗻𝘁, содержит 𝟭𝟬𝟬 воркеров и выводит их результаты на экран. При этом задействуйте 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 для блокирования выполнения.
Создайте начальную заглушку кода для функции 𝗺𝗮𝗶𝗻, а над ней напишите функцию,приведенную в коде ниже
Функция с воркером для выполнения задачи.
Функция 𝘄𝗼𝗿𝗸𝗲𝗿(𝗶𝗻𝘁, *𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽) получает два аргумента: канал типа 𝗶𝗻𝘁 и указатель на 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽. Канал будет использоваться для получения работы, а 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽 — для отслеживания завершения одной ее единицы.
Далее добавьте функцию 𝗺𝗮𝗶𝗻(), приведенную в коде ниже, которая будет управлять рабочей нагрузкой и обеспечивать работу функции 𝘄𝗼𝗿𝗸𝗲𝗿(𝗶𝗻𝘁, *𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽).
Базовый пул воркеров /tcp-sync-scanner/main.go
Сначала создается канал с помощью 𝗺𝗮𝗸𝗲(). В 𝗺𝗮𝗸𝗲() в качестве второго параметра передается значение 𝟭𝟬𝟬. Это добавляет каналу буферизацию, то есть в него можно будет отправлять элемент и не ждать, пока получатель этот элемент прочтет. Буферизованные каналы идеально подходят для поддержания и отслеживания работы нескольких производителей и потребителей. Емкость канала определяется как 𝟭𝟬𝟬. Значит, он может вместить 𝟭𝟬𝟬 элементов, до того как отправитель будет заблокирован. Это дает небольшой прирост производительности, поскольку все воркеры смогут запускаться сразу.
Далее с помощью цикла 𝗳𝗼𝗿 запускается заданное число воркеров — в данном случае 𝟭𝟬𝟬. В функции 𝘄𝗼𝗿𝗸𝗲𝗿(𝗶𝗻𝘁, *𝘀𝘆𝗻𝗰.𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽) с помощью 𝗿𝗮𝗻𝗴𝗲 происходит непрерывное циклическое получение данных из канала 𝗽𝗼𝗿𝘁𝘀, завершающееся только при закрытии канала. Обратите внимание: пока воркер никакой работы не выполняет — это произойдет чуть позже. Последовательно перебирая порты в функции 𝗺𝗮𝗶𝗻(), вы отправляете порт через канал 𝗽𝗼𝗿𝘁𝘀 воркеру. По завершении всей работы закрываете канал.
Запустив эту программу, вы увидите, как на экран выводятся числа. Здесь можно заметить кое-что интересное, а именно то, что выводятся они в конкретном порядке. Добро пожаловать в прекрасный мир многопоточности!
Многоканальная связь:
Чтобы завершить создание сканера, можно вставить код, использованный ранее в этом разделе, и это вполне сработает. Но в таком случае выводимые порты будут не отсортированы, так как сканер станет проверять их не по порядку. Решить эту проблему можно, реализовав упорядоченную передачу результатов сканирования в основной поток через дополнительный. Это изменение к тому же позволит полностью устранить зависимость от 𝗪𝗮𝗶𝘁𝗚𝗿𝗼𝘂𝗽, так как теперь у вас будет другой метод для отслеживания завершения. Например, если вы сканируете 𝟭𝟬𝟮𝟰 порта, то делаете по каналу воркера 𝟭𝟬𝟮𝟰 передачи, после чего снова выполняете 𝟭𝟬𝟮𝟰 передачи с результатами работы обратно в основной поток. Поскольку количество отправленных единиц работы и полученных результатов совпадает, программа понимает, когда нужно закрывать каналы и, следовательно, отключать воркеры.
Эта модификация кода представлена в коде ниже, которым завершается создание сканера.
Сканирование портов через несколько каналов /tcp-scanner-final/main.go
Функция 𝘄𝗼𝗿𝗸𝗲𝗿(𝗽𝗼𝗿𝘁𝘀, 𝗿𝗲𝘀𝘂𝗹𝘁𝘀 𝗰𝗵𝗮𝗻 𝗶𝗻𝘁) была изменена для получения двух каналов. Остальная логика почти полностью осталась прежней, за исключением того, что в случае закрытого порта вы отправляете ноль, а в случае открытого — значение этого порта. Кроме того, здесь вы создаете отдельный канал для передачи результатов от воркера в основной поток. Затем результаты сохраняются в срез, что позволяет выполнить их сортировку. Далее вам нужно реализовать отправку данных воркера в отдельной горутине, потому что цикл сбора результатов должен начаться до того, как сможет продолжиться выполнение более 𝟭𝟬𝟬 единиц работы.
Этот цикл получает по каналу 𝗿𝗲𝘀𝘂𝗹𝘁𝘀 𝟭𝟬𝟮𝟰 передачи. Если порт не равен 𝟬, он добавляется в срез. После закрытия каналов вы используете сортировку для упорядочивания среза открытых портов. Далее остается лишь перебрать срез и вывести открытые порты на экран.
Вот мы и написали высокопроизводительный сканер портов. Уделите время экспериментированию с кодом — в частности, с количеством воркеров. Чем их больше, тем быстрее должна выполняться программа. Но если их окажется слишком много, результаты станут ненадежными. При написании инструментов, которые будут применять другие люди, вам нужно использовать грамотное предустановленное значение, которое ориентировано на надежность, а не на скорость. При этом также следует предоставлять пользователям опцию самостоятельного выбора количества воркеров.
В полученную программу можно внести пару улучшений. Во-первых, вы отправляете по каналу 𝗿𝗲𝘀𝘂𝗹𝘁𝘀 результат сканирования каждого порта, что необязательно. Альтернативное решение потребует написания более сложного кода, который будет использовать дополнительный канал не только для отслеживания воркеро. Вам может потребоваться, чтобы сканер умел парсить строки с портами, например 𝟴𝟬,𝟰𝟰𝟯,𝟴𝟬𝟴𝟬,𝟮𝟭-𝟮𝟱, наподобие тех, что могут быть переданы в 𝗡𝗺𝗮𝗽. Я предлагаю вам освоить этот прием самостоятельно.
P.S: в следующей статье мы рассмотрим создание TCP-прокси.