Подписывайтесь на наш Telegram и не пропускайте важные новости! Перейти

Гайд Производительность в ущерб безопасности: уязвимости спекулятивного исполнения (Spectre & Meltdown)

Начинающий
Начинающий
Статус
Оффлайн
Регистрация
17 Авг 2024
Сообщения
18
Реакции
10
Небольшое вступление

Привет, в последнее время я изучал уязвимости спекулятивного исполнения, и мне они показались очень интересными.
Поэтому я решил собрать в одну статью две уязвимости раскрытые в 2018 году и актуальные по сей день.
Статья переведена с английского на русский с помощью ИИ, я поправил перевод, но перевод на русский убил лаконичность.
По этому рекомендую если английский не водводит прочитать ее в оригинале
Пожалуйста, авторизуйтесь для просмотра ссылки.
(мой блог).
Решил также на днях создать тг канал чтобы не пропадать, если интересно можете
Пожалуйста, авторизуйтесь для просмотра ссылки.

Некоторые части выходят за рамки Spectre и Meltdown, но я решил оставить их без изменений.
Статья рассчитана на людей незнакомых с этими уязвимостями. Если вы уже в теме то вряд ли найдёте здесь что-то новое.
Надеюсь, вам будет так же интересно, как и мне.

1. Параллелизм на уровне инструкций (ILP)

Перенесёмся в XX век, когда появились первые цифровые компьютеры.
Ранние компьютеры использовали скалярные процессоры, реализующие архитектуру SISD (один поток инструкций, один поток данных).
Каждая инструкция в процессорах проходит три основных этапа: Fetch (выборка), Decode (декодирование) и Execute (выполнение).
Скалярные процессоры выполняют одну инструкцию за такт (CLK).
Чтобы лучше понять, как это работает, можно взглянуть на диаграмму:

SpeculativeExecutionVulnsWriteup-Page-1.drawio.png


1.1 Конвейерное выполнение (Pipelining)

Как видно из иллюстрации, скалярные процессоры за один такт выполняют одну операцию.
Так компьютеры работали вплоть до появления IBM Stretch (7030) в 1961 году.
Эта машина использовала технику, известную сегодня как конвейерное исполнение инструкций (pipelining).

Конвейер позволяет процессору обрабатывать несколько операций за один такт.
Это возможно, потому что для каждого этапа (Fetch, Decode, Execute) есть отдельный аппаратный блок.
За один такт процессор может выбирать одну инструкцию, декодировать другую и выполнять третью.

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

SpeculativeExecutionVulnsWriteup-Page-2.drawio.png


За каждый такт процессор выполняет ранее декодированную инструкцию, декодирует ранее выбранную и при наличии новой выбирает следующую.
Напоминает водопад, не правда ли? Именно поэтому такую схему иногда называют «waterfall execution».

Конвейер способен увеличить производительность в три раза.
Без него каждая инструкция требует 3 такта. Для 10 инструкций это 10 × 3 = 30 тактов.
С конвейером первая инструкция завершается за 3 такта, каждая последующая за 1 такт. Итого: 3 + (10 − 1) = 12 тактов.

1.2 Суперскалярное исполнение (Superscalar Execution)

Конвейер даёт заметный прирост.
Но пока есть лишь один блок выборки, декодирования и исполнения, процессор не может выполнять больше одной инструкции за такт.
Этот предел был преодолён с появлением CDC 6600 в 1964 году, первый суперкомпьютер который использовал суперскалярный процессор.
Он имел несколько функциональных блоков, работающих параллельно. Это и есть суперскалярное исполнение.
Добавив суперскалярность к предыдущей конвейерной модели, получаем следующую картину:

SpeculativeExecutionVulnsWriteup-Page-3.drawio.png


Как видно из диаграммы, теперь процессор может выполнять до двух инструкций за такт, поскольку для каждого этапа (Fetch, Decode, Execute) есть два блока.

Но что, если вторая инструкция зависит от результата первой?
Здесь вступает в игру обнаружение конфликтов данных (data hazard detection).
Это блок, анализирующий инструкции на предмет возможности параллельного исполнения.
Если инструкции работают с разными регистрами, они могут выполняться параллельно.
Если используют один и тот же регистр, hazard detection обеспечивает корректность выполнения и соблюдение порядка.

1.3 Выполнение не по порядку (Out-of-Order Execution)

Мы научили процессор выполнять две инструкции параллельно.
Как оптимизировать дальше? Предположим, у нас есть такой набор инструкций:

C:
Expand Collapse Copy
mov rax, [Memory] ; 100 тактов
add rax, rax      ; 1 такт
mov rcx, [Memory] ; 100 тактов
add rcx, rcx      ; 1 такт

Что произойдёт при выполнении на нашем процессоре?
Он может обработать только две инструкции за такт.
Но поскольку вторая инструкция зависит от результата первой, процессор будет ждать 100 тактов завершения операции с памятью, после чего продолжит.
Итого: 202 такта.

А если переставить инструкции?

C:
Expand Collapse Copy
mov rax, [Memory] ; 100 тактов
mov rcx, [Memory] ; 100 тактов
add rax, rax      ; 1 такт
add rcx, rcx      ; 1 такт

Теперь можно параллельно читать данные из памяти (они не создают data hazard), а затем параллельно выполнять арифметические операции.
Итого: 101 тактов, вдвое быстрее.
Эта техника называется Out-of-Order execution (OoO).
Современные процессоры используют специальный OoO-движок, включающий также аллокацию, переименование регистров и многое другое, но здесь мы эти части пропустим.

Вы можете спросить: разве это не нарушает исходный порядок выполнения программы?
Нет. Инструкции выполняются не по порядку, но их результаты сохраняются в Reorder Buffer (ROB).
Данный буффер гарантирует фиксацию инструкций на программном уровне в исходном порядке.

1.4 Спекулятивное исполнение (Speculative Execution)

Можно ли оптимизировать ещё? Рассмотрим такой цикл:

C:
Expand Collapse Copy
start:
    xor rax, rax
check:
  inc rax
  cmp rax, 100
    jl check
    ret

Цикл инициализирует RAX нулём, инкрементирует до 100, затем возвращается.
Основной цикл состоит из трёх инструкций: INC, CMP и JL.
Процессор может зависнуть на инструкции CMP, потому что не знает заранее, будет ли переход, и вынужден ждать результата сравнения.
Если присмотреться: в 99 из 100 итераций переход берётся.

Возникает вопрос: что, если процессор предскажет, что переход будет взят, и продолжит выполнение спекулятивно, не дожидаясь результата CMP?
Именно в этом и состоит спекулятивное исполнение, это техника, используемая процессорами совместно с предсказателем ветвлений для предсказания потока управления и повышения производительности.
Спекулятивное исполнение использует механизмы сохранения архитектурного состояния до разрешения ветвления.
Если предсказание верно, результаты фиксируются. Если нет, конвейер сбрасывается и состояние откатывается.

В нашем примере инструкция CMP вводит задержку 1 такт за итерацию.
Если спекулировать что ветвление будет взято, мы экономим 1 такт за итерацию.
В последней итерации предсказание окажется неверным, что вызовет сброс конвейера (около 15 тактов).
Но мы уже выиграли ~99 тактов, так что итоговое улучшение около 84 тактов.

На практике предсказатель ветвлений не только предсказывает факт перехода, но и куда прыгнуть.
Для этого используется Branch Target Buffer (BTB), это буффер который хранит ранее встречавшиеся адреса переходов.
Когда ветвление предсказывается как взятое, процессор может вместо того чтобы тратить время на вычисление куда ему прыгать, посмотреть в BTB буффер есть ли сохраненная запись о данном прыжке.
Это позволяет процессору немедленно продолжить выполнение с предсказанного адреса, не ожидая фактического вычисления.

2. Цена производительности: Spectre и Meltdown

Любая оптимизация имеет свою цену. ILP-техники это не исключение.
Параллельное выполнение операций, не предназначенных для этого, порождает гонки состояний.
Выполнение не в исходном порядке создаёт расхождение между архитектурным и микроархитектурным состоянием, там, где «не должно было произойти» и «физически произошло» происходит.
Спекулятивное исполнение это самая агрессивная ILP-техника, так что неудивительно, что именно оно породило серьёзные проблемы безопасности.
Широко известных уязвимостей не было вплоть до 2018 года, когда были опубликованы статьи о Spectre и Meltdown.
Интересный факт что после публикации исследователи безопасности поначалу не верили, что это реальные уязвимости.



2.1 Разбор Spectre

Начнём с уязвимости Spectre.
Она эксплуатирует тот факт, что во время спекулятивного исполнения процессор может загружать данные в кэш, и в отличие от регистров, кэш не откатывается при отмене спекуляции.

Чтобы понять это, нужно разобраться в том, как работает кэш.

Кэш

