В этом посте раскрывается эксплойт CVE-2022-27666, уязвимость, позволяющая добиться локального повышения привилегий на последней версии Ubuntu Desktop 21.10. Изначально мы приберегли ее для pwn2own 2022, но она была пропатчена за 2 месяца до начала конкурса. Поэтому было решено обнародовать наш эксплойт и опубликовать PoC в конце поста.

Наше первичное исследование показало, что эта уязвимость затрагивает последние версии Ubuntu, Fedora и Debian. Наш эксплойт был создан для атаки на Ubuntu Desktop 21.10 (последняя версия на момент написания эксплойта).

Эксплойт достиг около 90% достоверности на свежеустановленной Ubuntu Desktop 21.10 (стандартная настройка VMware: 4G памяти, 2 CPU), нам удалось придумать несколько новых уловок по стабилизации кучи, чтобы осуществить митигацию уровня шума кучи ядра. Что касается самой техники эксплойта, то здесь я впервые занимаюсь кучей феншуй на уровне страницы и переполнением кросс-кэша, а для утечки оффсета KASLR и повышения привилегий я выбрал arb чтение/запись msg_msg. В процессе написания этого эксплойта я узнал очень много нового и надеюсь, что вы получите удовольствие от его прочтения.

Первопричина

CVE-2022-27666 — это уязвимость в криптомодуле Linux esp6, она была представлена в 2017 году в коммитах cac2661c53f3 и 03e2a30f6a27. Основная логика этой уязвимости заключается в том, что принимающий буфер пользовательского сообщения в модуле esp6 составляет 8 страниц, но отправитель может отправить сообщение большего размера, что явно приводит к переполнению буфера.

esp6_output_head отвечает за аллокацию принимающего буфера, размер allocsize здесь не важен, для  skb_page_frag_refill по умолчанию аллоцируется 8-страничный смежный буфер в строке 9.

int esp6_output_head(struct xfrm_state *x, struct sk_buff *skb, struct esp_info *esp)
{
        ...
        int tailen = esp->tailen;
        allocsize = ALIGN(tailen, L1_CACHE_BYTES);

        spin_lock_bh(&x->lock);

        if (unlikely(!skb_page_frag_refill(allocsize, pfrag, GFP_ATOMIC))) {
        	spin_unlock_bh(&x->lock);
	        goto cow;
        }
        ...
}

bool skb_page_frag_refill(unsigned int sz, struct page_frag *pfrag, gfp_t gfp)
{
        if (pfrag->offset + sz <= pfrag->size)
		return true;
	...
	if (SKB_FRAG_PAGE_ORDER &&
	    !static_branch_unlikely(&net_high_order_alloc_disable_key)) {

		pfrag->page = alloc_pages((gfp & ~__GFP_DIRECT_RECLAIM) |
					  __GFP_COMP | __GFP_NOWARN |
					  __GFP_NORETRY,
					  SKB_FRAG_PAGE_ORDER);
		...
	}
	...
	return false;
}

Для skb_page_frag_refill аллоцировано порядка 3 страниц, что является 8-страничным непрерывным буфером. Поэтому максимальный размер принимающего буфера составляет 8 страниц, а размер входных данных может быть больше 8 страниц, что создает переполнение буфера в null_skcipher_crypt. В этой функции (строка 11) ядро копирует данные размером N страниц в буфер размером 8 страниц, что явно приводит к внеполосной (out-of-band) записи.

static int null_skcipher_crypt(struct skcipher_request *req)
{
	struct skcipher_walk walk;
	int err;

	err = skcipher_walk_virt(&walk, req, false);

	while (walk.nbytes) {
		if (walk.src.virt.addr != walk.dst.virt.addr)
			// out-of-bounds write
			memcpy(walk.dst.virt.addr, walk.src.virt.addr,
			       walk.nbytes);
		err = skcipher_walk_done(&walk, 0);
	}

	return err;
}

Теперь давайте посмотрим на патч, устраняющий эту уязвимость. ESP_SKB_FRAG_MAXSIZE это 32768, что равно 8 страницам. Если allocsize больше 8 страниц, то происходит откат к COW;

