Как простой баг повреждения памяти ядра Linux может привести к полной компрометации системы

Kate

Administrator
Команда форума
5ckujge8lggnnz8uufqqqgxrgfu.png


Введение​


В этом посте описывается простой в реализации баг блокировки ядра Linux и то, как я использовал его против ядра Debian Buster 4.19.0-13-amd64. В посте рассматриваются варианты устранения бага, препятствующие или усложняющие использование подобных проблем злоумышленниками.

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

Многие описанные здесь отдельные техники эксплойтов и варианты их устранения не новы. Однако я считаю, что стоит объединить их в одну статью, чтобы показать, как различные способы устранения взаимодействуют друг с другом на примере достаточно стандартного эксплойта с использованием освобождённой памяти.
В нашем багтрекере этот баг вместе с proof of concept сохранён по адресу.

Приведённые в этом посте фрагменты кода эксплойта взяты из релиза 4.19.160, потому что на нём основано ядро Debian, на которое была нацелена атака; некоторые другие фрагменты кода взяты из основной ветви Linux.

(Для любопытствующих: баг и ядро Debian датированы концом прошлого года потому, что я написал основную часть этого поста примерно в апреле, но завершил его только недавно.)

Мне хотелось бы поблагодарить Райана Хайлмена за наши разговоры о том, как статический анализ может испоьзоваться в статическом предотвращении багов безопасности (но учтите, что Райан не проверял этот пост и может быть несогласен с какими-то моими суждениями). Также я хочу поблагодарить Kees Cook за отзывы о ранних версиях этого поста (здесь я тоже должен сказать, что он необязательно согласен со всем написанным) и моих коллег из Project Zero за вычитку этого поста и активные обсуждения способов предотвращения эксплойтов.

Предпосылки бага​


В Linux терминальные устройства (например, последовательная консоль или виртуальная консоль) представлены структурой struct tty_struct. Среди прочего, эта структура содержит поля, используемые для функций управления заданиями терминалов, которые обычно изменяются при помощи набора ioctls:

struct tty_struct {
[...]
spinlock_t ctrl_lock;
[...]
struct pid *pgrp; /* Protected by ctrl lock */
struct pid *session;
[...]
struct tty_struct *link;
[...]
}[...];


Поле pgrp указывает на группу приоритетных процессов терминала (обычно изменяемую из пользовательского пространства через ioctl TIOCSPGRP); поле session указывает на сессию, которая связана с терминалом. Оба этих поля указывают не напрямую на процесс/задачу, а на struct pid. struct pid привязывает конкретную копию числового ID к множеству процессов, которые используют этот ID в качестве своего PID (в пользовательском пространстве известного так же как TID), TGID (в пользовательском пространстве известного так же как PID), PGID или SID. Можно считать это слабой ссылкой на процесс, хоть это и не совсем точно. (Есть и другие нюансы struct pid, связанные с тем, когда execve() вызывается не главным потоком, но здесь они не важны.)

Все процессы, работающие внутри терминала и подчинённые управлению заданиями, обращаются к этому терминалу как их «контролирующему терминалу» (хранимому в ->signal->tty процесса).

Особым видом терминального устройства являются псевдотерминалы, используемые когда, например, вы открываете терминальное приложение в графическом окружении или подключаетесь к удалённой машине через SSH. Другие терминальные устройства подключаются к какому-то оборудованию, а оба конца псевдотерминала управляются пользовательским пространством; псевдотерминалы могут свободно создаваться пользовательским пространством (непривилегированным). Каждый раз, когда открывается /dev/ptmx (сокращение от «pseudoterminal multiplexor»), получаемый дескриптор файла представляет сторону устройства (называемую в документации и исходниках ядра "pseudoterminal master") нового псевдотерминала. Можно выполнять из него считывание, чтобы получать данные, которые должны выводиться на эмулируемый экран, и записывать в него, чтобы эмулировать ввод с клавиатуры. Соответствующее терминальное устройство (к которому обычно подключается оболочка) автоматически создаётся ядром в /dev/pts/<number>.

Особенную странность псевдотерминалам придаёт то, что оба конца псевдотерминала имеют собственные struct tty_struct, которые указывают друг на друга при помощи элемента link, даже если сторона устройства псевдотерминала не имеет таких функций терминала, как управление заданиями, поэтому многие элементы не используются.

Многие ioctls для управления терминалом могут вызываться на обоих концах псевдотерминала; но с какого бы конца их не вызвали, они влияют на одно и то же состояние, иногда с незначительными отличиями в поведении. Например, вот обработчик ioctl для TIOCGPGRP:

/**
* tiocgpgrp - get process group
* @tty: tty passed by user
* @real_tty: tty side of the tty passed by the user if a pty else the tty
* @p: returned pid
*
* Obtain the process group of the tty. If there is no process group
* return an error.
*
* Locking: none. Reference to current->signal->tty is safe.
*/
static int tiocgpgrp(struct tty_struct *tty, struct tty_struct *real_tty, pid_t __user *p)
{
struct pid *pid;
int ret;
/*
* (tty == real_tty) is a cheap way of
* testing if the tty is NOT a master pty.
*/
if (tty == real_tty && current->signal->tty != real_tty)
return -ENOTTY;
pid = tty_get_pgrp(real_tty);
ret = put_user(pid_vnr(pid), p);
put_pid(pid);
return ret;
}

