Admin
Администратор
Как простой баг повреждения памяти ядра Linux приводит к полной компрометации системы
Часть 1
Введение
В этом посте описывается простой в реализации баг блокировки ядра 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;
[...]
}[...];
Все процессы, работающие внутри терминала и подчинённые управлению заданиями, обращаются к этому терминалу как их «контролирующему терминалу» (хранимому в ->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);
[...]
}
Код:
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(...)
Разобравшись с этой проблемой, кажется, что устранить её можно очевидным образом:
Код:
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 несколькими висящими ссылками:
- Выделяем новый struct pid (создав новую задачу).
- Создаём множество ссылок на него (отправляя сообщения SCM_CREDENTIALS сокетам домена Unix и оставляя эти сообщения в очереди).
- Многократно вызываем гонку TIOCSPGRP для уменьшения количества ссылок; выбираем количество попыток таким образом, чтобы мы ожидали, что получившееся искажение refcount больше, чем количество ссылок, необходимое нам для оставшейся части атаки, но меньше, чем количество созданных нами дополнительных ссылок.
- Позволяем задаче, владеющей pid, выполнить выход и «умереть», потом подождать, пока RCU (read-copy-update — механизм, откладывающий освобождение некоторых объектов) сделает так, что ссылка задачи на pid пропадёт. (Ожидание времени отсрочки RCU из пользовательского пространства не является примитивом, намеренно видимым через UAPI, но существует множество способов, которыми его может реализовать пользовательское пространство, например, проверяя момент, когда освобождённая память программы BPF вычтется из общего подсчёта памяти, или злонамеренно используя системный вызов membarrier(MEMBARRIER_CMD_GLOBAL, ...) после версии ядра, где были объединены RCU flavors.)
- Создаём новый поток и позволяем этому потоку попытаться сбросить все созданные нами ссылки.
Код:
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);
}
}
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#
| смещение | pid | eventpoll_epi / epitem (освобождён RCU) | seq_file |
|---|---|---|---|
| 0x00 | count.counter (4) (CONTROL) | rbn.__rb_parent_color (8) (TARGET?) | buf (8) (TARGET?) |
| 0x04 | level (4) | ||
| 0x08 | tasks[PIDTYPE_PID] (8) | rbn.rb_right (8) / rcu.func (8) | size (8) |
| 0x10 | tasks[PIDTYPE_TGID] (8) | rbn.rb_left (8) | from (8) |
| 0x18 | tasks[PIDTYPE_PGID] (8) | rdllink.next (8) | count (8) |
| 0x20 | tasks[PIDTYPE_SID] (8) | rdllink.prev (8) | pad_until (8) |
| 0x28 | rcu.next (8) | next (8) | index (8) |
| 0x30 | rcu.func (8) | ffd.file (8) | read_pos (8) |
| 0x38 | numbers[0].nr (4) | ffd.fd (4) | version (8) |
| 0x3c | [hole] (4) | nwait (4) | |
| 0x40 | numbers[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 | --- | --- | --- |
Ещё один подход, который я здесь не рассматривал, заключается в попытке повреждения обфусцированного указателя списка свободной памяти SLUB (обфускация реализована в freelist_ptr()); но поскольку он хранит указатель в big-endian, count.counter, по сути, позволит нам только старшую часть указателя, и использовать это будет крайне неудобно.
Этап атаки: освобождение страницы объекта в распределитель страниц
В этом разделе я буду ссылаться на внутреннее устройство распределителя SLUB; если вы не знакомы с ним, то рекомендую хотя бы посмотреть на слайды 2-4 и 13-14 в обзорном докладе Кристофа Ламетера 2014 года о распределителе slab'ов. (Стоит заметить, что в докладе освещаются три разных распределителя; сегодня в большинстве систем используется распределитель SLUB.)Альтернативой использованию UAF на уровне распределителя SLUB является сброс страницы в распределитель страниц (также называемый buddy allocator), который является последним уровнем динамического распределения памяти в Linux (после того, как система достаточно далеко продвинулась в процессе загрузки и распределитель memblock больше не используется). Далее страница теоретически может оказаться практически в любом контексте. Сбросить страницу в распределитель страниц можно следующим образом:
- Приказать ядру закрепить нашу задачу за одним ЦП. И SLUB, и распределитель страниц используют структуры для каждого ЦП; поэтому если в процессе выполнения ядро перенесёт нас на другой ЦП, то наша попытка закончится неудачей.
- Перед распределением атакуемого struct pid, refcount был повреждён, распределяем большое количество объектов для вытягивания частично свободных страниц slab у их нераспределённых объектов. Если объект-жертва (который будет распределён на описанном ниже этапе 5) оказался на странице, которая на текущий момент уже частично используется, мы не сможем использовать эту страницу.
- Распределяем примерно objs_per_slab * (1+cpu_partial) объектов — другими словами, множество объектов, которое полностью заполнит не менее cpu_partial страниц, где cpu_partial — максимальная длина частичного списка каждого ЦП («percpu partial list»). В текущий момент на эти новые распределённые страницы, полностью заполненные объектами, не ссылаются списки свободной памяти SLUB, потому что SLUB отслеживает в своих списках свободной памяти только страницы с освобождёнными объектами.
- Добавляем ещё objs_per_slab-1 объектов, чтобы в конце этого этапа «slab ЦП» (страница, распределения с которой будут обрабатываться первыми) не содержала ничего, кроме свободного пространства и новых распределений (созданных на этом этапе).
- Распределяем объект-жертву (a struct pid). Страница-жертва (страница, с которой взят объект-жертва) обычно будет находиться в slab ЦП из этапа 4, но если на этапе 4 slab ЦП был заполнен полностью, страница-жертва тоже может быть новым, только что распределённым slab ЦП.
- Применяем баг к объекту-жертве, чтобы создать неподсчитанную ссылку, и освобождаем объект.
- Распределяем ещё objs_per_slab+1 объектов. После этого страница-жертва будет полностью заполнена распределениями с этапов 4 и 7, и это больше не будет slab ЦП (потому что последнее распределение не могло уместиться на странице-жертве).
- Освобождаем все распределения с этапов 4 и 7. Благодаря этому страница-жертва становится пустой, но не освобождает страницу; после того, как со страницы-жертвы освобождается один объект, эта страница помещается в percpu partial list, и потом остаётся в этом списке.
- Освобождаем один объект на страницу из распределений этапа 3. Это добавляет все эти страницы в percpu partial list, пока он не достигнет предела в cpu_partial, после чего будет сброшен: страницы, содержащие используемые объекты, помещаются в частичный список узла NUMA SLUB, а совершенно пустые страницы освобождаются обратно в распределитель страниц. (Мы не освобождаем все распределения с этапа 3, потому что хотим, чтобы в распределитель страниц была освобождена только страница-жертва.) Стоит заметить, что для этого этапа требуется, чтобы каждый objs_per_slab-ый объект, выданный нам распределителем на этапе 3, находился на отдельной странице.
На этом этапе мы можем выполнять операции доступа для использования освобождённой памяти на какое-то смещение внутри свободной страницы-жертвы при помощи путей выполнения кода, интерпретирующих часть страницы-жертвы как struct pid. Стоит заметить, что на этом этапе мы всё ещё не знаем точно, на каком смещении внутри страницы-жертвы расположен объект-жертва.
Этап атаки: перераспределение страницы-жертвы в качестве таблицы страниц
На этапе, когда страница-жертва достигла списка свободной памяти распределителя страниц, игра, по сути, закончена — после этого страницу можно использовать как что угодно в системе, что даёт нам широкий выбор для эксплойтов. По моему мнению, большинство защит, действующих после того, как мы достигли этой точки, достаточно ненадёжны.Один из типов распределения, который непосредственно передаётся из распределителя страниц и имеет подходящие свойства для эксплойта — это таблицы страниц (также они использовались для эксплойта Rowhammer). Одним из способов злонамеренного использования возможности модифицирования таблицы страниц будет включение бита чтения/записи в элементе таблицы страниц (page table entry, PTE), привязывающего файловую страницу, к которой мы должны иметь доступ только для чтения — например, это можно использовать для получения доступа на запись к части сегмента .text двоичного файла setuid и замены его зловредным кодом.
Мы не знаем, на каком смещении внутри страницы-жертвы находится объект-жертва; но поскольку таблица страниц, по сути, является массивом байт-синхронизированных элементов размером 8 байт, а привязка объекта-жертвы будет кратным восьми, если мы распределяем все элементы массива-жертвы, нам не нужно знать смещение объекта-жертвы.
Чтобы распределить таблицу страниц, заполненную PTE, привязывающим ту же файловую страницу, нам нужно:
- Выполнить подготовку упорядоченной по 2 МиБ области памяти (потому что каждая таблица страниц последнего уровня описывает 2 МиБ виртуальной памяти), содержащей одностраничные привязки mmap() одной файловой таблицы (то есть каждая привязка соответствует PTE); затем
- Вызвать распределение таблицы страниц и заполнить её PTE, считав их из каждой привязки
Код:
struct pid: count | level | tasks[0] | tasks[1] | tasks[2] | ...
pagetable: PTE | PTE | PTE | PTE | ...
- Выполнить инкремент PTE на 0x42, чтобы задать бит Read/Write и бит Dirty. (Если мы не зададим бит Dirty, то ЦП сделает это самостоятельно, когда мы будем выполнять запись в соответствующий виртуальный адрес, поэтому здесь мы просто можем выполнить инкремент на 0x2.)
- Пытаемся перезаписать содержимое каждой привязки зловредными данными и игнорируем ошибки отсутствия страниц.
- Из-за устаревших записей TLB это может вызывать ложные ошибки, однако игнорирование ошибок отсутствия страниц автоматически устраняет такие записи TLB, поэтому если мы попытаемся выполнить запись дважды, то при второй записи это не может произойти.
- Простой способ игнорирования ошибок отсутствия страниц заключается в том, чтобы позволить ядру выполнять запись в память при помощи pread(), которая возвращает при ошибке -EFAULT.
После этого мы запускаем исполняемый файл 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 для взаимодействия с двоичным файлом, требующим терминал для ввода/вывода.
- Оно открывает путь к проблеме реализации ядра (потенциальным проблемам с безопасностью памяти) в открытом пользователям API, что может привести к проблемам с совместимостью и усложнением поддержки. Например, мне кажется, что с точки зрения безопасности будет неплохо требовать от телефонных приложений и сервисов systemd заявлять о своём намерении использовать подсистему PTY ещё на этапе установки, но это изменение в API потребует определённых действий со стороны разработчиков приложения, что создаст сложности, которые не были бы необходимыми, если бы мы были уверены в правильности работы ядра. Всё может ещё сильнее запутаться в случае ПО, в определённой конфигурации вызывающего внешние двоичные файлы, например, веб-сервера, которому требуется PTY-доступ, когда он используется для серверного администрирования. (Эта ситуация окажется чуть менее сложной, если безопасное приложение с возможностью эксплойта активно накладывает на себя ограничения; но не каждый разработчик приложения обязательно возжелает проектировать для своего кода надёжную песочницу, и даже если он захочет, могут возникнуть проблемы совместимости, вызванные библиотеками, не находящимися под контролем разработчика приложения.)
- Оно может защитить подсистему от контекста, которому необходим доступ к ней. (Например, к /dev/binder в Android имеют прямой доступ рендереры Chrome на Android, потому что внутри них выполняется код для Android.)
- Это означает, что решения, которые не должны влиять на безопасность системы (создание API, не дающего расширенных привилегий потенциально ненадёжному контексту), по сути, включают в себя компромисс безопасности.
Против багов в исходном коде: валидация блокировки в процессе компиляции
Баг в 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.
$
Кроме того, некоторые объекты имеют больше состояний жизненного цикла; в частности, для многих объектов с жизненным циклом, регулируемым RCU, доступ через ссылку RCU без предварительного обновления ссылки до учитывающейся в refcount возможен только к части множества элементов. Возможно, эту проблему можно решить введением нового атрибута типа, который можно использовать для пометки указателей на структуры в особых состояниях жизненного цикла? (Для кода на C++ Thread Safety Analysis компилятора Clang просто отключает все проверки во всех функциях конструкторов/деструкторов.)
Я надеюсь, что с некими расширениями нечто напоминающее Thread Safety Analysis компилятора Clang можно использовать для модификации части уровня безопасности в процессе компиляции против непреднамеренных гонок данных. Для этого потребуется добавить множество аннотаций, в частности, к заголовкам, чтобы задокументировать необходимую семантику блокировок; но такие аннотации, вероятно, всё равно необходимы для обеспечения продуктивной работы над сложной кодовой базой. По моему опыту, в случае отсутствия подробных комментариев/аннотаций о правилах блокировок, любая попытка изменения фрагмента кода, с которым вы знакомы плохо, превращается в экскурсию по зарослям окружающих его графов вызовов в попытках распутать предназначение этого кода.
Серьёзный недостаток заключается в том, что для этого необходимо внушить сообществу разработчиков с готовой базой данных идею о заполнении и поддержке таких аннотаций. И ещё кому-то придётся писать инструментарий анализа для проверки этих аннотаций.
На данный момент в ядре Linux есть очень грубая валидация блокировки при помощи sparse; однако эта инфраструктура неспособна распознавать ситуации, при которых используется ошибочная блокировка, и не умеет валидировать то, что элемент структуры защищён блокировкой. Также она неспособна правильно работать с такими вещами, как условная блокировка, что усложняет использование всего, кроме спин-блокировок/RCU. Валидация блокировки во время выполнения в ядре при помощи LOCKDEP является более продвинутой техникой, но она в основном делает упор на правильность блокировки указателей RCU, а также на распознавание зависаний (его основная задача); повторюсь, нет механизма, позволяющего, например, автоматически валидировать то, что к элементу структуры доступ осуществляется только под конкретной блокировкой (вероятно, это было бы достаточно затратно реализовывать с валидацией во время выполнения). Кроме того, являясь механизмом валидации во время выполнения, он неспособен выявлять ошибки в коде, которые не исполняются при тестировании (хотя он может комбинировать отдельно наблюдаемое поведение в сценарии гонки даже без наблюдаемой гонки).
Защита против багов в исходном коде: глобальный статический анализ блокировок
Альтернативным подходом к проверке правил безопасности при работы с памятью во время компиляции заключается или в выполнении проверки после компиляции всей кодовой базы, или при помощи внешнего инструмента, анализирующего всю кодовую базу. Это позволит такому инструментарию анализа выполнять анализ во всех единицах компиляции, что уменьшает объём информации, которую необходимо явно указывать в заголовках. Если добавление аннотаций в заголовках для вас неприемлемо, то это может быть более подходящим решением; но это также уменьшает полезность живых читателей кода, если только предполагаемую семантику не сделать видимой для них при помощи какой-нибудь специальной программы для просмотра кода. Кроме того, в дальней перспективе это может быть менее эргономичным, если изменения в одной части ядра могут привести к сбою верификации других частей, особенно когда эти сбои проявляются только в некоторых конфигурациях.Я считаю, что глобальный статический анализ является хорошим инструментом для поиска некоторых подмножеств багов, а также он может помочь с поиском наихудших случаев глубины стеков ядра или с доказательством отсутствия взаимоблокировок, но, вероятно, он меньше подходит для доказательства корректности безопасности при работе с памятью?
Последнее редактирование модератором: