• Приглашаем на KubanCTF

    Старт соревнований 14 сентября в 10:00 по москве

    Ссылка на регистрацию в соревнованиях Kuban CTF: kubanctf.ru

    Кодебай является технологическим партнером мероприятия

CTF Codeby Games - randом?? [Writeup]

Всем привет!

Решил поделиться с вами подробным описанием решения довольно интересной задачи "random??" из раздела "Реверс-инжиниринг" на платформе Codeby Games.

Исходные данные:
- exe-файл encrypter.exe
- зашифрованный текст flag.crypt

Исходя из логики, нам необходимо разобрать, как же работает программа-шифратор, и эти знания применить для расшифровки шифротекста, в котором скорее всего и спрятан флаг (не просто так же нам его дали). Что ж, приступим.

dice.jpg



Распаковка​

Для анализа бинарных файлов я (как и многие) предпочитаю IDA Pro. Открываем наш бинарь encrypter.exe и видим какую-то хренотень, а не код программы:

Pasted image 20230804142448.png


Попробуем запустить наш бинарь - получаем ошибку. Мда, действительно хренотень…

Pasted image 20230804142516.png


Штош, попробуем посмотреть на данный бинарь в HEX-редакторе. Можно выбрать и скачать любой редактор, но зачем, если есть ?

Pasted image 20230804142658.png


Первое, что бросается нам в глаза (красные области) – «битые» 6D 7A (mz) вместо 4D 5A (MZ).
Второе, что бросается в глаза – сигнатуры UPX0, UPX1 и UPX2 (желтая область). Глядя на них, понимаем, что белиберда в IDA – результат упаковки программы при помощи UPX.

Меняем magic bytes на правильные:

Pasted image 20230804142916.png


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

Pasted image 20230804143253.png


Учитывая битую сигнатуру DOS Executable, предположим, что и для UPX сигнатура могла быть тоже повреждена.
Внимательно смотрим на и видим, что одна из них - UPX! (т.н. UPX_MAGIC_LE32).
Видим, что через 36 байтов после UPX2 идет версия упаковщика (4.01) и байты 43 44 42 21 (CDB!). Меняем их на 55 50 58 21 (UPX!)

Pasted image 20230804145601.png


Вроде, все поправили. Распаковываем бинарь через UPX:
Код:
> upx.exe -d encrypter.exe

PS C:\> C:\bin\upx-4.0.2-win64\upx.exe C:\temp\encrypter.exe -d
                    Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2023
UPX 4.0.2       Markus Oberhumer, Laszlo Molnar & John Reiser   Jan 30th 2023

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
     22016 <-     10752   48.84%    win64/pe     encrypter.exe
Отлично, теперь можно приступать к непосредственно анализу кода.

Реверс​

Снова открываем распакованный файл в IDA Pro, жмакаем на функцию main и открываем С-код по кнопке F5.

main​

Pasted image 20230809113145.png


Смотрим, что у нас тут есть:
Исходя из строк вывода (13, 14) понимаем, что указатель Stream - это объект шифруемого файла, который читается (23) и данные записываются из него в массив char Buffer (29). Примечательно, что из шифруемого файла считываются только 100 символов == 100 байт, при этом наш файл с шифротекстом flag.encrypt занимает аж 15 208 байт. Запомним это несоответствие.

После того как шифратор считал данные в переменную Buffer (29), происходит вызов трех функций:
  • sub_401BA6(Buffer, 100i64)
  • sub_4015EF(Buffer, 100i64)
  • sub_402314(Block, 100i64, argv[1])
Пойдем разбирать их по порядку.
Прежде чем идти дальше, мы можем обратить внимание на то, что размер переменной Buffer - 96 байт, а считываем в нее мы 100 байт. Это могло бы нас насторожить, но нет - после переменной Buffer идет неиспользуемая переменная v5 размером 4 байта (такой размер имеет тип int). В сумме получается как раз 100 байт. Так что, все OK, это просто особенность IDA трансляции ассемблерных инструкций в код на C.

sub_401BA6​

Открываем функцию sub_401BA6 и видим внутри еще две головы функции:
C:
  1   __int64 __fastcall sub_401BA6(__int64 a1, unsigned int a2)
  2   {
  3     sub_4016AD(a1, a2);
  4     return sub_401A37(a1, a2);
  5   }

Опять начинам с верхней - sub_4016AD.

sub_4016AD​

C:
 1    __int64 __fastcall sub_4016AD(__int64 a1, int a2)
 2    {
 3      __int64 result; // rax
 4      unsigned int i; // [rsp+2Ch] [rbp-4h]
 5
 6      for ( i = 0; ; ++i )
 7      {
 8        result = i;
 9        if ( (int)i >= a2 )
10          break;
11        dword_408970 = time64(0i64) ^ 0xCDBABC;
12        srand(dword_408970);
13        *(_BYTE *)(a1 + (int)i) ^= rand() % 255;
14        dword_40897C = time64(0i64) - 4919;
15        srand(dword_40897C);
16        *(_BYTE *)(a1 + (int)i) += dword_40897C ^ 0x15;
17        dword_408978 = time64(0i64) + 48879;
18        srand(dword_408978);
19        *(_BYTE *)(a1 + (int)i) -= rand() % 14;
20        dword_408974 = time64(0i64) / 2;
21        srand(dword_408974);
22        *(_BYTE *)(a1 + (int)i) += rand() % 100;
23      }
24      return result;
25    }

Смотрим на функцию и первое, что нам бросается в глаза, - рандомы.
spongebob.jpg

Вспомним, что передавалось в эту функцию:
  • __int64 a1 - указатель на массив char'ов Buffer с исходными данными
  • int a2 - количество данных в массиве Buffer - 100.

Разбираем по порядку:
C:
 6    for ( i = 0; ; ++i )
 7    {
 8        result = i;
 9        if ( (int)i >= a2 )
10            break;
Тут мы видим цикл, повторяющийся 100 раз (так как в переменной a2 была передана константа 100 - длина исходных данных).

C:
11    dword_408970 = time64(0i64) ^ 0xCDBABC;
12    srand(dword_408970);
Тут у нас высчитывается текущее время в формате Unix Epoch, потом к нему применяется операция XOR со статическим значением 0xCDBABC и получившимся в итоге значением псевдорандом.

C:
13    *(_BYTE *)(a1 + (int)i) ^= rand() % 255;
В левой части:
Тут происходит добавление к указателю на массив с исходными данными a1 значения итератора i, преобразование его в указатель на тип BYTE и обращению к памяти по полученному адресу.
В правой части:
Тут вызовом функции rand() псевдослучайное значение, от которого которого оставляется только последний байт а остальные байты обнуляются путем принятия поправок деления по модулю 255 (255 == 0xFF). Таким образом при применении операции ^= операция XOR применяется только к одному выбранному байту, несмотря на то, что rand() выдает нам целых 4 байта.

В общем случае размер возвращаемого значения указывается в stdlib.h.

Фактически эта строчка означает следующее:
C:
13    Buffer[i] = Buffer[i] ^ (rand() % 255);

В остальных участках повторно три раза происходят аналогичные действия - генерация инцициализирующего значения, инцициализация псевдорандома и изменение текущего байта.
C:
14    dword_40897C = time64(0i64) - 4919;
15    srand(dword_40897C);
16    *(_BYTE *)(a1 + (int)i) += dword_40897C ^ 0x15;

17    dword_408978 = time64(0i64) + 48879;
18    srand(dword_408978);
19    *(_BYTE *)(a1 + (int)i) -= rand() % 14;

20    dword_408974 = time64(0i64) / 2;
21    srand(dword_408974);
22    *(_BYTE *)(a1 + (int)i) += rand() % 100;

Итого
Фнукция sub_4016AD принимает на вход массив с исходными данными и к каждому значению четыре раза применяет операцию XOR с четырьмя разными значениями (три из которых псевдослучайные).

Тут у нас сразу возникает вопрос - если мы будем писать дешифратор, то откуда мы возьмем инициализирующие значения для наших рандомов - dword_408970, dword_40897C, dword_408978, dword_408974? Пока что не ясно. Оставим этот вопрос на потом, а пока продолжим дальше разбираться с логикой работы шифратора.

sub_401A37​

C:
 1    __int64 __fastcall sub_401A37(__int64 a1, int a2)
 2    {
 3        __int64 result; // rax
 4        int v3; // [rsp+24h] [rbp-Ch]
 5        int v4; // [rsp+28h] [rbp-8h]
 6        unsigned int i; // [rsp+2Ch] [rbp-4h]
 7
 8        Seed = time64(0i64) ^ 0xDEAD;
 9        srand(Seed);
10        for ( i = 0; ; ++i )
11        {
12            result = i;
13            if ( (int)i >= a2 )
14                break;
15            v4 = rand() % 255;
16            v3 = rand() % 255;
17            *(_BYTE *)(a1 + (int)i) += sub_4018AB((unsigned int)(char)v4) - v3;
18        }
19        return result;
20    }

spongebob.jpg

Становится понятно, почему таска так называется :geek:

Видим, что тут операции идентичны предыдущей функции - опять происходит вычисление инициализирующего значения на основе текущего времени,
C:
 8    Seed = time64(0i64) ^ 0xDEAD;
 9    srand(Seed);
и цикличное изменение каждого байта с использованием псевдослучайных значений:
C:
10   for ( i = 0; ; ++i )
11   {
12      result = (unsigned int)i;
13      if ( i >= a2 )
14        break;
15      v4 = rand() % 255;
16      v3 = rand() % 255;
17      *(_BYTE *)(a1 + i) += sub_4018AB((char)v4) - v3;
18    }

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

xzibit-original-meme.jpg

Тут опять же возникает вопрос о том, что нам делать с неизвестным сидом для рандома. Опять отложим этот вопрос на потом.

sub_4018AB
Штош, посмотрим, что у нее внутри.
C:
 1   __int64 __fastcall sub_4018AB(int a1)
 2   {
 3     struct tagRECT Rect; // [rsp+60h] [rbp-40h] BYREF
 4     struct _PEB *v3; // [rsp+70h] [rbp-30h]
 5     int v4; // [rsp+78h] [rbp-28h]
 6     HDC hdcDest; // [rsp+80h] [rbp-20h]
 7     HWND hWnd; // [rsp+88h] [rbp-18h]
 8     struct _PEB *v7; // [rsp+90h] [rbp-10h]
 9     unsigned int NtGlobalFlag; // [rsp+9Ch] [rbp-4h]
10
11     NtGlobalFlag = 0;
12     v4 = 96;
13     v3 = NtCurrentPeb();
14     v7 = v3;
15     NtGlobalFlag = v3->NtGlobalFlag;
16     if ( (NtGlobalFlag & 0x70) != 0 )
17     {
18       sub_401550();
19       sub_403C10(2i64);
20       while ( 1 )
21       {
22         hWnd = GetDesktopWindow();
23         hdcDest = GetWindowDC(hWnd);
24         GetWindowRect(hWnd, &Rect);
25         StretchBlt(
26           hdcDest,
27           50,
28           50,
29           Rect.right - 100,
30           Rect.bottom - 100,
31           hdcDest,
32           0,
33           0,
34           Rect.right,
35           Rect.bottom,
36           0xCC0020u);
37         BitBlt(hdcDest, 0, 0, Rect.right - Rect.left, Rect.bottom - Rect.top, hdcDest, 0, 0, 0x330008u);
38         ReleaseDC(hWnd, hdcDest);
39       }
40     }
41      return a1 ^ 0xA3u;
42    }
Опачке, а вот это уже что-то интересное и непонятное!
Непонятно тут все, кроме последней строчки:
C:
41   return a1 ^ 0xA3u;
Интересно, что кроме как в return входные данные никак не используются. Нафига тогда весь остальной код нужен? Можно, конечно, забить на него, но мы любопытные. Пробуем загуглить NtGlobalFlag = v3->NtGlobalFlag и получаем первой же ссылкой на xakep.ru об антиотладке.
Вот же хитрюги!
Внимательно читаем про NtGlobalFlag и понимаем, что в строчке
C:
16    if ( (NtGlobalFlag & 0x70) != 0 )
Происходит проверка на отладку.
Что происходит, когда проверка на отладку срабатывает, вы можете узнать самостоятельно, засунув программу в дебагер и протыкав несколько раз F8 😉.
Настоятельно не рекомендую осуществлять эту проверку эпилептикам!

Пока нам отладка кода не потребовалась, но на всякий случай лучше сразу грохнуть эту антиотладку.
Что нужно чтобы условие if никогда не сработало? Правильно, заменить 0x70 на 0x00, тогда что бы ни было в NtGlobalFlag, результат операции (NtGlobalFlag & 0x00) всегда будет равен 0.

Смотрим, как выглядят эти строчки кода в ассемблерных инструкциях и в hex:
Код:
.text:00000000004018EA                 and     eax, 70h
.text:00000000004018ED                 test    eax, eax

Pasted image 20230809144831.png


Инструкции and eax, 70h соответствуют байты 83 E0 70, в которых нам нужно поменять 70 на 00.

Открываем наш шифратор в hex-редакторе и ищем эти байты. С удивлением обнаруживаем не одно, а целых 4 вхождения. Возможно, что остальные 3 вхождения - это просто случайные совпадения, но что если создатели таска вкорячили сразу 4 блока кода с антиотладкой?

Возвращаемся в HEX-View в IDA Pro, ищем все последовательности байтов 83 E0 70 и находим, что это реально еще 3 блока антиотладки:
  1. sub_401BD8
  2. sub_401DC9
  3. sub_402016
Код:
.text:0000000000401C3C                 and     eax, 70h
.text:0000000000401C3F                 test    eax, eax

Код:
.text:0000000000401E93                 and     eax, 70h
.text:0000000000401E96                 test    eax, eax

Код:
.text:00000000004020E8                 and     eax, 70h
.text:00000000004020EB                 test    eax, eax

Возвращаемся в Sublime и с чистой совестью гасим все 4 проверки на отладку, заменяя 83 E0 70 на 83 E0 00

Pasted image 20230809150511.png


Pasted image 20230809150520.png


Pasted image 20230809150526.png


Pasted image 20230809150532.png


Переоткрываем в IDA измененный файл, заново открываем функцию sub_4018AB и с радостью обнаруживаем, что умная IDA заботливо убрала из отображаемого кода все, что было связано с блоком антиотладки. Резонно, ведь этот код стал недостижим для выполнения, так как блок if никогда выполнится.
Теперь эта функция просто-напросто применяет к передаваемому в нее значению операцию XOR с 0xA3.
C:
  1   __int64 __fastcall sub_4018AB(int a1)
  2   {
  3     return a1 ^ 0xA3u;
  4   }

Итого
Функция sub_401A37 принимает на вход массив с видоизмененными предыдущей функцией исходными данными и к каждому байту этих данных добавляет число, формируемое на основе двух псевдослучайных байтов - v3 и v4, к последнему из которых применяется операция XOR со значением 0xA3.
С функцией sub_401BA6 разобрались, идем дальше.

sub_4015EF​

Тут мы встречаем коротенькую функцию, в которой сходу не видим ничего криминального:
C:
 1   char *__fastcall sub_4015EF(__int64 a1, int a2)
 2   {
 3     char Buffer[4]; // [rsp+27h] [rbp-19h] BYREF
 4     char v4; // [rsp+2Bh] [rbp-15h]
 5     int v5; // [rsp+2Ch] [rbp-14h]
 6     char *Destination; // [rsp+30h] [rbp-10h]
 7     int i; // [rsp+3Ch] [rbp-4h]
 8
 9     *(_DWORD *)Buffer = 0;
10     v4 = 0;
11     Destination = (char *)calloc(a2, 4ui64);
12     for ( i = 0; i < a2; ++i )
13     {
14       sprintf(Buffer, "%03hhu", *(unsigned __int8 *)(a1 + i));
15       v5 = strlen(Buffer);
16       strncat(Destination, Buffer, v5);
17     }
18     return Destination;
19   }
Первое, что нам бросается в глаза - странный спецификатор формата для sprintf. Прежде чем разбирать функцию посмотрим, что это за говно такое - "%03hhu".
Лезем в и в первой же ссылке видим на Stack Overflow, где нам дают ссылку на форматов printf.

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

Изучаем данную спецификацию и понимаем, что этот модификатор делает следующее:
Код:
0  → дополнить запись числа символом 0 слева (если будет нехватать значений)
3  → представить аргумент как минимум в 3-х позициях
hh → интерпретировать аргумент как символ
u  → интерпретировать аргумент как беззнаковое целое
Месье, конечно, знает толк в извращениях...

Примеры:
'A' → 0x41 → 65 → 065 → "065" → 0x30 0x36 0x35 0x00
'z' → 0x7A → 122 → "122" → 0x31 0x32 0x32 0x00

Последний байт 0x00 - конец строки.


Вернемся к нашей функции и посмотрим, что же она делает.
размером в 4 раза больше, чем размер исходных данных. Указатель на эту память кладется в переменную Destination:
C:
11        Destination = (char *)calloc(a2, 4ui64);

Каждый байт из исходного массива данных конвертируется в строку из 4 байт по указанному выше алгоритму:
C:
14        sprintf(Buffer, "%03hhu", *(unsigned __int8 *)(a1 + i));

Считается длина получившейся строки:
C:
15        v5 = strlen(Buffer);

Может показаться, что длина у строки Buffer будет равна 4, но нет. Длина будет равна 3, так как нулевой символ

Полученная строка Buffer (а точнее, только 3 символа из нее) к той строке, которая уже лежит в памяти по указателю Destination:
C:
16        strncat(Destination, Buffer, v5);

И все это повторяется в цикле для каждого байта данных, ссылка на массив которых была передана в функцию в аргументе a1.

Итого
Функция берет входящие данные (если быть точным, то указатель на эти данные, но это не столь важно) и каждый байт данных преобразовывает в три байта по алгоритму:
  1. значение байта как десятичное беззнаковое число в десятичной системе счисления ("A" → 65)
  2. дополнить числа с двумя разрядам символом 0 слева (65 → 065)
  3. представить получившееся число как строку (065 → "065")
В результате работы функции 100 байт данных превращаются в 300 байт преобразованных данных.

sub_402314​

Первое, что нам бросается в глаза - вызов данной функции в main:
C:
32        sub_402314(Block, 100i64, argv[1]);
В нее передаются данные, полученные на предыдущем шаге в функции sub_4015EF, и константа 100 - размер исходных данных. Странно, как мы только что выяснили, в предыдущей функции должно было сгенерироваться 300 байт данных.
Третьим аргументом передается имя файла с исходными данными - argv[1].

Смотрим код самой функции:
C:
 1   int __fastcall sub_402314(__int64 a1, int a2, const char *a3)
 2   {
 3     char Buffer[2000]; // [rsp+20h] [rbp-60h] BYREF
 4     int v5; // [rsp+7F0h] [rbp+770h]
 5     _DWORD v6[501]; // [rsp+7F4h] [rbp+774h] BYREF
 6     _DWORD v7[1834]; // [rsp+FC8h] [rbp+F48h] BYREF
 7     _DWORD v8[490]; // [rsp+2C70h] [rbp+2BF0h] BYREF
 8     _DWORD v9[140]; // [rsp+3418h] [rbp+3398h] BYREF
 9     _DWORD v10[336]; // [rsp+3648h] [rbp+35C8h] BYREF
10     FILE *Stream; // [rsp+3B88h] [rbp+3B08h]
11     char *Destination; // [rsp+3B90h] [rbp+3B10h]
12     int v13; // [rsp+3B98h] [rbp+3B18h]
13     int i; // [rsp+3B9Ch] [rbp+3B1Ch]
14     int v17; // [rsp+3BC8h] [rbp+3B48h]
15
16     v13 = strlen(a3) + 2;
17     Destination = (char *)malloc(v13 + 10i64);
18     strncpy(Destination, a3, v13);
19     strcat(Destination, ".encrypt");
20     Stream = fopen(Destination, "wb");
21     if ( !Stream )
22     {
23       puts("Error opening the file!");
24       sub_40168E();
25     }
26     ((void (__fastcall *)(char *, __int64))sub_401DC9)(Buffer, 2000i64);
27     ((void (__fastcall *)(_DWORD *, __int64))sub_401DC9)(v6, 2000i64);
28     ((void (__fastcall *)(_DWORD *, __int64))sub_401DC9)(v8, 1550i64);
29     ((void (__fastcall *)(_DWORD *, __int64))sub_401DC9)(v9, 555i64);
30     ((void (__fastcall *)(_DWORD *, __int64))sub_402016)(v7, 7331i64);
31     ((void (__fastcall *)(_DWORD *, __int64))sub_402016)(v10, 1337i64);
32     v5 = dword_408970;
33     v6[500] = dword_40897C;
34     v7[1833] = dword_408978;
35     v8[388] = dword_408974;
36     v8[489] = unk_408980;
37     v9[139] = Seed;
38     v10[335] = dword_408034;
39     v17 = 4 * a2;
40     for ( i = 0; i < v17; ++i )
41       *((_BYTE *)&v8[389] + i) = *(_BYTE *)(a1 + i);
42     sub_40226B();
43     fwrite(Buffer, 0x3B68ui64, 1ui64, Stream);
44     return fclose(Stream);
45   }

В самом начале функции мы видим объявление кучи больших массивов данных Buffer[2000], v6[501], v7[1834], v8[490], v9[140], v10[336]. Вспоминаем то, что мы подметили в самом начале - из исходного файла с данными считываются всего 100 символов, а наш шифротекст имеет размер 15 208 байт.

Посчитаем суммарный объем всех объявленных в данной функции массивов, учитывая, что размер DWORD - 4 байта, а размер char - 1 байт:
2000*1 + 1*4 + 501*4 + 1834*4 + 490*4 + 140*4 + 336*4 = 15208
Совпадает. Значит, скорее всего отсюда и взялся наш размер итогового шифротекста.

Теперь внимательно смотрим, на то, что происходит с данными, передаваемыми в функцию и с удивлением обнаруживаем, что они используются всего в двух местах:
1. Добавление ".encrypt" к названию итогового файла:
C:
17    v13 = strlen(a3) + 2;
18    Destination = (char *)malloc(v13 + 10i64);
19    strncpy(Destination, a3, v13);
20    strcat(Destination, ".encrypt");

2. Копирование входных данных (указатель на которые лежит в a1) в переменную v8:
C:
41    for ( i = 0; i < v17; ++i )
42        *((_BYTE *)&v8[389] + i) = *(_BYTE *)(a1 + i);
Никаких преобразований, рандомов и прочей ерунды - благодать.

Возникает вопрос - а что же тогда делает весь остальной код в функции? Посмотрим по порядку:
C:
27    ((void (__fastcall *)(char *, __int64))sub_401DC9)(Buffer, 2000i64);
28    ((void (__fastcall *)(_DWORD *, __int64))sub_401DC9)(v6, 2000i64);
29    ((void (__fastcall *)(_DWORD *, __int64))sub_401DC9)(v8, 1550i64);
30    ((void (__fastcall *)(_DWORD *, __int64))sub_401DC9)(v9, 555i64);
31    ((void (__fastcall *)(_DWORD *, __int64))sub_402016)(v7, 7331i64);
32    ((void (__fastcall *)(_DWORD *, __int64))sub_402016)(v10, 1337i64);
Тут мы можем видеть 6 однотипных функций, которые генерируют случайные значения и складывают их в массивы Buffer, v6, v7, v8, v9, v10. Приводить их все нет смысла, но вот первая из них:
C:
 1   void __fastcall sub_401DC9(__int64 a1, signed int a2)
 2   {
 3     _BYTE *v2; // [rsp+88h] [rbp-38h]
 4     _BYTE *Block; // [rsp+B0h] [rbp-10h]
 5     int i; // [rsp+BCh] [rbp-4h]
 6     int j; // [rsp+BCh] [rbp-4h]
 7
 8     Block = malloc(a2);
 9     dword_408034 = time64(0i64) - 74565;
10     srand(dword_408034);
11     for ( i = 0; i < a2; ++i )
12       Block[i] = rand() % 255;
13     v2 = (_BYTE *)sub_401BD8(Block, (unsigned int)a2);
14     free(Block);
15     for ( j = 0; j < a2; ++j )
16       *(_BYTE *)(a1 + j) = v2[j];
17     free(v2);
18   }
Похоже, эти функции нужны просто чтобы нас запутать и накидать говна случайных данных в итоговый файл.

Смотрим дальше
C:
35    v5 = dword_408970;
36    v6[500] = dword_40897C;
37    v7[1833] = dword_408978;
38    v8[388] = dword_408974;
39    v8[489] = unk_408980;
40    v9[139] = Seed;
41    v10[335] = dword_408034;
Какие-то странные данные пишутся в этих строчках кода. В функции они нигде не инициализировались. Жмакнем на них и посмотрим, на них внимательнее.

Pasted image 20230810123509.png


Видим, что на эти переменные ведут сразу по несколько линков. Посмотрим, куда они ведут (ПКМ → Jump to xref to operand):

Pasted image 20230810123740.png


Идем с конца:
  • sub_402314 - это наша текущая функция. Листаем дальше
  • sub_40226B - тут наша переменная печатается в консоль. понятнее не стало
  • sub_4016AD - Опачке! Да это же наша самая первая функция по преобразованию исходных данных! А наша переменная - это тот самый сид, который мы не знали откуда брать.
C:
11    dword_408970 = time64(0i64) ^ 0xCDBABC;
12    srand(dword_408970);

Смотрим остальные сиды - все на месте:
  • sub_4016AD
    • v5 = dword_408970;
    • v6[500] = dword_40897C;
    • v7[1833] = dword_408978;
    • v8[388] = dword_408974;
  • sub_401A37
    • v9[139] = Seed;
Остальные переменные - это сиды из функций, генерирующих мусорные данные - нам они не интересны.

Ну и остается последняя строчка кода, которую нам надо разобрать:
C:
44    fwrite(Buffer, 0x3B68ui64, 1ui64, Stream);
Тут мы видим запись в файл Stream 0x3B68 байтов данных из переменной Buffer. Как-то многовато для переменной Buffer, там ведь всего 2000 байтов!
Открываем калькулятор и переводим 0x3B68 в десятичную систему - получаем 15208.
Теперь все сходится. Ведь Buffer - это всего лишь указатель на начало данных. После того, как закончатся 2000 байтов Buffer записываться пойдут остальные данные.

Итого
Функция генерирует 14808 байтов мусорных данных и куда-то в середочку между ними кладет 300 байтов входных данных. Начало полезных данных надо искать вот тут - v8[389].
После этого функция берет значения сидов для рандомов, используемые в предыдущих функциях и записывает их поверх пяти мусорных байтов чтобы они там "затерялись":
C:
v5 = dword_408970;       // сид из sub_4016AD
v6[500] = dword_40897C;  // сид из sub_4016AD
v7[1833] = dword_408978; // сид из sub_4016AD
v8[388] = dword_408974;  // сид из sub_4016AD
v9[139] = Seed;          // сид из sub_401A37

Пишем дешифратор​

Теперь, когда логика работы шифратора нам полностью понятна, мы можем написать дешифратор. Для того чтобы не создавать костыли и велосипеды, мы будем писать дешифратор на C/C++. Большая часть кода для нашего дешифратора будет составляться просто копипастом из IDA. Только в местах преобразований данных мы будем менять порядок действий - начинать с нижних действий и заканчивать верхними, а все операции заменять на обратные (сложение на вычитание, вычитание на сложение).
Соответственно, порядок преобразований по исходным функциям будет следующий:
1) sub_4015EF
2) sub_401BA6
2.1) sub_401A37
2.2) sub_4016AD

main​

Начнем с того, что считаем наш шифротекст и раскидаем его содержимое по тем же переменным, из которых эти данные были записаны в файл.
Подгружаем библиотечки и объявляем глобальные переменные с сидами чтобы не заморачиваться с передачей их в функции:
C++:
#include <iostream>
#include <string.h>
#include <windows.h>

DWORD dword_408970 = 0x00000000;
DWORD dword_40897C = 0x00000000;
DWORD dword_408978 = 0x00000000;
DWORD dword_408974 = 0x00000000;
DWORD Seed = 0x00000000;
DWORD unk_408980 = 0x00000000;
DWORD dword_408034 = 0x00000000;

Делаем функцию дешифровки
C++:
int main() {
    // Читаем содержимое файла с шифротекстом и складываем считанные данные в массив encryptedData
    const char* encryptedFile = "C:/temp/flag.encrypt";
    FILE* encryptedFileStream = fopen(encryptedFile, "rb");
    char encryptedData[15210];
    memset(encryptedData, 0, sizeof(encryptedData));
    fgets(encryptedData, sizeof(encryptedData), encryptedFileStream);
    fclose(encryptedFileStream);

    // Чтобы удобно оперировать данными в encryptedData создаем указатель
    DWORD* ptr = (DWORD*)encryptedData;

    // Объявляем все те же переменные, что были в функции sub_402314 шифратора
    char Buffer[2000];
    unsigned int v5 = 0;
    DWORD v6[501];
    DWORD v7[1834];
    DWORD v8[490];
    DWORD v9[140];
    DWORD v10[336];

    // Раскидываем байты из шифротекста по тем же переменным, в которых они хранились в шифраторе
    memcpy(Buffer, ptr, 2000);     ptr += sizeof(Buffer)/4;
    memcpy(&v5, ptr, 4);           ptr += sizeof(v5)/4;
    memcpy(v6, ptr, sizeof(v6));   ptr += sizeof(v6)/4;
    memcpy(v7, ptr, sizeof(v7));   ptr += sizeof(v7)/4;
    memcpy(v8, ptr, sizeof(v8));   ptr += sizeof(v8)/4;
    memcpy(v9, ptr, sizeof(v9));   ptr += sizeof(v9)/4;
    memcpy(v10, ptr, sizeof(v10)); ptr += sizeof(v10)/4;

    // Раскидываем значения сидов для рандомов по переменным
    dword_408970 = v5;
    dword_408974 = v8[388];
    unk_408980 = v8[489];
    Seed = v9[139];
    dword_40897C = v6[500];
    dword_408978 = v7[1833];
    dword_408034 = v10[335];

    // Подготавливаем память для немусорных байтов шифротекста
    unsigned int dataBlockLength = 100;
    int v17 = 4 * dataBlockLength;
    int i;
    char* dataBlock = (char*)calloc(dataBlockLength, 4ui64);
    void* SourceBuffer;

    // И копируем туда соответствующие данные
    for (i = 0; i < v17; ++i)
        *(BYTE*)(dataBlock + i) = *((BYTE*)&v8[389] + i);

    // Выполняем первый этап преобразований (последний в шифраторе)
    SourceBuffer = sub_4015EF_decrypt((__int64)dataBlock, 100);

    // Выполняем последний этап преобразований (первый в шифраторе)
    sub_401BA6_decrypt((__int64)SourceBuffer, 100);

    // Так как в исходных данных всего 100 байт, то нет смысла писать их в файл и мы просто выводим их на экран
    for (i = 0; i < 100; ++i){
        std::cout << *(unsigned __int8*)((__int64)SourceBuffer + (int)i);
    }

    return 0;
}

sub_4015EF_decrypt​

Пишем функцию обратную к sub_4015EF.
Ничего примечательного, просто меняем функцию sprintf() на обратную ей - sscanf():
C++:
char* __fastcall sub_4015EF_decrypt(__int64 dataPointer, int dataLength)
{
    char Buffer[3];
    char Ch[1];
    *(char*)Buffer = 0;
    *(char*)Ch = 0;
    char* Destination;
    int i;

    Destination = (char*)calloc(dataLength, 4ui64);
    for (i = 0; i < dataLength; ++i) {
        memcpy(Buffer, (unsigned __int8*)dataPointer + i*3, 3);
        sscanf(Buffer, "%03hhu", Ch);
        strncat(Destination, Ch, 1);
    }
    return Destination;
}

sub_401BA6_decrypt​

Пишем функцию обратную к sub_401BA6.
Это самая простая часть :) Просто меняем местами вызовы функций:
C++:
__int64 __fastcall sub_401BA6_decrypt(__int64 dataPointer, int dataLength)
{
    sub_401A37_decrypt(dataPointer, dataLength);
    return sub_4016AD_decrypt(dataPointer, dataLength);
}

sub_401A37_decrypt​

Меняем порядок действий на обратный, а также меняем операции на противоположные (сложение меняем на вычитание и наоборот)
C++:
__int64 __fastcall sub_401A37_decrypt(__int64 dataPointer, int dataLength) {
    __int64 result;
    int v3;
    int v4;
    unsigned int i;

    srand(Seed);
 
    for (i = 0; ; ++i){
        result = i;
        if ((int)i >= dataLength)
            break;
   
        v4 = rand() % 255;
        v3 = rand() % 255;
        *(BYTE*)(dataPointer + (int)i) -= ((char)v4 ^ 0xA3u) - v3;
    }
    return result;
}

sub_4016AD_decrypt​

Аналогично предыдущим действиям, меняем порядок преобразований данных снизу вверх, а также меняем операции на противоположные:
C++:
__int64 __fastcall sub_4016AD_decrypt(__int64 dataPointer, int dataLength)
{
    __int64 result;
    unsigned int i;

    for (i = 0; ; ++i) {
        result = i;
        if ((int)i >= dataLength)
            break;

        srand(dword_408974);
        *(BYTE*)(dataPointer + (int)i) -= rand() % 100;

        srand(dword_408978);
        *(BYTE*)(dataPointer + (int)i) += rand() % 14;

        srand(dword_40897C);
        *(BYTE*)(dataPointer + (int)i) -= dword_40897C ^ 0x15;

        srand(dword_408970);
        *(BYTE*)(dataPointer + (int)i) ^= rand() % 255;
    }
    return result;
}

Теперь собираем все это воедино, компилируем, запускаем и смотрим флаг.
C++:
#include <iostream>
#include <string.h>
#include <windows.h>

DWORD dword_408970 = 0x00000000;
DWORD dword_40897C = 0x00000000;
DWORD dword_408978 = 0x00000000;
DWORD dword_408974 = 0x00000000;
DWORD Seed = 0x00000000;

DWORD unk_408980 = 0x00000000;
DWORD dword_408034 = 0x00000000;

__int64 __fastcall sub_4016AD_decrypt(__int64 dataPointer, int dataLength)
{
    __int64 result;
    unsigned int i;

    for (i = 0; ; ++i) {
        result = i;
        if ((int)i >= dataLength)
            break;

        srand(dword_408974);
        *(BYTE*)(dataPointer + (int)i) -= rand() % 100;

        srand(dword_408978);
        *(BYTE*)(dataPointer + (int)i) += rand() % 14;

        srand(dword_40897C);
        *(BYTE*)(dataPointer + (int)i) -= dword_40897C ^ 0x15;

        srand(dword_408970);
        *(BYTE*)(dataPointer + (int)i) ^= rand() % 255;
    }
    return result;
}

__int64 __fastcall sub_401A37_decrypt(__int64 dataPointer, int dataLength) {
    __int64 result;
    int v3;
    int v4;
    unsigned int i;

    srand(Seed);

    for (i = 0; ; ++i) {
        result = i;
        if ((int)i >= dataLength)
            break;

        v4 = rand() % 255;
        v3 = rand() % 255;
        *(BYTE*)(dataPointer + (int)i) -= ((char)v4 ^ 0xA3u) - v3;
    }
    return result;
}

__int64 __fastcall sub_401BA6_decrypt(__int64 dataPointer, int dataLength)
{
    sub_401A37_decrypt(dataPointer, dataLength);
    return sub_4016AD_decrypt(dataPointer, dataLength);
}

char* __fastcall sub_4015EF_decrypt(__int64 dataPointer, int dataLength)
{
    char Buffer[3];
    char Ch[1];
    *(char*)Buffer = 0;
    *(char*)Ch = 0;
    char* Destination;
    int i;

    Destination = (char*)calloc(dataLength, 4ui64);
    for (i = 0; i < dataLength; ++i) {
        memcpy(Buffer, (unsigned __int8*)dataPointer + i*3, 3);
        sscanf(Buffer, "%03hhu", Ch);
        strncat(Destination, Ch, 1);
    }
    return Destination;
}


int main() {
    // Читаем содержимое файла с шифротекстом и складываем считанные данные в массив encryptedData
    const char* encryptedFile = "C:/temp/flag.encrypt";
    FILE* encryptedFileStream = fopen(encryptedFile, "rb");
    char encryptedData[15210];
    memset(encryptedData, 0, sizeof(encryptedData));
    fgets(encryptedData, sizeof(encryptedData), encryptedFileStream);
    fclose(encryptedFileStream);

    // Чтобы удобно оперировать данными в encryptedData создаем указатель
    DWORD* ptr = (DWORD*)encryptedData;

    // Объявляем все те же переменные, что были в функции sub_402314 шифратора
    char Buffer[2000];
    unsigned int v5 = 0;
    DWORD v6[501];
    DWORD v7[1834];
    DWORD v8[490];
    DWORD v9[140];
    DWORD v10[336];

    // Раскидываем байты из шифротекста по тем же переменным, в которых они хранились в шифраторе
    memcpy(Buffer, ptr, 2000);     ptr += sizeof(Buffer)/4;
    memcpy(&v5, ptr, 4);           ptr += sizeof(v5)/4;
    memcpy(v6, ptr, sizeof(v6));   ptr += sizeof(v6)/4;
    memcpy(v7, ptr, sizeof(v7));   ptr += sizeof(v7)/4;
    memcpy(v8, ptr, sizeof(v8));   ptr += sizeof(v8)/4;
    memcpy(v9, ptr, sizeof(v9));   ptr += sizeof(v9)/4;
    memcpy(v10, ptr, sizeof(v10)); ptr += sizeof(v10)/4;

    // Раскидываем значения сидов для рандомов по переменным
    dword_408970 = v5;
    dword_408974 = v8[388];
    unk_408980 = v8[489];
    Seed = v9[139];
    dword_40897C = v6[500];
    dword_408978 = v7[1833];
    dword_408034 = v10[335];

    // Подготавливаем память для немусорных байтов шифротекста
    unsigned int dataBlockLength = 100;
    int v17 = 4 * dataBlockLength;
    int i;
    char* dataBlock = (char*)calloc(dataBlockLength, 4ui64);
    void* SourceBuffer;

    // И копируем туда соответствующие данные
    for (i = 0; i < v17; ++i)
        *(BYTE*)(dataBlock + i) = *((BYTE*)&v8[389] + i);

    // Выполняем первый этап преобразований (последний в шифраторе)
    SourceBuffer = sub_4015EF_decrypt((__int64)dataBlock, 100);

    // Выполняем последний этап преобразований (первый в шифраторе)
    sub_401BA6_decrypt((__int64)SourceBuffer, 100);

    // Так как в исходных данных всего 100 байт, то нет смысла писать их в файл и мы просто выводим их на экран
    for (i = 0; i < 100; ++i){
        std::cout << *(unsigned __int8*)((__int64)SourceBuffer + (int)i);
    }

    return 0;
}

Как-то так :)

Спасибо за прочтение! Успехов в решении следующих тасков на Codeby Games!​

 
Последнее редактирование модератором:
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!