Как задокументировано в комментарии, эти обработчики получают указатель real_tty, указывающий на обычное терминальное устройство; дополнительный указатель tty передаётся для того, чтобы его можно использовать для определения стороны терминала, с которой изначально вызывался ioctl. Как видно из этого примера, указатель tty обычно вызывается только для таких операций, как сравнение указателей. В этом случае он используется для того, чтобы TIOCGPGRP не работал, когда его вызывает на стороне терминала процесс, для которого этот терминал не является контролирующим.

Примечание: если вы хотите узнать больше о том, как должны работать терминалы и управление заданиями, то в книге «The Linux Programming Interface» есть понятное введение в то, как должны работать эти старые части API пользовательского пространства. Однако в ней не описываются внутренности ядра, потому что она написана как справочное руководство по программированию в пользовательском пространстве. К тому же она написана в 2010 году, поэтому в ней нет ничего о новых API, появившихся за последнее десятилетие.

Баг​


Баг находился в обработчике ioctl tiocspgrp:

/**
* tiocspgrp - attempt to set process group
* @tty: tty passed by user
* @real_tty: tty side device matching tty passed by user
* @p: pid pointer
*
* Set the process group of the tty to the session passed. Only
* permitted where the tty session is our session.
*
* Locking: RCU, ctrl lock
*/
static int tiocspgrp(struct tty_struct *tty, struct tty_struct *real_tty, pid_t __user *p)
{
struct pid *pgrp;
pid_t pgrp_nr;
[...]
if (get_user(pgrp_nr, p))
return -EFAULT;
[...]
pgrp = find_vpid(pgrp_nr);
[...]
spin_lock_irq(&tty->ctrl_lock);
put_pid(real_tty->pgrp);
real_tty->pgrp = get_pid(pgrp);
spin_unlock_irq(&tty->ctrl_lock);
[...]
}

Элемент pgrp стороны терминала (real_tty) изменяется и количество ссылок старой и новой группы процессов соответствующим образом регулируется при помощи put_pid и get_pid; однако блокировка выполняется на tty, который может быть любым из концов псевдотерминальной пары, в зависимости от того, какой дескриптор файла мы передаём ioctl(). То есть одновременно вызвав ioctl TIOCSPGRP на обеих сторонах псевдотерминала, мы можем вызвать гонку данных между параллельными доступами к элементу pgrp. Это может привести к тому что количество ссылок будет искажено при помощи следующих гонок:

ioctl(fd1, TIOCSPGRP, pid_A) ioctl(fd2, TIOCSPGRP, pid_B)
spin_lock_irq(...) spin_lock_irq(...)
put_pid(old_pid)
put_pid(old_pid)
real_tty->pgrp = get_pid(A)
real_tty->pgrp = get_pid(B)
spin_unlock_irq(...) spin_unlock_irq(...)

ioctl(fd1, TIOCSPGRP, pid_A) ioctl(fd2, TIOCSPGRP, pid_B)
spin_lock_irq(...) spin_lock_irq(...)
put_pid(old_pid)
put_pid(old_pid)
real_tty->pgrp = get_pid(B)
real_tty->pgrp = get_pid(A)
spin_unlock_irq(...) spin_unlock_irq(...)

В обоих случаях, для refcount старого struct pid слишком много выполняется декремент на 1, а для A или B слишком много выполняется инкремент на 1.

Разобравшись с этой проблемой, кажется, что устранить её можно очевидным образом:

if (session_of_pgrp(pgrp) != task_session(current))
goto out_unlock;
retval = 0;
- spin_lock_irq(&tty->ctrl_lock);
+ spin_lock_irq(&real_tty->ctrl_lock);
put_pid(real_tty->pgrp);
real_tty->pgrp = get_pid(pgrp);
- spin_unlock_irq(&tty->ctrl_lock);
+ spin_unlock_irq(&real_tty->ctrl_lock);
out_unlock:
rcu_read_unlock();
return retval;

Этапы атаки​


В этом разделе я сначала расскажу, как работает мой эксплойт; далее я объясню различные защитные техники, нацеленные на эти этапы атаки.

Этап атаки: освобождение объекта с несколькими висячими ссылками​


Этот баг позволяет нам вероятностным образом уменьшить refcount struct pid, и это зависит от того, как происходит гонка: мы можем многократно запускать конкурирующие вызовы TIOCSPGRP из двух потоков, и время от времени это будет искажать refcount. Но мы не можем сразу же узнать, сколько раз произошло искажение refcount.

Мы как нападающий стремимся к тому, чтобы искажать refcount детерминированным образом. Нам каким-то образом нужно компенсировать недостаток информации о том, был ли успешно искажён refcount. Мы можем попытаться как-нибудь сделать гонку детерминированной (эта задача кажется сложной), или после каждой попытки искажения refcount предполагать, что гонка выполнена успешно, после чего выполнять оставшуюся часть эксплойта (так как если мы не исказили refcount, исходное повреждение памяти исчезло и ничего плохого не произойдёт), или мы можем попытаться найти утечку информации, позволяющую понять нам состояние количества ссылок.

В типичных десктопных/серверных дистрибутивах работает следующий подход (он ненадёжен и зависит от размера ОЗУ) для обеспечения освобождённого struct pid несколькими висящими ссылками:

  1. Выделяем новый struct pid (создав новую задачу).
  2. Создаём множество ссылок на него (отправляя сообщения SCM_CREDENTIALS сокетам домена Unix и оставляя эти сообщения в очереди).
  3. Многократно вызываем гонку TIOCSPGRP для уменьшения количества ссылок; выбираем количество попыток таким образом, чтобы мы ожидали, что получившееся искажение refcount больше, чем количество ссылок, необходимое нам для оставшейся части атаки, но меньше, чем количество созданных нами дополнительных ссылок.
  4. Позволяем задаче, владеющей pid, выполнить выход и «умереть», потом подождать, пока RCU (read-copy-update — механизм, откладывающий освобождение некоторых объектов) сделает так, что ссылка задачи на pid пропадёт. (Ожидание времени отсрочки RCU из пользовательского пространства не является примитивом, намеренно видимым через UAPI, но существует множество способов, которыми его может реализовать пользовательское пространство, например, проверяя момент, когда освобождённая память программы BPF вычтется из общего подсчёта памяти, или злонамеренно используя системный вызов membarrier(MEMBARRIER_CMD_GLOBAL, ...) после версии ядра, где были объединены RCU flavors.)
  5. Создаём новый поток и позволяем этому потоку попытаться сбросить все созданные нами ссылки.

Так как в начале этапа 5 refcount меньше, чем количество ссылок, которые мы сбросим, на этапе 5 pid будет освобождён; следующая попытка сбросить ссылку приведёт к использованию освобождённой памяти:

struct upid {
int nr;
struct pid_namespace *ns;
};

struct pid
{
atomic_t count;
unsigned int level;
/* lists of tasks that use this pid */
struct hlist_head tasks[PIDTYPE_MAX];
struct rcu_head rcu;
struct upid numbers[1];
};
[...]
void put_pid(struct pid *pid)
{
struct pid_namespace *ns;

if (!pid)
return;

ns = pid->numbers[pid->level].ns;
if ((atomic_read(&pid->count) == 1) ||
atomic_dec_and_test(&pid->count)) {
kmem_cache_free(ns->pid_cachep, pid);
put_pid_ns(ns);
}
}

При освобождении объекта распределитель SLUB обычно заменяет первые 8 байтов (примечание: начиная с 5.7 выбирается другая позиция, см. блог Kees) освобождённого объекта на обфусцированный XOR указатель списка свободной памяти; следовательно, поля count и level, по сути, содержат теперь случайный мусор. Это значит, что нагрузка из pid->numbers[pid->level] теперь будет находиться на каком-то случайном смещении от pid, в интервале от нуля до 64 ГиБ. Если у машины нет огромного количества ОЗУ, то это с большой вероятностью вызовет ошибку сегментации ядра. (Да, я понимаю, что это совершенно неудобный и ненадёжный способ эксплойта. Однако чаще всего он срабатывает, и я заметил эту проблему только когда написал статью целиком, поэтому не хотел переписывать её.)

Linux находится в его стандартной конфигурации, а конфигурация, поставляемая большинством дистрибутивов общего назначения, пытается устранить неожиданные ошибки отсутствия страниц ядра и другие неполадки убиванием только сбойного потока. Следовательно, здесь ошибки отсутствия страницы ядра действительно нам полезны в качестве сигнала: после смерти потока мы знаем, что объект освобождён и мы можем продолжать выполнение эксплойта.

Если бы этот код выглядел немного иначе и мы действительно бы достигли двойного освобождения, распределитель SLUB тоже бы это обнаружил и вызвал обработку сбоя ядра (см. set_freepointer() для случая CONFIG_SLAB_FREELIST_HARDENED).

Идея атаки, от которой я отказался: непосредственный эксплойт UAF на уровне SLUB​


В ядре Debian, которое я изучал, struct pid в исходном пространстве имён выделяется из того же kmem_cache, что и struct seq_file с struct epitem — эти три slab'а были объединены в один функцией find_mergeable() для снижения фрагментирования памяти, потому что их размеры объектов, требования к выравниванию и флаги совпадают:

root@deb10:/sys/kernel/slab# ls -l pid
lrwxrwxrwx 1 root root 0 Feb 6 00:09 pid -> :A-0000128
root@deb10:/sys/kernel/slab# ls -l | grep :A-0000128
drwxr-xr-x 2 root root 0 Feb 6 00:09 :A-0000128
lrwxrwxrwx 1 root root 0 Feb 6 00:09 eventpoll_epi -> :A-0000128
lrwxrwxrwx 1 root root 0 Feb 6 00:09 pid -> :A-0000128
lrwxrwxrwx 1 root root 0 Feb 6 00:09 seq_file -> :A-0000128
root@deb10:/sys/kernel/slab#

Простейший способ эксплойта висячей ссылки на объект SLUB заключался бы в повторном выделении объекта через тот же kmem_cache, откуда он взялся, так, чтобы страница никогда не достигала распределителя памяти. Чтобы понять, легко ли использовать этот баг таким образом, я создал таблицу, в которой перечислены поля, встречающиеся на каждом смещении в этих трёх структурах данных (при помощи pahole -E --hex -C <typename> <path to vmlinux debug info>):

смещениеpideventpoll_epi / epitem (освобождён RCU)seq_file
0x00count.counter (4) (CONTROL)rbn.__rb_parent_color (8) (TARGET?)buf (8) (TARGET?)
0x04level (4)
0x08tasks[PIDTYPE_PID] (8)rbn.rb_right (8) / rcu.func (8)size (8)
0x10tasks[PIDTYPE_TGID] (8)rbn.rb_left (8)from (8)
0x18tasks[PIDTYPE_PGID] (8)rdllink.next (8)count (8)
0x20tasks[PIDTYPE_SID] (8)rdllink.prev (8)pad_until (8)
0x28rcu.next (8)next (8)index (8)
0x30rcu.func (8)ffd.file (8)read_pos (8)
0x38numbers[0].nr (4)ffd.fd (4)version (8)
0x3c[hole] (4)nwait (4)
0x40numbers[0].ns (8)pwqlist.next (8)lock (0x20): counter (8)
0x48---pwqlist.prev (8)
0x50---ep (8)
0x58---fllink.next (8)
0x60---fllink.prev (8)op (8)
0x68---ws (8)poll_event (4)
0x6c---[hole] (4)
0x70---event.events (4)file (8)
0x74---event.data (8) (CONTROL)
0x78---private (8) (TARGET?)
0x7c------
0x80---------

В этом случае повторное выделение объекта как одного из этих трёх типов не показалось мне удобным способом работы (однако приложив определённые усилия, мы, возможно, смогли бы это как-то использовать, например, при помощи count.counter повредив поле buf у seq_file). Кроме того, некоторые системы могут использовать флаг командной строки ядра slab_nomerge, который отключает это поведение слияния.

Ещё один подход, который я здесь не рассматривал, заключается в попытке повреждения обфусцированного указателя списка свободной памяти SLUB (обфускация реализована в freelist_ptr()); но поскольку он хранит указатель в big-endian, count.counter, по сути, позволит нам только старшую часть указателя, и использовать это будет крайне неудобно.

Этап атаки: освобождение страницы объекта в распределитель страниц​


В этом разделе я буду ссылаться на внутреннее устройство распределителя SLUB; если вы не знакомы с ним, то рекомендую хотя бы посмотреть на слайды 2-4 и 13-14 в обзорном докладе Кристофа Ламетера 2014 года о распределителе slab'ов. (Стоит заметить, что в докладе освещаются три разных распределителя; сегодня в большинстве систем используется распределитель SLUB.)

Альтернативой использованию UAF на уровне распределителя SLUB является сброс страницы в распределитель страниц (также называемый buddy allocator), который является последним уровнем динамического распределения памяти в Linux (после того, как система достаточно далеко продвинулась в процессе загрузки и распределитель memblock больше не используется). Далее страница теоретически может оказаться практически в любом контексте. Сбросить страницу в распределитель страниц можно следующим образом:

  1. Приказать ядру закрепить нашу задачу за одним ЦП. И SLUB, и распределитель страниц используют структуры для каждого ЦП; поэтому если в процессе выполнения ядро перенесёт нас на другой ЦП, то наша попытка закончится неудачей.
  2. Перед распределением атакуемого struct pid, refcount был повреждён, распределяем большое количество объектов для вытягивания частично свободных страниц slab у их нераспределённых объектов. Если объект-жертва (который будет распределён на описанном ниже этапе 5) оказался на странице, которая на текущий момент уже частично используется, мы не сможем использовать эту страницу.
  3. Распределяем примерно objs_per_slab * (1+cpu_partial) объектов — другими словами, множество объектов, которое полностью заполнит не менее cpu_partial страниц, где cpu_partial — максимальная длина частичного списка каждого ЦП («percpu partial list»). В текущий момент на эти новые распределённые страницы, полностью заполненные объектами, не ссылаются списки свободной памяти SLUB, потому что SLUB отслеживает в своих списках свободной памяти только страницы с освобождёнными объектами.
  4. Добавляем ещё objs_per_slab-1 объектов, чтобы в конце этого этапа «slab ЦП» (страница, распределения с которой будут обрабатываться первыми) не содержала ничего, кроме свободного пространства и новых распределений (созданных на этом этапе).
  5. Распределяем объект-жертву (a struct pid). Страница-жертва (страница, с которой взят объект-жертва) обычно будет находиться в slab ЦП из этапа 4, но если на этапе 4 slab ЦП был заполнен полностью, страница-жертва тоже может быть новым, только что распределённым slab ЦП.
  6. Применяем баг к объекту-жертве, чтобы создать неподсчитанную ссылку, и освобождаем объект.
  7. Распределяем ещё objs_per_slab+1 объектов. После этого страница-жертва будет полностью заполнена распределениями с этапов 4 и 7, и это больше не будет slab ЦП (потому что последнее распределение не могло уместиться на странице-жертве).
  8. Освобождаем все распределения с этапов 4 и 7. Благодаря этому страница-жертва становится пустой, но не освобождает страницу; после того, как со страницы-жертвы освобождается один объект, эта страница помещается в percpu partial list, и потом остаётся в этом списке.
  9. Освобождаем один объект на страницу из распределений этапа 3. Это добавляет все эти страницы в percpu partial list, пока он не достигнет предела в cpu_partial, после чего будет сброшен: страницы, содержащие используемые объекты, помещаются в частичный список узла NUMA SLUB, а совершенно пустые страницы освобождаются обратно в распределитель страниц. (Мы не освобождаем все распределения с этапа 3, потому что хотим, чтобы в распределитель страниц была освобождена только страница-жертва.) Стоит заметить, что для этого этапа требуется, чтобы каждый objs_per_slab-ый объект, выданный нам распределителем на этапе 3, находился на отдельной странице.

Когда страницу передают распределителю страниц, мы получаем страницу нулевого порядка (4 КиБ, нативный размер страницы): для страниц нулевого порядка распределитель страниц имеет особые списки свободной памяти, по одному на каждое сочетание ЦП+зоны+migratetype. К страницам в этих списках в обычном состоянии не получают доступ другие ЦП, и они не объединяются мгновенно с соседними свободными страницами для создания свободных страниц более высокого порядка.

На этом этапе мы можем выполнять операции доступа для использования освобождённой памяти на какое-то смещение внутри свободной страницы-жертвы при помощи путей выполнения кода, интерпретирующих часть страницы-жертвы как struct pid. Стоит заметить, что на этом этапе мы всё ещё не знаем точно, на каком смещении внутри страницы-жертвы расположен объект-жертва.

Этап атаки: перераспределение страницы-жертвы в качестве таблицы страниц​


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

Один из типов распределения, который непосредственно передаётся из распределителя страниц и имеет подходящие свойства для эксплойта — это таблицы страниц (также они использовались для эксплойта Rowhammer). Одним из способов злонамеренного использования возможности модифицирования таблицы страниц будет включение бита чтения/записи в элементе таблицы страниц (page table entry, PTE), привязывающего файловую страницу, к которой мы должны иметь доступ только для чтения — например, это можно использовать для получения доступа на запись к части сегмента .text двоичного файла setuid и замены его зловредным кодом.

Мы не знаем, на каком смещении внутри страницы-жертвы находится объект-жертва; но поскольку таблица страниц, по сути, является массивом байт-синхронизированных элементов размером 8 байт, а привязка объекта-жертвы будет кратным восьми, если мы распределяем все элементы массива-жертвы, нам не нужно знать смещение объекта-жертвы.

Чтобы распределить таблицу страниц, заполненную PTE, привязывающим ту же файловую страницу, нам нужно:

  • Выполнить подготовку упорядоченной по 2 МиБ области памяти (потому что каждая таблица страниц последнего уровня описывает 2 МиБ виртуальной памяти), содержащей одностраничные привязки mmap() одной файловой таблицы (то есть каждая привязка соответствует PTE); затем
  • Вызвать распределение таблицы страниц и заполнить её PTE, считав их из каждой привязки

struct pid имеет то же выравнивание, что и PTE, и начинается с 32-битного refcount, поэтому этот refcount гарантировано наложится на первую половину PTE, который имеет размерность 64 бит. Так как процессоры X86 little-endian, инкремент поля refcount в освобождённом struct pid выполняет инкремент младшей половины PTE, то есть, по сути, инкрементирует PTE. (За исключением пограничного случая, когда младшая половина имеет значение 0xffffffff, но в нашем случае это не так.)

struct pid: count | level | tasks[0] | tasks[1] | tasks[2] | ...
pagetable: PTE | PTE | PTE | PTE | ...

Следовательно, мы можем выполнить инкремент один из PTE многократным срабатыванием get_pid(), который пытается выполнить инкремент refcount освобождённого объекта. Это можно превратить в возможность записи в файловую страницу следующим образом:

  • Выполнить инкремент PTE на 0x42, чтобы задать бит Read/Write и бит Dirty. (Если мы не зададим бит Dirty, то ЦП сделает это самостоятельно, когда мы будем выполнять запись в соответствующий виртуальный адрес, поэтому здесь мы просто можем выполнить инкремент на 0x2.)
  • Пытаемся перезаписать содержимое каждой привязки зловредными данными и игнорируем ошибки отсутствия страниц.
    • Из-за устаревших записей TLB это может вызывать ложные ошибки, однако игнорирование ошибок отсутствия страниц автоматически устраняет такие записи TLB, поэтому если мы попытаемся выполнить запись дважды, то при второй записи это не может произойти.
    • Простой способ игнорирования ошибок отсутствия страниц заключается в том, чтобы позволить ядру выполнять запись в память при помощи pread(), которая возвращает при ошибке -EFAULT.

Если позже ядро заметит бит Dirty, это может вызвать обратную запись, что приведёт к сбою ядра, если привязка не была настроена на запись. Следовательно, нам нужно сбросить бит Dirty. Мы не можем обеспечить надёжный декремент PTE, потому что put_pid() неэффективно выполняет доступ к pid->numbers[pid->level] даже когда refcount не снижается до нуля, но мы можем выполнить его инкремент на дополнительные 0x80-0x42=0x3e, то есть окончательное значение PTE по сравнению с исходным значением будет иметь дополнительный заданный бит 0x80, который ядро игнорирует.

После этого мы запускаем исполняемый файл setuid (который в версии в кэше страниц теперь содержит инъецированный нами код) и получаем права root:

user@deb10:~/tiocspgrp$ make
as -o rootshell.o rootshell.S
ld -o rootshell rootshell.o --nmagic
gcc -Wall -o poc poc.c
user@deb10:~/tiocspgrp$ ./poc
starting up...
executing in first level child process, setting up session and PTY pair...
setting up unix sockets for ucreds spam...
draining pcpu and node partial pages
preparing for flushing pcpu partial pages
launching child process
child is 1448
ucreds spam done, struct pid refcount should be lifted. starting to skew refcount...
refcount should now be skewed, child exiting
child exited cleanly
waiting for RCU call...
bpf load with rlim 0x0: -1 (Operation not permitted)
bpf load with rlim 0x1000: 452 (Success)
bpf load success with rlim 0x1000: got fd 452
....................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
RCU callbacks executed
gonna try to free the pid...
double-free child died with signal 9 after dropping 9990 references (99%)
hopefully reallocated as an L1 pagetable now
PTE forcibly marked WRITE | DIRTY (hopefully)
clobber via corrupted PTE succeeded in page 0, 128-byte-allocation index 3, returned 856
clobber via corrupted PTE succeeded in page 0, 128-byte-allocation index 3, returned 856
bash: cannot set terminal process group (1447): Inappropriate ioctl for device
bash: no job control in this shell
root@deb10:/home/user/tiocspgrp# id
uid=0(root) gid=1000(user) groups=1000(user),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),108(netdev),112(lpadmin),113(scanner),120(wireshark)
root@deb10:/home/user/tiocspgrp#

Стоит заметить, что во всём этом эксплойте нам не приходилось получать утечки виртуальных или физических адресов ядра; частично это вызвано тем, что мы используеим примитив инкремента вместо обычной записи; кроме того, мы не воздействуем напрямую на указатель команд.

Защита​


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

Защита от возможности достижения бага: уменьшение поверхности атаки​


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

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

  • эмуляторов терминала внутри графических пользовательских сессий
  • SSH-серверов
  • сессий screen, начатых из различных типов терминалов

Таким пользователям, как веб-серверы или телефонные приложения обычно не нужен доступ к таким устройствам, но бывают и исключения. Например:

  • веб-сервер, используемый для предоставления удалённого root shell для администрирования системы
  • телефонное приложение, задача которого — обеспечение доступа пользователя к оболочке
  • Шелл-скрипт, использующий expect для взаимодействия с двоичным файлом, требующим терминал для ввода/вывода.

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

  1. Оно открывает путь к проблеме реализации ядра (потенциальным проблемам с безопасностью памяти) в открытом пользователям API, что может привести к проблемам с совместимостью и усложнением поддержки. Например, мне кажется, что с точки зрения безопасности будет неплохо требовать от телефонных приложений и сервисов systemd заявлять о своём намерении использовать подсистему PTY ещё на этапе установки, но это изменение в API потребует определённых действий со стороны разработчиков приложения, что создаст сложности, которые не были бы необходимыми, если бы мы были уверены в правильности работы ядра. Всё может ещё сильнее запутаться в случае ПО, в определённой конфигурации вызывающего внешние двоичные файлы, например, веб-сервера, которому требуется PTY-доступ, когда он используется для серверного администрирования. (Эта ситуация окажется чуть менее сложной, если безопасное приложение с возможностью эксплойта активно накладывает на себя ограничения; но не каждый разработчик приложения обязательно возжелает проектировать для своего кода надёжную песочницу, и даже если он захочет, могут возникнуть проблемы совместимости, вызванные библиотеками, не находящимися под контролем разработчика приложения.)
  2. Оно может защитить подсистему от контекста, которому необходим доступ к ней. (Например, к /dev/binder в Android имеют прямой доступ рендереры Chrome на Android, потому что внутри них выполняется код для Android.)
  3. Это означает, что решения, которые не должны влиять на безопасность системы (создание API, не дающего расширенных привилегий потенциально ненадёжному контексту), по сути, включают в себя компромисс безопасности.

Тем не менее, я считаю, что на практике механизмы уменьшения поверхности атаки (особенно seccomp) на данный момент являются одними из самых важных механизмов защиты в Linux.

Против багов в исходном коде: валидация блокировки в процессе компиляции​


Баг в TIOCSPGRP был довольно прямолинейным нарушением недвусмысленного правила блокировки: пока tty_struct жива, доступ к её элементу pgrp запрещён, если только не используется ctrl_lock той же tty_struct. Это правило достаточно просто, поэтому вполне логично ожидать, что компилятор сможет проверить его выполнение, если только каким-то образом мы сообщим компилятору об этом правиле, ведь определение нужных правил блокировки изучением кода часто может быть сложным даже для людей (особенно когда часть кода некорректна).

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

Функция Thread Safety Analysis компилятора Clang делает нечто приблизительно похожее на то, что нам нужно для проверки блокировки в этой ситуации:

$ nl -ba -s' ' thread-safety-test.cpp | sed 's|^ ||'
1 struct __attribute__((capability("mutex"))) mutex {
2 };
3
4 void lock_mutex(struct mutex *p) __attribute__((acquire_capability(*p)));
5 void unlock_mutex(struct mutex *p) __attribute__((release_capability(*p)));
6
7 struct foo {
8 int a __attribute__((guarded_by(mutex)));
9 struct mutex mutex;
10 };
11
12 int good(struct foo *p1, struct foo *p2) {
13 lock_mutex(&p1->mutex);
14 int result = p1->a;
15 unlock_mutex(&p1->mutex);
16 return result;
17 }
18
19 int bogus(struct foo *p1, struct foo *p2) {
20 lock_mutex(&p1->mutex);
21 int result = p2->a;
22 unlock_mutex(&p1->mutex);
23 return result;
24 }
$ clang++ -c -o thread-safety-test.o thread-safety-test.cpp -Wall -Wthread-safety
thread-safety-test.cpp:21:22: warning: reading variable 'a' requires holding mutex 'p2->mutex' [-Wthread-safety-precise]
int result = p2->a;
^
thread-safety-test.cpp:21:22: note: found near match 'p1->mutex'
1 warning generated.
$

Однако в настоящий момент это не работает при компиляции кода как кода на языке C, поскольку атрибут guarded_by не может найти другого элемента структуры; похоже, он был спроектирован в основном для использования в коде C++. Ещё более фундаментальная проблема заключается в том, что, похоже, отсутствует встроенная поддержка различения других правил доступа к элементу структуры в зависимости от состояния жизненного цикла объекта. Например, почти все объекты с заблокированными элементами будут иметь функции инициализации/разрушения, имеющие эксклюзивный доступ к всему объекту и способны получать доступ к элементам без блокировки. (В таких состояниях блокировка может даже и не инициализироваться.)

Кроме того, некоторые объекты имеют больше состояний жизненного цикла; в частности, для многих объектов с жизненным циклом, регулируемым RCU, доступ через ссылку RCU без предварительного обновления ссылки до учитывающейся в refcount возможен только к части множества элементов. Возможно, эту проблему можно решить введением нового атрибута типа, который можно использовать для пометки указателей на структуры в особых состояниях жизненного цикла? (Для кода на C++ Thread Safety Analysis компилятора Clang просто отключает все проверки во всех функциях конструкторов/деструкторов.)

Я надеюсь, что с некими расширениями нечто напоминающее Thread Safety Analysis компилятора Clang можно использовать для модификации части уровня безопасности в процессе компиляции против непреднамеренных гонок данных. Для этого потребуется добавить множество аннотаций, в частности, к заголовкам, чтобы задокументировать необходимую семантику блокировок; но такие аннотации, вероятно, всё равно необходимы для обеспечения продуктивной работы над сложной кодовой базой. По моему опыту, в случае отсутствия подробных комментариев/аннотаций о правилах блокировок, любая попытка изменения фрагмента кода, с которым вы знакомы плохо, превращается в экскурсию по зарослям окружающих его графов вызовов в попытках распутать предназначение этого кода.

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

На данный момент в ядре Linux есть очень грубая валидация блокировки при помощи sparse; однако эта инфраструктура неспособна распознавать ситуации, при которых используется ошибочная блокировка, и не умеет валидировать то, что элемент структуры защищён блокировкой. Также она неспособна правильно работать с такими вещами, как условная блокировка, что усложняет использование всего, кроме спин-блокировок/RCU. Валидация блокировки во время выполнения в ядре при помощи LOCKDEP является более продвинутой техникой, но она в основном делает упор на правильность блокировки указателей RCU, а также на распознавание зависаний (его основная задача); повторюсь, нет механизма, позволяющего, например, автоматически валидировать то, что к элементу структуры доступ осуществляется только под конкретной блокировкой (вероятно, это было бы достаточно затратно реализовывать с валидацией во время выполнения). Кроме того, являясь механизмом валидации во время выполнения, он неспособен выявлять ошибки в коде, которые не исполняются при тестировании (хотя он может комбинировать отдельно наблюдаемое поведение в сценарии гонки даже без наблюдаемой гонки).

Защита против багов в исходном коде: глобальный статический анализ блокировок​


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

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

Защита против примитивов эксплойтов: уменьшение примитивов атак при помощи ограничений syscall​


Так как быстрые пути выполнения распределителя (и в SLUB, и в распределителе страниц) реализованы при помощи структур данных для каждого ЦП, простота и надёжность эксплойтов, стремящихся заставить распределитель памяти ядра перераспределить память конкретным образом, можно повысить, если нападающий имеет полный контроль над назначением потоков эксплойта ядрам ЦП. Я называю такую способность, позволяющую облегчить эксплойт влиянием на состояние/поведение соответствующей системы, «примитивом атаки». К счастью для нас, Linux позволяет задачам прикрепляться к конкретным ядрам ЦП без требования привилегий при помощи системного вызова sched_setaffinity().

(Ещё один пример: примитив, дающий нападающему довольно мощные возможности — это возможность создавать бессрочные сбои простоя ядра по адресам пользовательского пространства при помощи 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(n)).

Этот системный вызов можно злонамеренно использовать для повышения надёжности эксплойтов 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