В ранних системах (1960-е) скорости процессора и памяти были сопоставимы. Никакого существенного разрыва между ними не было.
К 1980-м годам процессоры начали ускоряться значительно быстрее ОЗУ.
Инженеры заметили, что процессор большую часть времени простаивает в ожидании данных из ОЗУ.
Эта проблема получила название «memory wall».
Стало очевидно: нужна небольшая быстрая память для хранения часто используемых данных, быстрее чем RAM.
Так появился кэш, маленькая и быстрая память, расположенная прямо на кристалле процессора.
Современные процессоры используют трёхуровневую иерархию кэша.
Самый быстрый L1, менее быстрый L2 и самый медленный L3.
L1 и L2 приватные для каждого ядра, L3 общий для всех ядер (новые процессоры AMD исключение).

Диаграмма ниже показывает, как это выглядит:

SpeculativeExecutionVulnsWriteup-Page-4.drawio.png


При обращении к однобайтовой переменной в памяти кэш загружает 64 байта начиная с этого адреса и сохраняет их на всех уровнях: L1, L2 и L3.

Почему 64 байта, если нужен только один?
Это связано с принципом пространственной локальности: если вы обращаетесь к каким-то данным, скорее всего скоро обратитесь и к соседним.
Размер кэш-линии в 64 байта это распространённый компромисс в дизайне процессоров.
Сделать кэш линии больше, процессор будет загружать больше лишних данных. Сделать их меньше, процессор будет чаще промахиватся (cache miss).

Почему данные хранятся сразу на всех уровнях?
Такой подход называется inclusive cache.
Это политика согласованности между уровнями: при загрузке одни и те же данные попадают на каждый уровень.
Поскольку L1 небольшой, со временем он перезапишет эту кэш-линию новыми данными.
При повторном обращении к той же памяти данные будут считаны из L2, он больше и всё ещё содержит нужную линию.

Теперь посмотрим на структуру кэша:

SpeculativeExecutionVulnsWriteup-Page-5.drawio.png


Кэш хранит данные в массиве кэш-линий (CL), каждая из которых содержит 64 байта.
Линии группируются в сеты, каждый сет содержит 8 путей.
Проще говоря, это двумерная таблица: сеты как строки, пути как столбцы, в каждой ячейке 64 байта.
Итого: 64 сета × 8 путя = 512 кэш-линий (512 × 64 байта = 32 КБ).
То же самое применимо к L2 и L3, с той разницей в размере (количество сетов) и скорости.

Tag Array

Если данные уже были закэшированы, процессор не перебирает все уровни подряд.
На каждом уровне есть структура под названием Tag Array.
Это массив, содержащий информацию о каждой ячейке кэша.
Процессор сначала проверяет Tag Array: если тег совпадает, данные считываются прямо с этого уровня.
Это означает, что промах в кэше определяется только по Tag Array, без обращения к массиву данных.

Во многих источниках кэш изображается как структура, где данные кэш-линии и её тег хранятся вместе, это не так.
Логически доступ именно такой, сначала тег, потом данные, но физически они расположены в разных массивах.

SpeculativeExecutionVulnsWriteup-Page-11.drawio.png


Для поиска данных процессор разбивает RAM-адрес на три поля: tag, index и offset, которые указывают, какой сет проверить в Tag Array и какие байты читать из массива данных.
Именно это делает кэш-поиск почти мгновенным.

SpeculativeExecutionVulnsWriteup-Page-7.drawio.png


Tag Array по структуре аналогичен кэшу, те же сеты и пути, только размер каждой ячейки 22 бита вместо 64 байт.
Каждая ячейка содержит Tag Entry (TE) с описанием соответствующей кэш-линии.
Для определения попадания или промаха процессору достаточно обратиться к компактному массиву из 22-битных записей, не трогая 32-килобайтный массив данных.

Основная проблема спекулятивного исполнения

Мы теперь знаем, как работают спекулятивное исполнение и кэш, оба созданы для максимальной производительности современных процессоров.
К сожалению, при их создании, никто не думал о совместном их использовании, и именно отсюда берётся уязвимость.

Вернёмся к примеру со спекулятивным исполнением.
Когда предсказатель ветвлений ошибается, процессор откатывает состояние к точке до начала спекуляции: регистры, счётчик программы, всё чисто.
Теперь добавим кэш.
Во время спекулятивного исполнения процессор может читать данные из RAM, и кэш незаметно их сохраняет.
Предсказатель оказывается неправ, процессор откатывается, но кэш нет.

