Flare-on 8 2021 CTF writeup'ы

Закончился очередной конкурс Flare-on от компании FireEye. Он привлекает любителей и профессионалов обратной разработки со всего мира. Ежегодно FireEye предлагает всем желающим 10-12 заданий нарастающей сложности. Участие в подобных конкурсах позволяет поддерживать свои инженерные навыки в тонусе и каждый раз стимулирует учиться новым интересным вещам. Субъективно задания этого года были проще, чем задания прошлых лет и это косвенно подтверждается большим числом победителей в этом году. Сложности при прохождении в основном возникали из-за неочевидности использования (применения) известных данных, нежели какие-то технологические или алгоритмические трудности - нужно только было правильно сопоставить "А" и "Б". Практически все предложенные задания решались в ручном режиме в отладчике без необходимости применения узкоспециализированных приёмов и инструментов.

Я узнал об этих соревнованиях только в 2019 году, сразу же решил зарегистрироваться и получил от решения всех головоломок огромное удовольствие. На счету у меня теперь третий памятный приз от команды FireEye и в этот раз занял 39 место в общемировом рейтинге (4-ое среди россиян):

Все флаги Flare-On Challenge традиционно оканчиваются на flare-on.com. Давайте приступим к разбору заданий.

Первое задание Flare-on обычно не вызывает никаких затруднений. В этом году это простая авторизационная html-форма с полями ввода имени пользователя и пароля. Посмотрим, как проверяется валидность данных:

исходный код функции checkCreds()
<script>
var form = document.getElementById("credform");
var username = document.getElementById("usrname");
var password = document.getElementById("psw");
var info = document.getElementById("infolabel");
var checkbtn = document.getElementById("checkbtn");
var encoded_key = "P1xNFigYIh0BGAofD1o5RSlXeRU2JiQQSSgCRAJdOw=="

function checkCreds() {

	if (username.value == "Admin" && atob(password.value) == "goldenticket") 
	{
		var key = atob(encoded_key);
		var flag = "";
		for (let i = 0; i < key.length; i++)
		{
			flag += String.fromCharCode(key.charCodeAt(i) ^ password.value.charCodeAt(i % password.value.length))
		}
		document.getElementById("banner").style.display = "none";
		document.getElementById("formdiv").style.display = "none";
		document.getElementById("message").style.display = "none";
		document.getElementById("final_flag").innerText = flag;
		document.getElementById("winner").style.display = "block";
	}
	else
	{
		document.getElementById("message").style.display = "block";
	}
}

</script>

Ну, ок. Прописываем перед if ( ... )

username.value = "Admin";
password.value = btoa("goldenticket");

вводим в форму произвольные данные для вызова проверочного скрипта и получаем первый ключ:

В наличии программа-распаковщик UnlockYourFiles.exe и 6 зашифрованных файлов:

capa.png.encrypted
cicero.txt.encrypted
commandovm.gif.encrypted
critical_data.txt.encrypted
flarevm.jpg.encrypted
latin_alphabet.txt.encrypted

При запуске программа просит ввести ключ расшифровки. Легко обнаружить, что программа читает и пишет блоками по 8 байт. Также легко проверить, что изменение одного байта ключа влияет только на один байт блока данных. Программа небольшая - всего 9 функций и единственная функция, похожая на дешифровщик данных, выглядит вот так:


Тот случай, когда ассемблер понятнее вывода дизассемблера.
Проще всего декодировать файл с алфавитом. Перепишем алгоритм на Python:

alphabet = "ABCDEFGH"
b = [0x0f,0xce,0x60,0xbc,0xe6,0x2f,0x46,0xea]
for i in range(8):
    for c in range(256):
        if (rol(c^b[i],i,8) -i )== ord(alphabet[i]):
            print(chr(c),end='')

Ключ расшифровки - No1Trust, а флаг - You_Have_Awakened_Me_Too_Soon_EXE@flare-on.com

Здесь мы добрались до чего-то интересного. Дан докер-контейнер. Я решил не возиться с установкой и настройкой системы развёртывания, а для начала изучить его структуру. Технически он представляет собой каталоги с json-описателем и с содержимым в виде tar-архива. Один контейнер содержит файл./AntiochOS, остальные контейнеры - некоторое подмножество файлов a.dat..z.dat. Файлы *.dat нигде не повторяются. У каждого контейнера свой уникальный автор, по другим полям (кроме ID) описания контейнеров не различаются.

Отлично. Запустим ./AntiochOS:

В гидре напрямую можно увидеть, что ./AntiochOS поддерживает четыре команды: "help", "quit", "approach" и "consult".

Вводный текст команды approach - отслылка к популярной британской фэнтезийной кинокомедии Монти Пайтон и Священный Грааль, и в сцене 22 можно найти ответы на вопросы: имя - Sir Lancelot, квест - Holy Grail, любимый цвет - Blue. Три правильно угаданные буквы дают право открыть две шкатулки угаданных слова выдают пользователю некоторое число. В ./AntiochOS есть массив структур {hash_name, hash_color, res_value}, Программа вычисляет хэши от введённых вами строковых данных, и если проверки пройдены - возвращает вам соответствующим им res_value - уникальный номер в диапазоне [1..30]. Иначе... AAARGH! Имя квеста нигде не используется.

Замечаем, что Sir Lancelot является создателем одного из контейнеров докера с [a.dat ... z.dat], следовательно, хэши от других имён тоже можно найти в этом массиве структур, а значит, для каждого имени можно подсмотреть свой res_value, даже не зная ответов на 2 и 3 вопросы, в gdb это несложно сделать:

как это сделано

после вычисления хеша мы проходим по циклу по i до совпадения с data[i].hash_name, после выхода из цикла регистр rdx уже будет указывать на следующий элемент структуры, а значит, что [rdx-0x4] - на data[i-1].res.value

и т.д. Смотрите - мы не угадали с цветом, но номер всё равно получили. Далее из каждого контейнера нужно скопировать [a.dat...z.dat] в общее хранилище согласно этим порядковым номерам (порядок важен, из-за пропусков не все *.dat результирующего набора будут из последнего добавленного хранилища). Если всё сделать верно, в псевдографике по команде "consult" можно прочитать флаг Five_Is_Right_0ut@flare-on.com.

Сидел над заданием больше суток - много неочевидных вещей, дольше всего думал над последним шагом - номера получил, все нужные файлы a.dat..z.dat на руках, бинарник изучен вдоль и поперёк, а что делать с этим богатством дальше - совершенно непонятно.

Нам предлагают поиграть в мультимедийную игру-угадайку и получить в качестве приза флаг. Стандартная практика для нулевых - standalone-приложение, движок + проект в одном флаконе. Например, AutoPlay Media Studio, генераторы электронных учебников, игровые движки - были сотни таких программ. CFF Explorer намекает нам, что приложение упаковано:

а комментарий приложения гласит, что это приложение сделано с помощью Multimedia Builder:

Приложение можно декомпилировать и сохранить проект Multimedia Builder отдельно от приложения. Я решил скачать триальную версию ММВ и открыть этот проект там. К сожалению, распакованный проект в нём открывается как-то совсем криво:

У меня не получилось получить доступ к ресурсам проекта через ММВ, а также понять в нём, к чему привязан каждый из скриптов, но зато получил подказку, что нужно обратить внимание на папку TEMP (см. скриншот). Аккуратно выпишем все скрипты, может пригодиться:

Script01: part1$='derelict:MZZWP'; PluginSet("PlugIn","part1$")
Script02: part2$='lagan:BAJkR'; PluginSet("PlugIn","part2$")
Script03: part2$='flotsam:DFWEyEW'; PluginSet("PlugIn","part2$")
Script04: part1$='flotsam:PXopvM'; PluginSet("PlugIn","part1$")
Script05: part2$='derelict:LDNCVYU'; PluginSet("PlugIn","part2$")
Script06: part3$='derelict:yXQsGB'; PluginSet("PlugIn","part3$")
Script07: part2$='jetsam:newaui'; PluginSet("PlugIn","part2$")
Script08: part3$='lagan:QICMX'; PluginSet("PlugIn","part3$")
Script09: part1$='lagan:rOPFG'; PluginSet("PlugIn","part1$")
Script10: part3$='jetsam:HwdwAZ'; PluginSet("PlugIn","part3$")
Script11: part1$='jetsam:SLdkv'; PluginSet("PlugIn","part1$")
Script12: part2$='derelict:LSZvYSFHW'; PluginSet("PlugIn","part2$")
Script13: part3$='flotsam:BGgsuhn'; PluginSet("PlugIn","part3$")
Script14: part4$='lagan:GTYAKlwER'; PluginSet("PlugIn","part2$")
Script15: part4$='derelict:RTYXAc'; PluginSet("PlugIn","part4$")
Script16: part2$='lagan:GTXI'; PluginSet("PlugIn","part2$")
Script17:
PluginRun("PlugIn","PluginFunc19")
PluginGet("PlugIn","var1$")
NextPage()
LoadVariable("visitors","count")
count = count + 1
SaveVariable("visitors","count")
vc$='Visitor Counter: ' + CHAR(count)
CreateText("mytext","visitlabel$,1050,580,yippy")
LoadText("visitlabel$","vc$")
colr$='TEXTCOLOR=255,0,0'
SetObjectParam("outlabel$","colr$")
SetObjectParam("outlabel$","FONTNAME=Comic Sans MS")
SetObjectParam("outlabel$","FONTSIZE=24")
colors$[1] = '255,0,0'
colors$[2] = '0,255,0'
colors$[3] = '0,0,255'
colors$[4] = '255,0,0'
colors$[5] = '0,255,0'
colors$[6] = '0,0,255'
colors$[7] = '255,0,0'
colors$[8] = '0,255,0'
colors$[9] = '0,0,255'
colors$[10] = '255,0,0'
colors$[11] = '0,255,0'
colors$[12] = '0,0,255'
colors$[13] = '255,0,0'
colors$[14] = '0,255,0'
colors$[15] = '0,0,255'
For Loop= 1 To 1000
  For Counter= 1 To 15
    CreateText("mytext","outlabel$,20,100 + 50 * Counter,yippy")
    LoadText("outlabel$","var1$")
    colr$='TEXTCOLOR='+colors$[Counter]
    SetObjectParam("outlabel$","colr$")
    SetObjectParam("outlabel$","FONTNAME=Comic Sans MS")
    SetObjectParam("outlabel$","FONTSIZE=48")
    Pause("1000")
    DeleteObject("outlabel$")
  Next Counter
Next Loop

Так. Значит, при вызове скриптов в памяти приложения формируются четыре строки (derelict, lagan, flotsam, jetsam), а на последнем шаге вызывается результирующий скрипт Script17. Согласно подсказке в папке TEMP обнаруживаем fathom.dll, а в нём - экспортируемую функцию PluginFunc19 (aдрес 0x10002e40). x64dbg позволяет установить брейкпойнт в момент загрузки конкретной DLL, а будучи загруженной, можно определить относительный адрес нужной функции. Каких-то хитрых трюков в решении данного таска как таковых нет, просто нужно раз -нцать пройтись отладчиком по данной функции (она небольшая) и получить ответы на следующие вопросы:

  • за какой скрипт отвечает каждая картинка игрового приложения

  • все ли данные для нас актуальны (ответ - не все)

  • что за преобразования происходят с данными (если вдруг запутались - очень помогает выписать на бумаге пошагово 1-2 прохода тела цикла - такого типа комментарии смотри в ch10)

  • на вход какой хеш-функции подаётся блок (ответ - 0x8003 - md5).

так выглядит пошаговый анализ этого задания в отладчике

Я переписал алгоритм PluginFunc19 на Python:

d = ('MZZWP','LDNCVYU', 'yXQsGB', 'LSZvYSFHW', 'RTYXAc')
l = ('BAJkR','QICMX', 'rOPFG', 'GTYAKlwER','GTXI')
f = ('DFWEyEW', 'PXopvM', 'BGgsuhn')
j  = ('newaui', 'HwdwAZ', 'SLdkv')

import binascii
import random
import hashlib

x = '00A8A3FCD1A79DD2BA8F8F87A4E4CBF9169E81F938E5AF9F909A96A3A9A42596'
x = binascii.unhexlify(x)
x =x[::-1]

for w in range(10):
    for loop in range(10*w):
        f_str = ''
        j_str = ''
        for i in range(3):
            j_str = j_str + j[random.randint(0,2)]
        for i in range(w+1):
            f_str = f_str + f[random.randint(0,2)]
            for i in range(31):
                r[i]= x[i] ^ ord(f_str[i % len(f_str)])
                r[i] = ( r[i] - ord(j_str[i % len(j_str)])) % 256
                if hashlib.md5(r).hexdigest() == '6c5215b12a10e936f8de1e42083ba184':
                    print ('yes')
                    print(j_str)
                    print(f_str)
                    print(hashlib.md5(r).hexdigest())
                    exit

запустил на исполнение, получил верную последовательность, которая даёт эталонный хэш. После нажатия правильной игровой комбинации получил верный ключ: s1gn_my_gu357_b00k@flare-on.com

Да, это виртуальная машина с неизвестным содержимым внутри. Минимальный набор уважаемого исследователя чужих виртуальных машин выглядит так:

cat ~/.bash*
find / -type f -mtime -60 -mtime +1 -exec ls -la {} \: 2>/dev/null | less
ls -la ~
ls -la Documents
crontab -a

Это даст нам полезные, явно не системные файлы, со свежей датой создания 26 августа 2021 года:

  • /usr/bin/zyppe // утилита, устанавливающая слой шифрования

  • /usr/bin/dot // утилита, требующая ввести какой-то пароль

  • некие константы NUMBER и aлиас FLARE

  • кучу зашифрованных файлов в папке Documents

Начинаем разбираться.

Необходимо снять слой шифрования. Все текстовые документы раздела Documents имеют размер 1024 байта. Утилита /usr/bin/zyppe использует симметричный алгоритм, так что всё, что нам нужно, это повторно применить её к содержимому папки Documents.

Теперь почитаем сами документы:

[user@localhost Documents]$ cat udon_noddles.txt.broken 
"ugali", "unagi" and "udon noodles" are delicious. What a coincidence that all of them start by "u"!

[user@localhost Documents]$ cat ugali.txt.broken 
Ugali with Sausages or Spaghetti is tasty. It doesn’t matter if you rotate it left or right, it is still tasty! You should try to come up with a great recipe using CyberChef

[user@localhost Documents]$ cat unagi.txt.broken 
The 1st byte of the password is 0x45

Документы на букву "s" зашифрованы с помощью команд ror или rol. Исходный вариант:

[user@localhost Documents]$ xxd sausages.txt.broken 
00000000: 2a34 b210 19b9 3a10 31bc 3ab2 10b7 3310  *4....:.1.:...3.
00000010: 3a34 b210 38b0 b9b9 bbb7 3932 10b4 b910  :4..8.....92....
00000020: 183c 991a 0500 0000 0000 0000 0000 0000  .<..............

Преобразованный вариант:

sausages.txt:         The 2st byte of the password is 0x34
strawberries.txt:     In the FLARE team we like to speak in code. 
spaghetti.txt:        In the FLARE language "spaghetti" is "c3BhZ2hldHRp". 
strawberries.txt:     You should learn our language, otherwise you want be able to speak with us when you escape 
                      (if you manage to escape!). For example, instead of "strawberries" we say "c3RyYXdiZXJyaWVz".

Подсказок, к каким документам применять указанные алгоритмы больше не будет и необходимо следовать своей интуиции. Документы на букву "r" написаны кодировкой Base64:

[user@localhost Documents]$ cat rasberries.txt.broken 
VGhlIDNyZCBieXRlIG9mIHRoZSBwYXNzd29yZCBpczogMHg1MQo=

Сконвертируем их в человеко-понятный вид:

rasberries.txt      The 3rd byte of the password is: 0x51
reeses.txt          We LOVE "Reese\'s", they are great for everything! They are amazing 
                    in ice-cream and they even work as a key for XOR encoding 

Документы на букву "b" накрыты операцией ХОR, где первый операнд - исходный текст, второй - i-тый байт "Reese's". Преобразовываем и продолжаем копаться в чужом белье:

blue_cheese.txt:         The 4th byte of the password is: 0x35
backberries.txt:         If you are not good in maths, the only thing that can save you is to be a bash expert. 
                         Otherwise you will be locked here forever HA HA HA!
banana_chips.txt:        Are you good at maths? We love maths at FLARE!
                         We use this formula a lot to decode bytes: "ENCODED_BYTE + 27 + NUMBER1 * NUMBER2 - NUMBER3"

Отлично, пока не очень сложно. Следующие документы - это просто chr(ord(symbol) + 0x4 )):

iced_coffee.txt          The only problem with RC4 is that you need a key. The FLARE team normally uses this number: "SREFBE" (as an UTF-8 string). 
                         If you have no idea what that means, you should give up and bake some muffins.
instant_noodles.txt      The 5th byte of the password is: 0xMS
ice_cream.txt            это рецепт вкусного десерта, попробуем его позже, на данный момент
                         оттуда нам важно выяснить, что C -> 0,  B -> 1,  L -> 2...

Зная ключ для RC4 вскроем документы на букву "n":

nachos.txt             In the FLARE team we really like Felix Delastelle algorithms,
                       specially the one which combines the Polybius square with transposition, 
                       and uses fractionation to achieve diffusion.
natillas.txt           Do you know natillas? 
                       In Spain, this term refers to a custard dish made with milk and KEYWORD, 
                       similar to other European creams as crème anglaise.
                       In Colombia, the delicacy does not include KEYWORD, and is called natilla
nutella.txt            The 6th byte of the password is: 0x36

По-видимому, этот шифр малоизвестен в русскоязычной среде. До Flare-2021 pанее в литературе мне нигде не попадались упоминания о нём. Расшифровать такой шифртекст удобно на ресурсе cryptii.com ( https://cryptii.com/pipes/bifid-cipher ). Ура, мы можем прочесть тексты на букву d, например, такой:

Abn lef emadkxp frceqdnhe? Tah gdcktm temyku xxo qo ktyhzn! Zd'k raooua, por uda ztykqh.
are you missing something? you should search for it better! it's hidden, but not really.

(читателю для сравнения дан текст до расшифровки и после). Узнаём, что есть скрытый файл с фразой "the 7th byte of the password is: 0x66" и что "giovan battista bellaso loved microwaves". А вы знали, что шифр Виженера придуман не им? Теперь и вы это знаете.

Применяем шифр Виженера с ключевым словом microwaves к текстам на букву "o", получаем 8-ой байт пароля "0x60", и информацию о том, что Flare Team публикует в твиттере массу интересных публикаций и это поможет вскрыть тексты на оставшуюся букву "t". Увы, я не справился с последним шагом, но известных символов мне хватило, чтобы брутфорсом подобрать нужный пароль для приложения /usr/bin/dot.

как это сделано

Приложение dot вычисляет sha256 от введённой строки и сравнивает её с эталонной. Можно воспользоваться мощной утилитой hashcat для подбора хеша, а можно написать свой брутфорс.

Пароль: E4Q45d6f`lD5I, флаг: H4Ck3r_e5c4P3D@flare-on.com

Весёлый этап, чтобы добраться до ключа, пришлось пройти эдакий лабиринт и распутать серию несложных школьных загадок.

Дан pcap-файл с двумя tcp-сессиями между хостом и A xn--zn8hscq4eeafedhjjkl.flare-on.com. Первая сессия - передача png-котика и некоторого двоичного файла. Вторая сессия - общение мелкими пакетами друг с другом. Заметна определённая структура - двочные файлы начинаются с "MEOW", Dword value, Dword value, "PA30". Увы, Wireshark умеет сохранять сессию в бинарном виде только как единое целое, то есть можно сохранить и после нарезать bin по совпадению с MEOW, но я принял более универсальное решение - сохранить дамп как массивы "C" и быстро набросал python-скрипт по конвертации массивов в множество bin-файлов, благо анализ сетевого траффика - частая встречающаяся задача и удобный инструмент конвертации наверняка пригодится ещё.

Так, что, собственно, с этим добром делать? Извлечённая картинка было совершнно типовой. Стеганографический анализ утилитой zsteg ничего не выявил, PNG-файл содержал правильные заголовки и никаких лишний секций не имел Второй двоичный файл первой сессии имел размер 10Кб и на полноценную программу (даже упакованную) никак не тянул.

Довольно быстро нашлось, что 50 41 33 30 (PA30) - это сигнатура Package Delta Format, но этот в сети пример повёл меня по ложному пути - видно, что у такого файла средняя энтропия и бывают повторяющеся символы, а своих файлов PA30 на компе не нашлось. Какое-то время потратил на подбор алгоритма сжатия, пока не понял, что это совершенно бесполезное занятие.

Выручила вот эта найденная ссылка и опубликованный проект delta_patch.py на гитхабе. И, да, это действительно Package Delta Format! Сложно передать, но это было огромное потрясение. Так умело подобрать данные, нормальное изображение 660 кб патчем в 10 кб превратить в полноценное dll-приложение весом в 1.4 мб. Фокус, похлеще фокусов Гуддини и Копперфильда.

Друзья, простите меня за это большое лирическое отступление, но я никак не ожидал, что оно сработает. Остальное оказалось элементарным делом. Порт и хост можно подсмотреть в tcp-трафике, настроить сеть, написать серверное приложение и сымитировать внешнюю атаку по перехваченным запросам со второй сессии. Выяснилось, dll при установлении соодинения создаёт cmd-подпроцесс, получает shell-команды и отправляет результат их исполнения. Злоумышленник с помощью специальных запросов мог переходить по каталогам файловой системы, читать файлы, запрашивать перечень файлов. В одном прочитанном таким образом файле и содержался ключ 1m_H3rE_Liv3_1m_n0t_a_C4t@flare-on.com.

Расшифровка и создание патчей осуществлялось библиотечными функциями CreateDeltaB и ApplyDeltaB. Второй вариант решения задачи - написать свой расшифровщик, но я предпочёл просто читать выделенную память в отладчике после вызова ApplyDeltaB, посылая на вход не только сами запросы, но и перехваченные ответы.

прикладываю пример расшифрованной команды и ответа:
@echo off & for /f %a in ('dir /s /b') do echo %~fa %~za

C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Cat_Memes 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Great_Ideas 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\No_Flags_Here 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\NO_SERIOUSLY_EVEN_MORE_BESTEST_IDEAS 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Okay_Ideas 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Swag 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\The_BEST_Ideas 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Cat_Memes\meow.txt 64
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Great_Ideas\meow.txt 64
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up\FlagPit 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up\Gotcha.txt 1806
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up\meow.txt 64
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up\The_Real_Challenge 0
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up\FlagPit\me0000000w.txt 812
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up\The_Real_Challenge\Mugatuware.exe 1532928
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Never\Gonna\Give\You\Up\The_Real_Challenge\mydude.exe 650105
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\No_Flags_Here\meow.txt 64
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\No_Flags_Here\what_did_you_expect.txt 1658
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\NO_SERIOUSLY_EVEN_MORE_BESTEST_IDEAS\meow.txt 64
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Okay_Ideas\meow.txt 64
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\Swag\meow.txt 64
C:\Users\user\Desktop\SuperSecret\2021_FlareOn\The_BEST_Ideas\meow.txt 64

Бонус - адрес сайта после декодирования url: 🐈😸😻😹😺🐱😼🙀😿😾😽😻.flare-on.com

Дано классическов MFC-приложение с задизабленными кнопками:

Сразу смутил слишком большой объём файла (4.2Мб) для такого небольшого приложения и поиск по сигнатуре выявил внутри exe скрытую dll, Системные библиотечные вызовы заменены хэшами. Здравая интуиция говорит, что здесь пытались спрятать важные вещи.

У доброго охотника на людей есть подборка утилит, разработанных по большей части 15 лет назад, но не потерявших актуальности до сих пор, позволяющие просматривать или менять стили окон, передавать или отслеживать сообщения и уведомления. Мы можем воспользоваться одной из них и привести приложение в работоспособное состояние. Изучение функциональных возможностей проги не даст вам информации о ключе, увы. Нам необходимо искать другие пути.

Через некоторое время вы обязательно заметите, что при закрытии программы за крест процесс остаётся висеть в системе. Их могут накопиться в системе несколько штук. Они не завершаются, а засыпают посредством вызова SleepEx на 360000мс. Такие паузы можно и нужно убирать. На этом этапе уже начал выполняться код из скрытой dll.

Программа открывает сетевое соедение с inactive.flare.com:888, посылает команду '@' и ожидает в ответ run|exe|flare-on. Чтобы выполнилась полезная для нас ветка условия, необходимо и достаточно послать приложению команду flare-on и пройти проверку на соответствие имени программы со строкой "Spell.EXE". Первые две команды нам не нужны - это встроенный бэкдор для удалённого управления - для запуска команд и произвольных приложений целевого компьютера.

В случае команды "flare-on" приложение обращается к встроенному ресурсу с именем PNG (картинка png), расшифровывает её ключом из хедера приложения exe - результат строка "l3rlcps_7r_vb33eehskc3". Но это ещё не всё, ибо данный текст - бессмыслица. Функция 0x180002730 подсказывает правильный порядок символов флага, шифрует некоторые данные и пишет полученные байт в куст реестра Software\Microsoft\Spel. В реестр можно и не смотреть, ведь флаг мы уже получили шагом ранее. Всё, можно переходить к следующей задаче. Ответ: b3s7_sp3llcheck3r_ev3r@flare-on.com

Не менее прекрасное очередное задание - свыше трёх мегабайт js-скрипта. После беглого осмотра понимаешь, что большая часть этого кода никогда не используется и самое оптимальное решение - вырезать такие фрагменты из файла.

my @matches = $content =~ /\{(?:[^}{]+|(?R))*+\}/mg;

В одной строке ужас и красота регулярных выражений. Мы ищем последовательность, которая начинается открывающейся фигурной скобкой "{" и оканчивается закрывающейся фигурной скобкой "}", содержащей внутри одну из двух альтернатив: либо последовательность из 1 и более символа без фигурных скобок или рекурсивно весь шаблон снова. Таким образом, если у нас не "{{" движок сьедает ненулевое количество нескобочных символов, упирается в открытую скобку и выполняет сопоставление с исходным РВ. Такой регескп работает для правильных скобочных структур.

Этот классический пример я доработал, а именно - перед удалением добавил проверку, что перед правильной скобочной структурой стоит ключевое слово "function" и далее идут необязательные входные параметры. После perl-скрипта и небольшой ручной работы очищенный код выглядит так:

ciphertext =" ..." // данные, что необходимо расшифровать
some_arr =  "..." // ключ, которым накрыт шифртекст, 221 байт
passwd='gflsdgfdjgflkdsfjg4980utjkfdskfglsldfgjJLmSDA49sdfgjlfdsjjqdgjfj'.split(''); //пароль, который преобразует байты ключа для шифртекста

pass4form=xDyuf5ziRN1SvRgcaYDiFlXE3AwG.ZJqLM97qEThEw2Tgkd8VM5OWlcFN6hx4y2.value.split(';')
if(pass4form[0].length==64)                   
             passwd=pass4form[0].split('');           // применяется пароль из формы ввода html, если он есть и длины 64

ciphertext2 = atob(ciphertext).split('');
ciphertext_len = ciphertext2.length;
 
 arr_221 = atob(some_arr).split('');

 
for (i=0; i < arr_221.length; i++) { arr_221[i] = (arr_221[i].charCodeAt(0) + passwd[i % 64].charCodeAt(0)) & 0xFF;	}
   
   
ciphertext3 = ciphertext2;
for (i=0; i < ciphertext_len; i++) { ciphertext3[i] = (ciphertext3[i].charCodeAt(0) - arr_221[i % arr_221.length]) & 0xFF;	}

res="";
for (i=0; i < ciphertext2.length; i++) { res+=String.fromCharCode(ciphertext3[i]);}
   
if('rFzmLyTiZ6AHlL1Q4xV7G8pW32'>=res)eval(res)

(имена переменных вида PyKEvIqAmUkUVL0Anfn9FElFUN2dic3z переименованы)

Очевидно, что после расшифровки шифртекста должен получиться валидный js-код (т.е. текстовый файл), поэтому можно написать скрипт, проитерироваться по всем байтам пароля, для каждой позиции подбирая верный, выполняя проверку, что

if chr(res[i]).isprintable():
   some code ...

Такая проверка потерпит неудачу.

почему?

потому что текстовый файл помимо печатных символов содержит непечатные "\n\r" (0xa,0xd)

Учтём этот небольшой нюанс, и тогда всё получится, правда, для некоторых позиции пароля будут возможны несколько вариантов значений. Это приведёт к тому, что результирующий текст будет местами искажен, если мы точно не угадаем с первоначальным символом на i-том месте пароля. Авторы задания любезно поместили в начало js-кода литературный англоязычный комментарий, который помогает выявить и исправить такие ошибки.

Итак, мы подобрали пароль и получили валидный js-код. Посмотрим на него:

[][(![]+[])[+[]]+(![]+[])[!+[]+!+[]]+(![]+[])[+!+[]]+(!![]+[])[+[]]][([][(![]+[])[+[]]+

и так далее. Метод обфрускации JSFuck, придуман 10 лет назад, примерно тогда описан и метод деобфрускации. Конвертируем и получаем js-код, скрытый той же методикой, что и изначальный js. Повторяем шаги 1 и 2 несколько раз, пока не доберёмся до флага.

Ответ: I_h4d_v1rtU411y_n0_r3h34rs4l_f0r_th4t@flare-on.com

Задача решена.

Самое сложное задание контеста. Вот примерный список трудностей, которые пришлось преодолеть:

  • разобраться с протоколом UDP и догадаться, что ПО функционирует только с админскими правами

написал тестовый пример и запустил:

WSAStartup(MAKEWORD(2, 2), &data);
SOCKET s = socket(AF_INET, SOCK_RAW, IPPROTO_UDP);

(c обычными правами это не работало)

  • обойти в отладчике все антиотладочные проверки

в x64dbg сделал принудительную установку регистров и флагов в ожидаемое значение,
в x64dbg сделал принудительную установку регистров и флагов в ожидаемое значение,

(крайне неочевидный факт - последней операцией необходимо указывать $breakpointcondition=0, иначе отладчик будет делать принудительный останов)

  • убрать паразитные потоки, которые ничего не делают (вылечилось выявлением мест запуска функции 0x402710 - они создавали случайный тред)

  • расшифровать вызовы функций. Дело в том, что прямых вызовов функций в программе нет, вместо этого есть создание исключительных ситуаций и обработка эксепшенов. В обработчик передаётся хеш имени библиотеки и хеш функции.

  • занопить фейковые инструкции, которые никогда не выполнялись, но запутывали работу дизассемблера. Этот и предыдущий пункты можно автоматизировать.

  • найти поток и участок кода, где происходит обработка входящего сообщения по сети

  • выяснить, что существуют 4 вида входящих сообщений (0x00000001, 0x00000002, 0x00000003, 0x00000004)

0x00000001 - печатает fake-флаг и рикролл, сбрасывает 128-битный ключ в нулевое состояние

0x00000002 - если сообщение совпадает с одной из четырёх констант s3cret,L0ve,5Ex,g0d, поток исполнения попадает в одну из ветвей второй части функции и заполняет свои 32бита из 128бит ключа, который используется командой 0x3

0x00000003 - если сообщение совпадает с 'MZ' - вызывает процедуру, что пытается расшифровать некоторые зашифрованные данные ключом 128 бит алгоритмом RC4.

0x00000004 (и далее) - эхо-ответ

Если вы справились с вышеперечисленными шагами - кажется, что победа вот-вот близко, но вместо заветного флага в консоль по-прежнему печатается белиберда. Что же не учтёно? Опытным путём было исследовано, что алгоритм в точности таков, каков он здесь описан, и не содержит скрытых условий и ветвей. Есть только одно место, где расшифровывается флаг, по динамическому ключу, части которого зависят от 4 входящих строк. Попробовал оставлять часть хеша незаполненным (нулевой ключ - тоже ключ!), эти 2^4 = 16 комбинаций не помогли. Наконец, заметил, что если посылать сообщения длиннее, чем контрольные константы, мы по-прежнему попадаем в ветви, где вычисляется хеши (результат, конечно же, получается другим), значит, правильный ключ генерируется из входящих сообщений большего размера и это оказались сообщения [b's3cret\0',b'L0ve\0',b'5Ex\0',b'g0d\0'].

Ответ: n0_mOr3_eXcEpti0n$_p1ea$e@flare-on.com

Дополнение: гайд по автоматизации

Из-за антиотладочных мер листинг evil.exe выглядит нечитабельно:

На скриншоте обфусцирован вызов CreateMutexA():

MOV       EDX,dword ptr [EBP + local_24]
MOV       ECX,dword ptr [EBP + local_28] // 0x1c5d2c5e = hash("CreateMutexA")
XOR       EAX,EAX
MOV       EAX,dword ptr [EAX]      // разыменование нулевого указателя

Обработчик исключения выполняет поиск функции по заданному хэшу и передаёт ей управление. Первые три байта после последнего MOV являются мусорными, т.к. возврат происходит по адресу 0x406512. Это сделано для запутывания дизассемблера и реверс-инженера. Давайте это исправим:

Скрипт для ghidra
from ghidra.program.model.listing import CodeUnit

listing = currentProgram.getListing()
addr = toAddr('00401000')

def check_exc1(addr):
    c = listing.getCodeUnitAt(addr)
    if c.getMnemonicString() != u'MOV':
        return 0
    addr = addr.add(c.length)
    c = listing.getCodeUnitAt(addr)
    if c.getMnemonicString() != u'MOV':
        return 0
    addr = addr.add(c.length)
    c = listing.getCodeUnitAt(addr)
    if c.toString() != u'XOR EAX,EAX':
        return 0
    addr = addr.add(c.length)
    c = listing.getCodeUnitAt(addr)
    if c.toString() != 'MOV EAX,dword ptr [EAX]':
        return 0
    return 1


#while addr < toAddr('00406600'):
while addr < toAddr('00444CED'):
    if check_exc1(addr):
        print(addr)
        addr2 = addr.add(10)
        clearListing(addr2)
        setByte(addr2,0x90)
        disassemble(addr2)

        addr2 = addr2.add(1)
        clearListing(addr2)
        setByte(addr2,0x90)
        disassemble(addr2)

        addr2 = addr2.add(1)
        clearListing(addr2)
        setByte(addr2,0x90)
        disassemble(addr2)

        addr3 = addr2.add(1)
        i = 0
        while i < 20:
            addr2 = addr2.add(1)
            clearListing(addr2)
            i = i + 1
        disassemble(addr3)
    c = listing.getCodeUnitAt(addr)
    addr = addr.add(c.length)

Топорный, но вполне работающий вариант. Здесь происходит следующее:

  • выполняется поиск четвёрки инструкций MOV +MOV + XOR EAX,EAX + MOV (в коде присутствуют и другие исключения, например - деление на ноль)

  • при успехе - очищается листинг на несколько инструкций вперед, и три последующих байта заменяются инструкциями NOP(0x90)

  • ассемблирование участка кода выполняется заново

Можно усовершенствовать этот скрипт, добавив распознавание функций и команду c.SetComment(CodeUnit.EOL_COMMENT,text). Словарь хеш -> "имя функции" можно собрать в x64dbg по адресу 0x4055d6 функции с условным названием API_RESOLVER:

ESI - указатель на строковое имя функции, ECX - её хеш, команда "log" отладчика поможет создать полезную для наших нужд базу данных хешей. Конечный результат может выглядеть следующим образом:

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

Дан pcap-файл с исполняемым файлом, который общается по протоколу IRC и сама переписка между двумя клиентскими приложениями. Переписка состоит из двух похожих диалогов и каждая из них оканчивается отправкой потока данных с целевого компьютера. Собственная реализация IRC-сервера показалась бесперспективной идеей, а вот готовое решение идеально подошло под задачу. Бот желает общаться с сервером wizardcult.flare-on.com и пользователем dung3onm4st3r13, требуемое легко настроить. Попытка повторить диалог как в pcap оказалась успешной, за исключением финальной стадии ( после фразы "I quaff my potion and attack!" в оригинальном диалоге возвращался шифркод). Strace прояснил ситуацию:

скриншот программы pidgin
скриншот программы pidgin
stat("/mages_tower",...) = -1 ENOENT
stat("/mages_tower",...) = -1 ENOENT
open("/mages_tower/cool_wizard_meme.png",...) = -1 ENOENT
open("/mages_tower/cool_wizard_meme.png",...) = -1 ENOENT

Я создал недостающий каталог и файл и немного поэксперементировал с входными данными: можно проверить, что длина выходных данных совпадает с длиной входных данных, т.е. вход шифруется побайтно, в первом диалоге по входу вида "aaaaaaaaaaaa" возвращается одинаковый вывод, то есть кодирование открытых данных не зависит от них позиции, а во втором случае одинаковым алгоритмом K кодируется каждый k+24 * i символ.
А вот, собственно, и всё - генерируем 256 открытых текстов с одинаковыми символами (или один длинный, содержащий все символы подряд), активируем бота и получаем в ответ множества значений всех 24-ёх функций f_0, f_1, ..., f_23 на всей области определения. Зная, как закодирован произвольный i-тый символ сообщения, по шифртексту можно восстановить исходное сообщение. Это оказалась png-картинка с флагом на ней. Самая сложная задача решена за несколько часов... До сих пор нахожусь в лёгком недоумении, это прокол организаторов или любезно оставленная калитка на заднем дворе. Заметим, что для её решения практически не потребовалось анализировать исходный код.

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

Требуется в точности узнать алгоритм шифрования входной последовательности. Этот алгоритм выполняется, очевидно, в некоторой виртуальной машине:

а сами данные для виртуальной машины загружаются злоумышленником извне как рецепт зелья, каждый ингридиент которого интерпретируется как некоторый байт, что позже поступает на вход декодеру - десериализатору данных в объект. Существует прекрасный инструмент для восстановления символов go-приложений, таких как методы и структуры - redress. Кажется, у нас всё есть для того, чтобы посмотреть на эти данные в человекопонятном виде.

Смотрите и запускайте этот код в онлайн-интерпретаторе. Немного пояснений. Типы данных скопированы как есть из выдачи утилиты redress, сериализованные данные программ ВМ можно сдампить в gdb как входной параметр функции Decoder(), использован базовый пример работы с encoding/gob и добавлена печать адресов полей структур для определения смещения, это нам пригодится для анализа приложения.

Вот наглядная интерпретация данных первого диалога из pcap:

то есть виртуальная машина - это многопроцессорная ЭВМ с памятями, входом и выходом и закешированными инструкциями. Я установил брейкпоинты на все ассемблерные команды (и на функции чтения/записи регистров) и обнаружил, что на такой программе срабатывают только три из них: MOV (чаще других), TEQ, XOR, Несложно сопоставить одно с другим и предположить, что программа выполняет out[i] = in[i] ^ 0xa2. и понять, что опкоды mov - 0x01, teq - 0x05, xor - 0x12. Условные операторы устанавливают флаг Cond процессора и инструкции могут пропускаться интерпретатором (если cond_cpu !=cond_instr). Инструкции с Сond=0 исполняются всегда.

Вот моя самая первая попытка расставить точки останова в ключевых местах и полученный при этом вывод (две итерации цикла по программе со скриншота выше):

к сожалению, хабраредактор не позволяет так разметить текст
к сожалению, хабраредактор не позволяет так разметить текст

Жирным выделены брейкпоинты от второго CPU. Видны переключения контекста от одного CPU к другому. Сначала потоки идут параллельно (от запуска к запуску последовательность инструкций может незначительно меняться), Ключевой момент для осознания реализации работы мультипроцессорности - в строке 02 предпринимается попытка получить значение регистра r0 cpu1, поток засыпает ровно до того момента, когда в регистр r2 cpu0 не будет записано значение (0x63), и, как можно заметить, возвращаемое значение r0 cpu1 = 0x63. Это указывает на жёсткую связь между r2 cpu0 и r0 cpu1. Далее в строках 06..10 происходит ровно тоже самое. Пытаемся прочитать r2 cpu0 и засыпаем ровно до того момента, как в r0 cpu1 не будет записано значение (0xc1).

Эти связи задаются в разделе Links:

LHDevice0 - Input Device
LHDevice1 - Output Device
LHDevice2 - CPU0
LHDevice3 - CPU1
....
LHDevice_(N+2) - CPU_N
LHDevice_(N+3) - ROM0
и так далее.

Здесь: r0 CPU0 - вход, r1 CPU0 - выход, r2 CPU0 == r1 CPU1 и программу можно переписать так:

CPU0: 
mov input, acc
teq acc,-1
если да: mov -1, output
			   mov input, acc
mov acc, r2      /// активируем cpu1 и посылаем входное значение
mov r2, acc      /// получаем данные из cpu1
mov acc, output

CPU1:
mov r0,acc
xor 0xa2,acc
mov acc,r0 (возвращаемся в cpu0)

После того, как первая программа помогла вам осознать заложенный принцип ВМ, можно разбираться с программой N2, которая несколько запутаннее. Здесь уже не 2 CPU, а 7, кроме того, памяти непусты и содержат некие двоичные данные. Перечень опкодов можно взять в ExecuteInstruction, где анализируется первый параметр и вызывается соответствующая функция ВМ. Для визуализации работы ВМ я написал отладочный модуль, используя gdb.python, главная функция которого для примера представлена ниже:


    def print_instr(self):
        pointer_addr_cpu = self.execute_and_get_value('x/g ($rax+0x20)')
        cpu_N_value      = self.execute_and_get_value('x/g ($rax+0x10)')

        pointer_addr_cpu = int(pointer_addr_cpu,0)
        cpu_N_value      = int(cpu_N_value,0)

        ind = self.cpu_addr.index(pointer_addr_cpu)

        cpu_r0      = self.get_reg('x/gx ($rax+0x38)')
        cpu_r1      = self.get_reg('x/gx ($rax+0x40)')
        cpu_r2      = self.get_reg('x/gx ($rax+0x48)')
        cpu_r3      = self.get_reg('x/gx ($rax+0x50)')
        cpu_acc     = self.get_reg('x/gx ($rax+0x0)')
        cpu_dat     = self.get_reg('x/gx ($rax+0x8)')
        cpu_cond    = self.get_param('x/gx ($rax+0x18)')

        instr_opcode = self.execute_and_get_value('x/g (0x%x+0x30*%d)'% (pointer_addr_cpu, cpu_N_value))
        instr_opcode      = (int(instr_opcode,0))
        instr_par1 = self.get_param('x/g (0x%x+0x30*%d+0x08)'% (pointer_addr_cpu, cpu_N_value))
        instr_par2 = self.get_param('x/g (0x%x+0x30*%d+0x10)'% (pointer_addr_cpu, cpu_N_value))
        instr_par3 = self.get_param('x/g (0x%x+0x30*%d+0x18)'% (pointer_addr_cpu, cpu_N_value))
        instr_par4 = self.get_param('x/g (0x%x+0x30*%d+0x20)'% (pointer_addr_cpu, cpu_N_value))
        instr_par5 = self.get_param('x/g (0x%x+0x30*%d+0x28)'% (pointer_addr_cpu, cpu_N_value))

        reg1 = 'r' if int(instr_par4) & 0x1 else ''
        reg2 = 'r' if int(instr_par4) & 0x2 else ''

        #                0     1     2      3      4      5      6      7     8     9   
        instr_names = ['___','mov', 'movc','___', '___', 'teq', 'tgt', 'tlt','tcp', 'add',
                       'sub','mul', 'div', 'not', '___', '___', 'and',' or', 'xor', 'shl', 'shr']

        shift = ' ' * (4 * ind)
        
        if instr_par5 == '0' or int(instr_par5) == int(cpu_cond):
             print ('%s cpu%d_%02d | %s %s%s %s%s %s %s | %s %s %s %s %s %s DEBUG' % (
                shift, ind, cpu_N_value,
                instr_names[instr_opcode], reg1, instr_par1, reg2, instr_par2, instr_par3, instr_par4, 
                cpu_r0,cpu_r1,cpu_r2,cpu_r3,cpu_acc,cpu_dat  ))

в результате получаем такой красивый вывод в отладчике:

лог
cpu0_00 | mov r0 r4 0 3 | CH0 CH1 CH2 0x0 0x21 0x0                         0x21 -> acc cp0             
cpu0_01 | teq r4 -1 0 1 | CH0 CH1 CH2 0x0 0x21 0x0                         0x21 != -1 ?
cpu0_04 | mov r4 r2 0 3 | CH0 CH1 CH2 0x0 0x21 0x0                         0x21 send r0 cp1
    cpu1_00 | mov r0 r4 0 3 | CH2 CH6 CH3 0x0 0x21 0x1b                    0x21 -> acc cp1
    cpu1_01 | mov r4 r1 0 3 | CH2 CH6 CH3 0x0 0x21 0x1b                    0x21 send r0 cp2
        cpu2_00 | mov r0 r4 0 3 | CH6 CH7 CH8 CH9 0x21 0x0                 0x21 -> acc cp2
        cpu2_01 | tgt r4 63 0 1 | CH6 CH7 CH8 CH9 0x21 0x0                 0x63 > 0x21 ?
        cpu2_04 | mov r4 r1 0 3 | CH6 CH7 CH8 CH9 0x21 0x0                 request data1[0x21]
        cpu2_05 | mov r2 r0 0 3 | CH6 CH7 CH8 CH9 0x21 0x0                 send data1[0x21]=0xf -> r1 cp1
    cpu1_02 | mov r1 r4 0 3 | CH2 CH6 CH3 0x0 0xf 0x1b                     0xf -> acc
    cpu1_03 | mov r4 r2 0 3 | CH2 CH6 CH3 0x0 0xf 0x1b                     0xf send r0 cp5
                    cpu5_03 | mov r0 r5 0 3 | CH3 CH4 CH5 0x0 0x1 0xf      0xf -> dat
                    cpu5_04 | mov r2 r4 0 3 | CH3 CH4 CH5 0x0 0x31 0xf     data4[i] -> acc
                    cpu5_05 | not 539 0 0 0 | CH3 CH4 CH5 0x0 0xce 0xf     acc ^ 0xff           
                    cpu5_06 | and ff 0 0 0 | CH3 CH4 CH5 0x0 0xce 0xf      = 0xce
                    cpu5_07 | xor r5 0 0 1 | CH3 CH4 CH5 0x0 0xc1 0xf      0xf ^ 0xce = 0xc1
                    cpu5_08 | mov r4 r0 0 3 | CH3 CH4 CH5 0x0 0xc1 0xf     0xc1 send r2 cp1
    cpu1_04 | mov r2 r4 0 3 | CH2 CH6 CH3 0x0 0xc1 0x1b                    0xc1 -> acc
                    cpu5_00 | mov r1 r4 0 3 | CH3 CH4 CH5 0x0 0x2 0xf      0x2 -> acc
    cpu1_05 | mov r4 r1 0 3 | CH2 CH6 CH3 0x0 0xc1 0x1b                    0xc1 -> send r0 cp2 
        cpu2_00 | mov r0 r4 0 3 | CH6 CH7 CH8 CH9 0xc1 0x0                 0xc1 -> acc
                    cpu5_01 | and 1 0 0 0 | CH3 CH4 CH5 0x0 0x0 0xf        0x2 & 0x1 = 0x0
        cpu2_01 | tgt r4 63 0 1 | CH6 CH7 CH8 CH9 0xc1 0x0                 0xc1 > 0x63?
                    cpu5_02 | teq r4 1 0 1 | CH3 CH4 CH5 0x0 0x0 0xf       (0x2 & 0x1) == 0x1 ?
        cpu2_02 | mov r4 r3 0 3 | CH6 CH7 CH8 CH9 0xc1 0x0                 0xc1 send r0 cp3
            cpu3_00 | mov r0 r4 0 3 | CH9 CH10 CH11 CH12 0xc1 0x0          0xc1 -> acc
            cpu3_01 | tgt r4 c7 0 1 | CH9 CH10 CH11 CH12 0xc1 0x0          0xc1 > 0xc7 ?
            cpu3_04 | sub 64 0 0 0 | CH9 CH10 CH11 CH12 0x5d 0x0           0xc1 - 0x64 = 0x5d
            cpu3_05 | mov r4 r1 0 3 | CH9 CH10 CH11 CH12 0x5d 0x0          request data2[0x5d] (=0xd6)
            cpu3_06 | mov r2 r0 0 3 | CH9 CH10 CH11 CH12 0x5d 0x0          send 0xd6 r3 cpu2
    cpu1_06 | mov r1 r5 0 3 | CH2 CH6 CH3 0x0 0xc1 0xd6                    0xd6 -> dat
        cpu2_03 | mov r3 r0 0 3 | CH6 CH7 CH8 CH9 0xc1 0x0                 send 0xd6 r1 cpu1
    cpu1_07 | mov 80 r4 0 2 | CH2 CH6 CH3 0x0 0x80 0xd6                    const 0x80 -> acc
    cpu1_08 | and r5 0 0 1 | CH2 CH6 CH3 0x0 0x80 0xd6                     0xd6 ^ 0x80
    cpu1_09 | teq r4 80 0 1 | CH2 CH6 CH3 0x0 0x80 0xd6                    (0xd6 ^ 0x80) == 0x80 ?
    cpu1_10 | mov r5 r4 0 3 | CH2 CH6 CH3 0x0 0xd6 0xd6                    0xd6 -> acc
    cpu1_11 | xor 42 0 0 0 | CH2 CH6 CH3 0x0 0x94 0xd6                     0xd6 ^ 0x42 = 0x94
    cpu1_13 | not 539 0 0 0 | CH2 CH6 CH3 0x0 0xffffff6b 0xd6              0xd6 ^ 0xff = 0x6b
    cpu1_14 | and ff 0 0 0 | CH2 CH6 CH3 0x0 0x6b 0xd6                     
    cpu1_15 | mov r4 r0 0 3 | CH2 CH6 CH3 0x0 0x6b 0xd6                    0x6b -> r2 cp0
cpu0_05 | mov r2 r4 0 3 | CH0 CH1 CH2 0x0 0x6b 0x0                         0x6b -> acc
cpu0_06 | mov r4 r1 0 3 | CH0 CH1 CH2 0x0 0x6b 0x0                         0x6b -> Output
cpu0_00 | mov r0 r4 0 3 | CH0 CH1 CH2 0x0 0x2f 0x0                         Input -> 0x2f

(CH{i} - это каналы, описанные в разделе Links)

Хотелось бы упомнянуть ещё один момент: существует заметная разница в том, когда снимать состояние ЭВМ - до входа в ExecuteInctruction или после. Это обусловлено особенностью работы горутин и go-каналов, а именно, тем фактом, что горутины выполняются асинхронно и могут приостанавливаться в ожидании данных из других горутин. Более понятным порядком следования инструкций будет, если останавливаться позже. Обратите внимание, здесь регистры процессора зафиксированы на момент, когда текущая инструкция уже отработала.

Оставляю читателю статьи возможность самостоятельно проследить за ходом исполнения программы по листингу и комментариям, а я приведу лишь итоговый алгоритм, записанный на языке Python:

алгоритм шифрования и дешифрования
def inv(perm):
    inverse = [0] * len(perm)
    for i, p in enumerate(perm):
        inverse[p] = i
    return inverse

import binascii

dat = '5a840645aecbe8f357fea63d5e4108d03322218120dd00a023af71048bf5181de10f6509ce42783ec337ca8f6432e0acde917c2ac007f4959f4053e567b67a524e3f834bc982722e761cf11eccb7d7c78a10791a4d1935167d432bcd86ab4492d40e9814b99ba7241b3ce23ad3f0fd4f77d1a30c48806adabdd8475bfa960beccf49d9117fb127e7c5b263e62836b35dfbdca87025f6b09ca55fb839e485a9fc13025130f269ff74bf59b54617c2586199eba49e89ee6cefa290738c54bc6ddb2cd6e3a18d50f734d5f9017b8ebe686b559d2ded2f93151fc488aaf80d5cea5603c19a38056f624a12df609429757eade90a31b4bbba873b26d26e66c84c97c6'

dat = binascii.unhexlify(dat)
datreverse = inv(dat)

dat4 = b'a11_mY_hom1es_h4t3_b4rds'

inputdata = '23212f75' # example
inputdata = binascii.unhexlify(inputd)

for i in range (len(inputd)):
    
    c = dat4[i%24] ^ dat[inputdata[i]]
    if i%2:
        c = c ^ 0xff
    c = dat[c]
 
    if c & 0x80:
        c = c ^ 0x42
    c = c ^ 0xff

    # -------------------- #
    
    c = c ^ 0xff
    if c & 0x80:
        c = c ^ 0x42
    c = datreverse[c]
    if i%2:
        c = c ^ 0xff
    
    c = datreverse [c ^ dat4[i%24]]
результат наших трудов - картинка с флагом

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