1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 1 из 5

Универсальное хеширование: случайность против злого входа

Коротко

Любая ФИКСИРОВАННАЯ хеш-функция уязвима: зная её, противник подберёт ключи, у которых один и тот же номер, свалит их в одну ячейку и превратит O(1) в O(n) - это реальная атака на веб-сервисы (переполнение хеш-таблиц запросами). Лечение - универсальное хеширование: взять не одну функцию, а СЕМЕЙСТВО и выбирать из него СЛУЧАЙНУЮ при создании таблицы. Семейство называют универсальным, если для любых двух разных ключей вероятность коллизии (по случайному выбору функции) не больше 1/m. Тогда ожидаемое число коллизий мало НА ЛЮБЫХ данных, даже подобранных врагом: он не знает, какую функцию выбрала твоя таблица, и не может подстроить вход. Это та же идея, что рандомизация опорного в быстром выборе - случайность внутри против злого входа снаружи.

Если ты понимаешь, почему фиксированную хеш-функцию можно атаковать, что такое универсальное семейство (Pr[коллизия] ≤ 1/m по случайному h) и почему это даёт O(1) в ожидании на ЛЮБОМ входе, листай до «Теперь сам».

Как думаешь, что будет, если противник знает твою хеш-функцию

Хеш-таблица даёт O(1) в среднем ПРИ равномерном разбросе ключей. Но «средний» случай считался в предположении, что ключи приходят как попало. А если их подбирает ВРАГ? Хеш- функция обычно известна (она в открытом коде библиотеки). Противник берёт h, находит кучу ключей с одним значением h(k) - например, все дающие остаток 0 по модулю m - и шлёт их твоему серверу. Все они падают в ОДНУ ячейку, цепочка вырастает до n, каждый поиск и вставка становятся O(n), таблица захлёбывается. Это не теория: так реально клали веб- сервисы (атака на хеш-таблицы HTTP-параметров). Раз фиксированную функцию можно вычислить и обмануть, надо лишить противника этой возможности.

На что это похоже

Сейф с одним заводским кодом против сейфа со случайным кодом. Если у ВСЕХ сейфов модели код «0000» (фиксированная функция), взломщик, узнав его однажды, открывает любой. Если каждый сейф при установке получает СВОЙ случайный код из огромного набора, взломщику нечего заучивать: он не знает код именно твоего сейфа. Универсальное хеширование - это случайный код для каждой таблицы: семейство функций как набор возможных кодов, а конкретная таблица тайно выбирает одну. Враг знает СЕМЕЙСТВО (модель сейфа), но не знает, какую функцию выбрала ты, - и не может подобрать ключи под неё.

Механизм: семейство функций и случайный выбор

Вместо одной функции берём целое СЕМЕЙСТВО H = {h₁, h₂, …}. При создании таблицы выбираем из него ОДНУ случайную функцию h и пользуемся ею всё время жизни таблицы.

создать_таблицу():
    h = случайная функция из семейства H     # выбор один раз, при создании
    # дальше все операции используют это h

# пример универсального семейства для целых ключей (метод Картера-Вегмана):
#   выбрать простое p > максимального ключа
#   выбрать случайные a ∈ [1, p−1], b ∈ [0, p−1]
#   h(k) = ((a·k + b) mod p) mod m
# случайность спрятана в a и b - их знает только таблица

Семейство H называют универсальным, если для ЛЮБЫХ двух разных ключей k₁ ≠ k₂ вероятность коллизии по случайному выбору h мала:

Pr[h(k₁) = h(k₂)] ≤ 1/m        (вероятность по выбору h из H)

Это ровно та вероятность, что была бы у идеально случайного разбрасывания. Отсюда: ожидаемая длина цепочки для любого ключа - 1 + α, как при равномерном хеше, но теперь ГАРАНТИРОВАННО на любых данных, а не в предположении «ключи как попало».

фиксированная h: атака удалась k₁·k₂·k₃·k₄·k₅ (все сюда) одна цепочка длиной n → O(n) случайная h из семейства k₂k₄ k₁ · k₅k₃ разбросаны ровно → O(1)
Те же подобранные врагом ключи: при фиксированной функции он свалил их в одну ячейку, при случайной из семейства - не смог, потому что не знает, какую функцию выбрала таблица.

Правило

Фиксированная хеш-функция уязвима: зная её, противник подбирает ключи с одним h(k) и превращает O(1) в O(n) (реальная атака). Универсальное хеширование берёт СЕМЕЙСТВО функций и выбирает из него СЛУЧАЙНУЮ при создании таблицы. Семейство универсально, если для любых k₁ ≠ k₂ вероятность Pr[h(k₁) = h(k₂)] ≤ 1/m (по выбору h). Тогда ожидаемая длина цепочки 1 + α ГАРАНТИРОВАНА на ЛЮБОМ входе - враг не знает выбранную функцию и не может подстроить ключи. Источник неудачи переносится с данных на случайный выбор функции.

Разбор: почему это защищает даже от знающего семейство противника

