Google CTF 2024 Quals — auxin2

от автора

Рустам Гусейнов

председатель кооператива РАД КОП

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

Ссылки на решения задач CTF-турниров

Сайт CTF

Материалы задания

Определенный организаторами в «миски» auxin2 оказался любопытным таском из разряда эзотерического программирования — в данном случае виртуальной системы varvara, работающей на машине uxn. Во время соревнования мне удалось решить ее первым (в составе MSLC), хотя это заняло большую часть первого дня — условия были довольно прямолинейными, но разобраться в особенностях набора инструкций незнакомой машины и обойти ограничения было более чем интересно для подробного описания решения.

Условия

В архиве задания нам дан образ для varvara и простой сервис, передающий ему на вход строчку с лимитом длины в 112 байт и отдающий его вывод в консоль — кроме того, исполнение не может занимать больше 0.5 секунды. Пролистав описание uxn, выясняем, что это стек-машина с двумя циклическими стеками, не использующая регистры как таковые, и общающаяся с виртуальными периферийными устройствами (в том числе необходимыми нам консолью и файловой системой, в которой — в файле flag — лежит флаг) через особую страницу памяти, недоступную стандартным инструкциям (для этого служат специальные инструкции ввода-вывода DEI и DEO). Набор инструкций довольно минималистичен — нет даже декремента, но у большинства имеются варианты, работающие над двухбайтными словами (суффикс 2), альтернативным стеком, используемым для возврата из функций (суффикс r), и оставляющие использованные инструкции в памяти вместо удаления их со стека (суффикс k) — это определяется флагами в трех верхних битах байта опкода (подробности).

Анализ

Дизассемблировать образ проще всего, собрав вдобавок к самому uxn (из указанного в описании коммита) образ утилиты uxndis из репозитория uxn-utils и использовав ее на файле:

$ uxncli uxndis.rom auxin2.rom

Не требуется особенно углубляться в короткий листинг на выходе, чтобы заметить проверки байта в цикле на равенство нижних нибблов (четырех бит) 0, 2, 3, 6, 7 или e:

... 0069:   06         DUP 006a:   80 0f      LIT 0f 006c:   1c         AND 006d:   06         DUP 006e:   80 00      LIT 00 0070:   08         EQU 0071:   20 00 34   JCI +52 0074:   06         DUP 0075:   80 02      LIT 02 0077:   08         EQU 0078:   20 00 2d   JCI +45 007b:   06         DUP 007c:   80 03      LIT 03 007e:   08         EQU 007f:   20 00 26   JCI +38 0082:   06         DUP 0083:   80 06      LIT 06 0085:   08         EQU 0086:   20 00 1f   JCI +31 0089:   06         DUP 008a:   80 07      LIT 07 008c:   08         EQU 008d:   20 00 18   JCI +24 0090:   06         DUP 0091:   80 0e      LIT 0e 0093:   08         EQU 0094:   20 00 11   JCI +17 0097:   02         POP ...

Догадка о том, что таким образом проверяются байты присланного кода, оказывается верной:

$ nc auxin2.2024.ctfcompetition.com 1337 == proof-of-work: disabled == input: f1 timeout! $ nc auxin2.2024.ctfcompetition.com 1337 == proof-of-work: disabled == input: f2 b'No.\n'

Это означает, что наша задача сводится к написанию кода без опкодов, заканчивающихся вышеприведенными нибблами. Взглянем на таблицу опкодов uxn:

Как видим, для нас оказываются недоступны опкоды LDZ, JCI, JMI, JCI, LIT, POP, LDR, NIP, STR, DUP, DEI, OVR, DEO, JSR и EOR. Так как по условиям таска нам необходимо прочитать флаг из файла (и вывести его в консоль), а доступ к устройствам требует использования DEI/DEO, понятно, что так или иначе нам придется каким-то образом спрятать эти инструкции, а значит, код должен быть самомодифицирующимся — для начала нам нужно написать загрузчик для собственно боевой, второй стадии эксплойта.

Первая стадия — декодирующий загрузчик

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

Обзор ограничений и исходного состояния

Единственные разрешенные прыжки — безусловный JMP и условный JCN; так как из инструкций убраны LIT (фактически пуш последующего литерала на стек) и POP, а также копирующие DUP и OVR, манипуляция стеком, видимо, ограничивается использованием SWP, меняющим местами первый и второй элемент стека (и SWP2 для двухбайтовых слов), ROT, циклически вращающим первые три элемента, и -k вариантами других возможных опкодов, позволяющими не убирать байты инструкции со стека после ее исполнения. Поскольку исключены LDZ и LDR, загрузку из памяти остается возможным осуществить только с помощью LDA, по абсолютному адресу; кроме того, из-за отсутствия LIT нам будет не так просто положить на стек произвольные значения.

Щедро снабдив обработчик инструкций в src/uxn.c отладочными принтами, чтобы быть в курсе состояния стека и исполняемого кода, выясняем, что присланный код попадает в память по адресу 0x1d0 и начинает исполняться оттуда (для теста отправим на вход 01INC):

$ uxncli auxin2.rom 01 ... pc=1d0, i=37 T=2 N=c2 L=1 X=4 Y=2 Z=2 DEO2 1d29aad0, 2, c2, 1 pc=1d1, i=1 T=4 N=2 L=2 X=0 Y=0 Z=0 INC pc=1d2, i=0 T=5 N=2 L=2 X=0 Y=0 Z=0 BRK

(i здесь показывает численное значение опкода, «регистры» T/N/L/X/Y/Z фактически представляют собой первые шесть байт стека, а счетчик pc по техническим обстоятельствам отстает на байт)

Расположение второй стадии

Так как из читающих память инструкций нам доступна только работающая с абсолютными значениями LDA, мы должны заранее определить адрес, по которому будет находиться вторая стадия, и иметь возможность легко положить его на стек для работы с раздвоенными байтами.

Увидев в вышеприведенном изначальном состоянии стека L=2 X=0, для удобства представим, что мы сумеем уместить загрузчик в 0x2000x1d0, то есть 48 байт — такая задача выглядит выполнимой, и если нам это не удастся, позже мы сможем подправить этот оффсет. С «регистрами» в таких значениях мы можем легко получить нужный нам для будущего прыжка адрес 0x200 — к примеру, опкод EQU (0x08) может сравнить 0x4 и 0x2, убрав их со стека, и положить на него результат сравнения 0x0, сформировав нужные нам для LDA T=0 N=2:

$ uxncli auxin2.rom 08 ... pc=1d1, i=8 T=4 N=2 L=2 X=0 Y=0 Z=0 EQU pc=1d2, i=0 T=0 N=2 L=0 X=0 Y=0 Z=0 BRK

Подготовка необходимых переменных

Понятно, что для декодирования второй стадии нам так или иначе потребуется цикл для последовательной обработки данных; единственная доступная нам для этого условная инструкция прыжка — JCN, и для ее использования нам нужен либо абсолютный адрес в двухбайтовом режиме JCN2, либо вычисленное позже относительное значение в одном байте. Предположим второе — мы можем расположить нужный байт, каким бы он ни оказался (помня, правда, что он тоже должен проходить проверку нижнего ниббла), в начале второй стадии, загрузить его и сохранить по удобному (то есть легко формируемому на стеке без использования LIT) адресу в памяти для будущего использования — ясно, что с нашими ограничениями удерживать его доступным на стеке без этого будет очень сложно.

Для этого мы могли бы просто использовать 0x0, но, помня о том, что нам потребуется еще одна переменная, причем двухбайтовая — инкрементируемый адрес результата сложения двух байт второй стадии — возьмем 0x2, и зарезервируем 0x0 для адреса декодированного байта.

Как гарантированно и достаточно лаконично получить на стеке 0x0 и 0x2? Например, расположив на нем два заведомо неравных байта с помощью INCk INCk, а затем сравнив их — EQU даст нам 0x0, а NEQ0x1, которую, конечно, можно превратить в 0x2 с помощью одного INC.

Что же, загрузим из 0x200 переменную прыжка (пока там будет находиться дефолтный ноль) и сохраним ее по адресу 0x2, используя для этого наиболее очевидную инструкцию — STZ, предназначенную специально для записи в первые 256 байт памяти:

EQU LDAk (k оставит адрес 0x200 на стеке) INCk INCk NEQ INC STZ

Проверим результат:

$ uxncli auxin2.rom 08948181090111 ... pc=1d1, i=8 T=4 N=2 L=2 X=2 Y=2 Z=2 EQU pc=1d2, i=94 T=0 N=2 L=2 X=2 Y=2 Z=2 LDA LDA 0 = ram[200] pc=1d3, i=81 T=0 N=0 L=2 X=2 Y=2 Z=2 INC pc=1d4, i=81 T=1 N=0 L=0 X=2 Y=2 Z=2 INC pc=1d5, i=9 T=2 N=1 L=0 X=0 Y=2 Z=2 NEQ pc=1d6, i=1 T=1 N=0 L=0 X=2 Y=2 Z=2 INC pc=1d7, i=11 T=2 N=0 L=0 X=2 Y=2 Z=2 STZ STZ ram[2] = 0 pc=1d8, i=0 T=0 N=2 L=2 X=2 Y=2 Z=2 BRK

Все выглядит верно, и адрес второй стадии находится сверху стека, что позволяет нам сразу же загрузить его же в 0x0 — его же, потому что мы спокойно можем записывать декодированные байты непосредственно поверх уже прочитанных байт второй стадии — с помощью

INCk INCk EQU STZ2k (2 - сохраняем два байта, и k - оставляем их на стеке)

Кроме того, так как, в отличие от LDAk, STZk оставит сверху стека адрес сохранения — 0x0, нам нужно «развернуть» его таким образом, чтобы его сменил лежащий за ним адресный счетчик декодера — для этого сработает двойной ROT:

... STZ2 STZ2 ram[0,1] = 2,200 pc=1dc, i=5 T=0 N=0 L=2 X=2 Y=2 Z=2 ROT pc=1dd, i=5 T=2 N=0 L=0 X=2 Y=2 Z=2 ROT pc=1de, i=0 T=0 N=2 L=0 X=2 Y=2 Z=2 BRK

Цикл декодера

Дальнейшие действия достаточно очевидны — нам нужно сдвинуть счетчик декодера, загрузить байт на стек и повторить эту операцию так, чтобы загруженные байты в результате лежали сверху стека, готовые для сложения с помощью ADD. С оглядкой на состояние стека в отладке получаем следующий код:

INC2 LDAk ROT ROT INC2 LDAk ROT ROT SWP2 ADD

Похоже, что (пока на пустых значениях второй стадии) это действительно работает:

... LDA LDA 0 = ram[201] pc=1e2, i=5 T=0 N=1 L=2 X=0 Y=2 Z=2 ROT pc=1e3, i=5 T=2 N=0 L=1 X=0 Y=2 Z=2 ROT pc=1e4, i=21 T=1 N=2 L=0 X=0 Y=2 Z=2 INC2 pc=1e5, i=94 T=2 N=2 L=0 X=0 Y=2 Z=2 LDA LDA 0 = ram[202] pc=1e6, i=5 T=0 N=2 L=2 X=0 Y=0 Z=2 ROT pc=1e7, i=5 T=2 N=0 L=2 X=0 Y=0 Z=2 ROT pc=1e8, i=24 T=2 N=2 L=0 X=0 Y=0 Z=2 SWP2 pc=1e9, i=18 T=0 N=0 L=2 X=2 Y=0 Z=2 ADD pc=1ea, i=0 T=0 N=2 L=2 X=0 Y=2 Z=2 BRK

