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

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

Ну а мы завершаем экспериментальный цикл материалов по 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 и начинает исполняться оттуда (для теста отправим на вход 01 - INC):

$ 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, для удобства представим, что мы сумеем уместить загрузчик в 0x200 - 0x1d0, то есть 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, а NEQ - 0x1, которую, конечно, можно превратить в 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 место мы можем, например, паддингом из SWP - 0x04.

Посчитав и проверив оффсет прыжка, выясняем, что нам нужно подходящее нам по условиям значение 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 name - 0xa8:

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, хотя местами может быть сокращено без изменения идеи за счет некоторых трюков и дополнительного массажа стека).

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