🗃️ Реверсирование бинара Go stripped

Оригинальный пост

Вы — новый член команды Gophers — ух ты! Но прежде чем вы станете официальным членом команды, вам нужно проверить Архив Сусликов и посмотреть, не знаете ли вы кого-нибудь из них, чтобы вы могли с ними поговорить. Одна маленькая проблема: Вам не дали пароль к этому архиву, только сам архив. Для вас не должно быть проблемой найти пароль к этому архиву благодаря вашим талантам реинжиниринга Gopher!

Я решил написать длинную статью в блоге об этой задаче, поскольку она представляет собой бинарный файл Go, что не так часто встречается в CTF, и к тому же она разделена на части, что делает ее сложнее. Учитывая это и описание задачи выше, давайте приступим к ее реверсированию 😄.

Анализ файла

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

krypton@spacelab:~$ file gopher-archive
gopher-archive: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=DZY5-NZWffdx_11K_RuZ/ecYcwlsBPPJC4gB4vUTq/R6IjWjgUnvavmgqByNvV/V2BcxgTPHcU3p7JRtY_J, stripped
Вход в полноэкранный режим Выход из полноэкранного режима

Как мы видим, это «обычный» исполняемый файл linux. Но есть одна особенность: мы видим, что это сборка Go, поэтому файл является двоичным файлом Go. Мы также видим, что двоичный файл отделен, что означает, что, скорее всего, будет ужасно прочитать ассемблерный код и распознать, что делает двоичный файл.

При выполнении двоичного файла мы имеем обычное приветственное сообщение с ASCII-артикулом Gopher. Оно запрашивает пароль для доступа к архиву. Мы можем попробовать переполнение, но это, конечно, не сработает, и в конце концов это обратный вызов, а не вызов pwn ¯_(ツ)_/¯.

Первый взгляд на дизассемблированную программу

Функции

Давайте откроем двоичный файл в дизассемблере; как и ожидалось, он немного беспорядочен из-за того, что двоичный файл был зачищен. Мы видим множество имен функций sub_XXXXXX, и ни одна из них не похожа непосредственно на функцию main бинарного файла. Однако мы видим функцию _start, но опять же, не похоже, что это функция main.

Строки

В двоичных файлах Go строки обрабатываются иначе, чем в двоичных файлах C. Сначала загружается указатель на строку, а затем нужная нам длина перемещается в регистр, согласно исходному коду Go:

type stringStruct struct {
    str unsafe.Pointer
    len int
}
Вход в полноэкранный режим Выход из полноэкранного режима

Если посмотреть на строки, то при поиске строки, которая появляется при неправильном вводе пароля, мы получим следующее:

810668945312588817841970012523233890533447265625[  @f | @))    |  | @))   l  0 _/  nSorry, that password is incorrect!
Вход в полноэкранный режим Выйти из полноэкранного режима

Наша строка находится внутри этой, но она определенно не используется только в том месте, где мы выводим сообщение об ошибке. Если посмотреть на перекрестные ссылки этой строки:

Мы видим, что она используется только в 4 местах. Теперь мы можем использовать GDB для установки точки останова на каждом из вызовов и посмотреть, достигает ли она точки останова перед печатью приветственного сообщения.

Отладка

Поиск главной функции

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

0x4347c2    mov     rax, qword [rel data_4aa980]  {sub_489920}
0x4347c9    lea     rdx, [rel data_4aa980] ; Less important
0x4347d0    call    rax  {sub_489920}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы делаем вызов $rax, который имеет значение адреса функции sub_489920. С этой информацией мы нашли главную функцию! 🥳

Разборка главной функции

Понимание логики

После того как мы нашли главную функцию, нам нужно понять, как работает система проверки паролей. Теперь мы можем зайти внутрь главной функции (по адресу 0x489920) и посмотреть на ее ассемблерный код.

Сначала мы видим множество вызовов одной и той же функции. Просмотр графика подтверждает, что никаких проверок не производится, а функции просто вызываются без какого-либо определенного поведения. У нас есть ASCII-арт Gopher, который печатается при запуске файла, мы можем смело считать, что эти вызовы отвечают за печать этого текста. Поэтому мы можем переименовать функцию sub_484100 в print и не смотреть на ее содержимое, поскольку это не то, что нас волнует.

Когда мы доходим до конца списка вызовов, мы видим два вызова, за которыми следует условие. Если условие выполнилось, мы переходим и печатаем один раз. Если условие не сработало, мы печатаем дважды. Таким образом, из сообщения об ошибке мы можем сказать, что слева — сообщение об ошибке, а справа — сообщение об успехе. Теперь нам осталось посмотреть на функции sub_44afc0 и sub_48a3c0, чтобы увидеть, что они делают. Одна из них, а может быть и обе, будет проверкой пароля.

Отладка двух неизвестных функций

Вторая неизвестная функция

Давайте поставим точку останова на первой функции, sub_44afc0. Мы не попадаем в нее до того, как нужно будет ввести пользовательский ввод, так что она не отвечает за запрос пользовательского ввода. Но давайте продолжим отладку и воспользуемся ni, чтобы перейти к следующему вызову функции. После вызова функции мы можем посмотреть на содержимое $al, чтобы увидеть, пройдет или не пройдет условие.

gef➤  p $al
$1 = 0x0
Вход в полноэкранный режим Выйти из полноэкранного режима

Итак, условие пройдет успешно (0x0 & 0x0 = 0x0), и мы перейдем к 0x489df1. Мы видим, что функция sub_48a3c0 будет проверять, действителен ли пароль, поэтому переименуем ее в isPasswordValid.

Первая неизвестная функция

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

Мы убедились, что это не функция выше функции проверки пароля, поэтому давайте сломаем на один вызов функции выше — в 0x489d2d. Мы запускаем программу, затем используем ni и теперь получаем ввод пользователя. После ввода мы видим наш пользовательский ввод в регистре $rbx.

gef➤  x/s $rbx
0xc0000b2000:   "1337testn"
Вход в полноэкранный режим Выход из полноэкранного режима

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

Разборка функции isPasswordValid

Разделение строк

При первом рассмотрении функции мы видим вызов sub_468740, а перед этим в регистр $rcx загружается строка "-./05:<=?CLMPSU...". В соответствии с тем, как Go работает со строками, мы видим, что длина строки, которая понадобится нам для следующего вызова, перемещается в регистр $edi:

0x48a3dd    lea     rcx, [rel data_4a1bdd]  {"-./05:<=?CLMPSUZ[]`hms{} + @ P […"}
0x48a3e4    mov     edi, 0x1
Вход в полноэкранный режим Выход из полноэкранного режима

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

0x48a3f7    cmp     rbx, 0x2
Войти в полноэкранный режим Выход из полноэкранного режима

Наша строка разделяется с помощью "-" и проверяется, есть ли в конце две строки! Мы можем подтвердить это предположение с помощью отладчика:

$rax   : 0x0000c0000b6040  →  0x0000c0000bc000  →  "hello-world-123-456"
$rbx   : 0x4
Войдите в полноэкранный режим Выйти из полноэкранного режима

Здесь у нас есть 4 элемента после разделения строки. Мы можем переименовать функцию sub_468740 в splitString.

Длина строк

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

Глядя на следующие условия, мы видим две проверки, сделанные с 0x6, мы можем предположить, что каждая часть пароля должна иметь длину 6. Таким образом, что-то вроде 123456-123456. Мы можем подтвердить это с помощью отладчика, но я чувствую, что мне не нужно показывать, как это делается.

Двигаясь дальше, мы видим две вызываемые функции: sub_48a0e0 и sub_48a2a0. И как всегда проверяется их возвращаемое значение в $al. Учитывая, что у нас есть две части строки, можно предположить, что первый вызов проверит первую часть строки, а второй вызов — вторую часть строки.

Отладка первой функции проверки строки

Разбор грубой логики

Первая функция, sub_48a0e0, отвечает за проверку первой части пароля, мы можем переименовать ее. Посмотрев на нее с помощью дизассемблера, мы не получим много информации, так как происходит много вызовов, а в конце проверка с длинной строкой 0x28"df4c2d865b38db152e7f6bb4ad2f325de9570185".

0x48a222    lea     rbx, [rel data_4a84fe]  {"df4c2d865b38db152e7f6bb4ad2f325d…"}
0x48a229    mov     ecx, 0x28
0x48a22e    call    compare
0x48a233    test    al, al
0x48a235    je      0x48a248
Вход в полноэкранный режим Выйти из полноэкранного режима

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

Опускание строки

Разбив код на 0x48a0e0 и пройдя одну инструкцию за другой, мы видим, что после вызова функции sub_468fc0 наша первая часть строки опустилась:

$rax   : 0x0000c000138010  →  0x00616161616161 ("abcdef"?)
Вход в полноэкранный режим Выход из полноэкранного режима

Так что теперь мы можем переименовать эту функцию, нам не нужно глубоко копать в то, как эта функция работает.

Строка становится обратной

После продолжения мы не видим ничего особенного, пока не столкнемся с вызовом в 0x48a1a9 функции sub_44b240, которая передаст в качестве аргумента строку "abcdef".

Посмотрев на результаты после вызова, мы видим в $rax регистре "fedcban:/bin:/snap/bin:/". Видите ли вы что-то особенное? 🤔 Да! Мы видим, что наша строка abcdef изменилась на fedcba — вспомните, как обрабатываются строки. Еще одна функция для переименования 😀

Перебор символов в строке

Если мы продолжим выполнение следующих инструкций, то увидим, что наткнулись на цикл, это также видно на графике:

При ближайшем рассмотрении видно, что сначала мы присваиваем регистру $edx значение 0x1ca3 (7331). После этого мы переходим в 0x48a1d2 и сравниваем, равно ли $rbx $rcx, если да, то переходим в 0x48a213 и выходим из цикла. В противном случае мы продолжаем и загружаем в регистр $rdi $rcx+0x1, это счетчик. Учитывая, что $rbx содержит длину строки, можно сказать, что этот цикл перебирает каждый символ строки.

Теперь, если мы посмотрим, что именно делает цикл, мы увидим следующий ассемблерный код:

0x48a1c2    movsxd  rsi, esi
0x48a1c5    imul    rcx, rsi
0x48a1c9    add     rsi, rcx
0x48a1cc    add     rdx, rsi
0x48a1cf    mov     rcx, rdi ; Not really useful
0x48a1d2    mov     qword [rsp+0x28 {var_60_1}], rdx
Вход в полноэкранный режим Выход из полноэкранного режима

Вот логика, объясняемая при использовании ABCDEF-GHIJKL в качестве пароля и i равном 1, что означает, что мы итерируем над e (помните, что он обратный):

  • Мы умножаем $rsi на $rcx, который содержит текущее значение i (0x65*0x1 хранится в $rcx).
  • Мы прибавляем к $rsi результат умножения (0x65+0x65 хранится в $rsi).
  • Мы прибавляем к $rdx значение $rsi (0x1d09+0xca хранится в $rdx).
  • Затем полученное значение сохраняется в $rsp+0x28.
  • Мы проверяем, равно ли $rsp+0x28 0x256c ниже, после вызова другой неизвестной функции.

Таким образом, мы можем представить этот цикл следующим образом:

x = 0x1ca3
while (True):
    if i >= len(s):
        break
    x += ord(s[i]) + (i * ord(s[i]))
    i += 0x1
if x == 0x256c:
    # Do something
Вход в полноэкранный режим Выход из полноэкранного режима

Функция хэширования

Совет: Когда две функции вызываются одна за другой, возвращаемое значение первой функции передается в качестве аргумента второй автоматически, вы увидите это в последующих шагах.

Как было сказано ранее, есть еще одна неизвестная функция (sub_489e60). Сразу после ее вызова проверяется, равна ли длина результирующей строки 0x28, что равно 40. Единственный известный мне алгоритм хэширования, у которого результирующая длина равна 40, это SHA1. Итак, давайте попробуем хэшировать fedcba с помощью SHA1.

Из функции выходит строка "82639466494baa873844ae6cdc593cd54b5c054e", а наш входной SHA1-хэш — "122fe41b518797f9474d5f6f4665e411c449512c". Похоже, что это не одно и то же, возможно, нам следует углубиться в функцию, поскольку Google подтвердил, что SHA1 имеет длину 40 символов. При установке точки останова на вызове функции мы можем использовать si для перехода в саму функцию. Мы снова входим в цикл, на этот раз цикл генерирует новую строку:

$rax   : 0x0000c0001b0000  →  "8df67bd6d5e123d52a9a"
$rax   : 0x0000c0001b0000  →  "8df67bd6d5e123d52a9a9"
...
$rax   : 0x0000c0001b0000  →  "8df67bd6d5e123d52a9a966bb31cf719"
Войти в полноэкранный режим Выйти из полноэкранного режима

Если мы продолжим цикл, то увидим новую функцию, вызванную с приведенной выше строкой в качестве аргумента. Если посмотреть на сгенерированную строку, то это значение SHA1, указанное выше, начиная с 826. Что же происходит во время первой части? Результирующая строка имеет длину 32 символа, это MD5. Итак, в основном происходит следующее: result = SHA1(MD5(string)). Мы можем легко убедиться в этом, сделав SHA1-хэш от "fedcba" и затем сделать MD5-хэш от полученного SHA1-хэша.

Фух! Вот и все для этой функции хэширования, мы можем переименовать ее во что-то вроде md5sha1.

Сравнение

В конце мы проверяем, равна ли полученная строка значению в $rbx и берем 0x28 символов этой строки.

$rbx   : 0x000000004a84fe  →  "df4c2d865b38db152e7f6bb4ad2f325de9570185failed to [...]"
$rcx   : 0x28
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, мы сравниваем, равно ли MD5(SHA1(string)) значению "df4c2d865b38db152e7f6bb4ad2f325de9570185".

Наконец-то! Первая проверка отлажена.

Отладка второй функции проверки строки

Разборка логики

Вторая функция проверки — sub_48a2a0, поэтому давайте переименуем ее.

Посмотрев на нее, мы увидим схему, похожую на предыдущую:

0x48a2c0    call    lower
; ...
0x48a360    call    reverse
0x48a365    call    md5sha1
; ...
0x48a374    lea     rbx, [rel data_4a845e]  {"a2ce7d4c220df20b186f5458d9449a56…"}
0x48a37b    mov     ecx, 0x28
0x48a380    call    compare
Вход в полноэкранный режим Выход из полноэкранного режима

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

Генерация флага

Теперь мы закончили с инверсией функций и можем приступить к генерации флага!

Первая строка

Первая строка действительна в TL;DR:

  • Перевернуть строку
  • Добавить шестнадцатеричные значения каждого символа в счетчик
  • Этот счетчик, добавьте шестнадцатеричные значения каждого символа, умноженные на текущий индекс счетчика
  • Если счетчик равен 9580, то первая проверка верна
  • Если обратная строка md5 hashed и затем sha1 hashed равна «df4c2d865b38db152e7f6bb4ad2f325de9570185», то вторая проверка действительна.
  • Если обе проверки действительны, то первая строка действительна

Вторая строка

Действительность второй строки в TL;DR такова:

  • Переверните строку
  • Если обратная строка md5 hashed и затем sha1 hashed равна "a2ce7d4c220df20b186f5458d9449a56e0e36149", то проверка действительна.
  • Если проверка верна, то вторая строка верна

solver.py

Весь код для решателя будет выглядеть следующим образом:

def solve_first() -> str:
    from string import ascii_lowercase
    import itertools
    import hashlib

    def iter():
        for s in itertools.product(ascii_lowercase, repeat=6):
            yield "".join(s)
    for s in iter():
        s = s[::-1]
        x = 7331
        i = 0
        while (True):
            if (i >= len(s)):
                break
            x += ord(s[i]) + (i * ord(s[i]))
            i += 1
        print(f"Trying {s[::-1]}", end="r")
        if (x == 9580) and (hashlib.sha1(hashlib.md5(s.encode()).hexdigest().encode()).hexdigest() == 'df4c2d865b38db152e7f6bb4ad2f325de9570185'):
            return s

def solve_second() -> str:
    import hashlib

    with open('rockyou.txt', 'r', encoding="latin-1") as f:
        for l in f.read().splitlines():
            print(f"Trying {l}", end="r")
            reversed = l[::-1]
            if (hashlib.sha1(hashlib.md5(reversed.encode()).hexdigest().encode()).hexdigest() == 'a2ce7d4c220df20b186f5458d9449a56e0e36149'):
                return l

first = solve_first()
print(f"Found first string: {first[::-1]}")
second = solve_second()
print(f"Found second string: {second}")

print(f"nFlag/password: {first}-{second}")
Вход в полноэкранный режим Выход из полноэкранного режима

Источник

Я мог бы использовать список слов в первой проверке, но я решил использовать два разных способа решения, даже несмотря на то, что это **очень* медленно. Я также попробовал использовать itertools.permutations и multiprocessing, но это не ускорило получение перестановок. Если вы хотите попробовать сами, вы можете отредактировать первую проверку, загрузив, например, список английских слов и сделав перебор слов, как во второй проверке — именно так я и поступил, чтобы действительно получить строку.*

Заключение

Если вы прочитали этот пост полностью до этого момента, вы просто молодцы!

Это был в целом потрясающий вызов, и я определенно многому научился, не только потому, что это был стрип, но и потому, что это был двоичный код на Go, а не обычный двоичный код на C. У меня ушло много времени, чтобы сделать эту запись и решить задачу, но я счастлив от этого 🙂 Надеюсь, вам понравилась эта задача, и если вы хотите попробовать сами, вы можете скачать бинарник и посмотреть на него!

~ Krypton 🐺

Оцените статью
devanswers.ru
Добавить комментарий