Статья Свой асинхронный сканер портов на Python: asyncio, сокеты и парсинг баннеров

Ноутбук на тёмном антистатическом коврике с терминалом, отображающим карту подсети с линиями соединений и строками SSH и HTTP в янтарном цвете. Жёсткий голубой свет подчёркивает алюминиевый корпус.


На одном из внутренних пентестов мы попали на 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: место в цепочке атаки​

1781917414798.webp

Сканирование портов - 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​

1781917477368.webp

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
Задрали слишком высоко - потеря пакетов и ложные негативы (порт открыт, но ответ не успел прийти). Поставили слишком низко - сканирование растягивается на минуты. На практике я начинаю с 300 и смотрю на процент пустых ответов.

Ядро сканера - одна корутина, которая совмещает проверку порта и чтение баннера в рамках одного соединения:
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 сервисов через сокеты​

1781917525752.webp

Баннер - первые байты, которые сервис отправляет при 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
Для запуска на pivot-хосте достаточно скопировать скрипт через 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-разработкой.
 
Последнее редактирование модератором:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

Похожие темы

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🧭 Навигатор · ИБ 2026
Не знаешь, какой трек твой?
5 направлений ИБ, реальные зарплаты и точка входа для каждого — в одном треде.
JuniorSenior+
100K → 600K+ ₽ /мес
Открыть навигатор →

Популярный контент

🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab