October 13

Что такое Rabbit Hole и как я в очередной раз загнал себя в тупик.

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

Я получил файл под названием huffpuff.elf и некоторую сопутствующую информацию:

###############################################################

We ran the program with flag inside, and it gave us this:

C=34, I=0, L=35, P=32, R=33, S=38, T=1, _=1, a=7, c=30, d=1, e=1, f=39, g=8, h=36, i=5, k=37, m=31, n=6, o=6, p=58, r=10, s=11, t=9, u=59, y=28 0000000011000110101100101000100100000111000110011001001 1110110000111100001100010110111111110101010000110111011 0101110011000100000110010000011101010111000011001111101 0100010000101110100101110011000001010110111100001101110 1101010110010000010111011000001001000000111110111010000 00100110000100011111011001000011001010100101011100110

Flag begins with 'I_Like_'.
Reverse the algorithm, and decrypt the entire flag!

###############################################################

Требовалось расшифровать указанную в бинарном представлении строку. Сказано, что программа была запущена с флагом внутри и в итоге выдала некоторые данные.

Флаг начинается с 'I_Like_' и необходимо изучить алгоритм, а после расшифровать флаг.

Хорошо, первым делом я решил запустить файл и посмотреть что он будет делать:

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

Теперь можно пойти в IDA и посмотреть что происходит под капотом:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char *v3; // rax
  int j; // [rsp+Ch] [rbp-894h]
  __int64 i; // [rsp+10h] [rbp-890h]
  char *k; // [rsp+10h] [rbp-890h]
  _QWORD *huffman_codes; // [rsp+18h] [rbp-888h]
  char s[256]; // [rsp+20h] [rbp-880h] BYREF
  char v10[104]; // [rsp+820h] [rbp-80h] BYREF
  unsigned __int64 v11; // [rsp+888h] [rbp-18h]

  v11 = __readfsqword(40u);
  memset(s, 0, 2048uLL);                        // Выделяется память под s
  for ( i = *test; *i; ++i )                    // test = 'YYYYAAAAHHHHHHOOOONOOOOTTTTTTFFFFLLLLAAAAGGGGGGGISSSSHEEERRREEEEEGUUUUUYYY', именно эта строка будет закодирована
  {
    v3 = i;
    ++*&s[8 * *v3];
  }
  huffman_codes = create_huffman_codes(s);      // Здесь присваивается код к каждому символу
  for ( j = 0; j <= 255; ++j )
  {
    if ( huffman_codes[j] )
    {
      inttobits(*(huffman_codes[j] + 4LL), *huffman_codes[j], v10);
      printf("%c=%d, ", j, *(huffman_codes[j] + 4LL));// Здесь будут выведены коды символов в формате символ=код
    }
  }
  puts(&::s);
  for ( k = *test; *k; ++k )
  {
    if ( huffman_codes[*k] )
    {
      inttobits(*(huffman_codes[*k] + 4LL), *huffman_codes[*k], v10);
      printf("%s", v10);                        // А здесь будет представлено бинарное представление кодов
    }
  }
  puts(&::s);
  free_huffman_codes(huffman_codes);            // Последовательность кодов обнуляется
  return 0;
}

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

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

Так я познакомился с алгоритмом сжатия данных за авторством американского учёного Дэвида Хаффмана, который был изобретён в 1952 году. Алгоритм Хаффмана считается базой для сжатия различных файлов, он всё ещё используется, хотя и в модифицированном виде.

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

https://ru.wikipedia.org/wiki/%D0%9A%D0%BE%D0%B4_%D0%A5%D0%B0%D1%84%D1%84%D0%BC%D0%B0%D0%BD%D0%B0

https://neerc.ifmo.ru/wiki/index.php?title=%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC_%D0%A5%D0%B0%D1%84%D1%84%D0%BC%D0%B0%D0%BD%D0%B0 - здесь описано в более академичной манере с формулами и т.д.

https://habr.com/ru/articles/144200/

https://habr.com/ru/articles/438512/

https://habr.com/ru/companies/otus/articles/497566/

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

Функция create_huffman_codes() очевидно создаёт коды для символов:

_QWORD *__fastcall create_huffman_codes(char *s)
{
  unsigned int i; // [rsp+14h] [rbp-183Ch]
  int i1; // [rsp+14h] [rbp-183Ch]
  unsigned int range_256; // [rsp+18h] [rbp-1838h]
  int v5; // [rsp+1Ch] [rbp-1834h]
  int v6; // [rsp+20h] [rbp-1830h]
  unsigned int v7; // [rsp+24h] [rbp-182Ch]
  int h_remove; // [rsp+28h] [rbp-1828h]
  int h_removea; // [rsp+28h] [rbp-1828h]
  int v10; // [rsp+2Ch] [rbp-1824h]
  int *heap_dest; // [rsp+30h] [rbp-1820h]
  _QWORD *output; // [rsp+38h] [rbp-1818h]
  int v13[512]; // [rsp+40h] [rbp-1810h]
  __int64 dest[256]; // [rsp+840h] [rbp-1010h] BYREF
  __int64 v15; // [rsp+1040h] [rbp-810h] BYREF

  range_256 = 256;
  memcpy(dest, s, sizeof(dest));
  memset(&v15, 0, 2048uLL);
  heap_dest = heap_create(512, dest);
  if ( !heap_dest )
    return 0LL;
  for ( i = 0; i <= 255; ++i )
  {
    if ( dest[i] > 0 )
      heap_add(heap_dest, i);
  }
  while ( heap_dest[2] > 1 )
  {
    h_remove = heap_remove(heap_dest);
    v10 = heap_remove(heap_dest);
    dest[range_256] = dest[v10] + dest[h_remove];
    heap_add(heap_dest, range_256);
    v13[h_remove] = range_256;
    v13[v10] = -range_256++;
  }
  h_removea = heap_remove(heap_dest);
  v13[h_removea] = h_removea;
  heap_destroy(heap_dest);
  output = malloc(2048uLL);
  for ( i1 = 0; i1 <= 255; ++i1 )
  {
    v5 = 0;
    v6 = 0;
    if ( dest[i1] )
    {
      v7 = i1;
      while ( abs32(v13[v7]) != v7 )
      {
        v5 |= (v13[v7] >= 0) << v6;
        v7 = abs32(v13[v7]);
        ++v6;
      }
      output[i1] = malloc(8uLL);
      *output[i1] = v6;
      *(output[i1] + 4LL) = v5;
    }
    else
    {
      output[i1] = 0LL;
    }
  }
  return output;
}

Функция inttobits() преобразует десятичные числа в двоичные:

unsigned __int64 __fastcall inttobits(int a1, int a2, __int64 a3)
{
  unsigned __int64 result; // rax
  int v4; // [rsp+8h] [rbp-8h]

  v4 = a2;
  result = a2 + a3;
  *result = 0;
  while ( v4 > 0 )
  {
    result = (a1 % 2 + 48);
    *(v4 - 1LL + a3) = a1 % 2 + 48;
    a1 >>= 1;
    --v4;
  }
  return result;
}

Функция heap_remove() что-то очищает:

int __fastcall heap_remove(int *heap_dest)
{
  if ( heap_dest[2] <= 0 )
    return -1;
  return *(*heap_dest + 4LL * --heap_dest[2]);
}

Функция heap_add() выполняет добавление каких-то значений в heap_dest() и после добавления сортирует массив функцией heap_sort():

void __fastcall heap_add(int *heap_dest, unsigned int *i)
{
  if ( heap_dest[2] + 1 > heap_dest[3] )
  {
    *heap_dest = realloc(*heap_dest, heap_dest[3] + heap_dest[4]);
    heap_dest[3] += heap_dest[4];
  }
  *(4LL * heap_dest[2]++ + *heap_dest) = i;     // похоже, что здесь перемешиваются индексы и таким образом присваиваются коды для символов
  heap_sort(heap_dest);                         // сортируем массив по возрастанию
}

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

Я предположил, что скорее всего программа сжимает строку, применяя тот самый алгоритм Хаффмана и в итоге мы получаем сжатую строку + символы с их частотами. Я даже нашёл какая конкретно фраза была зашифрована, она лежала в одной из переменных, но не придал почему-то этому должного значения, решив, что она бесполезна, пока у меня нет написанного дешифратора.

Собственно говоря, здесь я и угодил в кроличью нору (Rabbit Hole), решив не искать лёгких путей. По мере того, как я пытался расшифровать эту строку, проводя вечера за написанием кода по 6-7 часов к ряду, я падал в эту нору всё глубже и глубже, пока совсем не отчаялся.

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

В целом, я писал разные варианты, финальным был этот:

import queue

encoded_string = '0001000100010001110110110110000000000000000000000000100100100100011011100100100100001000100010001000100010011101110111011110101010101010101101101101101111111111111111111110110101011101110111011000001001001001100011000110001001001001001001001011100110011001100110011000100010001'

huffman_codes = [
    (6, 'A'), (2, 'E'), (7, 'F'), (7, 'G'), (0, 'H'), (26, 'I'), (10, 'L'), (27, 'N'), (4, 'O'), (12, 'R'), (11, 'S'), (2, 'T'), (3, 'U'), (1, 'Y')]
    
symbols = list()
    
binary_tree = list()

q = queue.PriorityQueue()

def is_multiple_by_two():
    
    if len(huffman_codes) % 2 == 0:
        
        return True
        
    else:
        
        return False
        


def create_queue(huffman_codes, q):

    for symbol in huffman_codes:
        q.put(symbol)


def clear_queue(q):

    while q.qsize() != 0:
        q.get()

    
def delete_descendants(symbol_a, symbol_b):
    
    huffman_codes.remove(symbol_a)
    huffman_codes.remove(symbol_b)


    
def create_last_node(huffman_codes):
    
    a = huffman_codes[1]
    b = huffman_codes[2]
    
    huffman_codes.append((a[0]+b[0],a[1]+b[1]))
    binary_tree.append((0, a[1]))
    binary_tree.append((1, b[1]))
    delete_descendants(a, b)
 
 
        
def create_tree(queue):

    while True:
        
        a = queue.get()
        b = queue.get()
        queue.task_done()
        queue.task_done()
        
        binary_tree.append((0, a[1]))
        binary_tree.append((1, b[1]))
        huffman_codes.append((a[0]+b[0],a[1]+b[1]))
        delete_descendants(a, b)
        
        if queue.qsize() == 1 or queue.qsize() == 0:
            break



def get_symbols(huffman_codes):
    
    for i in huffman_codes:
        symbols.append(['',i[1]])
 
 
        
def create_paths(binary_tree, symbols):
    
    for i in symbols:
        for j in binary_tree:
            if j[1].find(i[1]) != -1:
                i[0] += str(j[0])



def reverse_path(symbols):
    
    for i in symbols:
        i[0] = i[0][::-1]


 
def decode_string(encoded_string, symbols):
    
    counter = 0
    result = str()
    
    while counter != len(encoded_string):
        
        for i in symbols:
            if i[0] == encoded_string[counter:counter+len(i[0])]:
                result += i[1]
                counter += len(i[0])

    print(result)
       
get_symbols(huffman_codes)
create_queue(huffman_codes, q)

if is_multiple_by_two == True:
    
    while len(huffman_codes) > 2:
        create_tree(q)
        clear_queue(q)
        create_queue(huffman_codes, q)
    

    if len(huffman_codes) > 1:
        binary_tree.append((0, huffman_codes[0][1]))
        binary_tree.append((1, huffman_codes[1][1]))
        
else:
    
    while len(huffman_codes) > 3:
        create_tree(q)
        clear_queue(q)
        create_queue(huffman_codes, q)
    

    if len(huffman_codes) > 1:
        binary_tree.append((0, huffman_codes[0][1]))
        binary_tree.append((1, huffman_codes[1][1]))

create_paths(binary_tree, symbols)
reverse_path(symbols)

print(binary_tree)
print(symbols)
decode_string(encoded_string, symbols)

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

Что делает мой код:

  1. Строит дерево Хаффмана, формируя ветки слиянием листов и продвигаясь к корню (корень дерева не формируется, потому что здесь он мне без надобности).
  2. Очередь очищается после каждой итерации со слиянием элементов, происходит обновление приоритетов для объединённых элементов.
  3. На каждом этапе элементам присваивается 0, либо 1.
  4. Программа проходится по дереву, выстраивая маршрут к каждому символу и записывая этот маршрут в виде двоичного числа.
  5. На основе этих чисел происходит расшифровка строки.

Вроде бы всё работает в соответствии с алгоритмом, ну или по-крайней мере похоже на то.

И тем не менее, код выдавал мне бредик:

В целом, здесь правильно расшифровывались какие-то символы, вот для сравнения как выглядела исходная строка:

"YYYYAAAAHHHHHHOOOONOOOOTTTTTTFFFFLLLLAAAAGGGGGGGISSSSHEEERRREEEEEGUUUUUYYY"

Где я свернул не туда? Я думал, что наверное неправильно реализовал алгоритм или ещё что-то в таком духе.

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

И здесь я понял, что проблема не во мне и не в моём коде. Задача просто не использует алгоритм Хаффмана, она делает вид, что использует, но не использует. Во всяком случае, мне так кажется. От алгоритма здесь возможно есть только частотность символов и внешнее сходство. Мне дали подсказку в какую сторону копать и я понял, что всё это время летал по кроличьей норе, в то время, как ответы лежали на поверхности. Я собственноручно загнал себя в тупик и потратил много времени в никуда (в плане решения задачи, конечно, в остальном такая практика была мне очень полезна, я узнал много нового).

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

"0001000100010001110110110110000000000000000000000000100100100100011011100100100100001000100010001000100010011101110111011110101010101010101101101101101111111111111111111110110101011101110111011000001001001001100011000110001001001001001001001011100110011001100110011000100010001"

"YYYYAAAAHHHHHHOOOONOOOOTTTTTTFFFFLLLLAAAAGGGGGGGISSSSHEEERRREEEEEGUUUUUYYY"

Например, в начале строки идут "YYYY", а в зашифрованной строке начало это 0001 0001 0001 0001. 4 одинаковых сегмента. Далее идут "AAAA", а в двоичном коде 110 110 110 110. Снова совпадение. Это даёт основания полагать, что код для "Y" = 0001, а код для "A" = 110. В итоге есть предположение, что двоичные коды для символов это вовсе не маршруты. Хотя бы потому, что чем меньше десятичный код числа, тем реже оно встречается в сжатой строке, а значит и её двоичный код, её маршрут, будет очень длинным, а самые часто встречающиеся символы, наоборот, будут иметь самые короткие коды, в этом и есть суть алгоритма Хаффмана, за счёт этого итоговое количество битов меньше исходного.

Взглянем ещё раз на коды, которые программа присвоила символам:

A=6, E=2, F=7, G=7, H=0, I=26, L=10, N=27, O=4, R=12, S=11, T=2, U=3, Y=1

0001 в десятичной системе это 1, а 110 в десятичной системе это 6, собственно коды это десятичное представление двоичных кодов символов.

Теперь надо перевести для удобства все эти коды в двоичные числа:

code = [['A',6], ['E',2], ['F',7], ['G',7], ['H',0], ['I',26], ['L',10], ['N',27], ['O',4], ['R',12], ['S',11], ['T',2], ['U',3], ['Y',1]]

def get_bin(code):
    
    for i in code:
        
        i[1] = str(bin(i[1]))
        i[1] = i[1][2:]
        
get_bin(code)
print(code)

И тогда мы получаем:

Теперь по этой схеме можно попытаться расшифровать строку.

0001 Y 0001 Y 0001 Y 0001 Y 110 A 110 A 110 A 110 A

Далее идёт много нулей, как раз символ 'H' имеет код 0, но вот в чём проблема - как понять сколько именно нулей принадлежат 'H'? Ведь 0 в двоичной системе можно представить как 00 или 000 или 0000 или 00000 или 000000 и т.д.

Придётся тыкать ручками и руководствоваться логикой. Давайте подумаем! После 6 символов 'H' идёт 'O', которая обозначена как 100, это значит, что там где мы встретим единицу - начинается другой символ. Итого на 6 'H' выделено 24 бита. Ну а значит мы просто делим 24 на 6 и получаем по 4 бита на символ, значит 'H' = 0000. Ну и так далее, думаю суть ясна.

И вот почему здесь очень сложно автоматизировать декодирование - вы не сможете выловить какой-то чёткий паттерн, вам придётся экспериментировать вручную с такими вот нулями.

При расшифровке флага, например, возникает такая же проблема - там есть несколько символов, у которых десятичный код это 1. А 1 можно представить как 01 или 001 или 00001 или 000001 и так далее, значит один символ будет обозначаться как 01, какой-то другой, обозначенный единицей, будет обозначаться как 001 ну и так далее. Выяснять это уже придётся методом проб и ошибок, что я и сделал. Через 20 минут кропотливого подбора символов, я получил искомую фразу:

Флаги к некоторым задачам выкладывать не стоит :)

Что можно сказать в конце? Я извлёк ценный урок, в процессе мучений изучил алгоритм и даже написал какой-то код для него, по-моему неплохой итог.