Перейти к основному содержимому

WriteUp рыв йорс CTFZone 2024

·14 минут· loading · loading ·
Robert_sama
Reverse
Автор
robert 様です。
cR4.sh / чут чут рыв йор сер
Оглавление

Всям привет, дорогие папищеки! я очень долго не уделял вам внимание, поэтому пришла пора искупать свои грехи

Сегодня на повестке дня несколько рыв йор сов с прошедшего offzone ctf

Начнём с разминки!

Try To Easy Crackme
#

пакер 1
#

Как обычно, для начала попробуем запустить имеющийся бинарь и посмотрим что от нас требуется

Пробуем ввести случайный пароль и получаем

Окошко говорит, что у нас encrypted file, значит в ближайшее время будет разбираться с тем, как он зашифрован и зашифрован ли вообще. Возможно мы имеем дело с каким-то стандартным инструментом упаковки или защищенным загрузчиком, поэтому для начала закинем его в DetectItEasy.

Потенциально данный шаг может сэкономить нам время.

Да вы посмотрите, это же WinRar-овский зашифрованный sfx-архив!

Самораспаковывающийся (англ. self-extracting archive, сокращённо «SFX archive») — файл, компьютерная программа, объединяющая в себе архив и исполняемый код для его распаковки. Такие архивы, в отличие от обычных, не требуют отдельной программы для их распаковки (получения исходных файлов, из которых они созданы), если исполняемый код можно выполнить в указанной операционной системе. Это удобно, когда неизвестно, есть ли у пользователя, которому передаётся архив, соответствующая программа распаковки.

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

findcrypt

Найденные криптографические константы и беглый осмотр их xref-оф это только подтвердили.

Зашифрованный RAR-архив пароль в чистом виде в себе не хранит, как и его sfx обертка, поэтому и реверсить тут толком нечего. Остается только брутать.

Извлекаем архив из бинаря и пробуем брутать по rockyou

Пароль получен, access granted. После успешной разархивации получаем новый бинарь и начинаем цикл исследования заново.

пакер 2
#

На этот раз нас встречает UPX.

UPX is an advanced executable file compressor. UPX will typically reduce the file size of programs and DLLs by around 50%-70%, thus reducing disk space, network load times, download times and other distribution and storage costs.

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

Это совершенно не проблема, если это не битый UPX, ибо нативный упаковщик позволяет распаковать сжатые исполняемые файлы (в ином случае пришлось бы дампить программу в рантайме)

покер 3
#

Отлично, а теперь снова загоняем в DetectItEasy!

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

При вводе произвольного пароля программа создает шикарный MessageBox с сообщением Wrong password. Поиск по строкам и xref-ам вызова MessageBox позволили быстро локализовать место проверки пароля.

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

Функия sub_407CC0 побайтово сравнивала входные строки, исходя из чего я поменял её название на strcmp

Уже примерно понятно, что скорее всего где-то в бинаре зашита таргетная хэш-сумма от правильного пароля, хотя из-за отсутствия общих аргументов это может показаться не очевидным.

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

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

Скорее всего тут дело в порядке вычисления конечного адреса: база добавляется в последнюю очередь, поэтому и воспринимается как индекс.

Кстати говоря, вот вам скрин, подтверждающий мои слова о том, что heap_ptr таковым и является.

Ладно, это все конечно интересно, но таск таки надо решить. Дальше в статике смотреть не очень хочется, да и смысла реверсить местную криптографию я пока что не вижу. Попробуем посмотреть в отладчике конкретные значения, передаваемые в нашу strcmp_0.

Для простоты воспользуемся нативным отладчиком иды. Ставим точку останова на вызов функции проверки пароля и запускаем процесс отладки.

Судя по прологу функции, имеем дело с fastcall.

Microsoft __fastcall convention (aka __msfastcall) passes the first two arguments (evaluated left to right) that fit, into ECX and EDX.

Ого, похоже на md5 в хексово-текстовом виде! Вытаскиваем обы хэша из отладчика

И идём на crackstation

qwe было введено в поле проверки пароля, а вот super123 похож на нужный нам пароль.

На этом все.

По сути таск сводится к двойному бруту пароля, за что у автора хотелось бы списать немного социальных кредитов

