Разбираемся в работе Meltdown

  • Автор темы Tayler
  • Дата начала
  • Просмотров 5171 Просмотров

Tayler

Резидент
240
248
21 Апр 2017
Как работает процессор

Последние десятилетия, начиная с 1992 года, когда появился первый Pentium, Intel развивала суперскалярную архитектуру своих процессоров. Суть в том, что компании очень хотелось сделать процессоры быстрее, сохраняя при этом обратную совместимость. В итоге современные процессоры — это очень сложная конструкция. Просто представьте себе: компилятор изо всех сил трудится и упаковывает инструкции так, чтобы они исполнялись в один поток, а процессор внутри себя дербанит код на отдельные инструкции, и начинает исполнять их параллельно, если это возможно, при этом ещё и переупорядочивает их. А всё из-за того, что аппаратных блоков для исполнения команд в процессоре много, каждая же инструкция обычно задействует только один их них. Подливает масла в огонь и то, что тактовая частота процессоров росла сильно быстрее, чем скорость работы оперативной памяти, что привело к появлению кешей 1, 2 и 3 уровней. Сходить в оперативную память стоит больше 100 процессорных тактов, сходить в кэш 1 уровня — уже единицы, исполнить какую нибудь простую арифметическую операцию типа сложения — пара тактов.


e52a215798ea61792c7ba8925b9b2a99.svg

В итоге, пока одна инструкция ждёт получения данных из памяти, освобождения блока работы с floating point, ну или ещё чего нибудь, процессор спекулятивно отрабатывает следующие. Современные процессоры могут таким образом параллельно обрабатывать порядка сотни инструкций (97 в Sky Lake, если быть точным). Каждая такая инструкция работает со своими копиями регистров (это происходит в reservation station), и они, в момент исполнения, друг на друга не влияют. После того, как инструкция выполнена, процессор пытается выстроить результат их выполнения в линию в блоке retirement, как если бы всей этой магии суперскалярности не было (компилятор то про неё ничего не знает и думает, что там последовательное исполнение команд — помните об этом?). Если по какой-то причине процессор решит, что инструкция выполнена неправильно, например, потому, что использовала значение регистра, которое на самом деле изменила предыдущая инструкция, то текущая инструкция будет просто выкинута. То же самое происходит и при изменении значения в памяти, или если предсказатель переходов ошибся.


Кстати, тут должно стать понятно, как работает гипертрединг — добавляем второй Register allocation table, и второй блок Retirement register file — и вуаля, у нас уже как бы два ядра, практически бесплатно.


Память в Linux

В 64-битном режиме работы у каждого приложения есть свой выделенный кусочек доступной для чтения и записи памяти, который собственно и является userspace памятью. Однако, на самом деле память ядра тоже присутствует в адресном пространстве процесса (подозреваю, что сделано было с целью повышения производительности работы сисколов), но защищена от доступа из пользовательского кода. Если он попытается обратиться к этой памяти — получит ошибку, это работает на уровне процессора и его колец защиты.


Side-channel атаки

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


Соединяем эти знания вместе

Собственно, суть атаки то очень проста и достаточно красива:
  1. Сбрасываем кэш процессора.
    char userspace_array[256*4096];
    for (i = 0; i < 256*4096; i++) {
    _mm_clflush(&userspace_array);
    }
    [*]Читаем интересную нам переменную из адресного пространства ядра, это вызовет исключение, но оно обработается не сразу.
    const char* kernel_space_ptr = 0xBAADF00D;
    char tmp = *kernel_space_ptr;
    [*]Спекулятивно делаем чтение из массива, который располагается в нашем, пользовательском адресном пространстве, на основе значения переменной из пункта 2.
    char not_used = userspace_array[tmp * 4096];
    [*]Последовательно читаем массив и аккуратно замеряем время доступа. Все элементы, кроме одного, будут читаться медленно, а вот элемент, который соответствует значению по недоступному нам адресу — быстро, потому что он уже попал в кэш.
    for (i = 0; i < 256; i++) {
    if (is_in_cache(userspace_array[i*4096])) {
    // Got it! *kernel_space_ptr == i
    }
    }

Таким образом, объектом атаки является микроархитектура процессора, и саму атаку в софте не починить.


Код атаки

Код:
; rcx = kernel address
; rbx = probe array
retry:
mov al, byte [rcx]
shl rax, 0xc
jz retry
mov rbx, qword [rbx + rax]

Теперь по шагам, как это работает.
mov al, byte [rcx] — собственно чтение по интересующему атакующего адресу, заодно вызывает исключение. Важный момент заключается в том, что исключение обрабатывается не в момент чтения, а несколько позже.
shl rax, 0xc — зачение умножается на 4096 для того, чтобы избежать сложностей с механизмом загрузки данных в кэш
mov rbx, qword [rbx + rax] — "запоминание" прочитанного значения, этой строкой прогревается кэш
retry и jz retry нужны из-за того, что обращение к началу массива даёт слишком много шума и, таким образом, извлечение нулевых байтов достаточно проблематично. Честно говоря, я не особо понял, зачем так делать — я бы просто к rax прибавил единичку сразу после чтения, да и всё. Важный момент заключается в том, что этот цикл, на самом деле, не бесконечный. Уже первое чтение вызывает исключение


Как пофиксили в Linux

Достаточно прямолинейно — стали выключать отображение страниц памяти ядра в адресное пространство процесса. В результате на каждый вызов сискола переключение контекста стало дороже, отсюда и падение производительности до 1.5 раз.