Почему процессор не откатывает кэш тоже?
Потому что это было бы слишком дорого.
Откат кэша при каждом неверном предсказании полностью уничтожил бы смысл спекулятивного исполнения.
Вся выгода от оптимизации спекулятивного исполнения испарилась бы.
Это уже принятый компромисс в дизайне процессоров, менять это означало бы поменять принцип работы всех процессоров до этого.

Разберём конкретный пример:

C:
Expand Collapse Copy
    cmp  rcx, 1
    jg  normal_execution
 
    ; Спекулятивное исполнение происходит здесь
    mov rax, [SECRET_VAR]

normal_execution:
    ret

Здесь есть условный переход (JG), который берётся, если RCX больше 1.
Предсказатель ещё не знает результат сравнения, и предположим, что он предсказывает что переход не будет взят.
Процессор спекулятивно продолжает выполнение за ветвлением и считывает секретное значение из памяти в RAX.
Допустим что RCX был на самом деле равен единице, предсказатель ошибся.
Процессор сбрасывает конвейер и возобновляет выполнение, на этот раз делая переход.

Но вот в чём проблема.
Во время спекулятивного исполнения мы обратились к данным в RAM, и кэш незаметно загрузил 64 байта с этого адреса на все уровни.
Процессор делает всё возможное, чтобы скрыть сам факт спекулятивного исполнения, с программной точки зрения вы никогда его не увидите.
Но кэш помнит всё.

Proof Of Concept

Теперь, когда мы понимаем первопричину уязвимости, посмотрим, как секретные данные можно реально извлечь через кэш.
В предыдущем примере спекулятивное исполнение обратилось к секретному значению в RAM, заставив процессор загрузить соответствующую 64-байтную кэш-линию.
С программного уровня мы не можем напрямую читать кэш или инспектировать отдельные кэш-линии, для этого нет стандартных инструкций.
Именно здесь оригинальная статья о Spectre предлагает блестящую технику.

Сначала атакующий создаёт массив из 256 записей, по одной на каждое возможное значение байта (0–255).
Затем каждая запись вытесняется из кэша инструкцией CLFLUSH.
Далее, во время спекулятивного исполнения, процессор считывает один байт секрета и использует его как индекс в массиве.
Обращение к array[secret_byte] загружает соответствующую кэш-линию.
После отката спекуляции архитектурное состояние выглядит нетронутым, но состояние кэша изменено.
Атакующий измеряет время доступа к каждой записи массива.
Большинство займут ~100 тактов (чтение из памяти), а закэшированная запись, соответствующая секретному байту, лишь ~10 тактов.
Определив, к какому элементу массива доступ значительно быстрее, атакующий восстанавливает секретный байт.

Может звучать абстрактно, поэтому посмотрим на код.

Код ниже намеренно упрощён для наглядности.
Некоторые части не совсем точны и могут потребовать небольших доработок для реальной работы.

C:
Expand Collapse Copy
byte_t probe[ 256 * 0x1000 ];

Сначала создаём probe массив.
В большинстве PoC для Spectre можно встретить массив из 256 записей, это логично, ведь байт принимает 256 значений.
Однако массив часто умножают на размер страницы (4096 байт).

Причина этого prefetcher, ещё одна спекулятивная фича процессора.
Он умеет распознавать паттерны доступа и автоматически загружать соседние данные в кэш, что вносит шум в наши измерения.

Почему умножение на размер страницы помогает?
Потому что prefetcher не пересекает границы страниц. Это сохраняет чистоту измерений.

C:
Expand Collapse Copy
/* Вытесняем probe из кэша */
for ( int i = 0; i < 256; i++ )
    _mm_clflush( &probe[ i * 0x1000 ] );

Вытесняем каждую запись probe-массива из кэша с помощью CLFLUSH.
Этот шаг важен: даже одна закэшированная запись полностью поломает результат и даст ложноположительный результат.

C:
Expand Collapse Copy
for ( int i = 0; i < 100; i++ )
{
    if ( 1 )
        temp++;
}

Здесь мы пытаемся «натренировать» предсказатель ветвлений принимать одно и то же решение. Этот шаг встречается во многих PoC для Spectre.
Однако, на мой взгляд, он не является строго необходимым.
Современные предсказатели ветвлений достаточно сложны и могут сами внутренне пробовать несколько вариантов исхода.
Поэтому неясно, насколько такая «ручная тренировка» реально помогает.
К тому же сам цикл вводит собственные ветвления на уровне ассемблера, которые могут мешать тренировке или даже работать против неё.
В моих тестах эксплоит работает одинаково и без этого шага, но раз он часто встречается, пусть остаётся.