diff --git a/include/net/esp.h b/include/net/esp.h
index 9c5637d41d951..90cd02ff77ef6 100644
--- a/include/net/esp.h
+++ b/include/net/esp.h
@@ -4,6 +4,8 @@
 
 #include <linux/skbuff.h>
 
+#define ESP_SKB_FRAG_MAXSIZE (PAGE_SIZE << SKB_FRAG_PAGE_ORDER)
+
 struct ip_esp_hdr;
 
 static inline struct ip_esp_hdr *ip_esp_hdr(const struct sk_buff *skb)
diff --git a/net/ipv4/esp4.c b/net/ipv4/esp4.c
index e1b1d080e908d..70e6c87fbe3df 100644
--- a/net/ipv4/esp4.c
+++ b/net/ipv4/esp4.c
@@ -446,6 +446,7 @@ int esp_output_head(struct xfrm_state *x, struct sk_buff *skb, struct esp_info *
 	struct page *page;
 	struct sk_buff *trailer;
 	int tailen = esp->tailen;
+	unsigned int allocsz;
 
 	/* this is non-NULL only with TCP/UDP Encapsulation */
 	if (x->encap) {
@@ -455,6 +456,10 @@ int esp_output_head(struct xfrm_state *x, struct sk_buff *skb, struct esp_info *
 			return err;
 	}
 
+	allocsz = ALIGN(skb->data_len + tailen, L1_CACHE_BYTES);
+	if (allocsz > ESP_SKB_FRAG_MAXSIZE)
+		goto cow;
+
 	if (!skb_cloned(skb)) {
 		if (tailen <= skb_tailroom(skb)) {
 			nfrags = 1;
diff --git a/net/ipv6/esp6.c b/net/ipv6/esp6.c
index 7591160edce14..b0ffbcd5432d6 100644
--- a/net/ipv6/esp6.c
+++ b/net/ipv6/esp6.c
@@ -482,6 +482,7 @@ int esp6_output_head(struct xfrm_state *x, struct sk_buff *skb, struct esp_info
 	struct page *page;
 	struct sk_buff *trailer;
 	int tailen = esp->tailen;
+	unsigned int allocsz;
 
 	if (x->encap) {
 		int err = esp6_output_encap(x, skb, esp);
@@ -490,6 +491,10 @@ int esp6_output_head(struct xfrm_state *x, struct sk_buff *skb, struct esp_info
 			return err;
 	}
 
+	allocsz = ALIGN(skb->data_len + tailen, L1_CACHE_BYTES);
+	if (allocsz > ESP_SKB_FRAG_MAXSIZE)
+		goto cow;
+
 	if (!skb_cloned(skb)) {
 		if (tailen <= skb_tailroom(skb)) {
 			nfrags = 1;

Возможность эксплойта

В нашем предварительном эксперименте нам удалось отправить 16 страниц данных, что означает, что мы создаем переполнение на 8 страниц в пространстве ядра, этого вполне достаточно для обычной OOB (out-of-band)-записи. Стоит упомянуть, что esp6 добавляет несколько байт в хвост функции esp_output_fill_trailer. Эти байты рассчитываются исходя из длины входящего сообщения и используемого протокола.

static inline void esp_output_fill_trailer(u8 *tail, int tfclen, int plen, __u8 proto)
{
	/* Fill padding... */
	if (tfclen) {
		memset(tail, 0, tfclen);
		tail += tfclen;
	}
	do {
		int i;
		for (i = 0; i < plen - 2; i++)
			tail[i] = i + 1;
	} while (0);
	tail[plen - 2] = plen - 2;
	tail[plen - 1] = proto;
}

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

Куча фэншуй на уровне страницы

Эта уязвимость не похожа на другие эксплойты, которые я разрабатывал ранее, она требует кучи феншуй на уровне страницы. Помните, что OOB-запись происходит из 8-страничного смежного буфера, соответственно, переполнение распространяется на соседние страницы. В те дни, когда я начал писать эксплойт, у меня не было опыта работы с аллокатором страниц, поэтому мне пришлось буквально просмотреть весь page_alloc.c и понять основные механизмы, которые помогли разработать этот эксплойт.

Страничный аллокатор

Страничный аллокатор Linux, он же buddy аллокатор, управляет физическими страницами в ядре Linux. Страничный аллокатор поддерживает управление памятью более низкого уровня за аллокаторами памяти, такими как SLUB, SLAB, SLOB. Один простой пример: когда ядро использует все слэбы kmalloc-4k, аллокаторы памяти запрашивают новый слэб/память у аллокатора страниц, в данном случае kmalloc-4k имеет слэб на 8 страниц (порядок 3), поэтому он запрашивает память на 8 страниц у аллокатора страниц.

Аллокатор страниц хранит свободные страницы в структуре данных под названием free_area. Это массив, который содержит различные порядки/размеры страниц. Терм для дифференциации размеров страниц — это order (порядок). Чтобы вычислить размер страниц N-го порядка, используйте PAGE_SIZE << N. В этом случае страница order-0 равна 1 странице (PAGE_SIZE << 0), страница order-1 равна 2 страницам (PAGE_SIZE << 1), ... , страница order-3 равна 8 страницам (PAGE_SIZE << 3). Для каждого порядка в free_area ведется free_list. Страницы выделяются из free_list и высвобождаются обратно в free_list.

Различные слэбы ядра запрашивают разные порядки страниц по мере использования free_list. Например, в Ubuntu 21.10, kmalloc-256 запрашивает страницу order-0, kmalloc-512 запрашивает страницу order-1, kmalloc-4k запрашивает страницу order-3.

Если в этом free_list нет освобожденных страниц, то free_area низшего порядка заимствует страницы из free_area высшего порядка. В этом случае страницы вышестоящего порядка разделяются на два блока, блок с меньшим адресом отправляется источнику запроса страниц (обычно alloc_pages()), а блок с большим адресом отправляется в free_list нижестоящего порядка. Например, когда free_list order-2 (4 страницы) полностью исчерпан, он запрашивает страницы из order-3 (8 страниц), страницы order-3 разбиваются на два блока по 4 страницы, блок с меньшим адресом 4 страниц назначается объекту, который запрашивает эти страницы, блок с большим адресом 4 страниц отправляется в free_list order-2, так что в следующий раз, если процесс снова запросит страницы order-2, в его free_list будет одна освобожденная страница order-2.

Рисунок 1: разделение высшего порядка на низший
Рисунок 1: разделение высшего порядка на низший

Если в списке free_list слишком много освобожденных страниц, page_allocator начинает мерджить две одинаковые по порядку и физически соседние страницы в более высокий порядок. Для пояснения возьмем тот же пример. Если страница order-3 была разделена на две страницы order-2, то одна из них была аллоцирована, а другая осталась в списке free_list order-2. Как только аллоцированные страницы будут освобождены обратно в список order-2, аллокатор страниц проверяет, есть ли у этих вновь освобожденных страниц соседние в том же списке free_list (они называются 'buddy'), если да, то они будут смерджены в order-3.

Рисунок 2: Низший порядок мерджится в более высокий
Рисунок 2: Низший порядок мерджится в более высокий

Следующий сниппет показывает, как аллокатор страниц берет блоки страниц из free_area и как заимствовать страницы более высокого порядка.

static __always_inline
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
						int migratetype)
{
	unsigned int current_order;
	struct free_area *area;
	struct page *page;


	for (current_order = order; current_order < MAX_ORDER; ++current_order) {
		// Pick up the right order from free_area
		area = &(zone->free_area[current_order]);
		// Get the page from the free_list
		page = get_page_from_free_area(area, migratetype);
		// If no freed page in free_list, goes to high order to retrieve
		if (!page)
			continue;
		del_page_from_free_list(page, zone, current_order);
		expand(zone, page, order, current_order, migratetype);
		set_pcppage_migratetype(page, migratetype);
		return page;
	}

	return NULL;
}

static inline struct page *get_page_from_free_area(struct free_area *area,
					    int migratetype)
{
	return list_first_entry_or_null(&area->free_list[migratetype],
					struct page, lru);
}

Формирование кучи

Теперь поговорим о том, как организовать расположение кучи для OOB-записи. Как известно, аллокатор страниц ведет список free_list для каждого порядка в области free_area. Страницы приходят и уходят из free_list, нет гарантии, что два блока страниц в одном free_list являются смежными, поэтому даже если мы последовательно аллоцируем страницы в одном и том же порядке, они все равно могут быть далеко друг от друга. Чтобы правильно сформировать кучу, необходимо убедиться, что все страницы в одном списке free_list являются смежными. Для этого надо опустошить free_list нужного порядка и заставить его заимствовать блоки страниц у вышестоящего. Как только он позаимствует страницы у более высокого порядка, две последовательные аллокации разделят страницы вышестоящего порядка, и что особенно важно, страницы этого порядка будут представлять собой фрагмент непрерывной памяти.

Митигация шума

Одна из проблем формирования кучи — как ослабить шум? Мы заметили, что некоторые процессы демонов ядра постоянно аллоцируют и освобождают страницы туда и обратно, страницы низшего порядка могут мерджиться со страницами высшего порядка, страницы высшего порядка разделяются, чтобы удовлетворить потребности страниц низшего порядка, все это создает массу шума при формировании кучи. В нашем случае, мы пытаемся выполнить груминг кучи со страницами order-3, но order-2 всегда запрашивает и разделяет страницы order-3 или иногда страницы order-2 мерджатся обратно в free_list order-3.

Чтобы уменьшить этот шум, осуществить его митигацию, я решил сделать следующее:

  1. опустошить freelist порядков 0, 1, 2.

  2. аллоцировать множество объектов order-2 (предположим, что это N), при этом order-2 будет заимствовать страницы у order-3.

  3. освободить каждую половину объектов из шага 2, вторую половину оставить. Это приведет к появлению еще N/2 объектов, которые вернутся во freelist order-2.

  4. освободить все объекты с шага 1

На шаге 3 мы не хотим освобождать все объекты, потому что они вернутся в order-3, когда найдут соседние страницы в free_list order-2. Если освободить каждую половину страниц order-2, то они навсегда окажутся в списке free_list order-2. Этот метод создает еще N/2 страниц в free_list order-2 и таким образом предотвращает заимствование/мерджинг страниц order-2 из/в order-3. Эта стратегия защищает наш процесс груминга кучи.

Рисунок 3: Эта анимация показывает стратегию митигации уровня шума
Рисунок 3: Эта анимация показывает стратегию митигации уровня шума

Утечка KASLR-оффсета 

Вариант 1: struct msg_msg

Первым шагом для эксплойта является утечка KASLR-оффсета. Одна простая мысль, которая пришла мне в голову, заключается в использовании struct msg_msg для создания возможности произвольного чтения. Основная идея этого подхода заключается в перезаписи поля m_ts в struct msg_msg, которое изменяет длину сообщения (struct msg_msgseg). m_ts управляет длиной сообщения, на которое показывает указатель next . Перезаписывая m_ts, мы можем читать без ограничений по указателю next.

struct msg_msg {
	struct list_head m_list;
	long m_type;
	size_t m_ts;		/* message text size */
	struct msg_msgseg *next;
	void *security;
	/* the actual message follows immediately */
};

struct msg_msgseg {
	struct msg_msgseg *next;
	/* the next part of the message follows immediately */
};

Во время тестирования я понял, что байты мусора, которые добавляются в хвост, полностью разрушают этот подход. Когда мы перезаписываем поле m_ts, мусорные байты также перезаписывают несколько байт в указателе next. Поскольку next поврежден, внеполосное чтение не имеет смысла (OOB-чтение с адреса, к которому привязан указатель next.

Рисунок 4: Мусор повреждает указатель
Рисунок 4: Мусор повреждает указатель

Вариант 2: struct user_key_payload

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

struct user_key_payload {
	struct rcu_head	rcu;		/* RCU destructor */
	unsigned short	datalen;	/* length of this data */
	char		data[] __aligned(__alignof__(u64)); /* actual data */
};

Указатель rcu можно смело установить в NULL, datalen — это поле длины, которое мы планируем перезаписать, мусорные данные повреждают несколько байт в data[], что вполне нормально, поскольку это просто чистые данные.

Одно реальное ограничение этой структуры заключается в том, что обычные пользователи на Ubuntu имеют жесткий лимит на количество ключей и общее количество байт, которые вы можете аллоцировать. Как показано на рисунке ниже, Ubuntu позволяет обычному пользователю иметь максимум 200 ключей и 20000 байт полезной нагрузки в целом.

Это ограничивающее условие делает эксплойт немного сложным. Давайте еще раз посмотрим на предварительную схему кучи. Уязвимый буфер при переполнении распространяется на соседнюю память (Victim SLAB на рисунке). Для того чтобы аллоцировать валидный слэб на этой памяти-жертве, его размер должен составлять 8 страниц. В Ubuntu только kmalloc-2k, kmalloc-4k и kmalloc-8k имеют слэбы order-3.

Я решил заполнить SLAB жертвы объектами kmalloc-4k, поэтому минимальный размер user_key_payload составляет 2049 байт (округлите до 4k). Для kmalloc-4k каждый слэб имеет 8 объектов. Чтобы заполнить весь слэб user_key_payload, мы должны использовать 2049*8=16392 байт. Помните, что у нас всего 20000 байт, и, таким образом, остается только один user_key_payload ((20000-16392)/2049=1). В заключение, у нас есть только два слэба для кучи фэншуй, что создает нам очень слабую отказоустойчивость, любой шум может разрушить наш фэншуй.

Рисунок 5: SLAB-схема со слабой отказоустойчивостью
Рисунок 5: SLAB-схема со слабой отказоустойчивостью

Чтобы смягчить эту проблему, я выбрал подход "один user_key_payload на слэб". Каждый раз я аллоцирую только 1 user_key_payload в каждом слэбе и заполняю остальную его часть другими объектами. Позиция user_key_payload может быть произвольной в слэбе из-за рандомизации freelist. Для нашего случая это нормально, так как внеполосная запись может перезаписать весь слэб (8 страниц), который в конечном итоге покроет объект user_key_payload. При таком подходе я могу создать 9 слэбов вместо 2 для кучи фэншуй, и это удается до тех пор, пока один слэб находится в нужном месте.

Рисунок 6: SLAB-схема с высокой отказоустойчивостью
Рисунок 6: SLAB-схема с высокой отказоустойчивостью

Что читать?

Теперь у нас есть user_key_payload — давайте начнем думать о том, какой объект ядра мы планируем подвергнуть опасности. Самый простой способ — поместить объект с указателем функции рядом с user_key_payload, затем осуществить утечку KASLR-оффсета, вычисляя разницу между указателем функции и ее адресом в файле символов. Но на самом деле мне хочется продемонстрировать технику чтения и записи arb из этого замечательного поста, и именно поэтому я решил слить корректный next указатель struct msg_msg.

Если у меня есть корректный указатель next , то позже я смогу перезаписать m_ts и заменить его next, не беспокоясь о повреждении чего-либо (указатель security повреждается при перезаписи next, но в Ubuntu указатель security не используется, и он всегда равен нулю).

Этап 1: Утечка корректного указателя next из struct msg_msg

Схема кучи показана на рисунке ниже. Черная стрелка обозначает OOB-запись, оранжевая — OOB-чтение. Сначала мы используем начальную OOB-запись для перезаписи datalen struct user_key_payload, а затем выполним OOB-чтение, прочитав пейлоад поврежденной struct user_key_payload, наша цель — указатель  next в struct msg_msg.

Поскольку позже мы будем использовать этот next указатель для KASLR-утечки, нам необходимо подготовить массу struct seq_operations вместе со struct msg_msgseg. Обе структуры должны попасть в kmalloc-32, что накладывает ограничение на размер msg: 4056 байт - 4072 байт. Это связано с тем, что только 4096-48(заголовок msg_msg) байт будут помещены в основное тело msg, а остальное пойдет в связный список, поддерживаемый указателем next. Чтобы убедиться, что указатель next направляет именно на kmalloc-32, данные в struct msg_msgseg не должны превышать 32-8(заголовок msg_msgseg) байт и не могут быть меньше 16-8 байт (иначе они перейдут к kmalloc-16). Если вам все еще непонятно, посмотрите эту статью.

Рисунок 7: Расположение кучи высокого уровня для утечки следующего указателя
Рисунок 7: Расположение кучи высокого уровня для утечки следующего указателя

Чтобы инкрементировать процент успеха и уменьшить шум, мы создаем 9 пар такого расположения кучи (9 — это наибольшее количество user_key_payload, которое я могу выделить в пределах kmalloc-4k), до тех пор, пока одна пара будет успешна, мы получим корректный указатель next struct msg_msg.

Рисунок 8: Детализированное объяснение нашей кучи фэншуй
Рисунок 8: Детализированное объяснение нашей кучи фэншуй

Фаза 2: Утечка KASLR-оффсета 

Когда у нас есть корректный указатель next, переходим ко второй фазе. Создаем другой макет кучи для утечки KASLR-оффсета. struct seq_operations — подходящий кандидат. Используем начальную OOB-запись для перезаписи m_ts (длина сообщения) и указатель next (адрес сообщения); в результате мы прочитаем указатель функции из struct seq_operations.

Рисунок 9: Черная стрелка обозначает OOB-запись, оранжевая - OOB-чтение
Рисунок 9: Черная стрелка обозначает OOB-запись, оранжевая - OOB-чтение

Итак, мой путь к утечке таков:

Фаза 1

  1. Аллоцируем тонны 8-страничного буфера, чтобы опустошить free_list order-3. Затем order-3 заимствует страницы у вышестоящего порядка, что делает их смежными.

  2. Аллоцируйте три смежных 8-страничных объекта-пустышки.

  3. Освободите второй фиктивный объект, аллоцируйте 8-страничный слэб, содержащий 1 struct user_key_payload и 7 других объектов.

  4. Освободите третий фиктивный объект, аллоцируйте 8-страничный слэб, заполненный struct msg_msg. Размер сообщения должен быть в диапазоне от 4056 до 4072, чтобы struct msg_msgseg попал в kmalloc-32.

  5. Аллоцируйте тонны struct seq_operations. Эти структуры будут находиться в одном слэбе со struct msg_msgseg, который мы аллоцировали в шаге 4.

  6. Освобождаем первый фиктивный объект, аллоцируем буфер уязвимости и начинаем внеполосную запись — мы планируем модифицировать поле datalen в struct user_key_payload.

  7. Если шаг 6 завершится успешно, получение пэйлоад из этой user_key_payload будет приводить к внеполосному чтению. Это OOB-чтение сообщит нам содержимое struct msg_msg, включая его указатель next.

  8. Если шаг 7 завершится успешно, у нас теперь есть корректный указатель next на объект struct msg_msg.

Фаза 2

  1. Аллоцируйте два смежных объекта-пустышки на 8 страниц.

  2. Освободите второй фиктивный объект, аллоцируйте слэб, заполненный struct msg_msg.

  3. Освободите первый фиктивный объект, аллоцируйте уязвимый буфер, перезапишите поле m_ts на большее значение, а также перезапишите указатель next на тот, который мы получили на шаге 7 фазы 1.

  4. Если шаг 3 фазы 2 прошел успешно, у нас должно получиться OOB-чтение памяти kmalloc-32. Вполне вероятно, что мы прочитаем указатель функции из struct seq_operations, который был аллоцирован на шаге 5 фазы 1. Затем мы вычислим KASLR-оффсет.

Получить Root

Как только мы узнали в результате утечки KASLR-оффсет, произвольная запись msg_msg становится валидной опцией. Идея этой техники заключается в том, чтобы заблокировать первый copy_from_user от копирования пользовательских данных (строка 7), а затем изменить указатель next (строка 11 показывает, что seg поступает из msg->next), возобновить процесс, следующий copy_from_user будет произвольной записью (строка 17).

struct msg_msg *load_msg(const void __user *src, size_t len)
{

	...
	// hang the process at the first copy_from_user
	// modify the msg->next and resume the process
	if (copy_from_user(msg + 1, src, alen))
		goto out_err;

	// msg->next has been changed to an arbitrary memory
	for (seg = msg->next; seg != NULL; seg = seg->next) {
		len -= alen;
		src = (char __user *)src + alen;
		alen = min(len, DATALEN_SEG);

		// Now an arbitrary write happens
		if (copy_from_user(seg + 1, src, alen))
			goto out_err;
	}

	...
}

Если вы знакомы с userfaultfd, вам может быть известна техника, использующая userfaultfd для зависания процесса. Я писал об этом в блоге около двух лет назад. К сожалению, после ядра версии 5.11 обычному пользователю требуется специальная возможность для использования userfaultfd. Зато теперь у нас есть другая техника, позволяющая сделать то же самое. Спасибо Jann за то, что поделился идеей использования FUSE. FUSE — это файловая система для пользовательского пространства; мы можем создать свою собственную файловую систему и распределить на нее память, все операции чтения и записи через эту память будут обрабатываться нашей собственной файловой системой. Таким образом, мы можем просто подвесить процесс при чтении (copy_from_user читает данные из пользовательского пространства) и высвободить его, когда указатель next будет изменен.

Рисунок 10: Демонстрация того, как злоупотребление FUSE приводит к arb-записи
Рисунок 10: Демонстрация того, как злоупотребление FUSE приводит к arb-записи

Используя произвольную запись, мы перезаписываем путь modprobe. modprobe — это программа пользовательского пространства, которая загружает модули ядра. Каждый раз, когда ядро планирует загрузить модуль, оно выполняет вызов в пользовательское пространство и запускает modprobe на root-правах для загрузки целевого модуля. Чтобы знать, где находится modprobe, ядро компилирует путь в жестком коде; этот путь modprobe хранит в глобальной переменной modprobe_path.

Рисунок 11: Использование arb-записи для перезаписи modprobe_path
Рисунок 11: Использование arb-записи для перезаписи modprobe_path

В моем эксплойте modprobe_path изменяется  на мою собственную программу /tmp/get_rooot, которая запускает chmod u+s /bin/bash. Когда ядро запускает /tmp/get_rooot на root-правах, оно изменяет разрешение /bin/bash; любой, кто запустит bash, будет запускать его на root-правах.

Мои шаги для того чтобы получить root shell:

  1. Выделить два смежных 8-страничных фиктивных объекта.

  2. Маппировать содержимое сообщения с FUSE и освободить второй фиктивный объект, аллоцировать 8-страничный слэб, содержащий struct msg_msg. На этом шаге потоки будут зависать.

  3. Освободите первый фиктивный объект, аллоцируйте уязвимый объект, замените указатель next смежного struct msg_msg на адрес modprobe_path.

  4. Освободите потоки, которые зависли на шаге 2, скопируйте строку "/tmp/get_rooot" в modprobe_path.

  5. Запустите modprobe, запустив файл неизвестного формата

  6. Открываем /bin/bash, теперь мы root 

Доступ к эксплойту на моем Github


Приглашаем всех заинтересованных на открытое занятие «Основные принципы информационной безопасности стека приложений и инфраструктуры». На этом занятии мы рассмотрим и разберем основные принципы обеспечения информационной безопасности стека приложений и инфраструктуры. Поговорим о следующих подходах: Zero Trust Network Access, Security As A Service Edge, Defense In Depth. Регистрация по ссылке.

Комментарии (0)