Разбор задания Caller с отборочного тура IV Кубка CTF России
2020-11-28 21:44
На прошлых выходных проходил отборочный этап IV Кубка CTF России, состоящий по в основном из тасков категорий PWN и Reverse. Ниже разбирается одно из наиболее интересных заданий, которое с первого взгляда однозначно не определялось как простое или сложное (хотя, по мнению создателей, относилось к категории средних).
Открыв описание таска, мы не получаем ничего хорошего:
Что может сделать всего один маленький системный вызов?
Если вы получите RCE с его помощью, я выдам вам флаг.
Вернее, вы сами его заберёте. В приложении к описанию, вызывающего сущий страх у нормального человека, дается незамысловатый файлик caller.py:
И докерфайл, которым, собственно, и запускается данный таск:
Быстро пробежавшись по обоим файлам, становится ясно, что не особо заморачиваясь со внутренностями Python3.7, никакой возможности решить этот таск не будет — мы буквально не можем ничего, кроме как вызвать какой-то системный вызов с любыми числовыми параметрами, да и к тому же, программа после этого сразу завершается, вызывая только print с результатом сисколла.
Чувство лени и надежды на простое решение превзошли логическое мышление, и пара первых часов размышлений над таском были потрачены на то, чтобы сначала лишний раз проверить все сисколлы линукса. Это привело к выводу, что мы не сможем сделать ничего интересного, не зная адресов каких-либо строк в памяти.
Например, для классического execve нам необходим как минимум адрес в памяти со строкой "/bin/sh" или путём к некой другой программе, для того, чтобы её запустить и получить шелл.
Так же это даёт понять, что, если мы не сможем найти такую строчку в памяти (причем адрес, которой мы будем знать при каждом запуске), то вряд ли мы что-либо в принципе можем сделать за один системный вызов — другие кандидаты на чтение файла "/var/caller/flag.txt" не подходят как минимум из-за того, что нам нужно будет сначала открыть файл (syscall open), потом прочитать в память (syscall read), а затем вывести в стандартный поток вывода (syscall write). Если внимательно посчитать, то для такого второго по простоте решения необходим не один, а целых 3 системных вызова. Отсюда появляются уже идеи решения, которые нам нужно смотреть:
● Разобраться, что в памяти мы можем перезаписать, чтобы зациклить программу, тем самым получив бесконечность системных вызовов (изменить регистр RIP). ● Каким-то образом перенаправить ход исполнения программы, чтобы запустился интерактивный режим питона (снова изменить регистр RIP).
Как видим, оба варианта, впрочем, как и любые другие идеи, зависят от изменения регистра RIP, а для этого нам нужен был бы сисколл read (записать данные куда-то в память) и знания адресов в памяти процесса python3.7. Для начала, чтобы было понятно, как дебажить такое прямо в докере (лично мне не хотелось ставить специально Ubuntu ради таска), несколько действий:
# комментируем вот эту строчку в Dockerfile, чтобы запуститься от root, иначе беда будет с дебаггингом:
# как типичный лайфхак ставим sleep на кучу секунд, чтобы было время подключиться и отдебажить процесс:
# собираем контейнер с таском:
docker build . # немного магии, чтобы получить IMAGE ID нового контейнера:
IMAGE_ID=`docker image ls | head -n 2 | tail -n 1 | sed -n 's/ */ /gp' | cut -d " " -f 3` # запускаем контейнер с возможностью отладки:
docker run --cap-add=SYS_PTRACE $IMAGE_ID # снова магия, чтобы получить CONTAINER ID запущенного контейнера:
CONTAINER_ID=`docker ps | head -n 2 | tail -n 1 | sed -n 's/ */ /gp' | cut -d " " -f 1` # запускаем внутри контейнера bash (если бы это не был ubuntu to /bin/sh):
apt install gdb vim # выводим все процессы, смотрим, какой PID у запущенного Python3.7, он-то и будет с нашим таском:
# подключаемся к процессу с помощью gdb:
gdb -p 7 # т.к. у python3.7 pid=7, нужно каждый раз смотреть заново Научившись дебажить нужный нам процесс прямо в докер-контейнере, можем приступать к исследованиям. Запустив несколько раз процесс python3.7 (либо перезапустить контейнер, либо просто запустить еще один процесс внутри него), заметим несколько статических адресов (команда info proc mappings в gdb):
Если же вывести эту же информацию из файла /proc/{python_pid}/maps, где {python_pid} — это pid нашего процесса-пациента python3.7, получим вот это:
Мы нашли память со статическими адресами, в которую мы можем писать! Причем третий из этих сегментов — часть самого бинарного файла программы, и мы можем с ним играться вне контейнера, для чего достанем его командой:
docker cp $CONTAINER_ID:/usr/bin/python3.7 . С помощью утилиты rabin2 из комплекта тулз radare2 выведем секции файла:
Посмотрев в колонку vaddr, можно найти секции, которым соответствует третий замапленный сегмент в запущенном python3.7 — это будут .got.plt, .data и .bss. Откроем бинарь в radare2 и посмотрим, что же есть там с помощью команды is (вывести символы) и фильтра ~0x00a:
Пролистав данный списочек, отметим, что в основном здесь располагаются какие-то объекты с _Type в названии, а также некоторые стандартные функции каждого класса в питоне — PyObject_Str, PyObject_Repr и так далее… (напоминают str() и repr(), не так ли?)
Кажется, самое время снова скачивать сурсы CPython'а (каждый таск на питон одно и то же...)! Не испугавшись лагов vscode'а, поищем эти названия. Вот, например, интересные использования PyObject_Str:
Форматирование строк, что-то напоминает? (Например, caller.py)
А вот и имплементация!
Посмотрим, как работает PyObject_Str:
Здесь часто используется макрос Py_Type:
По сути, получается, он кастует переданную штуку в PyObject, а потом получает поле ob_type. Для того, чтобы понять, что это за ob_type, покопаемся и найдем, что же такое PyObject (в object.h):
Можно догадаться, что ob_refcnt — это счетчик ссылок на данный объект (видимо, для garbage collector'а или чего-то подобного). А ob_type, наш дорогой, это указатель на глобальный инстанс, описывающий все объекты такого типа (вспомним наши символы — там много таких имен, например, PyListIter_Type, PySet_Type, PyStringIO_Type, все из которых напоминают нам названия типов из стандартной библиотеки питона). Посмотрим, что такое PyTypeObject (определяется он в typestruct.h):
Тут и найдём те функции, которые мы уже видели в PyObject_Str!
Вот они, слева направо: tp_repr, tp_str. Итак, теперь небольшой recap:
● Нам нужно изменить RIP, для того, чтобы направить программу в нужное нам русло. ● Мы можем писать (права rw в памяти) в секции data, bss, где находятся глобальные объекты, описывающие все типы в стандартной библиотецке питона. К тому же, они находятся по известным нам адресам. ● В каждом глобальном объекте есть указатели на функции tp_str, tp_repr, которые используются PyObject_Str для того, чтобы представить объект в виде строки (привычные нам str() и repr()). ● Функция PyObject_Str вызывается в файле formatter_unicode.c, даже не читая который можно предположить, что он отвечает за форматные строки в питоне (что логично, ведь f'{}' просто переводит объект в его строковый вид).
И если всё это собрать вместе, то получается, что мы можем перезаписать указатель на функцию tp_str у объекта типа PyLong_Type (адрес его мы знаем заранее) на любой необходимый нам адрес (в нашем расположении весь арсенал функций и кода, имеющихся в исполняемой части бинарника python3.7). После чего наша функция вызовется при форматировании числа в форматной строке в caller.py, а значит, что мы наконец-то можем контролировать RIP и дело осталось за немногим!
Шеллкод в данном случае отпадает, ведь мы не можем писать ни в одну rwx-секцию, OneGadget также отпадает — мы не знаем никаких нестатичных адресов из-за ASLR, а обойти его нельзя, так как мы можем вызвать функцию только один раз. К счастью, теперь в нашем распоряжении есть функция system, адрес которой загружается в секцию .got.plt при запуске питона. Ну и в придачу к этому, вспомним всеми любимую технику ROP!
Посмотрим, какие значения регистров имеются в нашем расположении и какая функция лежит в tp_str у структуры PyLong_Type:
Если правильно посчитать оффсеты с учетом всех структур в PyTypeObject, получим, что tp_str должна лежать на 18-ой позиции, и к тому же быть равна tp_repr — здесь только 2 одинаковых указателя, поэтому сразу приходим к выводу, что при конвертации целого числа в python3.7 вызывается функция по адресу 0x5c37e0. Откроем в gdb бинарник python3.7, и запустимся с аргументом caller.py, заранее поставим брейкпоинт на 0x5c37e0. Несколько раз скипнем вызов этой функции и, дождавшись наконец-то правильного вызова (из форматной строки), посмотрим на регистры:
Ничего хорошего, с первого взгляда, не замечается, но если приглядеться — в rax лежит адрес объекта PyLong_Type. Пора искать ROP-гаджеты, для чего воспользуемся утилитами ropper, ROPgadget, и любыми другими, в зависимости от вкуса. Тщательно поискав гаджеты, которые перемещают в rdi (главный аргумент функции) значение регистра rax, или значение в памяти по оффсету rax, найдем такой красивый гаджет, который не только поместит в rdi адрес объекта PyLong_Type (а значит, мы можем туда записать любую строку), но и вызовет функцию, по задаваемому нами смещению:
Получается, что в [rax+8], то есть в &PyLong_Type+8 нам нужно положить, например, снова &PyLong_Type, в &PyLong_Type записать строку с именем файла, который хотим запустить, а в &PyLong_Type+0x30 — адрес функции system из .got.plt, и получим так долго желаемый шелл!