C:
Expand Collapse Copy
if (0)
{
    /* Спекулятивное исполнение здесь */
    temp += probe[ *(byte_t*)( 0xSECRET ) * 0x1000 ];
}

После тренировки предсказателя (или без неё) создаём ветвление, которое в норме никогда не выполняется, но будет выполнено спекулятивно.
Этот код считывает байт секретного значения из памяти и использует его как индекс в probe-массиве, обращаясь к конкретной записи, соответствующей этому значению.

C:
Expand Collapse Copy
/* Измеряем время доступа */
for ( int i = 0; i < 256; i++ )
{
    uint64_t start = __rdtscp();

    temp = probe[ i * 0x1000 ];

    uint64_t end = __rdtscp();
    uint64_t access_time = end - start;

    if ( access_time < 80 )
        printf("Fast access at index %d (%llu cycles)\n", i, access_time);
}

Перебираем все возможные значения байта и измеряем время доступа к каждой записи probe-массива.
В примере используется фиксированный порог (80 тактов).
Процессоры не идентичны: время кэш-попадания на AMD Ryzen может не подойти для процессоров Intel.
На практике лучше замерить время кэш-попадания и промаха и вычислять порог динамически.
Либо собрать все замеры и выбрать самый быстрый.

Атака не детерминированная: каждый запуск может дать другой результат.
Ядра разделяют исполнение с другими потоками, что может влиять на кэш и вносить шум.
Поэтому можно измерять загрузку ядер и переключаться на менее занятое, это снижает шум.
Обычно атака повторяется сотни или тысячи раз для статистического подавления шума.

Интересный момент: Spectre можно эксплуатировать и через разные потоки.
L1 и L2 приватны для каждого ядра, поэтому один поток может вызывать спекулятивное исполнение, а другой измерять эффект, если оба работают на одном ядре.

Если вы внимательно читали раздел о кэше: современные процессоры могут использовать inclusive cache, когда одни и те же кэш-линии присутствуют на всех уровнях.
Поскольку L3 общий для всех ядер, теоретически атаку можно провести и между разными ядрами.
Это чисто теоретически, потому что разница между попаданиями в L1 и даже L2 значительна, что сильно снижает процент успеха.
Но теоретически это возможно.

Spectre v1: обход проверки границ (bounds check bypass)

Вы можете спросить: как эту уязвимость вообще можно проэксплуатировать на практике?
В предыдущем примере мы спекулятивно обращались к секретному значению в том же адресном пространстве и том же процессе.
Там можно было просто разыменовать адрес и прочитать данные напрямую, спекулятивное исполнение как будто ни к чему.

Верно, но Spectre можно эксплуатировать и через легитимный код в других процессах.
Настоящая сила Spectre в доступе к данным, которые нам читать не положено.
Например, данным другого процесса или более привилегированного контекста.

Оригинальная статья о Spectre описывает два основных варианта атаки.
Spectre Variant 1 основан на технике обхода проверки границ.
Пример уязвимого кода жертвы:

C:
Expand Collapse Copy
if ( x < array_size )
    y = array[ x ];

Атакующему нужно контролировать значение x и базовый адрес массива.
Во многих уязвимых случаях оба этих значения берутся из указателей или входных данных, на которые атакующий может влиять.
Spectre v2 позволяет атаковать разные процессы, а v1 в этом плане ограничен и крайне сложен в эксплуатации между процессами.

Рассмотрим сценарий атаки на ядро.
Нужно найти похожий уязвимый паттерн кода в ядре, где x и массив зависят от пользовательского ввода, например, аргументов системного вызова.
Атакующий вызывает системный вызов и передаёт указатель на секретное значение, которое хочет прочитать, вместе с probe-массивом.
Если ядро содержит уязвимую проверку границ, оно может спекулятивно выполнить доступ, даже когда x выходит за пределы.
После отката спекуляции архитектурное состояние не изменится, но состояние кэша, да.
Атакующий измеряет время доступа к записям probe-массива и восстанавливает утёкшее значение.

Spectre v2: отравление BTB (branch target injection)

Теперь рассмотрим второй вариант Spectre.
Spectre v2 работает иначе. Вместо манипуляций с проверкой границ он воздействует на то, куда прыгнет процессор.

Отравление Branch Target Buffer

Современные процессоры используют упомянутый ранее Branch Target Buffer (BTB) для предсказания цели косвенных переходов (указателей на функции, виртуальных вызовов).
BTB хранит ранее встреченные адреса переходов и позволяет процессору продолжить выполнение без ожидания фактического вычисления цели.

Если атакующий отравит BTB, он может заставить процессор спекулятивно прыгнуть в контролируемое им место.
Несмотря на последующий откат, это может оставить наблюдаемые следы в кэше, точно как в Spectre v1.

SpeculativeExecutionVulnsWriteup-Page-10.drawio.png


Атака может работать следующим образом.
Сначала нужно убедиться, что наш процесс выполняется на том же ядре, что и другой процесс.
Это необходимо, потому что BTB является общим в пределах одного ядра.
Затем найти конкретный Spectre-гаджет в другом процессе.
Гаджет может выглядеть примерно так:

C:
Expand Collapse Copy
; Атакующий должен контролировать R1 (адрес для чтения)
; Атакующий должен контролировать R2 (база probe-массива)
mov rax, [R1]
mov rbx, [R2 + rax * 0x1000]

После нахождения гаджета нужно определить место в другом процессе, где мы хотим отравить BTB.
Там должен быть косвенный переход, например простой jmp [rax].
В этом месте мы должны контролировать через регистры как адрес обращения к памяти, так и базу probe-массива, используемых нашим гаджетом.
BTB-записи привязаны к адресам памяти соответствующих ветвлений.

Чтобы отравить запись BTB, нужно тренировать тот же переход с тем же адресом, по которому он существует в другом процессе.
После достаточного количества тренировок, другой процесс, выполняя этот переход, может спекулятивно выполнить наш гаджет из-за отравленной BTB-записи.
Во время спекулятивного исполнения атакующий применяет ту же кэш-технику извлечения данных, что и в Spectre v1.
Для атаки на другой процесс нужно контролировать минимум два значения в регистрах, что крайне сложно в реальных условиях.

Важно отметить, что эта атака надёжнее работает между пользовательским и ядерным режимом в рамках одного потока.
Для атаки на ядро нужно найти похожий Spectre-гаджет. Однако регистрами можно управлять проще, через системный вызов.
В ntoskrnl.exe нужно найти функции, вызываемые через syscall из user mode и содержащие косвенные переходы по памяти.
Затем отравить их BTB-запись.
После этого через системный вызов передать через регистры probe-массив и целевой адрес в ядре, который мы хотим прочитать.

Ключевые различия вариантов Spectre

1778693272266.png


Разные версии Spectre показывают лишь разные способы эксплуатации одной и той же кэш-атаки.

Меры защиты

После публикации статьи о Spectre были предложены несколько митигаций.

Spectre v2
Вендоры процессоров выпустили митигации на уровне микрокода: **IBRS**, **IBPB** и **STIBP** для процессоров Intel.

- Indirect Branch Restricted Speculation (IBRS)
Ограничивает использование истории предсказания ветвлений через границы привилегий.
Это не позволяет истории переходов юзермода влиять на спекуляцию в режиме ядра.
- Indirect Branch Predictor Barrier (IBPB)
Сбрасывает состояние предсказателя косвенных переходов при переключении контекста, не давая BTB-состоянию разделяться между процессами.
- Single Thread Indirect Branch Predictors (STIBP)
Изолирует предсказание ветвлений между потоками на системах с SMT, снижая утечку между потоками.

Вместе эти митигации существенно снижают практическую применимость BTB-атак.

Spectre v1
Spectre v1 митигируется иначе, он не полагается на отравление предсказателя, а эксплуатирует спекулятивное исполнение за проверкой границ.
Митигации реализованы преимущественно на уровне компиляторов и программного обеспечения.

- Барьеры спекуляции (LFENCE)
Компиляторы могут вставлять инструкции вроде LFENCE после проверок границ.
Эта инструкция заставляет процессор остановить спекулятивное выполнение, гарантируя разрешение предшествующих условий до последующих обращений к памяти.
- Маскирование индексов
Вместо того чтобы полагаться исключительно на условные ветвления, индексы маскируются так, чтобы гарантированно оставаться в допустимых границах, даже при спекулятивном исполнении.

Современные компиляторы могут автоматически вставлять такие митигации в чувствительных участках кода, особенно при работе с ненадёжным вводом.

2.2 Разбор Meltdown

