Введение
На прошедшем BSides Noida CTF мы столкнулись с двумя пывнами ядра. До этих соревнований мы всегда обходили эти таски стороной, потому что ни у кого не было достаточных навыков и экспертизы, чтобы взяться за такие задачи. Но в этот раз мы решили наконец-то научиться решать простые ядерные таски.
В целом задачи были действительно для начинающих, однако по количеству решений было заметно, что люди всё ещё боятся или не знают как решать несложные ядерные таски.
В этом посте мы покажем как решались эти два задания, но не будем уделять много внимания теоретической части (защиты ядра, концепция пывна ядра), однако оставим ссылку на хороший теоретический материал для начинающих, который помог нам при решении данных задач.
Хорошие материалы для начинающих
Мы узнали много полезного из данного цикла статей. Первых двух статей будет вполне достаточно, чтобы в полной мере понять, как решались данные таски.
Также есть статья про базовые вещи на русском (но вам нужна подписка на Хакер и, судя по заголовкам, там нет информации про ядерные митегейшены).
Для дальнейшего освоения темы можно использовать различные подборки ресурсов, например, репозиторий с полезными ссылками от Андрея xairy Коновалова.
Первый таск — suscall
Данный таск является очень приятным для новичков, потому что в нём даже не нужно взаимодействовать с модулем ядра и обходить какие-либо защиты в ядре.
Нам дан архив с файлами:
В файле suscall.c мы можем найти исходный код сискола, который реализовали разработчики таска. Также нам нужно обратить внимание на файл запуска системы. Чаще всего в нём содержатся аргументы для запуска эмулятора qemu, и из них мы можем узнать о том, какие защиты будут работать при запуске данной системы.
Из защит у нас только KASLR и KPTI, но в данной задаче нам нужно обойти только KASLR.
Посмотрим исходный код:
У нас есть системный вызов, который позволяет нам указать любой адрес, по которому будет вызван код. При этом у нас нет защиты SMEP и SMAP, что позволит нам просто расположить произвольный код в адресном пространстве пользователя и выполнить его.
Подробные описания защиты вы можете найти в ссылках, указанных выше, но если кратко, то SMEP (Supervisor Mode Execution Prevention) — это технология, которая запрещает выполнение кода в пользовательском пространстве во время нахождения в режиме ядра. SMAP (Supervisor Mode Access Prevention) — это технология, которая запрещает обращение к пользовательскому адресному пространству из режима ядра.
Так как этих защит нет, мы можем просто разместить код в режиме пользователя и передать указатель при вызове системного вызова. Системный вызов осуществит вызов нашего кода и вернёт управление.
Концепция эксплуатации ядра отличается от пользовательского пространства тем, что вы не можете просто запустить себе оболочку /bin/sh или вызвать другой исполняемый файл.
Однако вы можете изменить привилегии у процесса и, уже вернувшись из ядерного контекста, запустить оболочку в процессе. Для того, чтобы повысить себе привилегии, достаточно вызвать две функции — prepare_kernel_cred и commit_creds.
Чтобы их вызвать, нужно получить адреса, а с включенным KASLR потребуется сначала получить утечку. Сделать её довольно просто, потому что мы можем выполнять произвольный код. Начнём создавать наш эксплоит.
Пишем эксплоит для первого таска
Для того чтобы обойти KASLR нам достаточно выполнить одну ассемблерную инструкцию — pop <reg>. Это сработает, потому что наш код вызывается с помощью call, а значит — адрес возврата кладётся на стек, и мы можем получить его просто сделав pop. Имея адрес возврата, можем посчитать остальные адреса внутри ядра, используя знания о смещении (идея как и в обходе ASLR в юзер-спейсе).
Для того, чтобы узнать смещения, запустим отладку ядра и повысим себе привилегии, чтобы иметь возможность читать таблицу системных вызовов.
Для начала напишем простой шеллкод для проверки, что мы получаем верный адрес возврата:
Теперь скомпилируем этот код и добавим его в файловую систему. Запустим ядро в режиме отладки и поставим точку остановки на адрес 0xdeadb000.
Видим, что брейкпоинт сработал, проверим, что там лежит именно наш код.
Отлично, теперь посмотрим на стек.
Первый адрес — это то, куда мы вернёмся, то есть код кастомного системного вызова. Относительно этого значения мы можем рассчитать адреса остальных функций.
Для этого прочитаем /proc/kallsyms и найдём нужные нам функции:
Теперь посчитаем смещения:
Осталось добавить код обработки нашей утечки и расчёт адресов необходимых функций.
Итоговый код полезной нагрузки:
Добавляем его в эксплоит.
Запускаем эксплоит:
Первый таск решён.
Второй таск — K-HOP
Данная задача была на порядок сложнее. В ней нужно было применить технику эксплуатации разыменования нулевого указателя, обойти KPTI, SMEP и ядерную стековую канарейку.
В этой задаче нам уже дают код драйвера, а также включают дополнительные защиты (SMEP), но отключают KASLR.
Теперь мы не можем просто так выполнять код в пространстве пользователя, как мы делали это в предыдущем таске.
Начнём анализ кода драйвера.
У нас есть некоторый глобальный указатель на сообщение. Посмотрим, как с ним работает код:
При открытии девайса мы выделяем чанк на куче и помещаем указатель в глобальную переменную, после чего копируем туда строку:
При закрытии девайса мы освобождаем выделенный чанк и переписываем глобальную переменную на нулевой указатель. Здесь можно обнаружить первую ошибку. Дело в том, что если мы откроем этот драйвер два раза и закроем первый, то во втором этот указатель обнулится.
Единственная функция драйвера с которой мы можем взаимодействовать из нашей программы это dev_read.
В этой функции мы можем прочитать строку на которую указывает наш глобальный указатель. Данные будут помещены в указанный нами буфер. С первого взгляда кажется, что здесь всё безопасно. Но если представить, что глобальный указатель может оказаться нулевым, а в пространстве пользователя мы сможем создать страницу памяти на адресе 0x0, то всё становится довольно интересно. Но для начала посмотрим как считается размер сообщения:
Код очень простой, и можно заметить, что считаем мы размер не до нуль-байта (как в обычной версии функции), а до символа перевода строки.
mmap_min_addr
Вернёмся к обсуждению нулевого адреса. В современных ядрах при стандартных настройках вы не можете выделить страницу на нулевом адресе. Минимальный адрес на котором можно выделить память как раз определяется значением настройки mmap_min_addr, его можно узнать, прочитав файл /proc/sys/vm/mmap_min_addr.
На современной системе со стандартными настройками можно увидеть примерно следующее:
А в нашей задаче это значение было переписано в одном из инициализирующих скриптов. В итоге мы имеем:
То есть, мы можем создать страницу памяти на нулевом адресе. Теперь нам нужно придумать, как это эксплуатировать, но для начала надо понять, что мы можем получить, выделяя страницу на адресе 0.
Ещё раз посмотрев в код функции чтения (а именно в первую часть), можно увидеть, что мы копируем содержимое message в буфер на стеке. При этом размер вычисляется от сообщения.
Таким образом, если мы сможем записать по нулевому адресу данные, превышающие размер буфера на стеке, то мы получим переполнение ядерного стека.
Отлично. Теперь у нас есть полноценное понимание о том, какая у нас уязвимость. Но как же нам построить эксплуатацию?
Для начала нужно получить ядерную стековую канарейку. Это можно сделать с помощью создания буфера длины, достаточной для того, чтобы заполнить всё место на стеке до канарейки, но не затронуть её. Тогда функция расчёта длины пройдёт и по канарейке и мы получим её значение.
Итак, наша стратегия для получения канарейки:
- Создаём страницу памяти на адресе 0,
- Открываем девайс два раза и закрываем один из них, чтобы во втором указатель стал нулевым,
- Заполняем данными нашу память на нулевом адресе и читаем первый раз (первые 47 байт),
- Добавляем ещё один байт и читаем ещё раз (вторые 48 байт).
Двойное чтение сделано для того, чтобы сдвинуть оффсет и не получать переполнение стека.
Подключимся отладчиком и посмотрим на момент установки канарейки, после чего запустим наш эксплоит и сравним значения.
Теперь запустим эксплоит:
Отлично, у нас есть канарейка. Теперь мы можем переполнять стек. Дальнейшим нашим шагом будет обход SMEP, для этого нам потребуется использовать ROP, для которого нужно будет найти гаджеты. Для поиска гаджетов придётся распаковать ядро и пройтись утилитой для автоматического поиска ROP последовательности.
Для удобной эксплуатации мы будем осуществлять stack-pivoting на контролируемую нами память. Для этого нам потребуется всего один гаджет — pop rsp. После переноса стека мы будем выполнять нашу ROP-цепочку, которая должна сделать следующее:
- Вызвать функцию prepare_kernel_cred
- Вызвать функцию commit_creds
- Вернуться из ядра с помощью функции swapgs_restore_regs_and_return_to_usermode (так называемый KPTI трамплин, который используется для обхода этого митегейшина).
Для начала напишем пивотинг стека:
Мы передвигаем стек не в самое начало наших данных, потому что функции, которые мы собираемся вызывать, активно используют стек и могут выйти за границы.
Теперь, когда мы перевели стек, нам надо сделать правильную цепочку:
Так как KASLR выключен, все найденные нами гаджеты будут работать от запуска к запуску. Цепочка полностью совпадает с тем, что мы писали в нашем плане. Выход в пользовательское пространство будет осуществляться через KPTI трамплин.
Добавляем нашу цепочку в финальный пейлоад:
Можно заметить вызов некоторой функции save_state() — это довольно стандартная функция при разработке эксплоитов ядра. Она необходима для того, чтобы мы могли корректно вернуться из ядерного контекста в пользовательский. Данная функция позволяет нам сохранить все необходимые регистры, которые должны находиться на стеке в момент возврата из ядра.
Итоговый эксплоит можно посмотреть здесь.
Тестируем наш эксплоит.
Отлично, таск решён.
Если вам нужны файлы задания, эксплоиты, вспомогательные скрипты, то вы можете скачать их по данной ссылке.