Реверс-инжиниринг
Зачем использовать NotHoly для реверс-инжиниринга
Оставьте Ghidra, radare2 или IDA. Вставьте вывод декомпилятора: модель объяснит и напишет скрипт, не отказываясь. Статический triage для начала.
Скомпилируйте игрушечную проверку лицензии на C двумя способами, затем найдите её строку в Ghidra, пройдите по ссылкам и сравните вывод декомпилятора со своим исходником.
Реверс-инжинирингу проще учиться на коде, который вы написали сами. Вы знаете, что должна делать программа, и можете проверить каждое утверждение инструмента о ней. На этом подходе и построена статья. Вы напишете небольшую программу на C с игрушечной проверкой лицензии, скомпилируете её дважды и откроете обе сборки в Ghidra. Затем сравните то, что показывает декомпилятор, с исходником, который набрали сами.
Положите вывод декомпилятора рядом со своим кодом, и вы быстро поймёте, что инструмент определяет верно, о чём догадывается и что теряет, когда компилятор оптимизирует код. Это полезнее, чем заучивать меню.
Если чтение машинного кода для вас в новинку, сначала прочитайте статью Что такое реверс-инжиниринг?. Опыт работы с ассемблером не нужен. Нужны терминал и около часа времени.
Ghidra — это фреймворк для реверс-инжиниринга программ. Его разработало Агентство национальной безопасности США, а опубликован он на GitHub в репозитории NationalSecurityAgency/ghidra. Работает на Windows, macOS и Linux. Со скомпилированной программой Ghidra умеет делать четыре вещи, которые нам здесь нужны:
Исходный код Ghidra не восстанавливает, она строит его приближение. По форме псевдокод обычно близок к оригиналу, но имена, типы и часть потока управления могут отличаться от написанного вами. Эту разницу и должна показать лабораторная работа.
Можно скачать с форума какой-нибудь бинарник и начать изучать его. Не делайте этого. Незнакомый бинарник может оказаться вредоносным ПО, а проверить вывод инструмента вы не сможете, потому что правильного ответа не знаете.
Программа, которую вы скомпилировали сами, даёт три вещи:
check() сравнивает аргумент с "LAB-1234".ok или no. Она не обращается к сети и не записывает файлы.Всё в этой лабораторной работе остаётся на вашей машине. Вы разбираете собственный файл, и другого скоупа упражнению не нужно.
gcc через менеджер пакетов. В macOS подойдёт clang из инструментов командной строки Xcode, флаги те же. В Windows используйте WSL или контейнер с GCC.sha256sum. В Git Bash для Windows он есть.Установите тот JDK, который требует релиз Ghidra. Не полагайтесь на номер версии из старых руководств. Когда я смотрел README репозитория и руководство Getting Started в день написания статьи, оба требовали 64-битный JDK 25, а README ссылался на сборки Eclipse Temurin. Найдите в README или примечаниях к релизу точную версию для скачанного вами файла и используйте её. Несовпадение версии Java часто мешает Ghidra запуститься.
Скачивайте Ghidra только со страницы официальных релизов на github.com/NationalSecurityAgency/ghidra. Репозиторий ссылается на файлы релиза. Распакуйте релиз в новую папку: руководство Getting Started запрещает распаковывать Ghidra поверх существующей установки. Запускайте ghidraRun в Linux и macOS или ghidraRun.bat в Windows.
Прежде чем что-либо распаковывать, сверьте файл с опубликованным значением SHA-256. Выполните эту команду в папке, куда сохранили релиз:
sha256sum ghidra_*.zipСравните напечатанный хеш со значением SHA-256, которое GitHub показывает рядом с файлом на странице релиза или которое публикует проект. Хеш должен совпасть в точности. Если опубликованного SHA-256 для этого файла найти не удалось, запишите это в лабораторные заметки. Хеш, скопированный со случайного зеркала, за контрольную сумму издателя не считайте.
Проверка хеша доказывает только то, что файл совпадает с указанным издателем. Надёжность самого издателя она не доказывает. Берите файл из официального источника и сохраняйте его имя и хеш в заметках.
Создайте для лабораторной работы пустую папку. Исходник и бинарники держите вместе, а проекты Ghidra храните вне этой папки, чтобы они её не засоряли.
mkdir -p ghidra-lab && cd ghidra-lab
cat > toy.c <<'EOF'
#include <stdio.h>
#include <string.h>
int check(const char *k){ return strcmp(k, "LAB-1234") == 0; }
int main(int c, char **v){ if(c>1 && check(v[1])) puts("ok"); else puts("no"); return 0; }
EOFПрограмма читает один аргумент командной строки. Если он равен LAB-1234, она печатает ok. В остальных случаях печатает no. С точки зрения безопасности эта проверка ничего не стоит: это фиксированная строка в файле, который может прочитать кто угодно. Так и задумано. Вы найдёте строку в бинарнике за несколько минут.
Запишите хеш исходника, чтобы потом можно было доказать, какой файл вы анализировали:
sha256sum toy.cНа моей машине команда напечатала:
d9746d8fb118fa954a46b4d501ddf9b0ed86e986e7c99adb05e4a1a60ca0071d toy.cЕсли пробелы или окончания строк в вашем файле отличаются, хеш будет другим.
Соберите один исходник в два бинарника с разными настройками:
gcc -O0 -g -o toy_debug toy.c
gcc -O2 -s -o toy_stripped toy.cФлаги делают разные вещи. Описаны они в руководстве GCC, которое стоит прочитать хотя бы раз:
-O0 отключает большинство оптимизаций. Это значение по умолчанию, и руководство GCC описывает его как настройку, при которой отладка остаётся предсказуемой. Код близко следует исходнику.-O2 включает большинство оптимизаций, которые не жертвуют размером ради скорости. В руководстве сказано, что он также включает встраивание небольших функций, даже не объявленных как inline. Код может сильно отличаться от исходника.-g добавляет отладочную информацию в родном формате операционной системы. Ghidra умеет читать её часть, отладчики используют всю.-s удаляет таблицу символов из выходного файла на этапе линковки. Имена функций check и main в файле больше не хранятся.Польза лабораторной работы как раз в этих двух бинарниках: исходник один, флаги разные. Сохраните оба.
Запустите их до того, как откроете Ghidra. Программу, которую вы ещё не запускали, вы пока не понимаете.
./toy_debug LAB-1234
./toy_debug wrong
./toy_debug
./toy_stripped LAB-1234
./toy_stripped wrongЯ запускал их в контейнере Debian с GCC 14.2.0 на хосте x86-64. Вывод был таким:
ok
no
no
ok
noОбе сборки ведут себя одинаково. Удаление символов и оптимизация логику не изменили, чего и следовало ожидать. Если вывод у вас различается, остановитесь и выясните почему, прежде чем продолжать.
Перед Ghidra посмотрите, что хранится в каждом файле. Хватит двух инструментов: nm выводит символы, а strings выводит печатаемый текст.
nm toy_debug | grep -E " (check|main)$"
nm toy_stripped
strings toy_stripped | grep LAB
ls -l toy_debug toy_strippedНа моей машине вывод был таким:
0000000000001149 T check
0000000000001175 T main
nm: toy_stripped: no symbols
LAB-1234
-rwxr-xr-x 1 root root 17384 Nov 8 09:17 toy_debug
-rwxr-xr-x 1 root root 14480 Nov 8 09:17 toy_strippedПрочитайте эти строки внимательно:
check и main. Они пришли из вашего исходника.nm нечего выводить.LAB-1234 осталась в обоих файлах. Строка относится к данным, а удаление символов не трогает данные, нужные программе во время работы. Строка лежит в файле как константа, поэтому эту лабораторную работу легко начать.Теперь дизассемблируйте check в отладочной сборке:
objdump -d -M intel toy_debug | grep -A14 "<check>:"Вывод выглядел так:
0000000000001149 <check>:
1149: 55 push rbp
114a: 48 89 e5 mov rbp,rsp
114d: 48 83 ec 10 sub rsp,0x10
1151: 48 89 7d f8 mov QWORD PTR [rbp-0x8],rdi
1155: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
1159: 48 8d 15 a4 0e 00 00 lea rdx,[rip+0xea4] # 2004 <_IO_stdin_used+0x4>
1160: 48 89 d6 mov rsi,rdx
1163: 48 89 c7 mov rdi,rax
1166: e8 d5 fe ff ff call 1040 <strcmp@plt>
116b: 85 c0 test eax,eax
116d: 0f 94 c0 sete al
1170: 0f b6 c0 movzx eax,al
1173: c9 leave
1174: c3 retВ этих инструкциях виден исходник: вызов strcmp, проверка возвращаемого значения и sete, который превращает «равно нулю» в 0 или 1. Вспомните об этом, когда будете читать декомпилятор.
Теперь посмотрите на оптимизированную сборку. Я дизассемблировал toy_stripped тем же способом. Компилятор поместил сравнение прямо в main, которая в моей сборке находится по адресу 0x1060: cmp edi,0x1 для проверки c>1, затем mov rdi,[rsi+0x8], чтобы загрузить v[1], затем call strcmp@plt. Отдельную невстроенную копию check по адресу 0x1190 он тоже оставил, потому что функция не объявлена как static и её может вызвать другой файл. С другим тулчейном адреса будут другими, так что считайте эти числа картой того, что искать, и не пытайтесь их повторить.
Точные названия пунктов меню зависят от версии Ghidra, а на машине, где проверялась эта статья, графический интерфейс Ghidra я не запускал. Шаги ниже опираются на официальную документацию и на то, как обычно выглядит интерфейс. Сверяйте каждое название со своей версией. После каждого шага делайте снимок своего экрана и не используйте снимки из руководств.
ghidraRun (или ghidraRun.bat).toy-lab. Сохраните проект в папке, которой вы распоряжаетесь.toy_debug. Примите формат, который определила Ghidra, если он соответствует вашей системе. Файл, собранный на x86-64 Linux, должен отображаться как ELF с процессором x86.toy_stripped. Теперь в проекте две записи.Дождитесь окончания анализа. Ход анализа Ghidra показывает в нижней панели. Пока анализ не завершён, ничего не переименовывайте.
Начните с toy_stripped, потому что это более сложный случай. Затем сравните с toy_debug.
LAB-1234. Дважды щёлкните по ней, чтобы перейти к её адресу в листинге.В отладочной сборке Ghidra должна также показать имя функции check из таблицы символов. В очищенной сборке будет сгенерированное имя вроде FUN_00101190. Число зависит от сборки, поэтому ориентируйтесь на имя, которое Ghidra показывает у вас на экране.
То же самое проделайте со строками "ok" и "no". Обе используются в main. Если идти от них из main, получится та же картина с другой стороны.
Откройте панель декомпилятора для функции, которая сравнивает строку. Рядом, в другом окне, откройте toy.c.
Ниже описан примерный результат, который стоит искать. Это иллюстрация того, как читать вывод, а не вывод какой-то конкретной версии Ghidra:
strcmp или символа, который инструмент помечает как strcmp.Теперь перечислите различия, которые видите. Типичные такие:
char * как char * или взять более свободный тип вроде undefined8. Может не знать, что ввод объявлен как const.k появится как param_1 или похожая заглушка.strcmp(...) == 0 может выглядеть как сравнение и следующий за ним условный возврат. Смысл тот же, форма другая.main. Ваша проверка c > 1 превратится в сравнение количества аргументов. Следите за смещением на единицу: argv[0] содержит имя программы, а v[1] — первый настоящий аргумент.Оценки декомпилятору ставить незачем. Отмечайте, где его версия совпадает с вашей, а где расходится. При расхождении вернитесь к дизассемблированному коду и исходнику и решите, кто прав.
Теперь та часть работы, которая делает очищенный бинарник читаемым.
L. Назовите её input.check_input в листинге или дереве символов.main переименуйте переменную с массивом аргументов в argv, а счётчик в argc.Ghidra хранит эти имена в проекте. Бинарник на диске не меняется: вы записываете своё прочтение, а скомпилированный файл остаётся нетронутым.
Обратите внимание, насколько легче читать код после переименования. Сначала имена переменных были догадками, теперь они совпадают с вашим исходником. Так реверсеры работают каждый день. По этой же причине имена в декомпилированной программе надо считать гипотезами, пока вы их не проверили.
Поставьте рядом декомпиляцию отладочной и очищенной сборок. Ответьте в заметках на такие вопросы:
-O2 функцию check в main или оставила отдельной? Поищите в листинге отдельное место вызова strcmp и отдельный ret.На каждый вопрос напишите по одному предложению. Если на какой-то ответить не получается, это тоже результат. Вернитесь к листингу и найдите инструкцию, которая вас запутала.
Пропуск запуска. Если открыть бинарник в Ghidra до запуска, вы не будете знать, как программа должна себя вести. Сначала запустите её и запишите вывод.
Доверие к именам декомпилятора. Имя вроде FUN_00101190 или local_18 — заглушка. Новое имя становится вашим утверждением, и для него нужны доказательства.
Путаница со сборками. У отладочного и очищенного бинарников разные адреса и имена. Записывайте имена файлов в заметки и перед переименованием проверяйте, в какой записи проекта вы находитесь.
Образец из ненадёжного источника. Безопасная отправная точка — ваш собственный скомпилированный файл. Освоившись здесь, вы по-прежнему берёте либо свой код, либо образец из проверенного источника, причём источник надо проверить заранее. Статья о хешах файлов и этике VirusTotal объясняет, почему одного хеша для этого мало.
Восприятие лабораторной работы как теста безопасности. Строковая проверка здесь ломается элементарно. Её обнаружение ничего не говорит о настоящих программах. Реальный код лицензирования и защиты сложнее, и анализировать его можно только в пределах скоупа, в котором вам разрешено работать.
Забытая версия JDK. Если Ghidra не запускается, первым делом сверьте версию Java с примечаниями к релизу.
Когда игрушечная программа заработает, усложняйте упражнение под контролем:
"LAB-1234" более длинной строкой и перекомпилируйте. Снова найдите её в обеих сборках.-O0 -s и сравните результат с -O2 -s. Разница покажет, как сильно оптимизатор меняет форму кода, сохраняя логику.Чтобы лучше разобраться во флагах компилятора, обратитесь к первоисточникам: разделам руководства GCC об оптимизации и отладочных опциях. Прочитайте их, прежде чем опираться на какой-либо флаг в отчёте.
Сохраняйте заметки. В хорошем лабораторном журнале есть хеш исходника, команды компиляции, вывод запусков, версии Ghidra и JDK и присвоенные вами имена, каждое с подтверждающими его доказательствами. С такими записями упражнение превращается в навык, который пригодится на следующей программе.
Реверс-инжиниринг
Оставьте Ghidra, radare2 или IDA. Вставьте вывод декомпилятора: модель объяснит и напишет скрипт, не отказываясь. Статический triage для начала.
Реверс-инжиниринг
Что теряется при компиляции, чем дизассемблеры отличаются от декомпиляторов и какие правовые границы нужно знать перед анализом бинарного файла.