На одном из внутренних пентестов мы попали на GNU/Linux-хост с Python 3.10 и пользователем
www-data - ни nmap, ни masscan, ни возможности протащить бинари через узкий reverse-SSH. Задача: просканировать /24 за корпоративным VLAN и понять, куда двигаться для lateral movement. Asyncio-сканер на 80 строк решил вопрос за 47 секунд - 1024 порта на 254 хостах - и собрал баннеры SSH и HTTP, достаточные для выбора следующей цели. Ниже - полный разбор, как написать такой сканер портов на Python, почему asyncio здесь выигрывает у потоков, и где этот подход разваливается.Зачем пентестеру свой сканер портов на Python: место в цепочке атаки
Сканирование портов - Network Service Discovery (T1046, Discovery) по матрице MITRE ATT&CK. В цепочке атаки оно стоит между получением foothold на первом хосте и выбором цели для lateral movement. Типичный сценарий: initial access через фишинг или эксплуатацию веб-приложения, затем разведка внутренней сети - какие хосты живы, какие порты открыты, какие сервисы слушают. Подробнее - в нашем руководстве по разработка red team инструментов.
На этапе внешнего recon - Active Scanning (T1595) и Scanning IP Blocks (T1595.001) - nmap и masscan справляются отлично. А вот на pivot-хосте ситуация другая: нет root-прав для SYN scan, нет возможности поставить пакеты, а пропускная способность туннеля не позволяет тащить тяжёлые бинари.
Python-сканер закрывает конкретную нишу: быстрая разведка из-под непривилегированного пользователя на хосте, где Python уже стоит. Не более того.
[Применимо: внутренний пентест, pivot-хост без root-прав, Python 3.8+ доступен]
| Преимущества | Ограничения | Когда использовать | Когда не использовать |
|---|---|---|---|
| Нет зависимостей - только stdlib | Только TCP connect (полное рукопожатие) | Pivot-хост с Python без инструментов | Нужен SYN scan для скрытности |
| Работает без root | Нет OS fingerprinting | Автоматизация в Python-пайплайне | Нужна детекция ОС цели |
| Полная кастомизация логики | Шумный - оставляет полные TCP-сессии в логах | Сканирование из-под сервисного аккаунта | Доступен nmap и есть root |
| Встраивается в скрипт эксплуатации | Нет аналога NSE-скриптов | Разведка после initial access | Цель за stateful FW с deep inspection |
По данным проекта Simple-Async-Port-Scanner на GitHub (EONRaider), asyncio-сканер способен прогнать 1000 портов scanme.nmap.org за 1.68 секунды. Для сравнения - последовательный скан тех же 1000 портов с таймаутом 3 секунды растягивается на ~51 минуту. Разница в три порядка.
Требования к окружению
- Python: 3.8+ (для корректной работы
asyncio.open_connectionиwait_for) - ОС: GNU/Linux, macOS, Windows - connect scan работает везде
- RAM: минимум 50 МБ, рекомендуется 128 МБ для сканирования /16
- ulimit (Linux/macOS): дефолт 1024 файловых дескрипторов ограничивает конкурентность. Перед запуском:
ulimit -n 4096 - Зависимости: нет - только стандартная библиотека (asyncio, socket, ipaddress)
- Сетевые условия: прямой доступ к целевой подсети или проксирование через SOCKS
Asyncio сканер портов: TCP connect scan без root
TCP connect scan делает полное трёхстороннее рукопожатие: SYN -> SYN-ACK -> ACK. Это громче, чем SYN scan (который обрывает соединение после SYN-ACK и не завершает handshake), но не требует raw sockets и root-прав. На pivot-хосте, где вы сидите от
www-data или сервисного аккаунта - это единственный доступный вариант сканирования портов через Python.asyncio.open_connection(host, port)(Streams) - высокоуровневая обёртка, которая создаёт TCP-соединение и возвращает StreamReader и StreamWriter. Через них можно сразу читать баннеры и слать пробы - не нужно отдельно создавать socket.socket() и регистрировать его в event loop.Но есть критичный момент: закрытый порт за файрволом не отвечает RST, а просто молчит. Без таймаута корутина зависнет навсегда.
asyncio.wait_for(coro, timeout)(Coroutines and tasks) решает это - при превышении таймаута выбрасывает asyncio.TimeoutError.Семафор и управление файловыми дескрипторами
Каждое TCP-соединение - файловый дескриптор в ОС. Дефолтный лимитulimit -n на большинстве GNU/Linux-систем - 1024. Если asyncio попытается открыть 5000 соединений одновременно, вы получите OSError: [Errno 24] Too many open files, и сканер молча пропустит все порты. На StackOverflow есть характерный тред - пользователь получал нулевые результаты без видимых ошибок при конкурентности выше ulimit.Решение -
asyncio.Semaphore(N), который ограничивает число одновременно живых корутин. Значение N подбирается под среду:- LAN без ограничений: 500-1000
- Через VPN или pivot-туннель: 100-300
- Через медленный SOCKS-прокси: 50-100
Ядро сканера - одна корутина, которая совмещает проверку порта и чтение баннера в рамках одного соединения:
Python:
import asyncio
async def scan_port(sem, host, port, timeout=2.0):
async with sem:
try:
r, w = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout)
except (asyncio.TimeoutError, OSError):
return None
banner = b""
try:
banner = await asyncio.wait_for(r.read(1024), 1.0)
except asyncio.TimeoutError:
w.write(b"HEAD / HTTP/1.0\r\nHost: x\r\n\r\n")
await w.drain()
try:
banner = await asyncio.wait_for(r.read(1024), 1.0)
except asyncio.TimeoutError:
pass
w.close()
await w.wait_closed()
return port, detect_service(banner), banner
OSError или TimeoutError отправляет нас в return None. Порт открыт - пробуем прочитать пассивный баннер (SSH, FTP, SMTP отдают его при подключении). Сервер молчит - кидаем HTTP-пробу HEAD / HTTP/1.0\r\n\r\n и читаем ответ. В обоих случаях корректно закрываем StreamWriter через w.close() и await w.wait_closed() - без этого файловые дескрипторы утекают, и через пару тысяч портов сканер упадёт с тем самым Errno 24.Парсинг баннеров на Python: fingerprinting сервисов через сокеты
Баннер - первые байты, которые сервис отправляет при TCP-подключении. SSH-сервер отдаёт строку
SSH-2.0-OpenSSH_8.9p1, FTP - 220 ProFTPD 1.3.6, SMTP - 220 mail.corp.local ESMTP Postfix. Это пассивный fingerprinting без аутентификации, который работает на этапе recon до начала эксплуатации.Но не все сервисы такие разговорчивые. HTTP-серверы, MySQL (частично), SMB ждут запроса от клиента. Для них нужны активные пробы: HTTP HEAD-запрос,
\r\n для триггера ответа, или протокол-специфичные handshake.Функция
detect_service матчит сырые байты баннера по сигнатурам:
Python:
SERVICE_SIGS = [
(b"SSH-", "ssh"), (b"220 ", "ftp/smtp"),
(b"HTTP/", "http"), (b"+OK", "pop3"),
(b"* OK", "imap"), (b"MySQL", "mysql"),
(b"PostgreSQL", "postgresql"), (b"Redis", "redis"),
]
def detect_service(banner):
for sig, name in SERVICE_SIGS:
if sig in banner:
return name
return "unknown"
b"220 " встречается и в FTP, и в SMTP. Для грубой классификации на этапе recon этого хватает - точный fingerprinting (версия, конфигурация) делается на следующем шаге, уже нацеленными инструментами. Если баннер не опознан - socket.getservbyport(port, "tcp") даёт имя сервиса из /etc/services как fallback.Из практики: SSH-баннер - самый информативный. Строка
SSH-2.0-OpenSSH_7.4 сразу говорит о CentOS 7 или RHEL 7 (эта версия OpenSSH поставляется именно с ними). SSH-2.0-OpenSSH_8.9p1 - Ubuntu 22.04. Это не OS fingerprinting уровня nmap -O, но для выбора вектора lateral movement - вполне рабочий ориентир.Активные пробы для сервисов без баннера
HTTP-серверы не отправляют ничего до получения запроса. Поэтому в корутинеscan_port выше реализована двухфазная логика: сначала пассивное чтение с коротким таймаутом (1 секунда), затем - отправка HTTP HEAD-запроса. Заголовок Host: можно подставить любой - для детекции сервиса это не критично.Для более агрессивного fingerprinting можно добавить протокол-специфичные пробы:
EHLO test\r\n для SMTP, \x00 для MySQL, PING\r\n для Redis. Но каждая дополнительная проба увеличивает время сканирования и шум в сети. Для первичного recon хватает HTTP HEAD.Рабочий пример: собираем асинхронный сканер с banner grabbing
Оркестратор соединяет всё вместе: генерирует список целей, создаёт семафор и запускает корутины черезasyncio.as_completed - результаты выводятся по мере поступления, а не после завершения всех задач:
📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме
На тестовом стенде вывод выглядит так:
Код:
22 ssh SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.10
80 http HTTP/1.1 200 OK
3306 mysql 5.7.42-0ubuntu0.18.04.1
445 unknown
22 ssh SSH-2.0-OpenSSH_7.4
echo "..." | base64 -d > scan.py или вставить через интерактивный Python-shell. Никаких зависимостей, никаких pip install - работает на голом Python 3.8+.Результаты можно записать в JSON для автоматической передачи следующему скрипту - brute-force SSH по найденным хостам или проверку веб-приложений через
httpx. В этом и кайф кастомного инструмента: не нужно парсить XML-вывод nmap, данные сразу в Python-структурах.Ограничения: когда asyncio-сканер портов не работает
Stateful firewalls и IDS/SIEM. TCP connect scan оставляет полные сессии в логах. Любой stateful firewall фиксирует трёхстороннее рукопожатие. Snort и Suricata детектируют порт-сканы по пороговым правилам - N+ соединений от одного IP за M секунд. Для blue team это выглядит ровно как Network Service Discovery (T1046). Если на целевой сети стоит даже базовый IDS, сканирование 1024 портов на 254 хостах будет замечено. Снижение конкурентности и добавление случайных задержек (asyncio.sleep(random.uniform(0.1, 0.5)) после каждого скана) уменьшает шансы на триггер порога, но не устраняет проблему - в логах всё равно остаются записи о соединениях.Нет SYN scan - нет скрытности. SYN scan (nmap
-sS) не завершает рукопожатие и оставляет меньше следов. Для SYN scan нужны raw sockets и root-права. На Python это реализуемо через scapy (sr1(IP(dst=host)/TCP(dport=port, flags="S"))), но требует CAP_NET_RAW или root. На pivot-хосте с www-data - не вариант.UDP-сервисы невидимы. Connect scan работает только с TCP. DNS (53/UDP), SNMP (161/UDP), TFTP (69/UDP) - всё это остаётся за пределами. Для UDP-разведки нужен отдельный подход через
socket.SOCK_DGRAM.Ложные негативы при агрессивном сканировании. Слишком высокая конкурентность приводит к потере SYN-ACK ответов - ОС отбрасывает пакеты при перегрузке. Порт открыт, но сканер считает его закрытым. Если результаты подозрительно пустые - снизьте значение семафора вдвое и повторите.
Когда nmap всё-таки лучше. Если есть root и nmap - используйте nmap. Он лучше обрабатывает edge cases (фрагментированные ответы, rate-limited хосты, IPv6), имеет базу из 1500+ сервисных сигнатур в
nmap-service-probes и NSE-скрипты для автоматизации пост-сканирования. Кастомный сканер - не замена nmap, а инструмент для ситуаций, где nmap недоступен.Заключение
За четыре года я не был ни на одном проекте, где не пришлось бы написать хотя бы один кастомный скрипт - сканер, парсер логов или автоматизацию brute-force с нестандартной логикой. Умение быстро собрать инструмент под конкретное ограничение - не бонусный навык, а базовое требование для offensive-специалиста. Asyncio-сканер из этой статьи - 80 строк кода, но за ними стоит понимание TCP-стека, event loop, управления ресурсами ОС и fingerprinting. Именно этот стек знаний отличает человека, который "запускает nmap", от того, кто понимает, что nmap делает внутри, и может воспроизвести нужную часть за час. Формула на бумаге понятна, но настоящий скилл приходит только когда пишешь инструменты для реальных задач - на HackerLab.pro (https://hackerlab.pro) категория network дают именно такую практику, где нужно не просто прогнать готовый скан, а разобраться, почему стандартный подход не сработал. Если хотите выстроить этот навык системно - от сокетов и asyncio до полноценных recon-пайплайнов - курс «Python для пентестера» от Codeby Academy строит именно этот мост между скриптованием и offensive-разработкой.
Последнее редактирование модератором: