- 240
- 248
- 21 Апр 2017
Как работает процессор
Последние десятилетия, начиная с 1992 года, когда появился первый Pentium, Intel развивала суперскалярную архитектуру своих процессоров. Суть в том, что компании очень хотелось сделать процессоры быстрее, сохраняя при этом обратную совместимость. В итоге современные процессоры — это очень сложная конструкция. Просто представьте себе: компилятор изо всех сил трудится и упаковывает инструкции так, чтобы они исполнялись в один поток, а процессор внутри себя дербанит код на отдельные инструкции, и начинает исполнять их параллельно, если это возможно, при этом ещё и переупорядочивает их. А всё из-за того, что аппаратных блоков для исполнения команд в процессоре много, каждая же инструкция обычно задействует только один их них. Подливает масла в огонь и то, что тактовая частота процессоров росла сильно быстрее, чем скорость работы оперативной памяти, что привело к появлению кешей 1, 2 и 3 уровней. Сходить в оперативную память стоит больше 100 процессорных тактов, сходить в кэш 1 уровня — уже единицы, исполнить какую нибудь простую арифметическую операцию типа сложения — пара тактов.
В итоге, пока одна инструкция ждёт получения данных из памяти, освобождения блока работы с floating point, ну или ещё чего нибудь, процессор спекулятивно отрабатывает следующие. Современные процессоры могут таким образом параллельно обрабатывать порядка сотни инструкций (97 в Sky Lake, если быть точным). Каждая такая инструкция работает со своими копиями регистров (это происходит в reservation station), и они, в момент исполнения, друг на друга не влияют. После того, как инструкция выполнена, процессор пытается выстроить результат их выполнения в линию в блоке retirement, как если бы всей этой магии суперскалярности не было (компилятор то про неё ничего не знает и думает, что там последовательное исполнение команд — помните об этом?). Если по какой-то причине процессор решит, что инструкция выполнена неправильно, например, потому, что использовала значение регистра, которое на самом деле изменила предыдущая инструкция, то текущая инструкция будет просто выкинута. То же самое происходит и при изменении значения в памяти, или если предсказатель переходов ошибся.
Кстати, тут должно стать понятно, как работает гипертрединг — добавляем второй Register allocation table, и второй блок Retirement register file — и вуаля, у нас уже как бы два ядра, практически бесплатно.
Память в Linux
В 64-битном режиме работы у каждого приложения есть свой выделенный кусочек доступной для чтения и записи памяти, который собственно и является userspace памятью. Однако, на самом деле память ядра тоже присутствует в адресном пространстве процесса (подозреваю, что сделано было с целью повышения производительности работы сисколов), но защищена от доступа из пользовательского кода. Если он попытается обратиться к этой памяти — получит ошибку, это работает на уровне процессора и его колец защиты.
Side-channel атаки
Когда не получается прочитать какие либо данные, можно попробовать воспользоваться побочными эффектами от работы объекта атаки. Классический пример: измеряя с высокой точностью потребление электричества можно различить операции, которые выполняет процессор, именно так был взломан чип для автосигнализаций KeeLoq. В случае Meltdown таким побочным каналом является время чтения данных. Если байт данных, содержится в кэше, то он будет прочитан намного быстрее, чем если он будет вычитываться из оперативной памяти и загружаться в кэш.
Соединяем эти знания вместе
Собственно, суть атаки то очень проста и достаточно красива:
Таким образом, объектом атаки является микроархитектура процессора, и саму атаку в софте не починить.
Код атаки
Теперь по шагам, как это работает.
mov al, byte [rcx] — собственно чтение по интересующему атакующего адресу, заодно вызывает исключение. Важный момент заключается в том, что исключение обрабатывается не в момент чтения, а несколько позже.
shl rax, 0xc — зачение умножается на 4096 для того, чтобы избежать сложностей с механизмом загрузки данных в кэш
mov rbx, qword [rbx + rax] — "запоминание" прочитанного значения, этой строкой прогревается кэш
retry и jz retry нужны из-за того, что обращение к началу массива даёт слишком много шума и, таким образом, извлечение нулевых байтов достаточно проблематично. Честно говоря, я не особо понял, зачем так делать — я бы просто к rax прибавил единичку сразу после чтения, да и всё. Важный момент заключается в том, что этот цикл, на самом деле, не бесконечный. Уже первое чтение вызывает исключение
Как пофиксили в Linux
Достаточно прямолинейно — стали выключать отображение страниц памяти ядра в адресное пространство процесса. В результате на каждый вызов сискола переключение контекста стало дороже, отсюда и падение производительности до 1.5 раз.
Последние десятилетия, начиная с 1992 года, когда появился первый Pentium, Intel развивала суперскалярную архитектуру своих процессоров. Суть в том, что компании очень хотелось сделать процессоры быстрее, сохраняя при этом обратную совместимость. В итоге современные процессоры — это очень сложная конструкция. Просто представьте себе: компилятор изо всех сил трудится и упаковывает инструкции так, чтобы они исполнялись в один поток, а процессор внутри себя дербанит код на отдельные инструкции, и начинает исполнять их параллельно, если это возможно, при этом ещё и переупорядочивает их. А всё из-за того, что аппаратных блоков для исполнения команд в процессоре много, каждая же инструкция обычно задействует только один их них. Подливает масла в огонь и то, что тактовая частота процессоров росла сильно быстрее, чем скорость работы оперативной памяти, что привело к появлению кешей 1, 2 и 3 уровней. Сходить в оперативную память стоит больше 100 процессорных тактов, сходить в кэш 1 уровня — уже единицы, исполнить какую нибудь простую арифметическую операцию типа сложения — пара тактов.
В итоге, пока одна инструкция ждёт получения данных из памяти, освобождения блока работы с floating point, ну или ещё чего нибудь, процессор спекулятивно отрабатывает следующие. Современные процессоры могут таким образом параллельно обрабатывать порядка сотни инструкций (97 в Sky Lake, если быть точным). Каждая такая инструкция работает со своими копиями регистров (это происходит в reservation station), и они, в момент исполнения, друг на друга не влияют. После того, как инструкция выполнена, процессор пытается выстроить результат их выполнения в линию в блоке retirement, как если бы всей этой магии суперскалярности не было (компилятор то про неё ничего не знает и думает, что там последовательное исполнение команд — помните об этом?). Если по какой-то причине процессор решит, что инструкция выполнена неправильно, например, потому, что использовала значение регистра, которое на самом деле изменила предыдущая инструкция, то текущая инструкция будет просто выкинута. То же самое происходит и при изменении значения в памяти, или если предсказатель переходов ошибся.
Кстати, тут должно стать понятно, как работает гипертрединг — добавляем второй Register allocation table, и второй блок Retirement register file — и вуаля, у нас уже как бы два ядра, практически бесплатно.
Память в Linux
В 64-битном режиме работы у каждого приложения есть свой выделенный кусочек доступной для чтения и записи памяти, который собственно и является userspace памятью. Однако, на самом деле память ядра тоже присутствует в адресном пространстве процесса (подозреваю, что сделано было с целью повышения производительности работы сисколов), но защищена от доступа из пользовательского кода. Если он попытается обратиться к этой памяти — получит ошибку, это работает на уровне процессора и его колец защиты.
Side-channel атаки
Когда не получается прочитать какие либо данные, можно попробовать воспользоваться побочными эффектами от работы объекта атаки. Классический пример: измеряя с высокой точностью потребление электричества можно различить операции, которые выполняет процессор, именно так был взломан чип для автосигнализаций KeeLoq. В случае Meltdown таким побочным каналом является время чтения данных. Если байт данных, содержится в кэше, то он будет прочитан намного быстрее, чем если он будет вычитываться из оперативной памяти и загружаться в кэш.
Соединяем эти знания вместе
Собственно, суть атаки то очень проста и достаточно красива:
- Сбрасываем кэш процессора.
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 раз.