ну и прост кредит на него повесить

exitinator
#

Хотя этот таск стоил меньше всех, мне он показался куда интереснее и сложнее предыдущего.

Пробуем запустить бинарь!

К сожалению, amogusamogus123 не является флагом, поэтому придется продолжить вскрытие.

Посмотрим на декомпиль:

После первого просмотра листинга понятно было примерно ничего.

Итак, у нас есть buffer с несколькими отрезками данных: обработанный флаг и пара строк.

После буфера инициализируется глобальный массив гаджетов из либсихи.

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

ида не может продизассемблировать libc + 0x86F70

Спустимся ниже по листингу до вызова get_decoded_xorx

А вот тут уже что-то интересное. Некая глобальная магическая константа высчитывается путем преобразования указателя, возвращаемого как результат выполнения функции get_exit_func_entry.

get_exit_func_entry в свою очередь всего лишь возвращает get_libc_base() + 0x204FC0.

Если посмотреть содержимое либсихи со смещением 0x204FC0, то можно обнаружить область памяти, адресуемую при вызове функций exit и on_exit.

Реверсить опенсурс очень круто, но вместо этого предлагаю перейти на эксплорер исходников либси и почитать исходный код функции exit.

Принимая во внимание исходник, сразу подмечаем, что get_exit_func_entry возвращает __exit_funcs (ого, логично).

Заглянем чуть глубже.

void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
		     bool run_list_atexit, bool run_dtors)
{
  ...
  while (true) {
      ...
      cur = *listp;

      if (cur == NULL)
	{
	  /* Exit processing complete.  We will not allow any more
	     atexit/on_exit registrations.  */
	  __exit_funcs_done = true;
	  break;
	}

      while (cur->idx > 0)
	{
	  struct exit_function *const f = &cur->fns[--cur->idx];
	  const uint64_t new_exitfn_called = __new_exitfn_called;

	  switch (f->flavor)
	    {
	      void (*atfct) (void);
	      void (*onfct) (int status, void *arg);
	      void (*cxafct) (void *arg, int status);
	      void *arg;

	    case ef_free:
	    case ef_us:
	      break;
	    case ef_on:
	      onfct = f->func.on.fn;
	      arg = f->func.on.arg;
	      PTR_DEMANGLE (onfct);
        ...
	    case ef_at:
        ...
	      PTR_DEMANGLE (atfct);
        ...
	    case ef_cxa:
        ...
	      PTR_DEMANGLE (cxafct);
        ...
	    }

      ...
      cxafct (arg, status);
      ...
    }

Видно, что при вызове exit сначала происходит вызов зарегистрированных хуков из глобального массива внутри либси. Однако все указатели внутри этого списка “замаглены”, по всей видимости для усложнения процесса эксплуатации уязвимостей.

В функции get_decoded_xorx по сути выполняются действия из макроса PTR_DEMANGLE, необходимого для получения реального указателя, однако сам указатель нам не столько интересен, сколько сама секурити кука, получаемая из fs:POINTER_GUARD.

(ptr ^ guard) ^ ptr = guard

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

ОС использует эти регистры по своему усмотрению и в случае fs хранит вхождение структуры Thread Control Block.

Thread Control Block

Отсюда можно предположить, что далее мы будем регистрировать собственные exit хуки, но в обход стандартной функции on_exit.

Непосредственно перед вызовом exit происходит регистрация новых хуков - функций обработчиков.

Ранее гаджеты показались несколько невалидными, поскольку они указывали на некорректные места внутри либси, однако же внутри overwrite_entry видно, что к адресу регистрируемого указателя добавляется 0x100.

На примере адреса первого обработчика: 0x86F70 + 0x100 = 0x87070

libc + 0x87070

Таким же образом мы можем восстановить весь массив функций/rop-гаджетов.

Исходя из цикла регистрации хуков мы можем восстановить поток выполнения:

flowchart LR id1([gets]) --> id2(["ror flag[i], ??"]) --i++--> id2 --> id3([gadjet])

gadjet - функция проверки флага.

Вспоминая проинициализированный buffer и его передачу как аргумент в gets, у нас происходит побайтовое сравнение захардоженного обработанного флага с нашим обработанным вводом.

У нас есть все сдвиги и двинутый флаг, осталось только написать солвер!

import struct

raw_flag = [
    0x9BA1B93173893C99,
    0x33A13699915F2781,
    0x86AB39137D3733A1
]

shifts = [
    0x95, 0x89, 0x85, 0x84, 0x88, 0x8e
]


rol = lambda val, r_bits, max_bits: \
    (val << r_bits%max_bits) & (2**max_bits-1) | \
    ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))

if __name__ == '__main__':
    flag = bytearray(struct.pack('<QQQ', *raw_flag)) # преобразуем числа в байтовое представление
    for i in range(len(flag)):
        flag[i] = rol(flag[i], shifts[i % len(shifts)], 8) # применяем обратный циклический сдвиг
    print(flag)

И в результате видим: bytearray(b'3x171n470r_d3l43\xa1\xcc\xe6\xfab\x93\xab\xa1')

Часть флага успешно получена, а вот в конце какой-то шлак. Я решил перепроверить как именно инициализируется флаг и… обнаружил небольшую подлянку от автора

реакция на подлянку

Инициализация происходит путем перекрытия парочки байтов, из-за чего все съехало.

Немножко фиксим инициализацию:

flag = bytearray(struct.pack('<QQQ', *raw_flag))
flag = flag[:14] + flag[16:]

И получаем флаг: CTFZone{3x171n470r_d3l437_bruh}

silent DRM
#

А это крутейшая задачка со стульями, тока стулья - полиморфы!

По стандарту пробуем запустить бинарь и выполнить действия согласно инструкции:

Разочаровавшись в парадоксальности бинаря, плавно переходим к его изучению.

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

Посмотрев на условие, делаем вывод, что sub_1340(_0) отвечает за проверку лицензии, и, в случае возврата нуля, лицензия считается валидной.

Декомпилированная версия функции проверки лицензии весьма объемна, поэтому покажу вам только граф.

ничего особенного, просто граф

Глянем на декомпилированное начало функции:

Сразу после проверки длинны введенной лицензии происходит проваливание в объемный цикл, в начале итерации которого выделяется объемный участок памяти с правами на исполнение и запись. Первые два байта памяти инициализируется, а конструкция вида (__int64 (__fastcall *)(_QWORD))mempage означает приведение типа переменной к указателю на функцию. Это означает, что где-то дальше в листинге мы увидим вызов нашего участка памяти.

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

Попробуем их продизассемблировать:

Ближе к концу итерации можно найти вызов сформированного полиморфного кода, в качестве аргумента которому передается 4х байтный фрагмент лицензии, смещенный на один байт от i.

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

Разберём поподробнее логику генерации полиморфного обработчика:

if ( (license[i] & 0x40) != 0 )
{
	mempage[2] = 0xC1;
	mempage[5] = 0xC3;
	switch ( (license[i] >> 3) & 7 )
	{
		case 0:
			*(_WORD *)(mempage + 3) = 0x10C0;
			break;
		case 1:
		...
		case 7:
	}
	v49 = 37LL;
	v8 = 19LL;
	...
	v30 = 8LL;
	v31 = 7LL;
}
else
{
	mempage[2] = 0x35;
	mempage[7] = 0xC3;
	switch ( (license[i] >> 3) & 7 )
	{
		case 0:
			*(_DWORD *)(mempage + 3) = 0xABADBABE;
			break;
		case 1:
		...
		case 7:
	}
	v49 = 39LL;
	v8 = 21LL;
	...
	v30 = 10LL;
	v31 = 9LL;
}

mempage_func[v18] = -119;
mempage_func[v17] = -57;
...
mempage_func[v47] = 31;
mempage_func[v48] = 0;

switch ( license[i] & 7 )
{
	case 0:
		magic_table = &unk_E4020;
		break;
	case 1:
	...
	case 7:
}

По маске 0b1000000 (0x40) определяется базовый блок генерации кода.

Внутри каждого из блоков по маске 0b111000 через switch-case определяется неизвестное содержимое для соответствующих фрагментов машинного кода: 89 f8 c1 ?? ?? c3 или 89 f8 35 ?? ?? ?? ?? c3.

На примере case 0 для второго базового блока получим 89 f8 35 be ba ad ab c3

В данном случае эти биты определяют с чем будет поксорен фрагмент лицензии.

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

Последний switch-case, общий для базовых блоков генерации, по маске 0b111 определяет какой из огроменных блоков данных будет скопирован в конец полиморфного кода.

Очень хочется посмотреть результат генерации на конкретном примере, поэтому воспользуемся замечательным инструментом под название отладчик. Байты должен считать отладчик, а не рыв йор сер!

Попробуем получить ксор из второго базового блока с каким-нибудь ключом:

Тогда “управляющий” байт должен соответствовать виду 0b?0111???. Подставим нули во все оставшиеся поля и получим: 0b00111000 = 0x38 = 8 (по ascii)

В этот раз я бегом под wsl-ем стартанул gdb и поставил точку останова на место вызова сгенерированного кода.

Набор команд примерно такой:

gdb> file crackme
gdb> starti
gdb> b *0x555555554000+0x16BF
gdb> c

Пихаем 888888888888888888888888888888888888888888888888888888888888 во ввод и попадаем на точку останова.

Вызываемый абонент лежит в регистре r12, попробуем его продизассемблировать.

Получились ожидаемые первые инструкции.

Сразу можно обратить внимание, что сам обработчик очень маленький, всего лишь с десяток инструкций!

Если приводить данный фрагмент к Си подобному виду, получится что-то типа:

short magic_table[...];
int temp = arg ^ 0xfacefeed;
return magic_table[temp >> 16] << 16 | magic_table[temp & 0xffff];

Содержимое magic_table находится сразу после кода, а адресация к нему происходит относительно регистра инструкций. Такой код называется позиционно независимым.

Все трансформации над лицензией обратимы, поэтому мы без проблем сможем по необходимости получить исходное содержимое. Осталось только разобраться с тем, как используется результат трансформации.

Сразу после вызова полиморфного кода, буфер с результатом обработки чанками по 5 байтов копируется вперед, а на место первого чанка записывается новый результат.

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

Легенда примерно такая:

block-beta columns 3 b1["result_license"]:3 space space space block:b2:3 columns 3 c1["aa | 11 22 33 44"] space space end space space space block:b3:3 columns 3 c2["aa | 11 22 33 44"] c3["aa | 11 22 33 44"] space end space space space block:b4:3 columns 3 c4["bb | 99 88 77 66"] c5["aa | 11 22 33 44"] space end b1 --> b2 b2 --> b3 b3 --> b4 style b1 fill:#fff style b2 fill:#fff style b3 fill:#fff style b4 fill:#fff

После формирования буфера с обработанной лицензией, происходит его побайтовая проверка с константными значениями.

Зашитая обработанная лицензия состоит из тех самых 5-ти байтных чанков, первый байт которого остается без изменений и определяет применяемую обратимую трансформацию к оставшейся части чанка.

Значит нам нужно просто обратить каждый из чанков к исходному виду! И как же это сделать лучше всего?

Во время соревнования мой внутренний декомпилятор неправильно декомпилировал представленные трансформации, из-за чего они показались мне необратимыми и в результате пришлось брутать 4 байта по 12 раз.

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

my honest reaction

Итого, часов 6 минимум я потратил на написание бруталки, отладку и непосредственно брут :)

“skill issue!” - подумает обыватель, но тут дело скорее в невнимательности, пришедшей с ночным временем суток…

В общем, если вам в голову начинают лезть всякие странные мысли и ваше решение предполагает брутать 4 байта по 12 раз, возможно пришла пора прилечь отдохнуть или выйти погулять минут на 20.

имхо быстрее всего было бы вытащить все 12 трансформаций из отладчика и отреверсить каждый фрагмент руками, тем более, что базовых трансформаций всего две.

Для начала извлечем чанки из захардкоженной лицензии:

from math import log as l

parts = [
    0x06E39AD98C75F2C48DC245FC8F7BEA16C,
    0x0BC6E055DC0AC6E51A2810D7490D694A5,
    0x072CD335A44126C752C48DC245F45F97E,
    0x43ED19FACF6E3AFB,
    0xA0002EEB
]

license = b''.join(part.to_bytes(round(l(part, 16))//2, 'little') for part in parts)
for i in range(len(license) - 5, -1, -5):
    print(f"{chr(license[i])} {hex(int.from_bytes(license[i+1:i+5], 'little'))}")

В результате выполнения:

C 0xa0002eeb
n 0xed19facf
3 0x3afb72cd
u 0x5a44126c
_ 0x2c48dc24
n 0x45f97ebc
n 0x55dc0ac 
t 0x51a2810d
n 0x90d694a5
_ 0x39ad98c7
_ 0x2c48dc24
l 0xc8f7bea1

Пихаем C0000n000030000u0000_0000n0000n0000t0000n0000_0000_0000l0000 в отладчик и фиксируем прибыль машинный код для трансформации на каждой итерации.

раз…

0x7ffff7a2e000:      mov    eax,edi
0x7ffff7a2e002:      rol    eax,0x10
0x7ffff7a2e005:      mov    edi,eax
0x7ffff7a2e007:      lea    rdx,[rip+0x17]        # 0x7ffff7a2e025
0x7ffff7a2e00e:      movzx  edi,di
0x7ffff7a2e011:      shr    eax,0x10
0x7ffff7a2e014:      movzx  eax,WORD PTR [rdx+rax*2]
0x7ffff7a2e018:      movzx  edx,WORD PTR [rdx+rdi*2]
0x7ffff7a2e01c:      shl    eax,0x10
0x7ffff7a2e01f:      or     eax,edx
0x7ffff7a2e021:      ret

два…

0x7ffff7a2e000:      mov    eax,edi
0x7ffff7a2e002:      ror    eax,0x4
0x7ffff7a2e005:      mov    edi,eax
0x7ffff7a2e007:      lea    rdx,[rip+0x17]        # 0x7ffff7a2e025
0x7ffff7a2e00e:      movzx  edi,di
0x7ffff7a2e011:      shr    eax,0x10
0x7ffff7a2e014:      movzx  eax,WORD PTR [rdx+rax*2]
0x7ffff7a2e018:      movzx  edx,WORD PTR [rdx+rdi*2]
0x7ffff7a2e01c:      shl    eax,0x10
0x7ffff7a2e01f:      or     eax,edx
0x7ffff7a2e021:      ret

и так далее…

Подробно разберем как получить исходные данные из второго чанка: 0xed19facf ничто иное, как результат побитового ИЛИ 0xed19 << 16 и 9facf. В свою очередь каждый из фрагментов получен из массива подстановок.

Мне показалось проще сдампить массив подстановок из иды, поэтому я так и сделал.

'n' & 0b111 = 6, поэтому нам нужен массив из case 6

раз
CTRL + L два
SHIFT + E три

Пишем простенький скрипт для вычленения индексов искомых значений и применения обратных операций:

import struct

ror = lambda val, r_bits, max_bits: \
    ((val & (2**max_bits-1)) >> r_bits%max_bits) | \
    (val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))

rol = lambda val, r_bits, max_bits: \
    (val << r_bits%max_bits) & (2**max_bits-1) | \
    ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))

def find_idxs(case: int, a: int, b: int) -> tuple:
    raw = open(f'payloads/{case}', 'rb').read()
    vals = [struct.unpack('<H', raw[i:i+2])[0] for i in range(0, len(raw), 2)]
    assert vals.count(a) == 1 and vals.count(b) == 1
    return vals.index(a), vals.index(b)

if __name__ == '__main__':
    indx = find_idxs(6, 0xed19 , 0xfacf)
    temp = rol(indx[0] << 16 | indx[1], 4, 32)
    print(temp.to_bytes(4, 'little').decode())

out:
(22149, 30646)
e{Wh

Добавляем n в начало и полный фрагмент и получаем полный фрагмент: ne{Wh.

Повторяем это упражнение 11 раз и получаем флаг: CTFZone{Wh3re_y0u_w1n_54y_n07h1n6_but_wh3n_y0u_l053_54y_l355}

End
#

Соревнование отличилось высокой сложностью бинарных тасков, рекомендую всем бинарщикам!

Ну и почаще траву трогайте, это иногда бывает полезно.

всем пасеба всем пака 😘