Перейти до змісту

Хеш-функції

Навіщо потрібні хеш-функції

Хеш-функції — один з найважливіших інструментів в інформатиці. Вони використовуються всюди: від перевірки завантажених файлів до захисту паролів і побудови ефективних структур даних. В цій лекції ми розберемося, що таке хеш-функція, які властивості вона має, і чому без неї не обходиться жодна сучасна система.

Почнемо з кількох практичних прикладів, щоб зрозуміти, які задачі вирішують хеш-функції.

Перевірка цілісності даних

Уявіть, що ви завантажили з інтернету файл розміром 2 гігабайти. Як переконатися, що файл завантажився без помилок? Порівнювати кожен байт з оригіналом — довго і потребує доступу до оригіналу. А якщо замість цього порахувати контрольну суму — наприклад, скласти значення всіх байтів файлу і отримати одне число? Тоді достатньо порівняти лише це число з тим, що опублікував автор файлу.

Це і є найпростіша ідея хеш-функції: перетворити дані довільного розміру у значення фіксованого розміру — відбиток (fingerprint). Якщо відбитки збігаються — дані, найімовірніше, однакові. Якщо ні — точно різні.

На практиці проста сума байтів — занадто примітивна: якщо два байти поміняються місцями, сума не зміниться, і помилку не буде виявлено. Тому використовують складніші хеш-функції (MD5, SHA-256), які реагують на будь-яку зміну даних. Але ідея залишається тією самою: великий файл → короткий відбиток → порівняння.

Зберігання паролів

Коли ви реєструєтесь на сайті, ваш пароль потрібно десь зберегти, щоб потім перевіряти при вході. Але зберігати пароль у відкритому вигляді — небезпечно: якщо зловмисник отримає доступ до бази даних, він дізнається паролі всіх користувачів.

Рішення: зберігати не сам пароль, а його хеш. При вході користувач вводить пароль, система обчислює хеш і порівнює зі збереженим. Якщо хеші збігаються — пароль правильний. При цьому навіть адміністратор бази не знає паролів користувачів — він бачить лише хеші, а хеш-функція необоротна: з хешу неможливо відновити пароль.

Цифрові підписи

Уявіть, що викладач надсилає студентам PDF з умовами іспиту. Як студент може переконатися, що файл дійсно від викладача, а не підроблений кимось? І що ніхто не змінив умови після відправки?

Для цього існує цифровий підпис. Аналогія: коли ви підписуєте паперовий документ — ваш підпис підтверджує, що саме ви його схвалили. Цифровий підпис працює так само, але для електронних документів.

Чому тут потрібна хеш-функція? Справа в тому, що алгоритми шифрування (на яких побудований цифровий підпис) працюють дуже повільно для великих обсягів даних. Шифрування одного байта може потребувати сотні математичних операцій. Зашифрувати весь документ розміром 10 мегабайтів — це мільярди операцій.

А тепер порівняйте: хеш-функція перетворює ці 10 мегабайтів у рядок довжиною 32 байти. Зашифрувати 32 байти — миттєва операція. Тому замість того, щоб шифрувати весь документ, автор:

  1. Обчислює хеш документа (10 МБ → 32 байти)
  2. Шифрує лише цей хеш своїм приватним ключем — це і є цифровий підпис

Одержувач перевіряє підпис: обчислює хеш документа самостійно, дешифровує підпис публічним ключем автора і порівнює два хеші. Якщо збігаються — документ справжній і не був змінений.

Fingerprint (відбиток) SSH-ключа

Коли ви вперше підключаєтесь до віддаленого сервера через SSH, термінал показує щось на зразок наведеного нижче повідомлення. Ви могли бачити подібний вивід, коли працювали з Git — він використовує SSH для передачі файлів на сервер і завжди перевіряє цілісність з'єднання та авторизацію користувача:

The authenticity of host 'example.com' can't be established.
ED25519 key fingerprint is SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8.
Are you sure you want to continue connecting (yes/no)?

SSH-ключ сервера — це довгий рядок із сотень символів. Порівнювати його вручну — нереально. Замість цього SSH обчислює хеш ключа і показує вам короткий відбиток (fingerprint). Ви можете зателефонувати адміністратору сервера, попросити його продиктувати відбиток, і порівняти — цього достатньо, щоб переконатися, що ви підключаєтесь до правильного сервера, а не до підробки.

Ефективні структури даних

Хеш-функції також дозволяють будувати структури даних, в яких пошук, вставка і видалення працюють в середньому за \(O(1)\) — тобто за сталий час, незалежно від кількості даних. Як саме це працює — ми розглянемо в наступній лекції.


Саме це і робить хеш-функція: перетворює дані довільного розміру у значення фіксованого розміру. Перш ніж дати строге означення, нам потрібно згадати кілька важливих математичних понять.


Функція як відображення

Означення

Означення функції

Функція \(f: A \to B\) — це правило, за яким кожному елементу множини \(A\) (область визначення) ставиться у відповідність рівно один елемент множини \(B\) (область значень).

Простими словами: функція — це "чорна скринька", яка приймає щось на вхід і видає результат. Для одного й того самого входу вона завжди видає однаковий результат.

Наприклад:

  • \(f(x) = 2x\) — кожному числу ставить у відповідність його подвоєне значення
  • \(f(x) = x^2\) — кожному числу ставить у відповідність його квадрат
  • \(f(\text{"Іванов"}) = 5\) — студенту ставить у відповідність його оцінку

Оборотність функцій

