Рустам Гусейнов
председатель кооператива РАД КОП
Ну а мы завершаем экспериментальный цикл материалов по CTF последней статьей по мотивам недавнего мероприятия Google. Даже самые крупные компании не брезгуют организацией подобных событий, и это не только отличная возможность для тренировок и профессионального роста,но и неплохой способ нетворкинга, карьерного развития или онбординга в желаемую компанию. Тот случай, когда приятное совмещается с полезным и образует диалектическое целое, снимающее любые частные противоречия. Приступим к разбору кейса?
Ссылки на решения задач 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, хотя местами может быть сокращено без изменения идеи за счет некоторых трюков и дополнительного массажа стека).
ссылка на оригинал статьи https://habr.com/ru/articles/837672/
Добавить комментарий