Тонкость: противник ЗНАЕТ семейство H (оно в открытом коде) - разве это не то же, что знать функцию? Нет, и разница решающая. Знать семейство - знать НАБОР возможных функций, но не конкретную, выбранную твоей таблицей (её задают тайные случайные a, b). Чтобы гарантированно устроить коллизию, врагу нужно подобрать ключи, сталкивающиеся при ТВОЁМ h, - а он его не знает. Для любой пары ключей вероятность коллизии по случайному h не больше 1/m - значит какие бы ключи враг ни прислал, в среднем (по нашему случайному выбору) они разбросаются ровно. Он может знать всё про алгоритм и всё равно не сможет надёжно атаковать - потому что не контролирует нашу монетку. Это тот же принцип, что у рандомизированного quickselect из прошлого раздела: случайность ВНУТРИ алгоритма обезоруживает злой вход СНАРУЖИ.

Разбор: почему «в ожидании», а не «гарантированно»

Как и рандомизация опорного, универсальное хеширование даёт ОЖИДАНИЕ, а не железную гарантию на каждый случай. Может не повезти: конкретный случайный выбор a, b окажется неудачным для пришедших ключей, и цепочка выйдет длинной. Но вероятность этого мала и, главное, не управляется противником - она зависит от нашей монетки, а не от его данных. Ожидаемое время O(1 + α) на любом входе - сильная гарантия для практики (веб-сервер защищён от намеренной перегрузки), но это НЕ «O(1) всегда, что бы ни случилось». Железную гарантию O(1) в худшем случае даёт другое - идеальное хеширование (следующий урок), но только для СТАТИЧНОГО, заранее известного набора ключей. Различать «ожидаемо» и «гарантированно» здесь так же важно, как в задаче выбора: рандомизация покупает защиту от врага ценой крошечной вероятности невезения.

Что ломается без этого

Первая ошибка - использовать фиксированную, публично известную хеш-функцию для данных из недоверенного источника (HTTP-параметры, имена файлов от пользователя, ключи из сети): открыт путь к атаке перегрузкой, O(n) на операцию. Современные языки поэтому солят хеш строк случайным семенем при старте (Python с 2012 года - PYTHONHASHSEED) - это и есть универсальное хеширование в действии. Второе - выбирать случайную функцию на КАЖДУЮ операцию, а не один раз при создании: тогда один и тот же ключ будет попадать в разные ячейки, и ты его не найдёшь (нарушен детерминизм в пределах жизни таблицы). Функция выбирается однажды. Третье - думать, что универсальность даёт гарантию в худшем случае: она даёт ОЖИДАНИЕ. Четвёртое - брать «семейство» из двух-трёх функций: чем оно беднее, тем легче врагу перебрать варианты; нужно большое семейство.

Зачем это в работе

Универсальное хеширование - это защита реальных систем от атак на хеш-таблицы. После волны атак «HashDoS» (2011-2012), клавших веб-серверы горстью специально подобранных параметров, языки массово перешли на рандомизированное хеширование строк: Python, Ruby, Perl, JavaScript- движки солят хеш случайным семенем при запуске процесса. Понимать это - значит знать, почему hash("строка") в Python даёт РАЗНЫЕ значения в разных запусках (это не баг, а защита) и почему нельзя закладываться на конкретные хеши между запусками. Шире - это ещё один пример могущества рандомизации: там, где детерминированный алгоритм уязвим к подобранному входу, случайность внутри превращает уязвимость в надёжное ожидание. Тот же приём ты уже видел в выборе k-го элемента; здесь он защищает словари, и узнавание этого паттерна - признак зрелого инженера.

Теперь сам

Противник знает, что твой сервер использует h(k) = k mod 1000, и шлёт ключи 1000, 2000, 3000, … (тысячу штук). Что произойдёт с таблицей и как универсальное хеширование это предотвратило бы?

Ответ. Все присланные ключи кратны 1000, поэтому h(k) = k mod 1000 = 0 у ВСЕХ - тысяча ключей свалится в ячейку 0. Цепочка (или кластер) длиной 1000, и каждый поиск/вставка становится O(n) = O(1000) вместо O(1); сервер, обрабатывая такие запросы, захлёбывается - это и есть атака HashDoS. Универсальное хеширование предотвратило бы это так: таблица при создании выбрала бы случайную функцию из семейства (например, h(k) = ((a·k + b) mod p) mod 1000 с тайными случайными a, b). Противник не знает a и b, поэтому не может подобрать ключи, сталкивающиеся при ЭТОЙ функции: для любой пары его ключей вероятность коллизии всего ≤ 1/1000, и его «тысяча кратных 1000» разбросалась бы по таблице ровно, оставив операции O(1) в ожидании. Его знание формулы h бесполезно без знания a, b.

Главное

Фиксированная хеш-функция уязвима: зная её, враг подбирает ключи с одним h(k), валит их в одну ячейку и делает операции O(n) (реальная атака HashDoS). Универсальное хеширование берёт СЕМЕЙСТВО функций и выбирает СЛУЧАЙНУЮ при создании таблицы (случайность в тайных параметрах вроде a, b). Семейство универсально, если Pr[h(k₁) = h(k₂)] ≤ 1/m для любых k₁ ≠ k₂ по выбору h. Тогда ожидаемая длина цепочки 1 + α гарантирована на ЛЮБОМ входе: враг знает семейство, но не выбранную функцию, и не может подстроить ключи. Это ОЖИДАНИЕ, а не гарантия в худшем случае (её даёт идеальное хеширование). Тот же приём, что рандомизация опорного: случайность внутри против злого входа снаружи; поэтому языки солят хеш строк случайным семенем.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.