Деякі функції є оборотними: знаючи результат, можна однозначно відновити вхід. Наприклад, для \(f(x) = 2x\): якщо \(f(x) = 10\), то \(x = 5\) — єдиний варіант.

Означення: оборотна (ін'єктивна) функція

Функція \(f: A \to B\) називається оборотною (або ін'єктивною), якщо для будь-яких \(x_1 \neq x_2\) з множини \(A\) виконується \(f(x_1) \neq f(x_2)\). Тобто різні входи завжди дають різні результати.

Але не всі функції оборотні. І це ключовий момент для розуміння хеш-функцій.


Необоротні функції

Приклад 1: квадрат числа

\[f(x) = x^2\]

Якщо \(f(x) = 4\), то \(x = 2\) чи \(x = -2\)? Невідомо — обидва значення дають однаковий результат. Функція втрачає інформацію про знак.

Приклад 2: тангенс

\[f(x) = \tan(x)\]

Якщо \(f(x) = 0\), то \(x\) може бути \(0\), \(\pi\), \(2\pi\), \(-\pi\), \(3\pi\), \(\ldots\)нескінченна кількість прообразів. З одного значення тангенса неможливо визначити, який саме кут був на вході.

Приклад 3: кількість дільників

\[f(n) = \text{кількість дільників числа } n\]

Розглянемо:

  • \(f(12) = 6\) (дільники: 1, 2, 3, 4, 6, 12)
  • \(f(18) = 6\) (дільники: 1, 2, 3, 6, 9, 18)
  • \(f(8) = 4\) (дільники: 1, 2, 4, 8)
  • \(f(10) = 4\) (дільники: 1, 2, 5, 10)

Зовсім різні числа дають однаковий результат. Більше того, для будь-якого \(k\) існує нескінченно багато чисел, що мають рівно \(k\) дільників. Відновити вхід неможливо в принципі.

Строге означення

Означення: необоротна функція

Функція \(f: A \to B\) називається необоротною, якщо існує хоча б один елемент \(b \in B\), для якого рівняння \(f(x) = b\) має більше одного розв'язку (або не має розв'язків взагалі). Тобто знаючи значення \(f(x)\), неможливо однозначно визначити \(x\).

Всі три приклади вище — необоротні функції. Хеш-функція також є необоротною, і це одна з її ключових властивостей.

Чому це важливо

Хеш-функція — це яскравий приклад необоротної функції. Вона свідомо побудована так, щоб з результату (хешу) було неможливо відновити вхідні дані. Це фундаментально для безпеки: навіть знаючи хеш пароля, зловмисник не може обчислити сам пароль.


Властивість локальності

Означення

Перед тим як визначити хеш-функцію, введемо ще одне важливе поняття.

Розглянемо спочатку приклади. Функція \(f(x) = 2x\): якщо змінити \(x\) на \(0.01\), вихід зміниться на \(0.02\). Функція "плавна", передбачувана — невелика зміна входу дає невелику зміну виходу.

Ще приклад: \(f(x) = x^2\). При \(x = 100\) маємо \(f(100) = 10000\), а \(f(101) = 10201\) — різниця \(201\), пропорційна зміні входу. Знову: мала зміна аргументу — відносно мала зміна результату.

Функції, що поводяться так "гладко", мають спільну властивість. Дамо їй строге означення.

Означення: властивість локальності

Функція \(f\) має властивість локальності, якщо для будь-якого \(\varepsilon > 0\) існує таке \(\delta > 0\), що з \(|x_1 - x_2| < \delta\) випливає \(|f(x_1) - f(x_2)| < \varepsilon\).

Простими словами: якщо два входи близькі між собою, то і їхні виходи близькі.

Функції з властивістю локальності — це неперервні функції, графік яких можна намалювати, не відриваючи ручку від паперу. Більшість функцій, з якими ви працювали в математиці (\(\sin x\), \(x^2\), \(e^x\)), мають цю властивість.

Функція Діріхле: екстремальний приклад

Цікавий факт

Існує функція, яка повністю позбавлена локальності.

Функція Діріхле визначається так:

\[D(x) = \begin{cases} 1, & \text{якщо } x \text{ — раціональне число} \\ 0, & \text{якщо } x \text{ — ірраціональне число} \end{cases}\]

Чому вона не має локальності? Тому що в будь-якому, навіть найменшому околі будь-якої точки є і раціональні, і ірраціональні числа. Функція "стрибає" між 0 і 1 нескінченно часто — її неможливо намалювати. Будь-яка, навіть мікроскопічна зміна входу може повністю змінити вихід.

Локальність і хеш-функції: два різних світи

Тепер головне питання: чи повинна хеш-функція мати локальність? Відповідь залежить від того, для чого ми використовуємо хеш.

Криптографічні хеш-функції (для паролів, цифрових підписів, перевірки цілісності) — не повинні мати локальність. Це критична вимога безпеки. Уявіть: ваш пароль — password1, а хеш від нього — abc123. Якщо хеш-функція має локальність, то пароль password2 дасть щось близьке — наприклад, abc124. Зловмисник, знаючи хеш abc123, зможе здогадатися, що пароль схожий на щось, що дає abc12..., і перебрати варіанти значно швидше.

Тому криптографічні хеш-функції побудовані так, щоб мінімальна зміна входу повністю змінювала вихід:

hash("hello")  = 5d41402abc4b2a76b9719d911017c592
hash("hellp")  = 21604f5e1d469e34eb20a29e7d0e4b3e

Змінилася одна буква — хеш змінився повністю. В криптографії це називають лавинний ефект (avalanche effect): зміна одного біта входу змінює приблизно 50% бітів виходу.

Некриптографічні хеш-функції (для структур даних, пошуку дублікатів тощо) — можуть мати локальність. Для них головне — щоб значення розподілялися рівномірно по всіх можливих значеннях. Якщо сусідні числа дають сусідні хеші — це не проблема, поки результати розподілені більш-менш порівну. Ми побачимо це на конкретному прикладі нижче.

Це ключова різниця між двома типами хеш-функцій, і ми детальніше розглянемо її в кінці лекції.


Означення хеш-функції

Тепер ми готові дати строге означення.

Означення хеш-функції

Хеш-функція — це функція \(h: U \to \{0, 1, \ldots, m-1\}\), де:

  • \(U\) — множина всіх можливих вхідних даних (може бути нескінченною)
  • \(m\) — розмір множини можливих хеш-значень (фіксоване натуральне число)

що задовольняє такі властивості:

  1. Детермінованість — для одного входу завжди повертає однаковий результат
  2. Фіксований розмір виходу — незалежно від розміру входу, результат завжди з множини \(\{0, 1, \ldots, m-1\}\)
  3. Рівномірний розподіл — хеш-значення розподіляються якомога рівномірніше по всій множині \(\{0, \ldots, m-1\}\)

Для криптографічних хеш-функцій додається ще одна вимога:

  1. Відсутність локальності (лавинний ефект) — малі зміни входу мають повністю змінювати вихід

Прості хеш-функції для цілих чисел

Хеш через залишок від ділення

Найпростіша хеш-функція для цілих чисел:

\[h(x) = x \bmod m\]

де \(m\) — кількість можливих хеш-значень.

int hash_int(int x, int m) {
    int h = x % m;
    if (h < 0) h += m; // Handle negative numbers
    return h;
}

Чому h < 0?

В C++ оператор % для від'ємних чисел може повернути від'ємний результат: -7 % 5 = -2. Для хеш-функції нам потрібне значення з \(\{0, \ldots, m-1\}\), тому додаємо \(m\).

Приклад обчислення

Нехай \(m = 7\):

Ключ \(x\) \(h(x) = x \bmod 7\)
10 3
17 3
25 4
42 0
7 0

Зверніть увагу: ключі 10 і 17 дають однаковий хеш (3), і ключі 42 і 7 теж (0). Це колізії — ми розглянемо їх детальніше нижче.

Ця функція має локальність — і це нормально

Подивимось на послідовні числа:

Ключ \(x\) \(h(x) = x \bmod 7\)
10 3
11 4
12 5
13 6
14 0

Сусідні числа дають сусідні хеші — функція має чітку локальність. Якби це був криптографічний хеш, це була б катастрофа: знаючи хеш одного числа, можна вгадати хеш сусіднього.

Але для багатьох практичних застосувань це прийнятно. Чому? Уявіть гардероб у театрі з 7 вішалками, пронумерованими від 0 до 6. Кожен глядач здає пальто на вішалку з номером = номер квитка \(\bmod 7\). Глядачі з квитками 10 і 11 повісять пальта на вішалки 3 і 4 — сусідні. Але це не проблема! Головне, щоб пальта розподілилися рівномірно по всіх вішалках, а не скупчилися на одній. Функція \(x \bmod 7\) якраз це забезпечує.

А от для паролів така функція не підійде: якщо зловмисник знає, що хеш пароля — 3, він знає, що пароль при діленні на 7 дає залишок 3. Це значно звужує перебір.

Вибір \(m\): чому це важливо

Хеш-функція \(h(x) = x \bmod m\) виглядає простою, але її якість сильно залежить від вибору \(m\). Головна вимога — рівномірний розподіл. Якби ключі були рівномірно випадковими числами, будь-який \(m\) давав би хороший результат. Але на практиці ключі рідко бувають випадковими.

Реальні дані мають структуру

Подивіться на типові ключі, з якими працюють програми:

  • ID користувачів з автоінкрементом — послідовність з кроком 1 (10001, 10002, 10003, ...)
  • Адреси в пам'яті на 64-бітних системах завжди кратні 8 (вимоги вирівнювання)
  • Розміри буферів зазвичай кратні 1024 або 4096
  • Мітки часу (timestamps) — числа з кроком 1 секунда або 60 секунд
  • Штрих-коди, номери замовлень — часто ідуть з якимось фіксованим кроком

Спільне у всіх цих прикладах — ключі утворюють арифметичну прогресію: \(a, a+d, a+2d, a+3d, \ldots\) з якимось кроком \(d\).

Що стається, коли \(m\) і крок мають спільний дільник

Візьмемо реалістичний приклад: хешуємо парні числа (крок \(d = 2\)), а \(m\) обрали рівним 10:

Ключ \(x\) \(h(x) = x \bmod 10\)
100 0
102 2
104 4
106 6
108 8
110 0
112 2

Хеші попадають лише в комірки \(\{0, 2, 4, 6, 8\}\)половина можливих значень недосяжна в принципі, незалежно від того, скільки парних ключів ми розглянемо. Колізії стаються вдвічі частіше, ніж могли б.

Ще гірше, якщо крок кратний \(m\). Ключі 10, 20, 30, 40, ... при \(m = 10\)усі хешуються в 0. Із 10 можливих значень реально використовується лише одне.

Формальне пояснення

Лема про арифметичну прогресію

Якщо ключі утворюють арифметичну прогресію \(x_i = a + i \cdot d\), то значення \(h(x_i) = (a + i d) \bmod m\) набуває рівно

\[\frac{m}{\gcd(d, m)}\]

різних значень.

Простими словами: чим більший спільний дільник між кроком \(d\) і параметром \(m\), тим менше значень набуватиме хеш. Якщо \(\gcd(d, m) = k\), то \(\dfrac{k-1}{k}\) частини діапазону залишаються недосяжними — і це не залежить від того, скільки ключів ми розглянемо.

Перевіримо на попередніх прикладах:

  • \(d = 2\), \(m = 10\): \(\gcd(2, 10) = 2\) → використовується \(10 / 2 = 5\) комірок з 10 ✓
  • \(d = 10\), \(m = 10\): \(\gcd(10, 10) = 10\) → використовується \(10 / 10 = 1\) комірка з 10 ✓

Чому просте число рятує

Нехай \(m = p\) — просте число. Тоді для будь-якого \(d\), що не кратне \(p\), маємо \(\gcd(d, p) = 1\). А це означає:

\[\frac{p}{\gcd(d, p)} = \frac{p}{1} = p\]

— хеш набуває всіх \(p\) можливих значень. Будь-яка арифметична прогресія з кроком, меншим за \(p\), розподіляється рівномірно по всьому діапазону.

Подивимось на той самий приклад (парні числа, крок \(d = 2\)), але з \(m = 11\):

Ключ \(x\) \(h(x) = x \bmod 11\)
100 1
102 3
104 5
106 7
108 9
110 0
112 2
114 4
116 6
118 8
120 10
122 1

Всі 11 комірок заповнюються по черзі — ідеальний рівномірний розподіл. Просте число «не резонує» з жодним природним кроком у даних.

Інтуїція

Складене число \(m\) має дільники, за які «зачіпаються» патерни у ключах. У простого числа дільників немає (окрім 1 і самого себе), тому жоден регулярний патерн з ним не співпадає.

Окремий випадок: \(m = 2^k\)

Спокусливо обрати \(m\) як степінь двійки, бо \(x \bmod 2^k\) обчислюється через побітове AND:

int h = x & (m - 1); // Only works when m is a power of 2

Це найшвидша можлива операція взяття залишку — одна інструкція процесора. Але ціна така:

\[x \bmod 2^k = \text{останні } k \text{ бітів числа } x\]

Старші біти повністю ігноруються. Розглянемо \(m = 16\) (беремо 4 молодших біти):

Ключ \(x\) (hex) \(h(x) = x \bmod 16\)
0x10000005 5
0x20000005 5
0x30000005 5
0xFFFF0005 5

Числа можуть відрізнятися в мільярди разів, але якщо їхні молодші біти однакові — колізія гарантована.

Це особливо болюче для вказівників: на 64-бітних системах об'єкти вирівняні по 8 байтів, тобто молодші 3 біти адрес завжди нульові. Хеш-функція з \(m = 8\) дасть усім вказівникам значення 0.

Візуалізація різниці

Як розподіляються ключі з кроком 2 при різних значеннях \(m\) (X — значення, яке набуває хеш, . — недосяжне значення):

m = 10 (складене):   X . X . X . X . X .                 ← 5 з 10 використано
m = 16 (степінь 2):  X . X . X . X . X . X . X . X . X . ← 8 з 16 використано
m = 11 (просте):     X X X X X X X X X X X               ← 11 з 11 використано

Практична рекомендація

Практична рекомендація

Обирайте \(m\) як просте число, не близьке до степеня двійки. Наприклад:

  • замість \(m = 1024\) — візьміть \(m = 1009\) або \(m = 1013\)
  • замість \(m = 65536\) — візьміть \(m = 65521\) або \(m = 65539\)
  • замість \(m = 10^6\) — візьміть \(m = 999983\)

Знайти найближче просте число легко: достатньо перевірити кілька чисел поспіль на простоту.


Хеш-функції для рядків

Наївний підхід: сума кодів символів

Найпростіша ідея — додати коди (ASCII-значення) всіх символів:

int hash_string_naive(const char* str, int m) {
    int sum = 0;
    for (int i = 0; str[i] != '\0'; ++i) {
        sum += str[i];
    }
    int h = sum % m;
    if (h < 0) h += m;
    return h;
}

Проблема: рядки-анаграми дають однаковий хеш:

hash("abc") = (97 + 98 + 99) % m = 294 % m
hash("cba") = (99 + 98 + 97) % m = 294 % m
hash("bac") = (98 + 97 + 99) % m = 294 % m

Усі перестановки букв дають однаковий результат — це дуже погана хеш-функція.

Поліноміальний хеш

Щоб порядок символів впливав на хеш, помножимо кожен символ на степінь деякого числа \(p\):

\[h(s) = \left(s_0 \cdot p^0 + s_1 \cdot p^1 + s_2 \cdot p^2 + \ldots + s_{n-1} \cdot p^{n-1}\right) \bmod m\]

де \(s_i\) — код \(i\)-го символу, \(p\) — основа (зазвичай просте число, наприклад 31 або 53).

Тепер порядок символів має значення: кожен символ множиться на різну степінь \(p\).

int hash_string_poly(const char* str, int m) {
    long long h = 0;
    long long p_pow = 1; // p^0 = 1
    int p = 31;          // Base (prime number)

    for (int i = 0; str[i] != '\0'; ++i) {
        h = (h + (str[i] - 'a' + 1) * p_pow) % m;
        p_pow = (p_pow * p) % m;
    }
    return (int)h;
}

Приклад обчислення

Для рядка "abc" з \(p = 31\), \(m = 1000\):

\[h = 1 \cdot 31^0 + 2 \cdot 31^1 + 3 \cdot 31^2 = 1 + 62 + 2883 = 2946\]
\[h \bmod 1000 = 946\]

Для "cba":

\[h = 3 \cdot 31^0 + 2 \cdot 31^1 + 1 \cdot 31^2 = 3 + 62 + 961 = 1026\]
\[h \bmod 1000 = 26\]

Анаграми "abc" і "cba" тепер мають різні хеші: 946 і 26.

Чому \(p = 31\)?

Число 31 — просте, що дає хороший розподіл. Крім того, множення на 31 легко оптимізується компілятором: \(31 \cdot x = 32 \cdot x - x = (x \ll 5) - x\). Це одна з причин, чому Java використовує саме 31 у своїй стандартній хеш-функції для рядків.


Хеші в Linux

Обчислення хешу файлу

В Linux можна обчислити хеш будь-якого файлу прямо в терміналі:

# MD5 (128 біт, 32 hex-символи)
md5sum myfile.txt
# d41d8cd98f00b204e9800998ecf8427e  myfile.txt

# SHA-1 (160 біт, 40 hex-символів)
sha1sum myfile.txt
# da39a3ee5e6b4b0d3255bfef95601890afd80709  myfile.txt

# SHA-256 (256 біт, 64 hex-символи)
sha256sum myfile.txt
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  myfile.txt

Зверніть увагу на довжину: MD5 дає 32 символи, SHA-1 — 40, SHA-256 — 64. Чим довший хеш, тим менша ймовірність колізій.

Де зберігаються хеші паролів

В Linux паролі користувачів не зберігаються у відкритому вигляді. Замість цього зберігаються їх хеші у файлі /etc/shadow:

sudo cat /etc/shadow
# student:$6$xyz...$abc...123...:19500:0:99999:7:::

Формат запису: $алгоритм$сіль$хеш, де:

Префікс Алгоритм
$1$ MD5
$5$ SHA-256
$6$ SHA-512
$y$ yescrypt (сучасний)

Сіль (salt) — це випадковий рядок, який додається до пароля перед хешуванням. Завдяки цьому два користувачі з однаковим паролем матимуть різні хеші.

Коротка історія хеш-алгоритмів

Чому алгоритми змінюються

Історія хеш-функцій — це історія перегонів між криптографами і тими, хто намагається їх зламати.

MD5 (1991, Рональд Рівест):

  • Розмір хешу: 128 біт
  • Довгий час був стандартом
  • 2004 рік: китайська криптографиня Ван Сяоюнь (Wang Xiaoyun) знайшла спосіб генерувати колізії за хвилини
  • Сьогодні: не використовують для безпеки, але все ще використовують для перевірки цілісності (завантаження файлів)

SHA-1 (1995, NSA):

  • Розмір хешу: 160 біт
  • Замінив MD5 як стандарт безпеки
  • 2017 рік: Google продемонстрував практичну колізію (проект SHAttered) — два різних PDF з однаковим SHA-1 хешем
  • Сьогодні: застарілий, браузери не приймають SHA-1 сертифікати

SHA-256 (2001, NSA, сімейство SHA-2):

  • Розмір хешу: 256 біт
  • Поточний стандарт безпеки
  • Використовується в Bitcoin, TLS, цифрових підписах
  • Колізій не знайдено станом на сьогодні

Чому MD5 все ще використовується

Для перевірки цілісності (чи завантажився файл коректно) MD5 достатній — нам не потрібна стійкість до навмисних атак, лише виявлення випадкових помилок. А MD5 працює швидше за SHA-256. Тому на сайтах завантажень досі часто бачимо MD5 або SHA-1 контрольні суми.


Колізії

Означення

Означення колізії

Колізія — це ситуація, коли два різних входи дають однаковий хеш:

\[x_1 \neq x_2, \quad \text{але} \quad h(x_1) = h(x_2)\]

Ми вже бачили приклади колізій: при \(m = 7\) ключі 10 і 17 мають однаковий хеш 3. Але чи можна побудувати хеш-функцію без колізій?

Чому колізії неминучі

Відповідь: ні, якщо множина можливих входів більша за множину хеш-значень. Це прямий наслідок фундаментального математичного принципу.


Принцип Діріхле

Формулювання

Принцип Діріхле (Pigeonhole Principle)

Якщо \(n\) предметів розкласти в \(m\) комірок, і \(n > m\), то хоча б в одній комірці опиниться більше одного предмета.

Простими словами: якщо 10 голубів сідають у 9 клітинок, то хоча б в одній клітинці буде два голуби. Це здається очевидним, але наслідки для хеш-функцій — фундаментальні.

flowchart LR
    subgraph "Ключі (n = 5)"
        k1["10"]
        k2["17"]
        k3["25"]
        k4["42"]
        k5["7"]
    end
    subgraph "Хеш-значення (m = 4)"
        h0["0"]
        h1["1"]
        h2["2"]
        h3["3"]
    end
    k1 --> h3
    k2 --> h3
    k3 --> h2
    k4 --> h0
    k5 --> h0

5 ключів, 4 можливих хеш-значення → принаймні одна колізія гарантована.

Застосування до хеш-функцій

Розглянемо хеш-функцію для рядків. Множина всіх можливих рядків нескінченна, а множина хеш-значень \(\{0, 1, \ldots, m-1\}\) — скінченна (наприклад, \(m = 1000\)). За принципом Діріхле, для будь-якого \(m\) існують рядки з однаковим хешем.

Фундаментальний висновок

Колізії неможливо уникнути — це математичний факт. Тому завдання хеш-функції — не уникати колізій, а мінімізувати їх кількість через рівномірний розподіл.

Узагальнення принципу Діріхле

Принцип можна посилити з обох боків. Якщо \(n\) предметів розкласти в \(m\) комірок, то:

  • хоча б в одній комірці буде не менше ніж \(\lceil n/m \rceil\) предметів;
  • хоча б в одній комірці буде не більше ніж \(\lfloor n/m \rfloor\) предметів.

Обидва твердження випливають з того самого простого факту: якщо середнє значення по комірках дорівнює \(n/m\), то максимум не може бути меншим за середнє, а мінімум — більшим. Для хеш-функцій критичне саме перше твердження — воно дає нижню оцінку на кількість колізій у найбільш завантаженій комірці.

Наприклад: якщо 10 000 ключів хешуються в 1000 комірок, то хоча б одна комірка міститиме не менше \(\lceil 10000/1000 \rceil = 10\) ключів, і хоча б одна — не більше \(\lfloor 10000/1000 \rfloor = 10\) ключів. Тут обидва числа збіглись, бо ділення без залишку. Для 10 500 ключів у 1000 комірок нижня оцінка стане \(\lceil 10500/1000 \rceil = 11\), а верхня оцінка «найлегшої» — \(\lfloor 10500/1000 \rfloor = 10\).


Парадокс днів народження (Birthday Paradox)

Задача

Скільки людей потрібно зібрати в кімнаті, щоб ймовірність того, що хоча б у двох з них збігається день народження, була більшою за 50%?

Інтуїтивна відповідь: "мабуть, десь 180 — половина від 365". Правильна відповідь: лише 23 людини.

Чому так мало?

Ключове спостереження: ми шукаємо збіг не конкретної дати, а будь-якої пари. При 23 людях кількість можливих пар:

\[\binom{23}{2} = \frac{23 \cdot 22}{2} = 253\]

253 пари, кожна з яких може дати збіг — це вже багато.

Формула

Нехай хеш-функція ідеально рівномірна — тобто для кожного нового елемента всі \(m\) можливих значень однаково ймовірні. Розмістимо \(k\) елементів один за одним і порахуємо ймовірність, що жоден з них не зіткнеться з попередніми.

  • 1-й елемент: отримує будь-яке з \(m\) значень. Колізії ще бути не може — ймовірність «немає колізії» дорівнює \(\dfrac{m}{m} = 1\).
  • 2-й елемент: одне значення вже зайняте попереднім, вільних — \(m - 1\) з \(m\). Ймовірність уникнути колізії: \(\dfrac{m-1}{m}\).
  • 3-й елемент: зайняті 2 значення, вільних — \(m - 2\). Ймовірність: \(\dfrac{m-2}{m}\).
  • \(\ldots\)
  • \(k\)-й елемент: зайняті \(k - 1\) значень, вільних — \(m - k + 1\). Ймовірність: \(\dfrac{m-k+1}{m}\).

Щоб колізій не було жодного разу, всі \(k\) кроків мають пройти без зіткнення. Ймовірність послідовності таких подій — це добуток ймовірностей кожного кроку (за умови, що попередні пройшли успішно):

\[P(\text{немає колізій}) = \frac{m}{m} \cdot \frac{m-1}{m} \cdot \frac{m-2}{m} \cdot \ldots \cdot \frac{m-k+1}{m}\]

Кожен наступний множник менший за попередній рівно на \(\dfrac{1}{m}\). Це не випадково: з кожним додаваним елементом у «зайнятих» стає на одне значення більше, а у «вільних» — на одне менше. Тому чисельник кожного разу зменшується на 1.

Ймовірність хоча б однієї колізії — це доповнення до одиниці:

\[P(\text{є колізія}) = 1 - P(\text{немає колізій})\]

Тепер знайдемо, при якому \(k\) ймовірність колізії сягає 50% — перемножувати \(k\) дробів напряму для великих значень незручно, треба щось простіше.

Почнемо з переписування кожного множника у трохи іншому вигляді:

\[\frac{m}{m} = 1 - \frac{0}{m}, \quad \frac{m-1}{m} = 1 - \frac{1}{m}, \quad \frac{m-2}{m} = 1 - \frac{2}{m}, \quad \ldots, \quad \frac{m-k+1}{m} = 1 - \frac{k-1}{m}\]

Тож добуток виглядає так:

\[P(\text{немає колізій}) = \left(1 - \tfrac{0}{m}\right) \cdot \left(1 - \tfrac{1}{m}\right) \cdot \left(1 - \tfrac{2}{m}\right) \cdot \ldots \cdot \left(1 - \tfrac{k-1}{m}\right)\]

Прологарифмуємо обидві частини — логарифм добутку розпадається на суму логарифмів:

\[\ln P = \ln\!\left(1 - \tfrac{0}{m}\right) + \ln\!\left(1 - \tfrac{1}{m}\right) + \ldots + \ln\!\left(1 - \tfrac{k-1}{m}\right)\]

Тут стане в пригоді наближення для малих \(x\):

\[\ln(1 - x) \approx -x\]

(його легко перевірити: наприклад, \(\ln(1 - 0{,}01) = -0{,}01005\ldots\), а \(\ln(1 - 0{,}001) = -0{,}001000\ldots\)). Застосовуючи його до кожного доданку, отримуємо:

\[\ln P \approx -\frac{0}{m} - \frac{1}{m} - \frac{2}{m} - \ldots - \frac{k-1}{m} = -\frac{0 + 1 + 2 + \ldots + (k-1)}{m}\]

У чисельнику — знайома сума арифметичної прогресії: \(0 + 1 + 2 + \ldots + (k-1) = \dfrac{k(k-1)}{2}\). При великих \(k\) значення \(k - 1\) майже не відрізняється від \(k\), тому \(k(k-1) \approx k^2\). Отримуємо:

\[\ln P \approx -\frac{k^2}{2m} \quad\Longrightarrow\quad P(\text{немає колізій}) \approx e^{-k^2/(2m)}\]

Колізія стає ймовірною (\(P(\text{немає колізій}) < \tfrac{1}{2}\)) тоді, коли:

\[e^{-k^2/(2m)} < \frac{1}{2} \quad\Longleftrightarrow\quad \frac{k^2}{2m} > \ln 2 \quad\Longleftrightarrow\quad k > \sqrt{2 m \ln 2} \approx 1{,}177 \sqrt{m}\]

Тобто імовірність знайти колізію перевищує 50% тоді, коли кількість елементів \(k\) сягає приблизно \(\sqrt{m}\). У практичних оцінках так і пишуть: \(k \approx \sqrt{m}\) — це правильний порядок величини з точністю до сталого множника \(\approx 1{,}18\).

Для дня народження (\(m = 365\)): \(\sqrt{365} \approx 19\), а з поправкою — \(1{,}177 \cdot 19 \approx 22{,}5\). Звідси і знамениті «23 людини».

\(m\) (кількість хеш-значень) \(k\) для ~50% імовірності колізії
365 (дні року) ~23
1 000 ~39
1 000 000 ~1 177
\(2^{128}\) (MD5) \(\approx 2^{64}\)
\(2^{256}\) (SHA-256) \(\approx 2^{128}\)

Для допитливих: звідки взялося наближення \(\ln(1-x) \approx -x\)

Ця формула — приклад апроксимації, коли складну функцію замінюють простішою поблизу певної точки. У математичному аналізі існує потужний і елегантний інструмент — ряд Тейлора, — що систематично будує такі наближення для широкого класу функцій. З нього природно випливають відомі формули на кшталт \(\ln(1-x) \approx -x\), \(\sin x \approx x\), \(e^x \approx 1 + x\) для малих \(x\). Ряди Тейлора трапляються в найнеочікуваніших місцях — від фізики й комп'ютерної графіки до машинного навчання та фінансової математики. Якщо вам цікаво зазирнути глибше — час, присвячений їхньому вивченню, окупається багато разів.

Практичний висновок

Атака на основі парадоксу днів народження (birthday attack)

Парадокс днів народження пояснює, чому 128-бітний хеш (MD5) не такий надійний, як здається. Хоча \(2^{128}\) — астрономічне число, для знаходження колізії достатньо перебрати лише \(\approx 2^{64}\) варіантів — що цілком реалістично для сучасних комп'ютерів. Саме тому MD5 вважається зламаним для криптографічних цілей.

Для SHA-256 потрібно \(\approx 2^{128}\) спроб — це все ще далеко за межами можливостей будь-яких комп'ютерів.


Криптографічні vs некриптографічні хеш-функції

Не всі хеш-функції однакові. Залежно від призначення, до них висувають різні вимоги.

Некриптографічні хеш-функції

Призначення: структури даних для швидкого пошуку (розглянемо в наступній лекції), перевірка цілісності, виявлення дублікатів.

Вимоги:

  • Швидкість обчислення
  • Рівномірний розподіл
  • Мінімум колізій

Приклади: поліноміальний хеш, FNV, MurmurHash, CityHash.

Безпека не є вимогою — ці хеші не призначені для захисту від зловмисників.

Криптографічні хеш-функції

Призначення: зберігання паролів, цифрові підписи, криптовалюти (Bitcoin та інші).

Вимоги (на додачу до попередніх):

Властивість Зміст Складність атаки
Незворотність (preimage resistance) Дано хеш \(h\). Знайти будь-який вхід \(x\), для якого \(hash(x) = h\) — неможливо. \(O(m)\)
Стійкість до другого прообразу (second preimage resistance) Дано фіксований вхід \(x_1\). Знайти інший \(x_2 \neq x_1\), що дає той самий хеш — неможливо. Атакувальник не обирає \(x_1\). \(O(m)\)
Стійкість до колізій (collision resistance) Знайти будь-яку пару \(x_1 \neq x_2\) з однаковим хешем — неможливо. Атакувальник вільно підбирає обидва входи. \(O(\sqrt{m})\)
Лавинний ефект Зміна одного біта входу змінює ~50% бітів виходу

Три сценарії атаки

Розглянемо, що фіксоване, а що вільне для атакувальника у кожному з цих сценаріїв:

  • Незворотність: дано лише хеш, треба відновити вхід.
  • Другий прообраз: дано конкретний документ \(x_1\) (наприклад, легітимний договір), треба підробити інший документ \(x_2\) з таким самим хешем.
  • Колізія: атакувальник вільно генерує документи парами («чесний» і «шахрайський») і шукає збіг хешів.

Свобода вибору обох входів робить колізійну атаку значно дешевшою: за парадоксом днів народження достатньо \(O(\sqrt{m})\) спроб замість \(O(m)\).

Як це ламає цифрові підписи. Може здатися, що для підробки підпису потрібен саме другий прообраз (дано підписаний документ — треба знайти інший з тим самим хешем, \(O(m)\) операцій). Але на практиці атака йде простішим шляхом:

  1. Зловмисник заздалегідь готує дві різні версії документа — «чесну» та шахрайську — підбираючи їх так, щоб їхні хеші збіглися. Це колізійна атака, вона коштує лише \(O(\sqrt{m})\).
  2. Просить жертву підписати «чесну» версію. Жертва обчислює хеш, шифрує його своїм приватним ключем — отримує підпис для хешу \(h\).
  3. Оскільки шахрайська версія має той самий хеш \(h\), цей самий підпис дійсний і для неї. Зловмисник поширює шахрайську версію як нібито підписану жертвою.

Суть у тому, що атакувальник сам контролює обидва документи до моменту підписання — а отже, йому достатньо звичайної колізії, а не другого прообразу. Саме тому MD5 вважається зламаним для підписів: теоретична межа \(\sqrt{m} = 2^{64}\) уже цілком досяжна, а завдяки відомим слабкостям самого алгоритму колізії MD5 на практиці знаходять узагалі за секунди. У той самий час \(O(2^{128})\) операцій для другого прообразу все ще недосяжні.

Як згенерувати саме «документ», а не випадковий набір байтів

На перший погляд атака здається неможливою: щоб два файли мали однаковий хеш, треба ж наперед знати це значення і підібрати під нього байти — а це задача знаходження прообразу, \(O(m)\).

Але колізійна атака влаштована інакше. Криптоаналіз ніколи не фіксує хеш наперед — він просто шукає будь-яку пару вхідних даних, хеші яких випадково збігаються. Яким саме буде це число, ніхто не контролює; важливо лише те, що для обох файлів воно однакове. Саме тому задача реалістична: достатньо \(O(\sqrt{m})\) спроб.

Залишається інженерний нюанс: знайдені «колізійні байти» — це, з погляду людини, бінарне сміття. Щоб отримати два осмислені документи з одним хешем, використовують один із двох трюків.

Двоголовий файл. Готують єдиний шаблон документа, всередині якого одразу два варіанти видимого тексту — «чесний» і «шахрайський» — та логіка рендерингу: «якщо службовий блок \(X = B_1\), показати перший, інакше — другий». Криптоаналіз підбирає \(B_1 \neq B_2\), при яких хеш цілого файлу збігається для обох значень. Виходять два файли, що відрізняються лише блоком \(X\), але при відкритті показують різні документи. Так у середині 2000-х демонстрували пару PostScript-контрактів з однаковим MD5: один виводив «заплатити 1 000 доларів», другий — «заплатити 1 000 000 доларів».

Chosen-prefix collision. Тонший і потужніший підхід: атакувальник бере два цілком різних готових документи — з будь-яким видимим текстом і форматуванням — і додає до кожного коротке «склеювальне сміття» у ділянки, які формат ігнорує (коментарі, метадані, EXIF-теги). Це сміття обчислюється так, щоб хеші двох документів збіглися. Жодних умовних перемикачів — два повноцінно різні файли з однаковим хешем. Саме цей тип атаки в 2012 році використав шкідник Flame для підробки цифрового сертифіката Microsoft.

Цифровий підпис: як це працює

Одне з найважливіших застосувань криптографічних хешів — цифровий підпис. Ідея:

Автор підписує:

flowchart LR
    D1["Документ"] --> H1["hash(документ)\n= abc123..."]
    H1 --> E1["Шифруємо хеш\nприватним ключем"]
    E1 --> S1["Цифровий підпис"]

Одержувач перевіряє:

flowchart LR
    D2["Документ"] --> H2["hash(документ)\n= abc123..."]
    S2["Цифровий підпис"] --> E2["Дешифруємо підпис\nпублічним ключем"]
    H2 --> C["Порівнюємо хеші"]
    E2 --> C
    C --> R{"Збігаються?"}
    R -->|Так| OK["Документ автентичний ✓"]
    R -->|Ні| FAIL["Документ змінений ✗"]

Чому хешують, а не шифрують весь документ? Бо документ може важити гігабайти, а хеш — завжди фіксованого розміру (32-64 байти). Шифрування хешу — миттєве.

Цікавий факт

Відбиток (fingerprint) SSH-ключа, який ми бачили на початку лекції — це саме хеш публічного ключа. Коли ви вперше підключаєтесь до сервера, SSH показує відбиток, щоб ви могли переконатися, що підключаєтесь до правильного сервера, а не до підробки.


Підсумок

Ключові поняття

Поняття Що запам'ятати
Функція Відображення \(f: A \to B\) — правило, що кожному елементу \(A\) ставить у відповідність елемент \(B\)
Необоротна функція З результату неможливо однозначно відновити вхід
Локальність Малі зміни входу → малі зміни виходу. Криптографічні хеш-функції не мають мати цю властивість
Хеш-функція \(h: U \to \{0, \ldots, m-1\}\) — детермінована, фіксований вихід, рівномірний розподіл
Колізія Два різних входи з однаковим хешем — неминучі за принципом Діріхле
Принцип Діріхле \(n\) предметів у \(m\) комірок, \(n > m\) → є комірка з \(\geq 2\) предметами
Парадокс днів народження Колізія ймовірна при \(\approx \sqrt{m}\) елементах — значно менше, ніж \(m\)
Лавинний ефект Зміна 1 біта входу → зміна ~50% бітів виходу (вимога криптографічних хешів)

Попередня: Сортування | Задачі: Хеш-функції