Meltdown это уязвимость, раскрытая одновременно со Spectre, но принципиально иная по механизму.
В отличие от Spectre, который манипулирует предсказанием ветвлений, Meltdown эксплуатирует изъян в том, как процессоры обрабатывают права доступа к памяти во время out-of-order выполнения.
Конкретно: Meltdown разрушает изоляцию между пользовательским пространством и памятью ядра.
Он позволяет процессу в usermode читать привилегированную память, например память ядра, которая в норме недоступна.

Хотя процессор в итоге обнаруживает незаконный доступ и генерирует исключение, данные уже могут быть спекулятивно загружены и оставить наблюдаемые следы в кэше.
Это позволяет утечь чувствительную память ядра из user space и даже выйти за границы виртуальной машины, что довольно интересно.

Маппинг памяти

Первый важный фактор, стоящий за уязвимостью это то, как маппируется память ядра.
Память ядра отображается в каждом пользовательском процессе, тогда как пользовательская память не маппируется в ядро аналогичным образом.
Это сделано из-за производительности.
Когда пользовательский поток выполняет системный вызов и переходит в режим ядра, переотображение памяти ядра при каждом переходе создавало бы небольшие накладные расходы.
Чтобы этого избежать, ядро маппируется в каждый пользовательский процесс с самого начала.

Тогда почему нельзя просто обратиться к памяти ядра из usermode?
Из-за механизма страничной адресации памяти.

SpeculativeExecutionVulnsWriteup-Page-9.drawio.png


Виртуальная память это абстракция поверх физической памяти.
При обращении к виртуальному адресу процессору сначала нужно его транслировать в физический.
Эту трансляцию выполняет Memory Management Unit (MMU), обходя таблицы страниц чтобы найти физический адрес.

В большинстве систем трансляция виртуального адреса проходит через четыре уровня таблиц страниц: PML4, PDPT, PD и Page Table (PT).
На нижнем уровне Page Table содержит записи, называемые Page Table Entries (PTE).
Каждая PTE хранит информацию о физической странице: её адрес и права доступа.

SpeculativeExecutionVulnsWriteup-Page-8.drawio.png


PTE содержит 64 бита информации, но нас интересует бит User/Supervisor (U/S).
Этот бит определяет уровень доступа к странице:

- Если бит равен 0 страница помечена как только для супервизора (режим ядра).
- Если бит равен 1 страница доступна из user mode.

Когда пользовательский поток пытается обратиться к странице ядра, MMU проверяет этот бит при трансляции адреса.
Если страница недоступна для user mode, процессор генерирует fault и блокирует доступ.
Вот почему, несмотря на то что память ядра маппируется в адресное пространство процесса, из user mode к ней напрямую не добраться.
Meltdown нарушает эту гарантию, позволяя процессору временно получить доступ к данным до того, как проверка прав полностью завершится.

Out-of-Order Execution

Критический фактор, делающий Meltdown возможным это то, как процессоры обрабатывают обращения к памяти во время out-of-order выполнения.
На затронутых процессорах Intel процессор может выдать загрузку из памяти и продолжить выполнять следующие инструкции до того, как проверка прав полностью завершится.
Иными словами, данные могут быть выбраны и временно использованы, прежде чем процессор убедится, что доступ разрешён.
Если доступ окажется незаконным, процессор генерирует исключение и откатывает архитектурное состояние.
Однако любые микроархитектурные побочные эффекты, такие как изменения состояния кэша, не откатываются.
Это позволяет атакующему спекулятивно получить доступ к привилегированным данным и затем извлечь их с помощью кэш-атаки по сторонним каналам, аналогично Spectre.

Вы можете спросить: подождите, разве Out-of-Order execution не должен следовать исходному порядку программы?
Да, и на большинстве процессоров так и есть.
Проблема была специфична для Intel.
Их OoO-движок выполнял проверку прав уже после того, как обращение к памяти произошло, позволяя спекулятивному исполнению использовать значение из памяти и оставлять следы в кэше до того, как нарушение вообще было зафиксировано.
Именно это авторы Meltdown назвали первопричиной.

Proof Of Concept

C:
Expand Collapse Copy
byte_t probe[ 256 * 0x1000 ];

/* Вытесняем probe-массив из кэша */
for ( int i = 0; i < 256; i++ )
    _mm_clflush( &probe[ i * 0x1000 ] );

/* Читаем память ядра и используем значение для обращения к probe-массиву */
probe[ *(byte_t*)kernel_addr * 0x1000 ] += 1;