Хотя он выглядит случайным, мы видим, что этот список можно разделить на блоки длиной 32, содержащие перемешанные сплошные последовательности чисел:

Блок 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 * и числа дескриптора файла:

/* 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 переносить биты меток для соответствующего виртуального адреса в неиспользуемых/игнорируемых битах адреса?

Вероятность того, что эта защита может предотвратить превращение UAF в эксплуатируемое несоответствие типов, частично зависит от детализации slab'ов; если конкретные типы структур имеют собственные slab'ы, она обеспечивает большую защиту по сравнению с ситуацией, когда объекты сгруппированы только по размеру. То есть для повышения удобства slab-памяти с виртуальной поддержкой будет необходимо заменить стандартные slab'ы kmalloc (которые содержат различные объекты, сгруппированные только по размеру) на те, которые разделены по типу и/или участку распределения. (Ребята из сообщества grsecurity/PaX смутно намеали на что-то подобное с использованием инструментирования компилятора.)

После повторного распределения как таблицы страниц: рандомизация схемы структуры​


Проблемы безопасности памяти часто используются таким образом, что задействуется несоответствие типов; например, при использовании use-after-free заменой освобождённого объекта на новый объект другого типа.

Защита, впервые предложенная grsecurity/PaX, заключается в перемешивании порядка членов структуры во время сборки, чтобы усложнить использование несоответствия типов с применением структур; версия Linux с этим решением находится в scripts/gcc-plugins/randomize_layout_plugin.c.

Эффективность этого способа частично зависит от того, вынужден ли нападающий использовать проблему как несовпадение между двумя структурами, или может вместо этого использовать её как несовпадение между структурой и массивом (например, содержащим символы, указатели или PTE). Особенно если выполняется доступ к одному члену структуры, атака через несоответствие массивов и структур всё равно является возможной: можно заполнить весь массив одинаковыми элементами. Против описанного в этом посте несоответствия типов (между struct pid и записями в таблице страниц), частично эффективной может быть рандомизация схемы структуры, так как количество ссылок в два раза меньше размера PTE, и поэтому их случайно можно расположить с пересечением с верхней или нижней половиной PTE. (Только нужно учесть то, что Linux-версия randstruct вверх по потоку рандомизирует только явно размеченные структуры или структуры, содержащие исключительно указатели на фукнции, а у struct pid нет подобной разметки.)

Разумеется, чёткое разграничение структур и массивов — это чрезмерное упрощение; например, в некоторых типах структур, как и в массиве, может быть большое количество указателей одного типа или контролируемые нападающим значения.

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

  • Если сборки создаются централизованно одним поставщиком и распространяются большому количеству пользователей, то нападающий, желающий скомпрометировать пользователей этого поставщика, должен будет переработать свой эксплойт таким образом, чтобы в каждом релизе можно было использовать отдельное несоответствие типов; для этого нападающему может потребоваться переписать объёмные части эксплойта.
  • Если ядро по отдельности собирается для каждой машины (или схожим образом), а образ ядра хранится в секрете, то нападающий, желающий надёжным образом подвергнуть эксплойту атакуемую систему, может быть вынужден каким-то образом обеспечить утечку информации о схемах каких-то структур, а затем или заранее подготовить эксплойты для множества возможных схем структур, или писать части эксплойта интерактивно после обеспечения утечки из атакуемой системы.

Чтобы максимизировать пользу рандомизации схем структур в среде, где ядра собираются централизованно дистрибутивом или поставщиком, будет необходимо сделать рандомизацию процессом этапа загрузки, обеспечив возможность релокации смещений структур. (Или процессом этапа установки, но это нарушит подпись исполняемого кода.) Чтобы сделать это чисто (например, так, чтобы по возможности можно было всё равно использовать 8-битные и 16-битные непосредственные замены для доступа к членам структур), вероятно, потребуется много повозиться с внутренностями компилятора, от фронтенда на C вплоть до создания переадресаций. Немного грязная версия такой системы уже существует для компиляции C->BPF в виде BPF CO-RE; в нём используется встроенный в clang __builtin_preserve_access_index, но применяется debuginfo и это, наверно, не особо чистый подход.

Потенциальные проблемы с рандомизацией схемы структуры:

  • Если структуры создаются вручную для обеспечения повышенной эффективности использования кэша, то полностью рандомизированная структура может ухудшить поведение кэша. Готовая реализация randstruct может опционально избегать этой проблемы, выполняя рандомизацию только в строке кэша.
  • Если рандомизация применяется не так, что она отражается в отладочной информации DWARF (чего нет в существующей реализации на основе GCC), то это может усложнить отладку и интроспекцию.
  • Это может поломать код, предполагающий определённую схему структуры; однако этот код вреден и его в любом случае нужно очистить (и Густаво Силва уже работал над устранением некоторых подобных проблем).

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

(Кстати, если схемы структур рандомизированы, то, вероятно, паддинг тоже нужно явным образом рандомизировать, чтобы он не находился на одной стороне; так мы максимально рандомизируем члены структуры с низкой согласованностью; подробности см. в моём посте по этой теме.)

Целостность потоков управления (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
      • рассмотреть возможность создания проверки во время выполнения, заполняющей пробелы в статической верификации.
 
Сверху