Пора загрузить нашу переменную адреса выгрузки декодированных байт из 0x0, положить результат по этому адресу, инкрементировать ее и вернуть на место. LDZ нам недоступно, поэтому нам придется сформировать на стеке полный адрес для LDA2, то есть 0x0 0x0. Для этого здесь можно использовать LTH вдобавок к уже знакомой последовательности:

INCk INCk EQU LTH LDA2 - загрузка адреса  STAk - выгрузка декодированного байта INC2 - инкрементируем переменную INCk INCk EQU STZ2 - выгрузка адреса

Вернем на стек переменную прыжка из 0x2, тем же образом:

INCk INCk EQU LTH INC INC LDA

И используем ее для JCN — после наших манипуляций декодированный байт оказывается как раз на месте условия, поэтому, если байты второй стадии складываются в 0x0 (например, 0xff01), цикл прервется и исполнение пойдет за пределы первой стадии.

JCN

Финальные поправки

Рассмотрев результат, впрочем, заметим, что после прыжка — прыгнуть нам нужно в начало цикла INC2 LDAk ROT ROT — нам необходимо вернуть на место адресный счетчик декодера с помощью SWP2. Так как делать это нужно только после прыжка, мы можем поместить перед INC2 два свопа — SWP2 SWP2, которые сохранят стек как есть в первой итерации, но позволят нам выполнить задуманное прыжком на второй SWP2.

Итак, соберем окончательный вариант первой стадии на пайтоне:

code =  "0894" # EQU (prepare 0200), LDAk - load jump back value code += "8181090111" # INCk INCk NEQ INC STZ, store jump back value to 0x02                                                                          code += "818108b1" # INCk INCk EQU STZ2k, store start address to 0x00 code += "0505" # ROT ROT  code += "24" # SWP2 code += "24" # SWP2 - not doing anything first go, swaps when we jump in between the instructions  code += "21" # INC2 addr code += "94" # LDAk code += "0505" # ROT ROT  code += "21" # INC2 addr code += "94" # LDAk  code += "0505" # ROT ROT code += "24" # SWP2 code += "18" # ADD  code += "8181088b34" # INCk INCk EQU LTH LDA2, load start address from 0x00  code += "95" # STAk, store the decoded byte code += "21" # INC2 addr code += "81810831" # INCk INCk EQU STZ2, store start address to 0x00  code += "8181088b010114" # INCk INCk EQU LTH INC INC LDA, load jump back value from 0x02  code += "0d" # JCN - if new value is 00, continue (need to mark the end of the payload with e.g. ff01)

Проверив размер кода, обнаруживаем, что угадали с размером — загрузчик умещается в 44 байта. Заполнить оставшееся до 0x200 место мы можем, например, паддингом из SWP0x04.

Посчитав и проверив оффсет прыжка, выясняем, что нам нужно подходящее нам по условиям значение 0xe1 — это тоже приятно! К тому же инструкция, соответствующая этому байту, не делает ничего страшного — это INC2kr, даже не трогающая обычный стек. Добавим к коду паддинг и 0xe1:

pad_len = 0x200 - 0x1d0 - len(code)//2 full = code + ("04" * pad_len) + "e1"  

Дописав к этому временно 0xff01 и просмотрев вывод отладки, убеждаемся, что цикл прерывается корректно:

$ uxncli auxin2.rom 08948181090111818108b105052424219405052194050524188181088b349521818108318181088b0101140d04040404e1ff01 ... pc=1fc, i=d T=e1 N=0 L=2 X=2 Y=0 Z=2 JCN pc=1fd, i=4 T=2 N=2 L=0 X=2 Y=2 Z=2 SWP pc=1fe, i=4 T=2 N=2 L=0 X=2 Y=2 Z=2 SWP pc=1ff, i=4 T=2 N=2 L=0 X=2 Y=2 Z=2 SWP pc=200, i=4 T=2 N=2 L=0 X=2 Y=2 Z=2 SWP pc=201, i=0 T=2 N=2 L=0 X=2 Y=2 Z=2 BRK

