Статья Как простой баг повреждения памяти ядра Linux приводит к полной компрометации системы(Часть 1)

Admin

Администратор

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

Часть 1​


1767306832048

Введение​

В этом посте описывается простой в реализации баг блокировки ядра 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, а также на распознавание зависаний (его основная задача); повторюсь, нет механизма, позволяющего, например, автоматически валидировать то, что к элементу структуры доступ осуществляется только под конкретной блокировкой (вероятно, это было бы достаточно затратно реализовывать с валидацией во время выполнения). Кроме того, являясь механизмом валидации во время выполнения, он неспособен выявлять ошибки в коде, которые не исполняются при тестировании (хотя он может комбинировать отдельно наблюдаемое поведение в сценарии гонки даже без наблюдаемой гонки).

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

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

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

 
Последнее редактирование модератором:
Похожие темы
Admin Статья Как простой баг повреждения памяти ядра Linux приводит к полной компрометации системы(Часть 2) Уязвимости и взлом 0
Support81 Личное в публичное: как простой вызов такси оборачивается потерей данных Новости в сети 0
Admin Интересно «Пароль01» и дырявый VPN. Как пустить хакеров в сеть, чтобы они сломали вообще всё (пошаговая инструкция). Новости в сети 0
Admin Статья Как "Казаки" паттерны мошенников-"Разбойников" вычисляют, вооружаясь технологиями. Анонимность и приватность 0
Admin Интересно Ваш сервер — их притон: как группа UAT-7290 сдает ваши сервера в аренду своим друзьям. Дорого. Новости в сети 0
Admin Интересно Как стать «богом» в Linux, просто правильно подгадав время. Спойлер: вам понадобится Chronomaly. Новости в сети 0
Admin Статья Как оставаться незаметным в 2025 году – простые правила оперативной безопасности для всех. Анонимность и приватность 0
Admin Статья HTTP Request Smuggling в 2025: Как обходить современные WAF Уязвимости и взлом 0
Admin Статья Криптография в малвари: Как работают вымогатели (Ransomware). Полезные статьи 0
Admin Статья Право на root. Как повышают привилегии в Linux. Уязвимости и взлом 0
Admin Статья Как Mozilla упустила (не)очевидную уязвимость Уязвимости и взлом 0
Admin Статья Почему ваш «Windows» прокси палится как Linux: Глубокий разбор TCP Window Size, о котором молчат. Анонимность и приватность 0
Admin Интересно Старый конь борозды не испортит. Как сертификат десятилетней давности помог хакерам проникнуть в госучреждения Азии. Новости в сети 0
Admin Статья Direct Syscalls vs EDR: Как заставить Windows выполнять ваши команды в обход хуков защитного ПО Вирусология 0
Admin Интересно Gemini лезет из каждой дыры Chrome? Вот как убить все ИИ-кнопки и вернуть нормальный браузер. Новости в сети 0
Admin Интересно «Здравствуйте, я журналист, заполните анкету». Как хакеры из КНДР «разводят» южнокорейских экспертов. Новости в сети 0
Admin Статья Гейминг как источник данных: OSINT в виртуальных мирах OSINT 0
Admin Статья Крипто-детектив: Идем по следу транзакций. Как деанонить блокчейн. OSINT 0
Admin Интересно Семь миллионов долларов за одну ночь. Рассказываем, как пострадали пользователи Trust Wallet и что делать сейчас. Новости в сети 0
Admin Интересно Казалось, что летим, а на деле — ползём. Как ИИ-помощники незаметно крадут время у профессиональных кодеров. Новости в сети 0
Admin Статья Анонимные мессенджеры: Как общаться, не оставляя следов Анонимность и приватность 0
Admin Интересно Охотник стал добычей. Как «безопасники» ловят вирусы, пытаясь скачать инструменты для их поиска. Новости в сети 0
Admin Интересно Цифровое чудо на Рождество. Как ученым удалось восстановить UNIX V4 с ленты 1970-х годов. Новости в сети 0
Admin Статья Взгляд с другой стороны: как Linux админ ловит вас Полезные статьи 0
Admin Статья Как отслеживается e-mail? OSINT 0
Support81 «Менеджер» с архивом и черным ходом через Yandex. Как группировка APT31 годами шпионила за российскими IT-компаниями Новости в сети 1
Support81 От 314 до 968 млрд рублей. Как российский рынок кибербезопасности станет монополией за 6 лет Новости в сети 0
Support81 Перевод крупной суммы по СБП на свой же счёт будет расцениваться банком как подозрительный Новости в сети 0
Support81 Перехват DNS – что это за атака и как она работает? Новости в сети 0
Support81 Суверенный Рунет. Мишустин подписал постановление о том, как им будут управлять (и от чего защищать) Новости в сети 0
Support81 $120000000 испарились за утро: как хакерам удалось обойти 10 аудитов и причем здесь ракетная программа КНДР Новости в сети 0
Support81 Одна буква — миллионные потери. Как русская «Е» обманула разработчиков и присвоила их крипту Новости в сети 0
Support81 «Ага, туннель! Придушим». Ваш VPN тоже лагает на 4G? Объясняем, как операторы видят ваш трафик (и что с этим делать) Новости в сети 0
Support81 «Магический пакет» творит чудеса: как хакеры превратили Linux-сервер в невидимку Новости в сети 0
Support81 Касперский против ChatGPT: как антивирус вычислил вредонос, написанный ИИ Новости в сети 0
Support81 Не Таиланд, а рабство в Мьянме: как туристический рай стал перевалочной базой для похитителей россиян Новости в сети 0
Support81 Оригинальный соучредитель Tesla, управлявший компанией до прихода Маска, заявил, что Cybertruck выглядит как «мусорный контейнер» Новости в сети 0
wrangler65 Как стать хакером для «самых маленьких» Ч.2 Полезные статьи 0
wrangler65 Как стать хакером для «самых маленьких» Ч.1 Полезные статьи 0
Support81 Как война в Украине стала полигоном для наркокартелей Новости в сети 0
Support81 Вайб-кодинг звучал как шутка, пока Opal от Google не начал делать сайты по вашему описанию Новости в сети 0
Support81 Серые токены, чёрные схемы: как российский бизнес уходит в крипту до принятия закона Новости в сети 1
Support81 Нажали Play — хакер уже в системе. Как работает звуковой троян Новости в сети 0
Support81 Белым по белому: как стать «гением» в науке с помощью ChatGPT Новости в сети 0
Support81 Телефон против владельца: как Android помогает хакерам воровать криптовалюту Новости в сети 0
Support81 Операторы хакерского форума BreachForums, как сообщается, арестованы во Франции Новости в сети 0
Support81 Российская ИБ стала как медицина в глубинке — врач один, а если заболеет, то все умрут Новости в сети 0
Support81 Важно! Мобильный аудит Wi-Fi сетей: как быстро найти уязвимости с помощью Stryker Уязвимости и взлом 0
wrangler65 Интересно Как МВД России ищет киберпреступников и как оставаться анонимным в 2025 Анонимность и приватность 0
Support81 JPEG, пицца и разоблачения: как Error Level Analysis считывает ложь по пикселям Новости в сети 0

Название темы