Слово "хакер" давно уже утратило свой изначальный смысл: если раньше это были люди, которые хорошо разбирались в аппаратном и программном обеспечении, то теперь это программисты неизмеримой квалификации, чьи действия вполне можно воспринимать как чёрную магию. Попробую приоткрыть завесу таинственности.
Будем ломать это
Я написал небольшую программу на C++, которая проверяет введённый текст на правильность. Попробуем её вскрыть. Вот исходник:
#include <iostream>
#include <string.h>
using namespace std;
int validate(const char* password) {
int valid = 0;
if(strcmp((const char*)password, "SECRET")) {
valid = 1;
}
return valid;
}
int main () {
string password;
cout << "Введите пароль: ";
getline(cin, password);
if(validate(password.c_str()) == 0) {
cout << "Пароль принят" << endl;
} else {
cout << "Неверный пароль" << endl;
}
return 0;
}
Я специально вынес её сюда, чтобы повторить мои действия мог бы каждый. В своих опытах я буду использовать инструментарий GNU.
Файл я назвал crack.cpp. Для начала соберём приложение (под linux естественно):
$ gcc crack.cpp -lstdc++
У нас получился выходной файл a.out так как я собирал безо всяких дополнительных флагов.
Проверка на недальновидность разработчика
Вполне возможно, разработчик оставил пароль прямо внутри файла. Обычно каждый исполняемый файл содержит некоторое количество секций, каждая из которых имеет свою назначение.
Вообще, есть разные форматы исполняемых файлов. В моём случае это будет elf64-x86-64. Например, тут можно посмотреть побольше про содержание таких файлов; в разделе 2.1.2 есть доступные для файла секции. Не все они будут заполнены, но некоторые будут.
Среди них есть секция с данными только для чтения - называется .rodata. Если пароли зашиты в программу напрямую, очень вероятно, что они будут там. Давайте заглянем! Для этого воспользуемся программой objdump, которая как раз и позволяет это делать. Выполним:
$ objdump -sj .rodata a.out
Это значит показать содержимое (s) секции (j) .rodata для файла a.out. Получается следующее:
Нетрудно заметить наш пароль в данной секции. А точки - это текст, не попавший в кодировку ASCII (те слова, который писали на русском языке). Например, если немного изменить код, заменив фразу " Введите пароль" на "Enter the password", получится так:
Ну, в общем таким образом, мы нашли пароль. Даже проверять не буду - и так знаю, что сработает. Но мы пойдём дальше - изменим пароль. Для этого откроем наш файл через hexedit и заменим пароль.
Тут требуется пояснение: если заменить пароль на нули - это будет воспринято приложением как пустая строка, то есть просто нажатие Enter приведёт к принятию пароля.
Откроем файл на редактирование:
$ hexedit a.out
Комбинацией ctrl+s найдём наш пароль (SECRET) предварительно нажав клавишу TAB и перейдя в столбец с формально-текстовыми данными.
Дальше жмякаем опять TAB и возвращаемся в редактор HEX кодов; заменяем циферки на нули, наблюдая за тем, как меняется текст в правом столбце редактора. Когда все буквы стали точками - жмём F2, сохраняя файл и жмём ctrl+x, чтобы выйти
Запускаем наше приложение, сходу жмём Enter - и вуаля, пароль принят!
Используем ассемблер, как взрослые
Этот подход требует определённой сноровки и, возможно, для большинства случаев не сгодится, но в нашем вполне себе подойдёт. Что мы будем делать: будем искать то место, где проверяется пароль вносить изменения, которые всегда будут приводить нас в нужную секцию (файл, само собой, надо будет пересобрать).
Спойлер! Скорее всего, будет непонятно, что происходит, но повторить такое реально.
Для начала запустим наше приложение через отладчик gdb. Поскольку мы его собирали без необходимости отладки, то отладочной информации в файле нет, но существующий код всегда можно посмотреть в виде ассемблера. Для этого выполним ряд действий:
- откроем файл через gdb a.out
- добавим точку остановки командой b main (main - это стандартная точка входа)
- запустим приложение run и сразу попадём на остановку
- просмотрим текст командой disassemble
Ничего непонятно. Но нам и не надо всё понимать, лишь несколько мнемоник:
- call - это вызов подпрограммы (+16)
- jmp - безусловный переход на указанный адрес (+110)
- je - переход на указанный адрес, если условие в предыдущей команды верно (+80)
Программа работает сверху вниз, инструкции выполняются последовательно. Первый столбец - это адреса (синим), в угловых скобках (белым) смещение относительно первой инструкции, зелёным - мнемоника команды, далее - идут аргументы (один или два).
Что происходит: в какой-то момент происходит сравнение введённого текста и некоторого оригинала, после чего происходит переход в определённый раздел.
Если внимательно посмотреть аргументы для call (выделены жёлтым), то можно разглядеть в них название функций. Лично меня заинтересовал вызов функции на строке <+68> с непонятным именем _Z8validatePKc, где явно прослеживается слово validate. Предположительно, я не знаю что делает эта функция, но давайте начнём исследование с него: поставим точку остановки именно в месте вызова. Делается это так:
(gdb) b *0x0000000000400b41
Где *0x0000000000400b41 - это просто адрес. Запускаем выполнение кода и ждём остановки в том месте. Если не знаете, как управлять отладчиком - посмотрите эту мою статью, она немного прояснит. Или документацию на официальном сайте.
Итак, мы остановились на выполнении функции. Что бы она ни делала, в строчке +80 она, возможно, выполняет переход на строку +112.
В процесс отладки мы можем наблюдать (а я добавил несколько точек остановки в тех местах, где мы оказываемся), как программа запрашивает пароль, мы вводим какое-то произвольное значение, затем происходит переход на строку +112, а после выводится сообщение, что пароль неверный.
То есть, после того, как мы вводим неправильный пароль - то переходим к выводу уведомления, что пароль неверный, а переход это происходит по команде je, которая что-то проверяет. Рискну предположить, что команда test что-то выясняет. Давайте посмотрим, что она вообще сравнивает.
В строке +78, по всей видимости, происходит сравнение значение регистров al (не так важно, для чего они, главное, что он, по всей видимости, влияет на результат проверки; если очень хочется - чуть больше информации). Перезапустим выполнение и поглядим его значение в ранее заданной точке остановки на +80:
(gdb) info registers al
Получим результат, что его значение равно нулю. А что будет, если задать значение равное 1? Для этого мы удалим точку остановки на +80 и поставим на +78, чтобы изменить значение ещё до сравнения.
Перезапускаем! И ошибка. Я указал неверный адрес точки остановки. Удаляем последнюю добавленную и добавляем с адресом 0x0000000000400b3f. Опять перезапускаем.
Теперь остановились где надо. Пробуем модифицировать регистр:
(gdb) set $al=1
И вуаля - пароль принят!
Безусловно, такое решение хорошо для нашего учебного примера; в реальной жизни такого не бывает, но, понимая общий принцип, можно попробовать масштабировать процесс.
Можно пойти и дальше и внести изменение в исходный код приложения, поменяв команду ассемблера, но тут лучше понимать что делаешь и, хотя бы, немного разбираться в том, что значат команды. Это уже не уровень мамкиного хакера; тянет на сына маминой подруги!
Переполнение буфера
А вот этот способ посложней. Потребуются дополнительные пояснения.
Данные находятся в памяти последовательно: сперва переменная 1, потом переменная 2 и так далее. Есть несколько видов блоков памяти: только для чтения, куча, стек - вот стек нам и нужен! Каждый блок имеет свои адреса и, если представить память как большой стакан, то наверху (наименьшие адреса) - будет исходный код программы, который нельзя поменять, посередине - будет куча - та память, которую программа щедро использует для своих структур и переменных, а в самом низу - стек, растущий снизу вверх (это значит, что самая первая запись стека будет на самом последнем адресе, следующая, скажем, на максимальный адрес минус один и так далее).
Зачем нам всё это знать? А затем, что данные идут в памяти последовательно. А это значит, что если мы сможем модифицировать данные какой-то переменной так, чтобы они вылезли за её пределы, то "вылезшие" данные перепишут те, которые идут следом. И если предположить, что в этой переменной идёт флаг, выставляющийся в true, если пароль правильный - то можно перезаписать значение этого самого флага и пройти проверку пароля введя любую абракадабру. Для иллюстрации пример придётся немного поменять: сделаем так, чтобы пароль вводился как параметр приложения, а не после запуска (знаю, способ редкий, но наглядный). Код теперь выглядит вот так:
#include <iostream>
#include <string.h>
using namespace std;
int validate(char *password) {
char password_buffer[16];
int valid = 0;
strcpy(password_buffer, password);
if(strcmp(password_buffer, "SECRET") == 0) {
valid = 1;
}
return valid;
}
int main (int argc, char* argv[]) {
if(argc > 1 && validate(argv[1]) > 0) {
cout << "Пароль принят" << endl;
} else {
cout << "Неверный пароль" << endl;
}
return 0;
}
Собирается всё так же (файл я назвал crack.cpp):
$ gcc crack.cpp -lstdc++
В этот раз отладчик нам не понадобится, но я всё равно буду запускать программу через него, по привычке. На всякий случай уточню, что команда run с аргументами - это всё равно, что запустить исполняемый файл с этими самыми аргументами. То есть gdb a.out, а затем run 123 - всё равно, что ./a.out 123.
Итак, проверяем, что всё работает.
Однако, в нашем коде есть некоторая уязвимость, которая, возможно, поможет нам обойти проверку пароля. А что, если мы попробуем ввести достаточно длинный пароль? Скажем, символов 50? Пробуем.
И что же мы получаем? Да это же ошибка сегментации. Что она значит? А то, что вводом своего пароля мы сломали внутреннюю структуру программы.
Дополнительное пояснение: программа работает последовательно, записывая данные в стек при каждом вызове подпрограммы (функции). И хранит данные о том, сколько этих данных в стеке. Например, для подпрограммы А у нас хранится 16 байт. Назовём это кадром стека.
Стек не имеет защиты; то есть если я попробую записать в переменную внутри кадра значение, которое окажется больше предельной длины - эти данные запишутся уже в следующий (на самом деле в предыдущий) кадр и исказят его.
Стек хранит "адрес возврата" - это что-то вроде указателя, куда надо вернуться с данными после того, как подпрограмма отработает (по сути, мы вызвали функцию и теперь в место вызова она должна вернуть результат).
Но если память стека нарушена - вместо адреса возврата можем получить пустой или недоступный адрес, что приведёт к ошибке сегментации.
Это если упрощённо.
Теперь попробуем постепенно увеличивать пароль, пока не появится ошибка сегментации. Методом проб и ошибок я увеличил пароль до 30 символов и...
Что за магия такая?
На самом деле то, что произошло тут - прямо-таки хрестоматийный случай. Разработчиком был допущен целый ряд ошибок, которые допускаются нечасто: был использован спорный подход в написании функции проверки пароля, который позволил, введя слишком длинный пароль ключ, выйти за границы буфера пароля и "залезть" на значение переменной valid, фактически сделав проверку её бесполезной.
Ещё раз: есть данные. Буфер на 16 юникод символов - для проверки пароля и ещё один байт - для флага, пройдена ли авторизация. Как только в буфер мы записали более длинную строку - он изменила байт флага, который из нулевого стал ненулевым, что привело к тому, что функция вернула положительный результат проверки, хотя это не так.
Заключение
Этот пример не то, чтобы сильно выдуман, но он довольно рафинированный, предназначенный больше для демонстрации возможностей, нежели для фактического взлома мало-мальски серьёзной программы.
Однако, программы пишут люди, а людям свойственно чего-то не знать и ошибаться, даже не понимая того, что они ошибаются.
Рискну предположить, что что-то из написанного выше может показаться непонятным: это не проблема. Чаще всего разработчикам нет нужды погружаться так глубоко (хотя это совсем не глубоко, если честно). В любом случае, даже если это и непонятно - значит найдена зона роста, что уже хорошо.