Время заняться второй стадией!

Вторая стадия — чтение и вывод флага

Учитывая общий лимит в 112 байт, уже использованные из них 51 и то, что все последующие инструкции будут занимать в два раза больше места из-за нашего метода декодирования, у нас остается 30 байт возможной полезной нагрузки. Подумаем о том, что от нас требуется: для обращения к файловой системе нам нужно держать где-то имя файла flag; нужно, судя по ее описанию, передать по правильным адресам страницы устройств через DEO адрес этого имени, желаемую длину буфера и адрес вывода; нужно в цикле вывести на консоль байты прочитанного флага.

Имя файла

Логичнее всего будет разместить строчку flag в конце; мы узнаем ее абсолютный адрес, закончив писать код, поэтому пока загрузим на стек пустышку с помощью LIT2:

LIT2 ff ff ... 66 6c 61 67 - flag

Работа с файлом

Загрузим полученный адрес на адрес file name0xa8:

LIT a8 DEO2k - оставим аргументы на стеке

Увеличив адрес вызова до 0xaa, загрузим длину — в качестве длины для нас вполне сойдет тот же адрес строки flag, поскольку значение 0x2xx даст нам более чем достаточно байт:

INC INC - 0xaa и адрес строки на стеке DEO2k

Наконец, заставим систему записать прочитанный флаг поверх все той же строки (0xac):

INC INC - 0xac и адрес строки на стеке DEO2k

Вернем адрес на верх стека — теперь для этого нам не нужен ROT ROT:

POP

Вывод в консоль

Нам остается устроить простой цикл, рассчитав (или подобрав) относительный оффсет прыжка. Мы можем не прерывать его, так как сервис все равно отдаст нам вывод консоли после полусекундного таймаута:

LDAk - загрузим байт флага на стек LIT 18 - адрес вывода байта на консоль DEO - вывод INC2 - следующий байт LIT f8 - отрицательный оффсет JMP

Финализация

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

payload = ""  payload += "9f01" # a0 LIT2 payload += "0101" # 02  payload += "1401" # 15  payload += "7f01" # 80 LIT payload += "9f09" # a8  payload += "af08" # b7 DEO2k # a8 file name  payload += "fd04" # 01 INC payload += "fd04" # 01 INC payload += "af08" # b7 DEO2k # aa file length  payload += "fd04" # 01 INC payload += "fd04" # 01 INC payload += "af08" # b7 DEO2k # ac file read  payload += "0101" # 02 POP  payload += "8f05" # 94 LDAk payload += "7f01" # 80 LIT payload += "1404" # 18  payload += "0f08" # 17 DEO payload += "1d04" # 21 INC2 - next byte payload += "7f01" # 80 LIT payload += "ef09" # f8 payload += "010b" # 0c JMP  payload += "6105" # f payload += "610b" # l payload += "5d04" # a payload += "5d0a" # g  full += payload + "ff01"  print(full)

Отправим сформированную строчку серверу:

$ echo $(python3 sol.py) | nc auxin2.2024.ctfcompetition.com 1337 == proof-of-work: disabled == input: timeout! b'CTF{Sorry__n0_Music_thi5_t1m3}\n\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

Остается только сдать полученный флаг и насладиться победой.

Заключение

Удачный миск с ясной целью и реальной (пусть достаточно игрушечной) машиной в основе, заставляющий повозиться с выбором стратегии упаковки пейлоада и требующий некоторого кодгольфинга на незнакомом ассемблере; впрочем, я не почувствовал себя особенно скованным — обе стадии улеглись в лимит без особых сокращений относительно оригинальной задумки (в комментариях к сервису указано, что авторское решение занимало 91 байт — наше занимает 101, хотя местами может быть сокращено без изменения идеи за счет некоторых трюков и дополнительного массажа стека).


ссылка на оригинал статьи https://habr.com/ru/articles/837672/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *