В середине девяностых годов интернет был сетью доверчивых инженеров, а операционные системы массового рынка только учились жить в этой сети постоянно. На этом фоне осенью 1996 года и в течение 1997 года широкую известность получила уязвимость с простым и запоминающимся именем Ping of Death. Суть её сводилась к тому, что специально сформированный ICMP echo запрос, разбитый при передаче на несколько IP фрагментов, при сборке на принимающей стороне превращался в пакет, чей реальный объём данных превышал теоретический максимум протокола, и сетевой стек жертвы переполнял выделенный буфер. Windows 95, Windows NT, ранние версии Linux и даже отдельные модели маршрутизаторов реагировали на такой пакет зависанием, аварийной перезагрузкой или остановкой сетевой службы. Механизм не требовал шеллкода, эксплойта памяти в современном понимании или каких либо привилегий на машине отправителя: достаточно было уметь передать фрагменты, а они могли прийти даже из легального на вид трафика. Этот случай стал одним из первых массовых уроков о том, что валидация данных на границе входа в парсер протокола является обязанностью разработчика, а не пожеланием, и что правило Постела о лояльности при приёме имеет обратную сторону, о которую разбился не один сетевой стек той эпохи.

Техническая канва протокола и его цифровой потолок

Чтобы понять, откуда взялась сама возможность такой атаки, нужно вернуться к фундаментальным числам IPv4. Заголовок IP пакета содержит поле Total Length длиной шестнадцать бит, а значит максимальная длина всего IP пакета, включая заголовок, ограничена числом 65 535 байт. Протокол ICMP, на котором построена утилита ping, живёт внутри IP: его сообщение echo request несёт небольшой служебный заголовок и произвольную полезную нагрузку, которую принимающий узел обязан вернуть в ответе. В обычной жизни нагрузка пинга измеряется десятками байт, и никаких проблем не возникает. Однако IP предусматривает фрагментацию: если пакет не помещается в MTU очередного участка пути, маршрутизатор или отправитель режет его на фрагменты, каждый из которых содержит смещение начала своих данных в исходной датаграмме и флаг More Fragments, показывающий, есть ли продолжение. Смещение хранится в виде тринадцатибитного числа, измеряемого в блоках по восемь байт, что соответствует максимальному смещению 65 528 байт. Принимающий узел обязан собрать фрагменты обратно, выровняв их по смещениям, и только после этого передать собранную датаграмму наверх, то есть ICMP обработчику. Здесь и ждала засада. Формат позволял описать фрагмент, у которого сумма смещения и длины выходила за границу 65 535 байт. Сам по себе такой фрагмент был «законным» для канала: каждый фрагмент в отдельности влезал в допустимые размеры, а аномалия проявлялась только в момент сборки, когда логическая длина целого превышала физический максимум протокола. Иными словами, протокол описывал состояние, которое не должно существовать, но никто не удосужился проверить, что оно действительно не существует.

Как реализовывалась сборка и где прятался дефект

Код reassembly в ядрах того времени писали под жёсткую экономию памяти и тактов. Типичный подход выглядел так: при получении первого фрагмента система создавала запись о незавершённой датаграмме, под буфер сборки часто выделялся блок, ограниченный максимально возможной длиной IP пакета, 65 535 байт, либо память наращивалась по мере поступления фрагментов в предположении, что итог всё равно не превысит этот предел, ведь шестнадцатибитное поле длины физически не позволяет записать больше. Разработчики рассуждали следующим образом: отправитель в принципе не может сформировать пакет длиннее 65 535 байт, значит нечего проверять выход за этот предел. В этом рассуждении была изящная логическая дыра. Максимум действительно исчерпывается полем длины заголовка, однако геометрия фрагментов определяется не полем длины целого пакета, а парой значений offset и fragment length в каждом фрагменте по отдельности. Контролируемая комбинация этих полей могла задать внутри буфера точку записи, лежащую за его концом. Когда последний фрагмент приходил со смещением близким к максимуму и с длиной, которая в совокупности перешагивала рубеж, операция копирования затирала соседние структуры ядра. В зависимости от реализации это давало повреждение указателей, порчу служебных очередей пакетов или просто чтение за границей. Важно подчеркнуть: никакого «вирусного» механизма тут не было. Ничего не исполнялось, ничего не распространялось самостоятельно, не было исполняемого нагрузки. Это была чистая ошибка валидации на границе, отсутствие элементарного bounds check в reassembly code перед тем, как данные попадали в зону ответственности memcpy.

Реакция систем эпохи и почему страдали одновременно разные платформы

Особенность Ping of Death заключалась в широченности списка пострадавших, что само по себе было диагностикой состояния отрасли. Windows 95 и Windows NT 4.0 демонстрировали зависание системы или экран критической ошибки при приёме такого набора фрагментов. Ранние версии Linux ветки до соответствующих исправлений в 1996 году отвечали аварийной паникой ядра или сбоем сетевого субсистемного обработчика. Некоторые маршрутизаторы и принтеры с встроенными стеками TCP/IP падали ещё удивительнее для администраторов, ведь они даже не были хостами, о которых кто то думал как о целях. Общая причина сводилась к тому, что код сборки фрагментов писали очень похожим образом по всей отрасли, ориентируясь на одни и те же RFC и на один и тот же набор молчаливых допущений. Когда допущение ломалось, ломались все, кто его разделял. Атака также приобрела крайне низкий порог применения: на многих платформах штатная утилита ping позволяла задать крупный размер данных, а программная фрагментация делала остальное. Поэтому осенью 1996 года и в начале 1997 года история распространилась через списки рассылки администраторов с огромной скоростью, и патчи появились относительно быстро, в течение недель и месяцев. Microsoft выпустила обновления для Windows 95 и NT, Linux сообщество включило проверку в стек, производители сетевого железа подтянулись следом. Параллельно в практику вошла простая фильтрация: если фрагментированная датаграмма претендует на длину сверх допустимой, её безопаснее всего отбросить ещё до сборки.

Не вирус, а отказ протоколного договора, кейс в контексте эволюции атак

Ping of Death стоял в ряду событий, которые в 1996 и 1997 годах изменили само представление о том, что значит «безопасная реализация протокола». Рядом по времени шли smurf, где усиливался перепад между запросом и лавиной ответов через broadcast адрес, teardrop, эксплуатировавший перекрытие смещений фрагментов в той же самой процедуре сборки, и land, где совпадение адресов источника и назначения вводило стек в бесконечный внутренний цикл обработки. Все три, как и Ping of Death, строились не на «взломе кода», а на том, что реализация верила полям протокола так, как будто их заполняет благонамеренный корреспондент. Это и есть главный контекст эпохи: Инженерная культура раннего интернета опиралась на принцип Постела, который гласил, что нужно быть консервативным в том, что отправляешь, и либеральным в том, что принимаешь. Для открытых исследовательских сетей это было разумное правило совместимости. В мире, где появился массовый пользователь, модемный провайдер и начинающие коммерческие сервисы, правило обернулось дырой конструкции. Либеральный приём обозначал готовность интерпретировать любые биты как осмысленные. Ping of Death показал, что либеральность без верификации превращается в приглашение использовать твой буфер как черновик чужих вычислений. С этого периода в индустрии началось постепенное, сопровождавшееся болью переобучение: писать сетевой код от обороны, допуская, что любое поле любого кадра может содержать произвольную дичь.

Почему сегодня этот сценарий не срабатывает

На современной системе классический Ping of Death превратится в аномальную кривую на графике входящего трафика в лучшем случае. Причин тому несколько слоёв. Во первых, все актуальные сетевые стеки давно содержат проверку: до выделения или копирования сумма смещения и длины сравнивается с пределом, и аномальные фрагменты дисциплинированно отбрасываются. Во вторых, по умолчанию межсетевые экраны и операционные системы дропают фрагментированный ICMP вовсе, поскольку легитимные сценарии такого трафика крайне редки, а фильтровать его проще, чем доверять сборке. В третьих, сами привычки изменились: Type of Service, MTU Path Discovery и повсеместное использование фрагментационно устойчивых стратегий сделали крупный фрагментированный трафик диковиной. Попытка повторить старый номер на актуальной Windows или Linux приведёт лишь к тихому отказу фрагментов ещё на входе интерфейса. Поэтому, если кто то говорит, что «попробует Ping of Death на соседнем ПК», безопасный и честный ответ таков: это исторический кейс, а не рабочий сценарий, и повторять его бессмысленно, цель не пострадает, а рассказчик только покажет незнание тридцатилетней эволюции стеков. Инженерная ценность кейса давно перешла из плоскости атаки в плоскость архитектуры и методологии.

Уроки для современного кода, которые выводятся из этого случая

Ping of Death остаётся в учебниках не из сентиментальности, а потому что он предельно чисто демонстрирует несколько принципов, которые не состарились ни на один день. Если их свести к рабочим положениям, получится короткий набор:

  1. Никогда не закладывать в код неявную веру в максимумы протокола: если поле длины потенциально противоречит другим полям, проверять их согласованность до первого выделения буфера, а не после.
  2. Считать границу входа точкой недоверия: любые данные, пришедшие из сети, файла, селектора или IPC, подлежат полной валидации структуры, диапазонов и соотношений между полями.
  3. Выполнять все расчёты смещений и размеров в арифметике с защитой от переполнения, даже когда тип данных кажется достаточно широким, потому что суммы размеров и смещений долговечнее конкретных типов.
  4. Отдавать предпочтение функциям копирования с явным лимитом и проверять возвращаемое число записанных байт, не полагаясь на то, что «такого пакета в природе не бывает».
  5. Включать фаззинг парсеров как обязательную стадию разработки и релиза, особенно для кода, который собирает фрагменты, распаковывает контейнеры или исполняет сериализацию.

К этому стоит добавить методологическое наблюдение. Атаки той эпохи показали, что переполнение буфера является не столько проблемой языка C, сколько проблемой отсутствующей проверки контракта. Это важно, потому что отсюда выводится и современный вывод: замена языка на более безопасный не отменяет необходимости явно декларировать и проверять инварианты. Там, где язык гарантирует отсутствие записи за границей, он не гарантирует корректность смысловых допущений, которые программист вложил в разбор сообщения. Ping of Death чинили не «правильным языком», а правильной валидацией, и именно эта традиция продолжается в современных практиках fuzz тестирования, дифференциального прогона двух реализаций одного и того же парсера, и в дисциплине никогда не интерпретировать заголовок раньше, чем проверена его согласованность с общей длиной.

Историческое место кейса и его нрав для инженера

Оценивая Ping of Death спустя почти три десятилетия, историк сетевой безопасности видит в нём не курьёз, а симптом взросления отрасли. До него безопасность сетевого стека воспринималась как задача гигиены: не потерять пакет, корректно обработать таймаут, не заблокировать очередь. После него стало очевидным, что стек это пограничный код, который стоит лицом к произвольному миру, и что его надёжность определяется не качеством отправителей, а силой своих допущений. Понятие «sanity check длин до memcpy» перестало быть фольклором и вошло в учебные программы, чек листы ревью и в культуру написания драйверов и системных сервисов. Эпоха подарила индустрии и более широкий урок: спецификации описывают то, что должно существовать, а код обязан уметь отклонять всё остальное без сожаления. С этой точки зрения Ping of Death завершил благотворную проверку раннего интернета на прочность. Он не был изощрён, не требовал таланта криптографа или реверсера, он требовал лишь небрежности принимающей стороны. Этого было достаточно, чтобы заставить всю индустрию переписать сборку фрагментов, ввести привычную и поныне дисциплину фильтрации, и вспомнить, что лучший сетевой код живёт в постоянном подозрении, что входные данные написал кто угодно, у кого есть сокет. Память о таких историях все ещё работает: каждый раз, когда современный разработчик добавляет проверку длины перед копированием, не потому что «так в линтере написано», а потому что понимает, чем заканчивается её отсутствие, он живёт в мире, который научился этому у старой ошибки 1996 года.