/* Измеряем время доступа */
for ( int i = 0; i < 256; i++ )
{
    uint64_t start = __rdtscp();

    temp = probe[ i * 0x1000 ];

    uint64_t end = __rdtscp();
    uint64_t access_time = end - start;

    if ( access_time < 80 )
        printf("Fast access at index %d (%llu cycles)\n", i, access_time);
}

В этом примере используется та же техника probe-массива.
Сначала подготавливаем массив и вытесняем его из кэша.
Затем ключевой шаг: пытаемся прочитать байт из привилегированного адреса ядра и использовать его значение как индекс в probe-массиве.
Хотя это обращение к памяти недопустимо и в конечном счёте вызовет исключение, процессор может спекулятивно выполнить этот код:

C:
Expand Collapse Copy
probe[ *(byte_t*)kernel_addr * 0x1000 ] += 1;

Во время спекулятивного выполнения значение по адресу из ядра используется как индекс и выбирает кэш-линию в probe-массиве.
После исключения мы измеряем время доступа и восстанавливаем утёкший байт по кэш-эффектам.
В отличие от Spectre, Meltdown не зависит от предсказания ветвлений и тренировки.
Он напрямую эксплуатирует тот факт, что загрузки из памяти могут обходить проверку прав.
В результате атакующий может дампить всю память ядра из usermode, используя ту же кэш атаку как в Spectre.
В некоторых сценариях эту уязвимость можно использовать и для прорыва изоляции между виртуальными машинами, в зависимости от того, как маппируется память хоста.

Меры защиты

После раскрытия Meltdown были выпущены несколько митигаций.

KAISER изначально был предложен как защита от атак на KASLR (Address Space Layout Randomization) ядра.
Его основная идея это изолировать память ядра от пользовательских процессов, убрав её из их адресного пространства.
После раскрытия Meltdown выяснилось, что KAISER также эффективно митигирует эту уязвимость, поскольку Meltdown опирается на присутствие маппингов ядра в пользовательском пространстве.
KPTI (Kernel Page Table Isolation) это практическая реализация идеи KAISER, принятая современными операционными системами.
Она разделяет таблицы страниц пользователя и ядра, гарантируя, что память ядра не маппируется во время работы в usermode.

4. Выводы

В этой статье я хотел показать не просто две уязвимости. Я хотел показать кое-что глубже.
Для меня Spectre это не просто уязвимость. Это провал в дизайне.
Точка, где оптимизация процессора зашла слишком далеко, где погоня за производительностью начала перевешивать гарантии безопасности.

Как мы видели, некоторые атаки можно исправить.
Spectre v2 и Meltdown можно исправить обновлениями микрокода и архитектурными изменениями.
Но саму идею Spectre, спекулятивное исполнение, нельзя просто «запатчить». Это фундамент производительности современных процессоров.

Именно это делает Spectre особенным.
Это не баг в конкретной реализации. Это следствие архитектурных решений, принимавшихся на протяжении десятилетий.
Компромисс, который всегда существовал, но стал виден только тогда, когда кто-то посмотрел достаточно внимательно.
В этом смысле Spectre не уязвимость. Это граница. Точка, где оптимизации производительности нарушают гарантии безопасности.
И когда эта граница пройдена, пути назад нет.

Оригинальная идея

Моей первоначальной идеей после прочтения о Spectre было использовать его как технику обфускации.
Поскольку спекулятивное исполнение невидимо на программном уровне, я хотел создать нечто вроде состояния суперпозиции (как в квантовой физике) выполнения, архитектурного состояния, которое просто нельзя определить или зафиксировать на программном уровне.
После нескольких экспериментов, однако, стало ясно, что идея не сработает. Поэтому я решил написать о самих уязвимостях.

Если говорить об экспериментах: если поставить hardware брейкпоинт внутри спекулятивно исполняемого кода, процессор обрабатывает его так как и при обычном выполнении и вызывает обработчик.
Одного этого оказалось достаточно, чтобы убить мою идею, хотя пару других обстоятельств тоже внесли свой вклад.



Конец

И как всегда, спасибо за чтение. Надеюсь, вы узнали что-то новое.

Источники

Эта статья не была бы возможена без материала следующих авторов.
Их работы значительно облегчили мне понимание концепций и их изложение в этой статье.

-
Пожалуйста, авторизуйтесь для просмотра ссылки.

-
Пожалуйста, авторизуйтесь для просмотра ссылки.

-
Пожалуйста, авторизуйтесь для просмотра ссылки.

-
Пожалуйста, авторизуйтесь для просмотра ссылки.
 
Назад
Сверху Снизу