Атака Eclipse на Kademlia DHT: почему подпись записей не спасает, и как ограничение подсетей в k-buckets (#1399) защищает сеть. Узнайте, как применить это в Python.
Представьте: вы подключены к децентрализованной сети, но каждый ваш запрос обрабатывают узлы злоумышленника. Вы не замечаете подмены, ведь все ответы выглядят валидными. Это не сценарий из фантастики, а реальная атака Eclipse на DHT. В этой статье мы разберем, как она работает, почему подписи записей не спасают, и как простое ограничение на подсети в k-buckets (PR #1399) может защитить сеть. Вы узнаете, как воспроизвести атаку в py-libp2p и какие выводы сделать для своих проектов.
Kademlia — это распределенная хеш-таблица (DHT), где каждый узел хранит маршрутную таблицу из других узлов, сгруппированных по расстоянию их ID. Поиск узла или записи происходит итеративно: вы спрашиваете ближайших к цели узлов, они указывают на более близких, и так до тех пор, пока не найдете нужное. Вся эта схема работает благодаря одному тихому предположению: узлы в вашей маршрутной таблице — честная выборка из всей сети.
Атака Eclipse разрушает именно это предположение. Если атакующий может внедрить свои узлы в достаточное количество ваших k-bucket'ов, особенно в слоты, ближайшие к целевой записи, ему не нужно взламывать криптографию. Он просто окружает вас. Каждый ваш запрос будет обрабатываться его узлами. Он может скрывать записи, подсовывать устаревшие маршруты или незаметно изолировать вас от настоящей DHT. Вы остаетесь онлайн, вы «подключены», но на самом деле общаетесь с ложью.
В DHT уже есть механизм целостности: записи подписаны. Поэтому интуитивно кажется, что поверхность атаки покрыта. Если злоумышленник не может подделать запись, какой ущерб он может нанести? Оказывается, большой, потому что подпись и Eclipse отвечают на разные вопросы.
Подпись записи аутентифицирует контент. Если вы получили запись DHT, вы можете проверить, что ее создал владелец ключа. Вам не могут подсунуть поддельные значения.
Атака Eclipse атакует членство. Злоумышленник ничего не подделывает. Он лишь делает так, что единственные узлы, к которым вы обращаетесь, — его собственные, а затем отвечает абсолютно валидными, подписанными записями, но не всем набором. Сокрытие не имеет подписи. Молчание не имеет подписи.
Таким образом, реальная поверхность атаки — не запись, а то, как узел получает слот в k-bucket'е.
Я не хотел обсуждать это теоретически, поэтому создал симуляцию атаки Eclipse внутри py-libp2p. Она находится в tests/examples/attack_simulation/eclipse_attack/. Симуляция запускает сеть честных узлов KadDHT, внедряет злонамеренные узлы, которые засоряют маршрутные таблицы и отравляют записи DHT, а затем измеряет, что реально деградирует: сборщик RealAttackMetrics выполняет реальные поиски и фиксирует процент успеха по мере развития атаки.
# tests/examples/attack_simulation/eclipse_attack/attack_scenarios.py
async def execute(self):
async with trio.open_nursery() as nursery:
for mp in self.malicious_peers:
for target in self.honest_peers:
nursery.start_soon(mp.poison_dht_entries, target)
nursery.start_soon(mp.flood_peer_table, self.honest_peer_tables[target])Цель этого стенда — превратить «eclipse возможен» в число: какую долю поисков может захватить атакующий и как это меняется, если маршрутная таблица перестает так легко принимать их.
Я прогнал реальный KBucket.add_peer(k=20) с сибил-узлами из контролируемых атакующим подсетей /24, установив MAX_PEERS_PER_SUBNET в 0 (поведение до #1399) и в значение по умолчанию 2. Это измерение на уровне компонента — оно изолирует, что именно меняет правило допуска:
До фикса одна арендованная /24 владела всем bucket'ом. После — атакующему нужно десять действительно разных сетей, чтобы сделать то же самое. Стоимость сместилась с «нагенерировать дешевых ID» на «арендовать разнообразные адресные пространства», что и является сутью.
В стандартном Kademlia узел получает слот в bucket'е, если он жив и его ID попадает в нужный диапазон. ID узлов дешевы — вы можете нагенерировать их сколько угодно. Поэтому атакующий создает флот сибил и, что критично, размещает их на IP-адресах в подсети, которой он управляет. Его дешевый ресурс — ID узлов; ресурс, который ему пришлось бы реально тратить, — это различные сетевые позиции. И ничто в базовом правиле допуска не учитывало этот фактор.
Арендованный облачный блок обычно представляет собой /24, а не разброс несвязанных адресов. В этом и рычаг: если допуск оценивается по разным подсетям, а не по разным ID, то окружение узла внезапно требует реального разнообразного адресного пространства, которое атакующий не может дешево создать.
PR libp2p/py-libp2p#1399 (закрывает #1383) заставляет KBucket.add_peer отклонять нового пира, если его глобально маршрутизируемая подсеть /24 (IPv4) или /48 (IPv6) уже содержит MAX_PEERS_PER_SUBNET (по умолчанию 2) пиров в этом bucket'е:
# Лимиты разнообразия IP/подсетей для k-bucket'ов (issue #1383). В пределах одного bucket'а
# максимум MAX_PEERS_PER_SUBNET пиров могут разделять одну и ту же глобально маршрутизируемую подсеть.
# /24 соответствует реальной экономике атакующего — арендованный облачный блок обычно /24, а не /16,
# и позволяет избежать включения набора данных ASN.
MAX_PEERS_PER_SUBNET = 2
def _subnet_key(peer_info: PeerInfo) -> str | None:
for addr in peer_info.addrs:
if "p2p-circuit" in str(addr):
# релейный адрес = IP релея, а не пира
continue
for proto, prefix_len in (("ip4", SUBNET_PREFIX_LEN_V4), ("ip6", SUBNET_PREFIX_LEN_V6)):
...
if not ip.is_global:
# loopback/private/CGNAT/link-local → исключено
continue
return str(ip_network(f"{ip}/{prefix_len}", strict=False))Детали — вот где настоящая инженерия:
ip.is_global) — loopback, RFC1918/ULA приватные, CGNAT (100.64.0.0/10), link-local и документационные диапазоны исключены, поэтому CI и локальные тестовые сети не затрагиваются.TryAddPeer из go-libp2p, что избегает открытия вектора churn/DoS, где атакующий может принудительно вызывать вытеснения.MAX_PEERS_PER_SUBNET <= 0 и защита от ложного разделения bucket'а при отклонении подсети в неполном bucket'е.Ловушка была не в баге кода, а в баге вопроса. «Записи подписаны, значит DHT безопасен» незаметно подменяет вопрос, на который нужно ответить («является ли этот набор пиров честной выборкой?»), на тот, на который вы уже ответили («подлинна ли эта запись?»). Аутентификация и разнообразие членства ортогональны: подпись — реальная защита от подделки, но вообще не защита от окружения валидными лжецами.
Более широкая привычка, которую я сохраняю: когда кто-то говорит «это уже обработано», спрашивайте, какое свойство обработано. Самые опасные уязвимости живут в зазоре между двумя защитами, каждая из которых выглядит полной по отдельности.
Прямо сейчас проверьте свою реализацию DHT или систему на основе Kademlia: есть ли ограничение на количество пиров из одной подсети в k-bucket'ах? Если нет — вы уязвимы к атаке Eclipse. Внедрите правило, аналогичное #1399: группируйте по глобально маршрутизируемым подсетям /24 для IPv4 и /48 для IPv6, исключайте relayed-адреса и приватные диапазоны. Если используете py-libp2p, обновитесь до версии с #1399 или примените патч самостоятельно. И запомните: подпись записей не защищает от окружения — защищает только разнообразие.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →