Рустам Гусейнов
председатель кооператива РАД КОП
Ну а мы завершаем экспериментальный цикл материалов по 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, хотя местами может быть сокращено без изменения идеи за счет некоторых трюков и дополнительного массажа стека).