Admin
Администратор
Как простой баг повреждения памяти ядра Linux приводит к полной компрометации системы(Часть 2)
Защита против примитивов эксплойтов: уменьшение примитивов атак при помощи ограничений syscall
Так как быстрые пути выполнения распределителя (и в SLUB, и в распределителе страниц) реализованы при помощи структур данных для каждого ЦП, простота и надёжность эксплойтов, стремящихся заставить распределитель памяти ядра перераспределить память конкретным образом, можно повысить, если нападающий имеет полный контроль над назначением потоков эксплойта ядрам ЦП. Я называю такую способность, позволяющую облегчить эксплойт влиянием на состояние/поведение соответствующей системы, «примитивом атаки». К счастью для нас, Linux позволяет задачам прикрепляться к конкретным ядрам ЦП без требования привилегий при помощи системного вызова sched_setaffinity().(Ещё один пример: примитив, дающий нападающему довольно мощные возможности — это возможность создавать бессрочные сбои простоя ядра по адресам пользовательского пространства при помощи FUSE или userfaultfd.)
Как и в случае с описанным в разделе «Уменьшение поверхности атаки», возможности нападающего по использованию таких примитивов можно уменьшить благодаря фильтрации системных вызовов; но хотя механизм и проблемы совместимости в этих случаях схожи, всё остальное достаточно сильно различается:
Уменьшение примитивов атак в обычном случае не может надёжно защитить от эксплойта бага; нападающий иногда может получить схожий, но менее удобный (более сложный, менее надёжный/обобщённый...) примитив косвенно, например:
- Вместо использования sched_setaffinity() нападающий может попытаться запустить несколько потоков, заставить их опрашивать getcpu(), чтобы понять, в каких ядрах они выполняются, а затем соответствующим образом распределить нагрузку по потокам.
- Вместо откладывания ошибок отсутствия страниц при помощи FUSE или userfaultfd может злонамеренно использовать несмежные сопоставления файлов и поведение планировщика.
Уменьшение примитивов атаки ограничивает доступ к коду, который подозревается в том, что предоставляет, или точно предоставляет примитивы для эксплойтов (иногда очень конкретные). Например, можно решить запретить доступ конкретно к FUSE и userfaultfd для основной части кода, потому что они удобны для эксплойтов ядра, а если один из этих интерфейсов действительно нужен, спроектировать обходной путь, избегающий раскрытия примитива атаки в пользовательское пространство. Это отличается от уменьшения поверхности атаки, когда часто бывает логично ограничивать доступ к любой функции, которую захочет использовать допустимая рабочая нагрузка.
Хорошим примером уменьшения примитивов атак является vm.unprivileged_userfaultfd sysctl, изначально введённый для того, чтобы userfaultfd стал полностью недоступным для обычных пользователей, а позже был модифицирован, чтобы пользователям можно было предоставить часть функциональности, не выдавая опасного примитива атаки. (Но если вы можете создавать пользовательские пространства имён без привилегий, то всё равно можете использовать FUSE для достижения эквивалентного эффекта.)
В случае реализации поддержки списков допустимых системных вызовов для находящегося в песочнице системного компонента или чего-то подобного правильно будет явным образом отслеживать явно запрещённые системные вызовы для уменьшения примитивов атак; в противном случае кто-нибудь может случайно разрешить их в будущем. (Думаю, это схоже с проблемами, с которыми может столкнуться разработчик при поддержке ACL...)
Но аналогично действиям из предыдущего раздела, уменьшение примитивов атак тоже склонно к отключению доступности части функциональности, поэтому может быть применимо не во всех ситуациях. Например, новые версии Android намеренно косвенно дают приложениям доступ к FUSE через механизм AppFuse. (Это API на самом деле не предоставляет прямого доступа к /dev/fuse, однако перенаправляет запросы на чтение/запись приложению.)
Защита от оракулов, связанных с oops: захват или паника при сбое
Способность восстанавливаться после oops ядра в эксплойте может помочь нападающему компенсировать нехватку информации о состоянии системы. При определённых условиях она даже может служить в качестве «двоичного оракула», который можно с большим или меньшим удобством использовать для двоичного поиска значения или чего-то подобного.(В некоторых дистрибутивах ситуация была ещё хуже, если для непривилегированных пользователей был доступен dmesg; так что если вам удавалось вызвать oops или WARN, вы могли получить состояния регистров но всех фреймах IRET в стеке ядра; это можно использовать для утечки такой информации, как указатели ядра. К счастью, сегодня большинство дистрибутивов, в том числе и Ubuntu 20.10, ограничивает доступ к dmesg.)
В настоящее время Android и Chrome OS устанавливают флаг panic_on_oops, означающий, что машина сразу же перезапускается после того, как произойдёт oops ядра. Это усложняет использование oops в качестве части эксплойта и логичнее с точки зрения надёжности — система какое-то время будет отключена и потеряет существующее состояние, однако сбросится в точно хорошее состояние вместо того, чтобы продолжать работу в потенциально поломанном состоянии, особенно если сбойный поток содержит мьютексы, которые никогда бы больше не были освобождены, или нечто подобное. С другой стороны, если происходит сбой какого-то сервиса в десктопной системе, это, вероятно, не должно приводить к немедленному отключению всей системы и потере несохранённого состояния, поэтому panic_on_oops может быть в этом случае слишком радикальным решением.
Для качественного решения этой проблемы может потребоваться более тонкий подход. (Например, grsecurity уже долгое время имеет возможность захвата конкрентых UID, вызвавших сбои.) Возможно, будет логично позволить демону init использовать разные политики для сбоев в разных сервисах/сессиях/UID?
Против доступа UAF: детерминированное устранение UAF
Защита, которая надёжно бы предотвратила эксплойт этой проблемы, заключалась бы в детерминированном устранении возможности использования освобождённой памяти. Такое решение надёжно защитило бы ранее занятую объектом память от доступов через висящие указатели на объект, по крайней мере после того, как память была использована для другой цели (в том числе и для хранения метаданных кучи). В случае операций записи для этого, вероятно, потребуется или атомарность проверок доступа и самой записи, или отложенный механизм освобождения наподобие RCU. Для простых операций чтения это также может быть реализовано выполнением проверки доступа после чтения, но перед использованием считанного значения.Серьёзный недостаток такого подхода самого по себе заключается в том, что дополнительные проверки при каждом доступе памяти, вероятно, чрезвычайно сильно снизят эффективность, особенно если процесс устранения не может делать никаких допущений о том, какие виды параллельного доступа могут происходит с объектом или какую семантику имеют указатели. (В реализации proof-of-concept, которую я демонстрировал на LSSNA 2020 (слайды, видеозапись) увеличение нагрузки на ЦП составляет приблизительно 60%-159% в активно задействующих ядро бенчмарках и около 8% в активно задействующих пользовательское пространство бенчмарках.)
К сожалению, даже детерминированного устранения use-after-free часто бывает недостаточно для детерминированного ограничения радиуса поражения чего-то наподобие ошибки с refcount для объекта, с которым она произошла. Рассмотрим случай, когда два пути исполнения кода параллельно работают с одним объектом: Путь A предполагает, что объект жив и подвержен обычным правилам блокировки. Путь B знает, что количество ссылок достигло нуля, предполагая, что он, следовательно, имеет эксклюзивный доступ к объекту (то есть все элементы мутабельны без ограничений блокировок), и пытается удалить объект. Затем Путь B может начать удалять ссылки, которые объект хранил на другие объекты, а Путь A будет следовать по тем же ссылкам. Далее это может привести к использованию освобождённой памяти для объектов, на которых показывают указатели. Если одному и тому же процессу исправления подвергаются все структуры данных, это может и не быть особой проблемой; но если некоторые структуры данных (например, struct page) незащищены, это может допустить обход процесса исправления.
Подобные проблемы относятся и к структурам данных с элементами union, используемыми в других состояниях объектов; например, вот некая произвольная структура данных ядра с rcu_head в union (просто произвольный пример; насколько я знаю, в этом коде нет никаких ошибок):
Код:
struct allowedips_node {
struct wg_peer __rcu *peer;
struct allowedips_node __rcu *bit[2];
/* While it may seem scandalous that we waste space for v4,
* we're alloc'ing to the nearest power of 2 anyway, so this
* doesn't actually make a difference.
*/
u8 bits[16] __aligned(__alignof(u64));
u8 cidr, bit_at_a, bit_at_b, bitlen;
/* Keep rarely used list at bottom to be beyond cache line. */
union {
struct list_head peer_list;
struct rcu_head rcu;
};
};
Если всё работает правильно, элемент peer_list используется только когда объект жив, а элемент rcu используется только тогда, когда запланировано отложенное освобождение объекта; поэтому с кодом всё в порядке. Однако если баг каким-то образом заставит выполнять считывание peer_list после инициализации элемента rcu, результатом станет несоответствие типов (type confusion).
На мой взгляд, это демонстрирует, что хотя способы устранения UAF имеют большую ценность (и позволяют надёжно предотвращать эксплуатацию этого конкретного бага), use-after-free — это просто одно из возможных последствий класса симптомов «запутанности состояния объектов» (который не всегда будет таким же, как класс багов первопричины проблемы). Будет ещё лучше наложить правила на состояния объектов и гарантировать то, что к объекту, например, больше нельзя было получить доступ через ссылку, подвергнутую атаке refcount после того, как refcount достиг нуля и логично переведён в состояние «отличные от RCU элементы, которыми эксклюзивно владеет поток, выполняющий удаление», «ожидается обратный вызов RCU, отличные от RCU неинициализированы» или «выполняющему удаление потоку дан эксклюзивный доступ к защищённым RCU элементам, другие элементы неинициализированы». Разумеется, применение такого способа устранения проблемы во время выполнения будет затратнее и запутаннее, чем надёжное устранение UAF; такой уровень защиты, вероятно, реалистичен только с определённым уровнем аннотаций и статической валидации.
Защита от доступа UAF: вероятностное устранение UAF; утечки указателей
Краткое описание: некоторые типы вероятностных защит от UAF ломаются, если нападающий может организовать утечку информации о значениях указателей; а информация о значениях указателей легко утекает в пользовательское пространство, например, через сравнение указателей в структурах наподобие map/set.Если детерминированное устранение UAF слишком затратно, альтернативой может быть вероятностное решение; например, пометка указателей небольшим количеством бит, проверяемым при доступе относительно метаданных объекта, а затем изменение метаданных этого объекта при освобождении объектов.
Недостаток этого подхода в том, что для разрушения защиты можно использовать утечки информации. Для примера я хочу рассказать об одном типе утечки информации (без каких-либо оценок его относительной важности по сравнению с другими типами утечек информации) — намеренных сравнениях указателей, которые являются многогранным процессом.
Относительно простым примером того, где он может стать проблемой, является системный вызов kcmp(). Этот системный вызов сравнивает два объекта ядра при помощи арифметического сравнения их пермутированных указателей (при помощи рандомизируемой при каждой загрузке пермутации, см. kptr_obfuscate()) и возвращает результат сравнения (меньше, равно или больше). Это позволяет пользовательскому пространству упорядочивать идентификаторы объектов ядра (например, дескрипторы файлов) на основе идентификации этих объектов ядра (например, экземпляров struct file), что, в свою очередь, позволяет пользовательскому пространству группировать множество таких идентификаторов при помощи поддерживающего объекта ядра стандартным алгоритмом сортировки за время O(n*log
Этот системный вызов можно злонамеренно использовать для повышения надёжности эксплойтов use-after-free против некоторых типов структур, потому что он проверяет равенство двух указателей на объекты ядра без доступа к этим объектам: нападающий может распределить объект, каким-то образом создать ссылку на неправильно подсчитанный объект, освободить объект, перераспределить его, а затем проверить, использовало ли перераспределение тот же адрес, при помощи kcmp() сравнив висящую ссылку и ссылку на новый объект. Если kcmp() содержит в сравнении биты метки указателя, это это с большой вероятностью также позволит разрушать вероятностные защиты против UAF.
По сути, та же проблема возникает, когда указатель ядра шифруется и передаётся в пользовательское пространство в fuse_lock_owner_id(), который шифрует указатель в files_struct в версию XTEA с открытым кодом, прежде чем передать его демону FUSE.
В обоих этих случаях приемлемым обходным решением будет явное вырезание битов меток, потому что указатель без битов метки по-прежнему уникальным образом идентифицирует область памяти; И учитывая, что это очень конкретные интерфейсы, намеренно раскрывающие определённую долю информации об указателях ядра в пользовательское пространство, будет логично изменить этот код вручную.
Более интересным примером поведения является этот фрагмент кода пользовательского пространства:
Код:
#define _GNU_SOURCE
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <sys/resource.h>
#include <err.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sched.h>
#define SYSCHK(x) ({ \
typeof(x) __res = (x); \
if (__res == (typeof(x))-1) \
err(1, "SYSCHK(" #x ")"); \
__res; \
})
int main(void) {
struct rlimit rlim;
SYSCHK(getrlimit(RLIMIT_NOFILE, &rlim));
rlim.rlim_cur = rlim.rlim_max;
SYSCHK(setrlimit(RLIMIT_NOFILE, &rlim));
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
SYSCHK(sched_setaffinity(0, sizeof(cpuset), &cpuset));
int epfd = SYSCHK(epoll_create1(0));
for (int i=0; i<1000; i++)
SYSCHK(eventfd(0, 0));
for (int i=0; i<192; i++) {
int fd = SYSCHK(eventfd(0, 0));
struct epoll_event event = {
.events = EPOLLIN,
.data = { .u64 = i }
};
SYSCHK(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event));
}
char cmd[100];
sprintf(cmd, "cat /proc/%d/fdinfo/%d", getpid(), epfd);
system(cmd);
}
Сначала он создаёт множество неиспользуемых eventfd. Затем он создаёт ещё больше eventfd и контрольные значения epoll для них (в порядке создания), с монотонно увеличивающимся счётчиком поля «data». После этого он просит ядро вывести текущее состояние экземпляра epoll, содержащее список всех зарегистрированных контрольных значений epoll, в том числе и значение элемента data (в шестнадцатеричном виде). Но как сортируется этот список? Вот результат выполнения этого кода на виртуальной машине с Ubuntu 20.10 (урезанный, потому что он немного длинный):
Код:
user@ubuntuvm:~/epoll_fdinfo$ ./epoll_fdinfo
pos: 0
flags: 02
mnt_id: 14
tfd: 1040 events: 19 data: 24 pos:0 ino:2f9a sdev:d
tfd: 1050 events: 19 data: 2e pos:0 ino:2f9a sdev:d
tfd: 1024 events: 19 data: 14 pos:0 ino:2f9a sdev:d
tfd: 1029 events: 19 data: 19 pos:0 ino:2f9a sdev:d
tfd: 1048 events: 19 data: 2c pos:0 ino:2f9a sdev:d
tfd: 1042 events: 19 data: 26 pos:0 ino:2f9a sdev:d
tfd: 1026 events: 19 data: 16 pos:0 ino:2f9a sdev:d
tfd: 1033 events: 19 data: 1d pos:0 ino:2f9a sdev:d
[...]
Здесь поле data: — это индекс цикла, который мы храним в элементе .data, отформатированный в шестнадцатеричном виде. Вот полный список значений data в десятеричном виде:
Код:
36, 46, 20, 25, 44, 38, 22, 29, 30, 45, 33, 28, 41, 31, 23, 37, 24, 50, 32, 26, 21, 43, 35, 48, 27, 39, 40, 47, 42, 34, 49, 19, 95, 105, 111, 84, 103, 97, 113, 88, 89, 104, 92, 87, 100, 90, 114, 96, 83, 109, 91, 85, 112, 102, 94, 107, 86, 98, 99, 106, 101, 93, 108, 110, 12, 1, 14, 5, 6, 9, 4, 17, 7, 13, 0, 8, 2, 11, 3, 15, 16, 18, 10, 135, 145, 119, 124, 143, 137, 121, 128, 129, 144, 132, 127, 140, 130, 122, 136, 123, 117, 131, 125, 120, 142, 134, 115, 126, 138, 139, 146, 141, 133, 116, 118, 66, 76, 82, 55, 74, 68, 52, 59, 60, 75, 63, 58, 71, 61, 53, 67, 54, 80, 62, 56, 51, 73, 65, 78, 57, 69, 70, 77, 72, 64, 79, 81, 177, 155, 161, 166, 153, 147, 163, 170, 171, 154, 174, 169, 150, 172, 164, 178, 165, 159, 173, 167, 162, 152, 176, 157, 168, 148, 149, 156, 151, 175, 158, 160, 186, 188, 179, 180, 183, 191, 181, 187, 182, 185, 189, 190, 184
Код:
Блок 1 (32 значения в интервале 19-50):
36, 46, 20, 25, 44, 38, 22, 29, 30, 45, 33, 28, 41, 31, 23, 37, 24, 50, 32, 26, 21, 43, 35, 48, 27, 39, 40, 47, 42, 34, 49, 19
Блок 2 (32 значения в интервале 83-114):
95, 105, 111, 84, 103, 97, 113, 88, 89, 104, 92, 87, 100, 90, 114, 96, 83, 109, 91, 85, 112, 102, 94, 107, 86, 98, 99, 106, 101, 93, 108, 110
Блок 3 (19 значений в интервале 0-18):
12, 1, 14, 5, 6, 9, 4, 17, 7, 13, 0, 8, 2, 11, 3, 15, 16, 18, 10
Блок 4 (32 значения в интервале 115-146):
135, 145, 119, 124, 143, 137, 121, 128, 129, 144, 132, 127, 140, 130, 122, 136, 123, 117, 131, 125, 120, 142, 134, 115, 126, 138, 139, 146, 141, 133, 116, 118
Блок 5 (32 значения в интервале 51-82):
66, 76, 82, 55, 74, 68, 52, 59, 60, 75, 63, 58, 71, 61, 53, 67, 54, 80, 62, 56, 51, 73, 65, 78, 57, 69, 70, 77, 72, 64, 79, 81
Блок 6 (32 значения в интервале 147-178):
177, 155, 161, 166, 153, 147, 163, 170, 171, 154, 174, 169, 150, 172, 164, 178, 165, 159, 173, 167, 162, 152, 176, 157, 168, 148, 149, 156, 151, 175, 158, 160
Блок 7 (13 значений в интервале 179-191):
186, 188, 179, 180, 183, 191, 181, 187, 182, 185, 189, 190, 184
Происходящее здесь становится понятным, когда мы посмотрим на структуры данных, которые epoll использует внутри. ep_insert вызывает ep_rbtree_insert для вставки struct epitem в красно-чёрное дерево (разновидность отсортированного двоичного дерева); а это красно-чёрное дерево сортируется при помощи кортежа из struct file * и числа дескриптора файла:
Код:
[CODE]/* Compare RB tree keys */
static inline int ep_cmp_ffd(struct epoll_filefd *p1,
struct epoll_filefd *p2)
{
return (p1->file > p2->file ? +1:
(p1->file < p2->file ? -1 : p1->fd - p2->fd));
}
То есть увиденные нами значения упорядочены на основании виртуального адреса соответствующего struct file; а SLUB распределяет struct file из страниц первого порядка (например, размером 8 КиБ), каждая из которых может хранить по 32 объекта:
Код:
root@ubuntuvm:/sys/kernel/slab/filp# cat order
1
root@ubuntuvm:/sys/kernel/slab/filp# cat objs_per_slab
32
root@ubuntuvm:/sys/kernel/slab/filp#
Это объясняет увиденное нами группирование чисел: каждый блок из 32 смежных значений соответствует странице первого порядка, которая ранее была пуста и использовалась SLUB для распределения объектов, пока не стала полной.
Обладая этим знанием, мы можем немного преобразовать эти числа, чтобы показать порядок, в котором объекты распределялись внутри каждой страницы (за исключением страниц, для которых мы не видели все распределения):
Код:
$ cat slub_demo.py
#!/usr/bin/env python3
blocks = [
[ 36, 46, 20, 25, 44, 38, 22, 29, 30, 45, 33, 28, 41, 31, 23, 37, 24, 50, 32, 26, 21, 43, 35, 48, 27, 39, 40, 47, 42, 34, 49, 19 ],
[ 95, 105, 111, 84, 103, 97, 113, 88, 89, 104, 92, 87, 100, 90, 114, 96, 83, 109, 91, 85, 112, 102, 94, 107, 86, 98, 99, 106, 101, 93, 108, 110 ],
[ 12, 1, 14, 5, 6, 9, 4, 17, 7, 13, 0, 8, 2, 11, 3, 15, 16, 18, 10 ],
[ 135, 145, 119, 124, 143, 137, 121, 128, 129, 144, 132, 127, 140, 130, 122, 136, 123, 117, 131, 125, 120, 142, 134, 115, 126, 138, 139, 146, 141, 133, 116, 118 ],
[ 66, 76, 82, 55, 74, 68, 52, 59, 60, 75, 63, 58, 71, 61, 53, 67, 54, 80, 62, 56, 51, 73, 65, 78, 57, 69, 70, 77, 72, 64, 79, 81 ],
[ 177, 155, 161, 166, 153, 147, 163, 170, 171, 154, 174, 169, 150, 172, 164, 178, 165, 159, 173, 167, 162, 152, 176, 157, 168, 148, 149, 156, 151, 175, 158, 160 ],
[ 186, 188, 179, 180, 183, 191, 181, 187, 182, 185, 189, 190, 184 ]
]
for alloc_indices in blocks:
if len(alloc_indices) != 32:
continue
# indices of allocations ('data'), sorted by memory location, shifted to be relative to the block
alloc_indices_relative = [position - min(alloc_indices) for position in alloc_indices]
# reverse mapping: memory locations of allocations,
# sorted by index of allocation ('data').
# if we've observed all allocations in a page,
# these will really be indices into the page.
memory_location_by_index = [alloc_indices_relative.index(idx) for idx in range(0, len(alloc_indices))]
print(memory_location_by_index)
$ ./slub_demo.py
[31, 2, 20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17]
[16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17, 31, 2, 20, 6, 14]
[23, 30, 17, 31, 2, 20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27]
[20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15, 5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17, 31, 2]
[5, 25, 26, 12, 28, 21, 4, 9, 1, 27, 23, 30, 17, 31, 2, 20, 6, 14, 16, 3, 19, 24, 11, 7, 8, 13, 18, 10, 29, 22, 0, 15]
И эти последовательности почти одинаковы, только они передвинуты на разные величины. Именно так работает схема рандомизации списков свободной памяти SLUB, введённая в коммите 210e7a43fa905!
При создании kmem_cache SLUB (экземпляр распределителя SLUB для конкретного класса размера и потенциально других конкретных атрибутов, обычно инициализируемых на этапе загрузки) init_cache_random_seq и cache_random_seq_create заполняют массив ->random_seq случайно упорядоченными индексами объектов при помощи тасования Фишера-Йетса. Длина массива при этом равна количеству объектов, помещающихся на страницу. Когда затем SLUB берёт новую страницу из распределителя страниц более низкого уровня, он инициализирует список свободной памяти страницы, используя индексы из ->random_seq, начиная со случайного индекса в массиве (и переходя в начало, когда достигнут конец).
То есть в итоге мы можем обойти рандомизацию SLUB для slab, из которого распределяется struct file, потому что кто-то использовал его в качестве ключа поиска в определённом типе структуры данных. Это уже довольно нежелательно, если рандомизация SLUB должна обеспечивать защиту от некоторых типов локальных атак для всех slab'ов.
Эффект ослабления рандомизации кучи таких структур данных не обязательно ограничен случаями, в которых элементы структуры данных в пользовательском пространстве можно перечислить по порядку: если существует путь выполнения кода, производящий итерацию по упорядоченному дереву и освобождающий все узлы дерева, то это может иметь схожий эффект, потому что объекты будут размещены в списке свободной памяти распределителя с сортировкой по адресу, что аннулирует рандомизацию. Кроме того, у вас может получиться устроить утечку информации о порядке итерации через побочные каналы кэша и тому подобное.
Если мы добавляем реализацию устранения проблемы использования после освобождения, которая зависит от того, что атакующие не могут узнать, изменились ли после повторного распределения верхние биты адреса объекта, эта структура данных тоже может её разрушить. Этот случай запутаннее, чем такие вещи, как kcmp(), потому что здесь источником утечки порядка адресов является стандартная структура данных.
Вы могли заметить, что в некоторых из использованных мной примеров более-менее ограничены случаями, когда нападающий перераспределяет память с тем же типом, что и старое распределение, хотя типичная атака use-after-free заменяет объект другим типом, чтобы вызвать несоответствие типов. Пример бага, который можно использовать для повышения привилегий без несоответствия типов на уровне структуры C см. в записи 808 нашего багтрекера. Мой эксплойт этого бага начинается с операции writev() для файла с возможностью записи, он позволяет ядру удостоверитсья, что в файл действительно можно выполнять запись, а затем заменяет struct file на file только для чтения, указав на /etc/crontab, а затем позволяет writev() продолжить выполнение. Это позволяет получить root-привилегии при помощи бага use-after-free без необходимости разбираться с указателями ядра, схемами структур данных, ROP и тому подобным. Разумеется, такой подход работает не с каждым use-after-free.
(Кстати, пример утечек указателей через контейнерные структуры данных в движке JavaScript см. в этом баге, о котором я сообщил Firefox в 2016 году, когда ещё не был сотрудником Google; он вызывает утечку младших 32 битов указателя при помощи операций с таймингами над некачественными хэш-таблицами, по сути, превращая атаку HashDoS в утечку информации. Разумеется, сегодня утечку указателей через побочные каналы в движке JS, вероятно, больше не стоит считать багом безопасности, потому что, скорее всего, можно получить тот же результат при помощи Spectre...)
Защита от освобождения страниц SLUB: предотвращаем повторное использование виртуальных адресов за пределами slab
(Небольшое обсуждение о kernel-hardening list находится в этой теме.)Более слабая, но менее затратная с точки зрения ЦПУ альтернатива обеспечения полной защиты от use-after-free для отдельных объектов заключалась бы в обеспечении гарантии того, что виртуальные адреса, которые использовались для памяти slab, никогда повторно не используются за пределами slab, но чтобы при этом физические страницы можно было использовать повторно. По сути, это будет тот же подход, что использован в PartitionAlloc и других. С точки зрения ядра это по сути будет означать передачу распределений SLUB из пространства vmalloc.
Вот некоторые из сложностей этого подхода, которые мне удалось придумать:
- Распределения SLUB в текущее время передаются из линейного отображения, для которого обычно используются hugepages; если вместо них использовать отображения vmalloc с PTE на 4 КБ, то давление TLB может увеличиться, что может привести к частичной деградации производительности.
- Чтобы иметь возможность использования распределений SLUB в контекстах, работающих непосредственно с физической памятью, иногда необходимо, чтобы страницы SLUB были физически смежными. Это не проблема, но такое поведение отличается от стандартного поведения vmalloc. (Примечание: буферы DMA не обязаны всегда быть физически смежными — если есть IOMMU, то можно отобразить несмежные страницы на смежный диапазон адресов DMA, точно так же, как обычные таблицы страниц создают виртуально смежную память. См. пример использования в этом внутреннем API ядра, и высокоуровневый обзор того, как это работает в целом, в документации Fuchsia.)
- Некоторые части ядра выполняют преобразования между виртуальными адресами, указателями struct page и физическими адресами (для взаимодействия с оборудованием). Это относительно простое отображение для адресов в линейном отображении, но оно слегка усложняется для адресов vmalloc. В частности, необходима настройка page_to_virt() и phys_to_virt().
- Вероятно, это также станет проблемой для вещей наподобие Memory Tagging, поскольку метки указателей при преобразовании обратно в виртуальный адрес должны быть пересозданы. Возможно, будет логично отказаться от этих вспомогательных функций вне рамок низкоуровневого управления памятью и пусть вместо этого пользователи хранят обычный указатель на распределение? Или может быть позволить указателям на struct page переносить биты меток для соответствующего виртуального адреса в неиспользуемых/игнорируемых битах адреса?
После повторного распределения как таблицы страниц: рандомизация схемы структуры
Проблемы безопасности памяти часто используются таким образом, что задействуется несоответствие типов; например, при использовании use-after-free заменой освобождённого объекта на новый объект другого типа.Защита, впервые предложенная grsecurity/PaX, заключается в перемешивании порядка членов структуры во время сборки, чтобы усложнить использование несоответствия типов с применением структур; версия Linux с этим решением находится в scripts/gcc-plugins/randomize_layout_plugin.c.
Эффективность этого способа частично зависит от того, вынужден ли нападающий использовать проблему как несовпадение между двумя структурами, или может вместо этого использовать её как несовпадение между структурой и массивом (например, содержащим символы, указатели или PTE). Особенно если выполняется доступ к одному члену структуры, атака через несоответствие массивов и структур всё равно является возможной: можно заполнить весь массив одинаковыми элементами. Против описанного в этом посте несоответствия типов (между struct pid и записями в таблице страниц), частично эффективной может быть рандомизация схемы структуры, так как количество ссылок в два раза меньше размера PTE, и поэтому их случайно можно расположить с пересечением с верхней или нижней половиной PTE. (Только нужно учесть то, что Linux-версия randstruct вверх по потоку рандомизирует только явно размеченные структуры или структуры, содержащие исключительно указатели на фукнции, а у struct pid нет подобной разметки.)
Разумеется, чёткое разграничение структур и массивов — это чрезмерное упрощение; например, в некоторых типах структур, как и в массиве, может быть большое количество указателей одного типа или контролируемые нападающим значения.
Если нападающий не может полностью обойти рандомизацию схемы структуры, заполнив всю структуру, то уровень защиты зависит от способа распространения сборок ядра:
- Если сборки создаются централизованно одним поставщиком и распространяются большому количеству пользователей, то нападающий, желающий скомпрометировать пользователей этого поставщика, должен будет переработать свой эксплойт таким образом, чтобы в каждом релизе можно было использовать отдельное несоответствие типов; для этого нападающему может потребоваться переписать объёмные части эксплойта.
- Если ядро по отдельности собирается для каждой машины (или схожим образом), а образ ядра хранится в секрете, то нападающий, желающий надёжным образом подвергнуть эксплойту атакуемую систему, может быть вынужден каким-то образом обеспечить утечку информации о схемах каких-то структур, а затем или заранее подготовить эксплойты для множества возможных схем структур, или писать части эксплойта интерактивно после обеспечения утечки из атакуемой системы.
Потенциальные проблемы с рандомизацией схемы структуры:
- Если структуры создаются вручную для обеспечения повышенной эффективности использования кэша, то полностью рандомизированная структура может ухудшить поведение кэша. Готовая реализация randstruct может опционально избегать этой проблемы, выполняя рандомизацию только в строке кэша.
- Если рандомизация применяется не так, что она отражается в отладочной информации DWARF (чего нет в существующей реализации на основе GCC), то это может усложнить отладку и интроспекцию.
- Это может поломать код, предполагающий определённую схему структуры; однако этот код вреден и его в любом случае нужно очистить (и Густаво Силва уже работал над устранением некоторых подобных проблем).
(Кстати, если схемы структур рандомизированы, то, вероятно, паддинг тоже нужно явным образом рандомизировать, чтобы он не находился на одной стороне; так мы максимально рандомизируем члены структуры с низкой согласованностью; подробности см. в моём посте по этой теме.)
Целостность потоков управления (CFI)
Я хочу открытым текстом указать на то, что целостность потоков управления ядра не повлияет на всю эту стратегию борьбы с эксплойтами. Используя стратегию «только данные» мы избегаем необходимости утечки адресов, необходимости поиска ROP-гаджетов для конкретной сборки ядра, и на нас совершенно не влияют любые меры, предпринимаемые для защиты кода ядра или потока управления ядра. Такие действия, как получение доступа к произвольным файлам, повышение привилегий процесса и тому подобные, не требуют управления указателем команд ядра.Как и в моём предыдущем посте об эксплойтах ядра Linux (он посвящён забагованной подсистеме, которую поставщик Android добавил в своё ядро вниз по потоку), я придерживаюсь того мнения, что стратегия борьбы с эксплойтами «только данные» кажется очень естественной и менее запутанной, чем попытки перехвата потока управления.
Возможно, для кода пользовательского пространства ситуация иная; но с точки зрения атак из пользовательского пространства против ядра я пока не вижу особой полезности в CFI, поскольку обычно она влияет только на один из множества способов эксплойта бага. (Хотя, разумеется, возможны специфические случаи, в которых баг можно использовать только перехватом потока управления, например, если несовпадение типов позволяет только переписывать указатель функции и ни одна из разрешённых вызываемых сторон не делает предположений о типах входящих данных или привилегиях, которые можно нарушить изменением указателя функции.)
Превращение важных данных в readonly
Во многих случаях используется следующая идея защиты (в том числе в ядрах телефонов Samsung и в ядрах XNU для iOS): превращение критически важных для безопасности ядра данных в read-only за исключением ситуаций, в которых запись в них производится намеренно — смысл заключается в том, что даже если у нападающего есть возможность записи в произвольную память, он не должен быть способен напрямую переписывать конкретные элементы данных, имеющие чрезвычайную важность для безопасности системы, например, структуры идентификационных данных, таблицы страниц или (на iOS с использованием PPL) страниц кода пользовательского пространства.Я вижу в таком подходе следующую проблему: большая часть того, что делает ядро, в том или ином смысле критично для правильной работы системы и её безопасности. Управление состоянием MMU, планирование задач, распределение памяти, файловые системы, кэш страниц, IPC,… — если любая из этих частей ядра будет достаточно серьёзно повреждена, нападающий, вероятно, сможет получить доступ ко всем пользовательским данным в системе или использовать это повреждение для передачи поддельных данных одной из подсистем, структуры данных которой являются read-only.
На мой взгляд, вместо того, чтобы пытаться отделить самые критичные части ядра и запускать их в контексте с повышенными привилегиями, вероятно, более продуктивно будет пойти в противоположном направлении и попытаться приблизиться к чему-то типа настоящего микроядра: отделить драйверы, которые необязательны в ядре, и запускать их в контексте с более низкими привилегиями, который взаимодействует с самим ядром через API. Разумеется, это проще сказать, чем сделать! Но в Linux уже есть API для безопасного доступа к PCI-устройствам (VFIO) и USB-устройствам из пользовательского пространства, хотя работа с драйверами пользовательского пространства и не являются его основной задачей.
(Можно также предложить сделать read-only таблицы страниц, не из-за их важности для целостности системы, а из-за того, что структура записей таблицы страниц повышает удобство работы с ними в эксплойтах, которые ограничены в способах внесения изменений в памяти. Мне не нравится такой подход, потому что я считаю, что из этого нельзя сделать чёткий вывод и потому что это сильно инвазивно с учётом возможных схем структур данных.)
Заключение
По сути, это был скучный баг блокировки в какой-то произвольной подсистеме ядра и если бы небезопасность памяти, он не сильно был бы связан с безопасностью системы. Я написал очень простой и неинтересный эксплойт этого бага; наверно, самым сложным в его создании в Debian было разобраться, как работает распределитель SLUB.В этой статье я хотел описать этапы эксплойта и то, как на него могут повлиять различные меры, для того, чтобы подчеркнуть, что чем дальше развивается эксплойт повреждения памяти, тем больше возможностей появляется у нападающего; к тому же в общем случае, чем раньше остановлен эксплойт, тем надёжнее защита. Следовательно, если меры защиты, останавливающие эксплойт раньше, тратят больше ресурсов, они всё равно могут быть полезными.
Я считаю, что текущую ситуацию в сфере безопасности ПО можно значительно улучшить — в мире, где небольшой баг в какой-то произвольной подсистеме ядра может привести к полной компрометации системы, ядро не способно обеспечить надёжное изолирование защиты. Инженеры по защите информации могут сосредоточиться на таких вещах, как забагованные проверки разрешений и правильность управления памятью ядра, а не тратить время на проблемы в коде, которые не должны иметь никакой связи с безопасностью системы.
В ближайшей перспективе для улучшения ситуации можно использовать быстрые реализуемые решения, например, разбиение кучи или более детальное устранение UAF. Они могут влиять на производительность, из-за чего могут показаться непривлекательными; но я всё равно считаю, что лучше тратить время разработки на них, чем на вещи типа CFI, пытающиеся защититься от гораздо более поздних этапов эксплойтов.
В дальней перспективе что-то должно измениться в сфере языка программирования — обычный C попросту слишком подвержен ошибкам. Возможно, решением станет Rust; а может, внедрение достаточного количества аннотаций в C (что-то в духе проекта Microsoft Checked C, хотя насколько я понимаю, он в основном занимается вещами наподобие границ массивов, а не временными проблемами), что позволит использовать во время сборки проверки в стиле Rust на правила блокировки, состояния объектов, refcounting, приведение пустых указателей и т. д. А может быть, в конечном итоге станет популярным совершенно другой безопасный по памяти язык, не C и не Rust?
Я надеюсь, что хотя бы в перспективе у нас сможет появиться статически проверенная высокопроизводительная основа кода ядра, работающая совместно с инструментированным, проверяемым во время выполнения, не критичным для производительности легаси-кодом, чтобы разработчики могли обеспечить компромисс между вложениями времени в заполняющие правильные аннотации и замедлением инструментирования времени выполнения без компрометации безопасности.
TL;DR
Повреждение памяти — это серьёзная проблема, потому что небольшие баги даже за пределами связанного с безопасностью кода могут привести к полной компрометации системы; чтобы решить эту проблему, нам нужно:- в кратчайшие сроки или среднесрочно:
- спроектировать новые решения проблем безопасности памяти:
- в идеале они должны предотвращать атаки на ранних этапах, когда у нападающих ещё нет множества вариантов выбора
- возможно, на уровне распределителя памяти (т. е. SLUB)
- они не должны быть подвержены повреждению при помощи утечек меток адресов (или мы должны попытаться предотвратить утечки меток, но сделать это очень сложно)
- в идеале они должны предотвращать атаки на ранних этапах, когда у нападающих ещё нет множества вариантов выбора
- продолжить заниматься уменьшением поверхности атак
- в частности, seccomp
- явным образом предотвращать получение ненадёжным кодом важных примитивов атак
- наподобие FUSE, и потенциально рассмотреть возможность детального контроля за планировщиком
- спроектировать новые решения проблем безопасности памяти:
- в долговременной перспективе:
- статически убедиться в корректности большинства кода, критичного для производительности
- для этого потребуется определить, как модифицировать аннотации для состояний объектов и блокировок в легаси-коде на C
- рассмотреть возможность создания проверки во время выполнения, заполняющей пробелы в статической верификации.
- статически убедиться в корректности большинства кода, критичного для производительности