Python скрипт для перебора субдоменов: пишем свой vhost fuzzer вместо gobuster

На HTB-машине Nineveh я три раза подряд запустил gobuster dns с разными словарями — и получил ноль. IP пингуется, веб-сервер отвечает, порты 80 и 443 открыты, а субдоменов якобы нет. Через час до меня дошло: нужный поддомен не существует в DNS вообще. Он живёт только в конфигурации веб-сервера как виртуальный хост и откликается исключительно на HTTP-запрос с правильным заголовком Host. Тогда я решил разобраться, как это устроено изнутри, и написать собственный Python-скрипт для перебора субдоменов через vhost fuzzing — с фильтрацией wildcard, многопоточностью и разбором типичных граблей.
DNS brute-force и vhost fuzzing: почему gobuster dns не находит скрытые субдомены
Перебор субдоменов (subdomain enumeration) — один из первых шагов разведки на пентесте. Но за одним названием прячутся две принципиально разные техники, и путаница между ними — причина пустых результатов у каждого второго новичка на HTB.
DNS brute-force отправляет DNS-запросы к резолверу: «существует ли admin.target.htb?». Если A-запись (запись, которая связывает доменное имя с IPv4-адресом) есть — получаем IP. Если нет — ответ NXDOMAIN, домен не найден. Так работают gobuster dns, Amass, Subfinder.
Vhost fuzzing (перебор виртуальных хостов) работает на уровне HTTP. Запрос летит к уже известному IP-адресу, но с разными значениями в заголовке Host. Веб-сервер (Apache, Nginx) смотрит на этот заголовок, чтобы решить, какой сайт показать. На одном IP могут жить nineveh.htb и secret.nineveh.htb — DNS знает только о первом, а второй отзывается исключительно по Host-заголовку.
Согласно thehacker.recipes, vhost fuzzing рекомендуется при работе с любым доменом в scope — DNS-записи покрывают далеко не все виртуальные хосты. В терминах MITRE ATT&CK (открытая база тактик и техник атак — каждая техника имеет уникальный T-код, по которому удобно ссылаться в отчётах) обе техники относятся к фазе Reconnaissance, конкретно к Vulnerability Scanning (T1595.002). Место в цепочке атаки: после обнаружения открытых портов (Network Service Discovery, T1046) и до непосредственной эксплуатации. Каждый найденный виртуальный хост — дополнительная поверхность атаки с потенциально отдельным веб-приложением, своими багами и своей авторизацией.
| Параметр | DNS brute-force | Vhost fuzzing |
|---|---|---|
| Протокол | DNS (UDP/TCP 53) | HTTP/HTTPS |
| Что проверяем | Наличие DNS-записи | Ответ веб-сервера на Host-заголовок |
| Команда gobuster | gobuster dns -d target.htb -w wordlist.txt |
gobuster vhost -u http://target.htb -w wordlist.txt |
| Срабатывает на HTB | Редко — DNS часто не настроен | Почти всегда — хосты задаются в конфиге сервера |
На HTB DNS-сервер зачастую не содержит записей для скрытых субдоменов. gobuster dns возвращает пустоту, а gobuster vhost находит цель. Ловушка классическая: на форуме HTB в тредах по модулю Information Gathering десятки пользователей описывают ту же проблему — запускают DNS-режим вместо vhost и удивляются тишине.
Как работает gobuster vhost под капотом
При вызове gobuster vhost -u http://nineveh.htb -w wordlist.txt --append-domain инструмент делает следующее:
- Берёт слово из словаря (например,
admin) - Формирует HTTP-заголовок
Host: admin.nineveh.htb - Отправляет GET-запрос на URL цели
- Сравнивает ответ с «базовым» — тем, что сервер возвращает для несуществующего хоста
- Если ответ отличается по статус-коду, размеру тела или количеству слов — хост считается найденным
Флаг --append-domain здесь критичен: без него gobuster подставляет слово как полный Host (Host: admin вместо Host: admin.nineveh.htb). На форуме HTB это причина номер один пустых результатов — люди забывают флаг и думают, что хостов нет.
[Применимо: внешний и внутренний пентест, любой HTTP/HTTPS-сервер с виртуальным хостингом]
Предусловие: перед запуском добавьте IP цели и базовый домен в /etc/hosts — sudo nano /etc/hosts, строка вида 10.10.10.43 nineveh.htb. Без этого gobuster не сможет резолвить домен и запросы уйдут в никуда.
Из инструментов thehacker.recipes упоминает три альтернативы: gobuster vhost, wfuzz -H "Host: FUZZ.target.htb" и ffuf -H "Host: FUZZ.target.htb". Логика у всех одинаковая — подстановка в Host-заголовок. Разница в скорости, фильтрах по умолчанию и обработке редиректов — и именно эта разница иногда определяет, найдёте вы хост или нет.
Python скрипт для перебора субдоменов: пишем vhost fuzzer с нуля
Зачем писать своё, когда есть gobuster?
Три причины из практики:
- Понимание механики. Пока не отправишь HTTP-запрос с кастомным Host руками, не поймёшь, почему
ffufнаходит хост, а gobuster — нет. Разный парсинг ответов, разные дефолтные фильтры — это всё становится видно только когда ты сам пишешь логику сравнения - Гибкость фильтрации. gobuster фильтрует по статус-коду и размеру. В кастомном скрипте можно добавить фильтрацию по содержимому тела — это спасает при wildcard-конфигурациях, где всё возвращает 200 OK
- Встраивание в цепочку. Когда результат перебора нужно автоматически передать дальше (добавить найденные vhost’ы в
/etc/hosts, запустить сканер директорий) — кастомный скрипт экономит ручную работу
Требования к окружению
- ОС: Kali Linux 2023+, Ubuntu 22.04+ или любая с Python 3.8+
- RAM: 512 МБ минимум (скрипт нетребователен)
- Зависимости:
pip install requests— единственная внешняя библиотека - Сеть: доступ до целевого IP (VPN-подключение к HTB при работе с лабораторией)
- Права: обычный пользователь, root не нужен (в отличие от scapy-based сканеров, которым нужны raw sockets)
- Словарь: SecLists, установка:
sudo apt install seclists. Файл:/usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
Базовая версия: requests и цикл по словарю
Задача — отправить HTTP-запрос с подставным Host-заголовком к известному IP и посмотреть, что вернётся.
import requests
url, domain = "http://10.10.10.43", "nineveh.htb"
with open("subdomains-5000.txt") as f:
for word in f:
sub = word.strip()
try:
r = requests.get(url, headers={"Host": f"{sub}.{domain}"}, timeout=3)
print(f"{sub}.{domain} -> {r.status_code} [{len(r.text)}]")
except requests.RequestException:
pass
Что тут происходит:
1. Открываем файл словаря, читаем по строке
2. Для каждого слова формируем заголовок Host: слово.nineveh.htb
3. Отправляем GET на IP-адрес цели (не на домен — чтобы DNS не вмешивался)
4. Печатаем статус-код и размер тела ответа в символах
Как понять, что получилось: большинство строк покажут одинаковый размер — это дефолтный ответ для несуществующего хоста. Строка с другим размером — потенциальная находка.
Проблема: если сервер настроен с wildcard (перехватывает все поддомены), каждый запрос вернёт 200 OK с одинаковым телом. Скрипт покажет «всё найдено» — и результат бесполезен. На codeby.net эта ловушка описана прямо: без фильтрации wildcard словарь в 10000 строк выдаст 10000 «найденных» поддоменов.
Фильтрация wildcard и автоматизация subdomain enumeration
Решение — baseline-запрос. Перед перебором отправляем запрос с заведомо несуществующим поддоменом (xyz999randomnonsense.nineveh.htb) и запоминаем размер ответа. Всё, что совпадает по размеру — wildcard, отбрасываем.
Вторая проблема — скорость. Один запрос за раз при словаре в 5000 строк и таймауте 3 секунды — до 4 часов ожидания. concurrent.futures.[ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html#threadpoolexecutor) из стандартной библиотеки запускает запросы параллельно.
import requests
from concurrent.futures import ThreadPoolExecutor
url, domain = "http://10.10.10.43", "nineveh.htb"
bl = len(requests.get(url, headers={"Host": f"xyz999.{domain}"}, timeout=3).text)
def check(sub):
try:
r = requests.get(url, headers={"Host": f"{sub}.{domain}",
"User-Agent": "Mozilla/5.0"}, timeout=3, allow_redirects=False)
if len(r.text) != bl: print(f"[+] {sub}.{domain} [{r.status_code}]")
except: pass
with ThreadPoolExecutor(max_workers=30) as p:
p.map(check, open("subdomains-5000.txt").read().splitlines())
Разбор по шагам:
1. Строка 4 — baseline: запоминаем размер ответа для заведомо несуществующего хоста. Если скрипт вдруг начнёт фильтровать настоящие находки — значит baseline случайно совпал, попробуйте другое несуществующее имя
2. Функция check(): для каждого слова подставляет Host-заголовок, сравнивает размер ответа с baseline. Отличается — печатает находку с пометкой [+]
3. User-Agent: Mozilla/5.0: подменяет дефолтный python-requests/2.x. Некоторые серверы режут запросы с нестандартным User-Agent — и вы получите пустоту не потому что хостов нет, а потому что сервер вас не пустил
4. allow_redirects=False: отключает автоматическое следование за 301/302. Без этого все ответы могут стать одинаковыми, если сервер редиректит неизвестные хосты на одну страницу
5. ThreadPoolExecutor(max_workers=30): запускает 30 параллельных потоков. Для HTB-лабов это разумный предел — больше уже не ускорит, а нестабильность соединения добавит
Ожидаемый вывод при успехе: [+] admin.nineveh.htb [200] — строки с отличающимся размером тела.
Если вывод пустой — виртуальных хостов нет, либо фильтрация слишком строгая. Попробуйте заменить точное сравнение != bl на диапазон abs(len(r.text) - bl) > 50 — иногда сервер вставляет в ответ динамический элемент (timestamp, токен), и размер «плавает» на десяток символов.
Типичные ошибки при написании python скриптов для htb
HTTPS и самоподписанные сертификаты. Если цель на порту 443, используйте https:// в URL и добавьте verify=False в requests.get(). Подавите предупреждения: requests.packages.urllib3.disable_warnings(). На HTB самоподписанные сертификаты — стандарт, и без verify=False requests просто откажется соединяться.
Кодировка словаря. SecLists в UTF-8, но кастомные словари бывают в другой кодировке. open(wordlist, encoding='utf-8', errors='ignore') спасает от падения скрипта посреди перебора — обидно потерять прогресс на 4000-й строке из-за одного кривого символа.
Потоки на реальном пентесте. Больше 10 потоков на продакшн-серверах — риск триггернуть WAF (Web Application Firewall — межсетевой экран уровня приложений, который анализирует HTTP-трафик и блокирует подозрительные паттерны) или rate-limiter. По рекомендациям yeswehack, на Bug Bounty-программах нужен time.sleep() между запросами и ротация IP через прокси. На HTB это необязательно, но привычку стоит формировать сразу.
Ограничения скрипта и когда написать скрипт вместо gobuster не получится
Работает если: — Цель — веб-сервер с виртуальным хостингом (Apache VirtualHost, Nginx server_name) — Есть HTTP/HTTPS-доступ к серверу — Нужна кастомная логика фильтрации или встраивание в цепочку скриптов
Не работает если:
— Субдомены существуют в DNS, но не как виртуальные хосты — тут нужен DNS brute-force (gobuster dns, Amass, Subfinder)
— Сервер использует SNI (Server Name Indication — расширение TLS, при котором клиент указывает hostname ещё на этапе TLS-handshake, до передачи HTTP-заголовков). Host-заголовок не поможет — нужно работать на уровне TLS. ffuf с TLS-поддержкой справляется лучше
— Нужна скорость на больших словарях (100k+ строк) — gobuster и ffuf написаны на Go и работают в разы быстрее Python-реализации. На словаре в 100 тысяч строк разница ощутимая: минуты против десятков минут
— Перед вами продакшн с WAF: Cloudflare, Akamai или FortiGate забанят IP после нескольких сотен запросов в минуту. Для таких целей лучше пассивные методы — Certificate Transparency логи через crt.sh, Subfinder, Amass в пассивном режиме
Что видит защита. Sigma-правило net_dns_external_service_interaction_domains.yml (из репозитория SigmaHQ — коллекция детектирующих правил в формате YAML, которые можно конвертировать в запросы для конкретных SIEM) фиксирует аномальную DNS-активность, хотя для HTTP vhost fuzzing это менее релевантно. На стороне веб-сервера — лог покажет сотни запросов с разными Host-заголовками с одного IP. WAF типа Palo Alto детектирует это как сканирование и может заблокировать. На HTB-лабах защиты нет, но на реальном пентесте — учитывайте.
Смежные инструменты workflow. После обнаружения виртуальных хостов следующий шаг — сканирование директорий (gobuster dir, ffuf) и поиск уязвимостей (nikto, nuclei). Результат vhost fuzzing стоит автоматически записывать в /etc/hosts и передавать на вход следующему инструменту — руками это делать утомительно уже на третьей машине.
Написание собственного vhost fuzzer — из тех упражнений, которые убирают магию из готовых инструментов раз и навсегда. Не потому что кастомный скрипт лучше gobuster — он почти всегда медленнее и покрывает меньше граничных случаев. А потому что после написания ты видишь HTTP-запрос, Host-заголовок, сравнение размеров ответов — и понимаешь, почему ffuf нашёл хост, а gobuster нет. Разная обработка редиректов, разные дефолтные фильтры, разный User-Agent — всё это перестаёт быть загадкой.
Половина тредов на форуме HTB — люди, которые запускают gobuster dns вместо gobuster vhost и удивляются пустоте. Разница между DNS-резолвом и HTTP Host-заголовком — не про инструменты, а про то, как устроен виртуальный хостинг. Пока это не уложится в голове, никакой инструмент не вытащит — ни чужой, ни свой.
И второй момент: зацикливаться на написании инструментов с нуля не стоит. Скрипт на 10 строк, который ты понимаешь построчно, полезнее тулзы на 2000 строк с GitHub. Но когда механика разобрана — возвращайся к gobuster и ffuf. Они быстрее, надёжнее и покрывают corner-кейсы, которые ты неизбежно пропустишь. Базовый трек IB Basics — это то, что я бы прошёл вместо того, чтобы тыкаться в YouTube, собирая разрозненные знания из десятков writeup’ов.
Эту тему и смежные навыки разбирают на практике в курсе «Python для пентестера» Codeby Academy.