Хакер - Фундаментальные основы хакерства. Ищем операнды при взломе программ
Содержание статьи
- Идентификация констант и смещений
- Определение типа непосредственного операнда
- Сложные случаи адресации или математические операции с указателями
- Порядок индексов и указателей
- Использование LEA для сложения констант
- Заключение
Когда мы занимаемся анализом ломаемой программы, пытаясь восстановить алгоритм ее работы, нам нужно определить типы операндов ассемблерных инструкций. Для этого есть несколько простых правил. Между тем среди операндов присутствуют константы и смещения, которые внешне очень похожи, но в то же время сильно различаются по способам и целям взаимодействия. Поэтому важно отделить одно от другого, так как такие «игры» — один из главных инструментов разработчиков защит.
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
ИДЕНТИФИКАЦИЯ КОНСТАНТ И СМЕЩЕНИЙ
Микропроцессоры серии 80x86 поддерживают операнды трех типов: регистр, непосредственное значение, непосредственный указатель. Тип операнда явно задается в специальном поле машинной инструкции, именуемом mod
, поэтому никаких проблем в идентификации типов операндов не возникает. Регистр — ну, все мы знаем, как выглядят регистры; указатель по общепринятому соглашению заключается в квадратные скобки, а непосредственное значение записывается без них. Например:
MOV ECX, EAX; ← регистровые операнды
MOV ECX, 0x666; ← левый операнд регистровый, правый — непосредственный
MOV [0x401020], EAX; ← левый операнд — указатель, правый — регистр
Кроме этого, микропроцессоры серии 80x86 поддерживают два вида адресации памяти: непосредственную и косвенную. Тип адресации определяется типом указателя. Если операнд — непосредственный указатель, то и адресация непосредственна. Если же операнд‑указатель — регистр, то такая адресация называется косвенной. Например:
MOV ECX,[0x401020] ← непосредственная адресация
MOV ECX, [EAX] ← косвенная адресация
Для инициализации регистрового указателя разработчики микропроцессора ввели специальную команду, вычисляющую значение адресного выражения addr
и присваивающую его регистру REG
, — LEA REG, [addr]
. Например:
LEA EAX, [0x401020] ; Регистру EAX присваивается значение указателя 0x401020
MOV ECX, [EAX] ; Косвенная адресация — загрузка в ECX двойного слова,
; расположенного по смещению 0x401020
Правый операнд команды LEA
всегда представляет собой ближний (near) указатель (исключение составляют случаи использования LEA
для сложения констант — подробнее об этом см. в одноименном пункте). И все было бы хорошо... да вот, оказывается, внутреннее представление ближнего указателя эквивалентно константе того же значения. Отсюда LEA EAX, [0x401020]
равносильно MOV EAX, 0x401020
. В силу определенных причин MOV
значительно обогнал в популярности LEA
, практически вытеснив последнюю инструкцию из употребления.
Отказ от LEA
породил фундаментальную проблему ассемблирования — проблему OFFSET’a. В общих чертах ее суть заключается в синтаксической неразличимости констант и смещений (ближних указателей). Конструкция MOV EAX, 0x401020
может грузить в EAX
и константу, равную 0x401020
(пример соответствующего C-кода: a=0x401020
), и указатель на ячейку памяти, расположенную по смещению 0x401020
(пример соответствующего C-кода: a=&x
). Согласись, a=0x401020
совсем не одно и то же, что a=&x
! А теперь представь, что произойдет, если в повторно ассемблированной программе переменная х
окажется расположена по иному смещению, а не 0x401020
? Правильно — программа рухнет, ибо указатель a
по‑прежнему указывает на ячейку памяти 0x401020
, но здесь теперь «проживает» совсем другая переменная!
Почему переменная может изменить свое смещение? Основных причин тому две. Во‑первых, язык ассемблера неоднозначен и допускает двоякую интерпретацию. Например, конструкции ADD EAX, 0x66
соответствуют две машинные инструкции: 83 C0 66
и 05 66 00 00 00
длиной три и пять байт соответственно. Транслятор может выбрать любую из них, и не факт, что ту же самую, которая была в исходной программе (до дизассемблирования). Неверно «угаданный» размер вызовет смещение всех остальных инструкций, а вместе с ними и данных. Во‑вторых, смещение не замедлит вызвать модификацию программы (разумеется, речь идет не о замене JZ
на JNZ
, а о настоящей адаптации или модернизации), и все указатели тут же «посыплются».
Вернуть работоспособность программы помогает директива offset
. Если MOV EAX, 0x401020
действительно загружает в EAX
указатель, а не константу, по смещению 0x401020
следует создать метку, именуемую, скажем, loc_401020
. Также нужно MOV EAX, 0x401020
заменить на MOV EAX, offset loc_401020
. Теперь указатель EAX
связан не с фиксированным смещением, а с меткой!
А что произойдет, если предварить директивой offset
константу, ошибочно приняв ее за указатель? Программа откажет или станет работать некорректно. Допустим, число 0x401020
выражало собой объем бассейна, в который вода втекает через одну трубу, а вытекает через другую. Если заменить константу указателем, то объем бассейна станет равен... смещению метки в заново ассемблированной программе и все расчеты полетят к черту.
Таким образом, очень важно определить типы всех непосредственных операндов, и еще важнее определить их правильно. Одна ошибка может стоить программе жизни (в смысле работоспособности), а в типичной программе тысячи и десятки тысяч операндов!
Отсюда возникает два вопроса:
- Как вообще определяют типы операндов?
- Можно ли их определять автоматически (или на худой конец хотя бы полуавтоматически)?
Определение типа непосредственного операнда
Непосредственный операнд команды LEA
всегда указатель (исключение составляют ассемблерные «извращения»: чтобы сбить хакеров с толку, в некоторых защитах LEA
используются для загрузки константы).
Непосредственные операнды команд MOV
и PUSH
могут быть как константами, так и указателями. Чтобы определить тип непосредственного операнда, необходимо проанализировать, как используется его значение в программе. Для косвенной адресации памяти — это указатель, в противном случае — константа.
Например, мы встретили в тексте программы команду MOV EAX, 0x401020
. Что это такое: константа или указатель?
Ответ на вопрос дает строка MOV ECX, [EAX]
, подсказывающая, что значение 0x401020
используется для косвенной адресации памяти. Следовательно, непосредственный операнд не что иное, как указатель.
Существует два типа указателей — указатели на данные и указатели на функцию. Указатели на данные используются для извлечения значения ячейки памяти и встречаются в арифметических командах и командах пересылки (например, MOV
, ADD
, SUB
). Указатели на функцию используются в командах косвенного вызова и реже в командах косвенного перехода — CALL
и JMP
соответственно.
Следующий пример (const_pointers_cb
) откомпилируй с помощью C++Builder. В нем мы изучим разницу между константами и указателями:
int _tmain(int argc, _TCHAR* argv[])
Результат компиляции должен выглядеть приблизительно так:
; Выделение 0x20 байт для локальных переменных
; Кадр стека указывает на дно стека
Название смещения unk_451110
говорит о том, что значение по адресу 451110
имеет неопределенный тип.
Перейдем по нему и посмотрим, что там находится.
Так как число 0x777
не умещается в одном байте, компилятор разместил его в двух байтах. Следовательно, в RAX
помещается ссылка на это число.
Хотя IDA представила данные программы так, как их приготовил компилятор, мы уже самостоятельно определили, что по смещению unk_451110
находится число, занимающее больше одного байта. Поэтому мы можем помочь IDA правильно отобразить данные. Для этого, перейдя по смещению, надо нажать клавишу с английской o, что соответствует команде: Edit → Operand Type → Offset → Offset (data segment). В результате смещение будет переименовано, а значение, на которое оно указывает, примет благородный вид: 777h
. Кроме того, команда преобразования неопределенных байтов в данные с db
(один байт) изменится на dq
(восемь байт).
Инициализация локальных переменных:
В RAX
расположена ссылка на значение, она копируется в переменную var_18
. Поскольку значение по ссылке unk_451110
находится в сегменте данных, можно сделать вывод, что var_18
— статическая переменная.
Копируем ссылку на переменную в памяти и таким образом получаем возможность изменить ссылку, но не значение.
Загружаем содержимое локальной переменной var_18
в регистр ECX
. Отсюда можно сделать вывод, что в RAX
все‑таки указатель. Тогда локальная переменная var_18
тоже указатель!
Присваиваем локальной переменной var_1C
значение, содержащееся в ECX
. А там хранится указатель на 0x777
.
Черт ногу сломит с этими указателями! Теперь рассмотрим пример func_pointers_cb
с косвенным вызовом функции (также скомпилированный с помощью C++Builder):
int _tmain(int argc, _TCHAR* argv[])
int (*zzz) (int a, int b) = func;
// Вызов функции происходит косвенно — по указателю zzz
Результат компиляции должен выглядеть приблизительно так:
; Выделяем 0x40 под локальные переменные
; В EAX заносим значение 0x666, пока непонятно для чего, но явно не для передачи
; В R8D заносим значение 0x777
; Смотри! В R9 заносим указатель на функцию
; Инициализируем локальные переменные
; В var_18 помещаем указатель на функцию func
; а EDX — 0x777, регистры загружены и готовы для передачи параметров
; Погляди-ка! Косвенный вызов функции!
А вот и косвенно вызываемая функция func
. Исследуем ее, чтобы определить тип передаваемых ей непосредственных значений.
IDA не определила аргументы, но мы‑то знаем, что они есть! Сейчас, когда параметры всегда передаются через регистры, различие между аргументами и локальными переменными — чистая формальность.
; Выделяем память для содержимого стека
; Кадр стека указывает на дно стека
Присваиваем значение переменной var_4
, учитывая, что в регистре ECX
передается параметр, var_4
— аргумент:
Присваиваем значение переменной var_8
, учитывая, что в регистре EDX
передается параметр, var_8
— аргумент:
; В ECX размещаем первое слагаемое
; Выполняем сложение с переменной, записывая сумму на место первого слагаемого
; Значение суммы копируем в переменную var_C
; В качестве результата возвращаем сумму
Сложные случаи адресации или математические операции с указателями
C/C++ и некоторые другие языки программирования допускают выполнение над указателями различных арифметических операций, чем серьезно затрудняют идентификацию типов непосредственных операндов. В самом деле, если бы такие операции с указателями были запрещены, то любая математическая инструкция, манипулирующая с непосредственным операндом, однозначно указывала бы на его константный тип.
К счастью, даже в тех языках, где это разрешено, над указателями выполняется ограниченное число математических операций. Так, совершенно бессмысленно сложение двух указателей, а уж тем более умножение или деление их друг на друга. Вычитание — дело другое. Используя тот факт, что компилятор располагает функции в памяти согласно порядку их объявления в программе, можно вычислить размер функции, отнимая ее указатель от указателя на следующую функцию. Такой трюк встречается в упаковщиках (распаковщиках) исполняемых файлов, защитах с самомодифицирующимся кодом, но в прикладных программах используется редко.
Сказанное выше относилось к случаям «указатель + указатель». Между тем указатель может сочетаться и с константой. Причем такое сочетание настолько популярно, что процессоры серии 80x86 даже поддерживают для этого специальную адресацию — базовую. Пусть, к примеру, имеется указатель на массив и индекс некоторого элемента массива. Очевидно: чтобы получить значение этого элемента, необходимо сложить указатель с индексом, умноженным на размер элемента.
Вычитание константы из указателя встречается гораздо реже: этому соответствует меньший круг задач, и сами программисты избегают вычитания, поскольку оно нередко приводит к серьезным проблемам. Среди новичков популярен следующий прием: если им требуется массив, начинающийся с единицы, они, объявив обычный массив, получают на него указатель и... уменьшают его на единицу! Элегантно, не правда ли?
Но подумай, что произойдет, если указатель на массив будет равен нулю. Правильно, змея укусит свой хвост — указатель станет очень большим положительным числом. Вообще‑то под Windows NT массив гарантированно не может быть размещен по нулевому смещению, но не стоит привыкать к трюкам, привязанным к одной платформе и не работающим на других.
«Нормальные» языки программирования запрещают смешение типов, и правильно делают. Существует и еще одна фундаментальная проблема дизассемблирования — определение типов в комбинированных выражениях. Рассмотрим следующий пример:
Сумма двух непосредственных значений здесь используется для косвенной адресации. Ну, положим, оба они указателями быть не могут, исходя из самых общих соображений. Наверняка одно из непосредственных значений — указатель на массив (структуру данных, объект), а другое — индекс в этом массиве. Для сохранения работоспособности программы указатель необходимо заменить смещением метки, а вот индекс придется оставить без изменений (ведь индекс — это константа).
Как же различить, что есть что? Увы, нет универсального ответа, а в контексте приведенного выше примера это и вовсе невозможно!
Рассмотрим следующий пример, демонстрирующий определение типов в комбинированных выражениях (combined_exp_types
):
static char buff[] = "Hello,Sailor!";
Результат компиляции с помощью Microsoft Visual C++ должен выглядеть так:
; Выделение памяти для локальных переменных
; Определяем смещение — указатель на элемент в массиве
; В регистр RCX загружаем указатель на строку
lea rcx, buff ; "Hello, Sailor!"
; Указатель на строку копируется в RAX
; в регистр EDX помещается число 5 — второй параметр
; указатель на строку вновь копируется в RCX — первый параметр
; Параметры укомплектованы — вызываем функцию
Мы можем с полной уверенностью сказать, где какой параметр, только в наших искусственных примерах. При изучении чужих программок такой уверенности, к сожалению, не будет. Поэтому, рассматривая параметры в функции ниже, мы, по идее, должны видеть их как два числовых аргумента. И наша задача разобраться, представляют ли они константы или указатели.
void MyFunc(char *, int) proc near
; Перенос значений параметров из регистров в память
mov [rsp+arg_8], edx ; Число 5
mov [rsp+arg_0], rcx ; Указатель на строку
; Копирование двойного слова со знаком в четверное
; Копирование четверного слова в четверное
Сумма непосредственных значений используется для косвенной адресации памяти, значит, это константа и указатель. Но кто есть кто?
Для ответа на этот вопрос нам необходимо понять смысл кода программы — чего же добивался программист сложением указателей? Предположим, что значение 5 — указатель. Логично? Да вот не очень‑то логично: если это указатель, то указатель на что?
Первые 64 килобайта адресного пространства Windows NT заблокированы для «отлавливания» нулевых и неинициализированных указателей. Ясно, что равным пяти указатель быть никак не может, разве что программист использовал какой‑нибудь очень извращенный трюк. А если указатель 0x140003038
(фактический адрес buff
)? Выглядит правдоподобным легальным смещением...
Кстати, что там у нас расположено? Секундочку...
.data:0000000140003038 buff db 'Hello, Sailor!',0
Теперь все сходится — функции передан указатель на строку "Hello, Sailor!" (значение 0x140003038
) и индекс символа этой строки (значение 5). Функция сложила указатель со строкой и записала в полученную ячейку символ "\n".
Следующая инструкция заносит значение аргумента arg_8
в регистр EAX
. Как мы установили, это константа:
; Инкрементируем значение в EAX на 1
; Преобразуем двойное слово (значение в EAX) в четверное слово (значение в RAX)
; Помещаем в RCX значение аргумента arg_0
; Как мы выяснили, оно представляет собой указатель на строку
Сумма RCX
и RAX
используется для косвенной адресации памяти, точнее косвенно‑базовой, так как к указателю прибавляется еще и единица. В эту ячейку памяти заносится ноль. Другими словами, мы прописываем ноль за символом "\n".
Наши предположения подтвердились — функции передаются указатель на строку и индекс первого «отсекаемого» символа строки. А теперь скомпилируем тот же самый пример компилятором Embarcadero C++Builder и сравним, чем он отличается от Microsoft Visual C++:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; DATA XREF: __acrtused+29↑o
; Выделение 0х30 байт памяти для локальных переменных
; Копирование в RBP указателя на дно стека, ибо после вычитания получилась вершина,
lea rax, aHelloSailor ; "Hello, Sailor!"
; Инициализация локальных переменных...
; ...перебрасываем содержимое регистров в память
; Готовим параметры для передачи:
; в RCX копируем указатель на строку...
; Вызов функции вместе с передачей параметров
; Обнуляем EAX для возвращения нуля
; __int64 __fastcall MyFunc(char *, int)
MyFunc(char *, int) proc near ; CODE XREF: main+2B↓p
; Принятые параметры размещаем в локальных переменных
; Первый параметр возвращаем в RCX
; Копирование двойного слова со знаком в четверное
Сумма непосредственных значений используется для косвенной адресации памяти. Этот прием мы уже проходили, разбирая дизассемблерный листинг от Visual C++. Однако вопрос все тот же: как понять, где константа, а где указатель? Как и в предыдущем случае, необходимо проанализировать их значения.
; Копирование значений из локальных переменных в регистры
; Увеличение var_C на 1, а Visual C++ в этом месте использовал инструкцию inc
; Копирование двойного слова со знаком в четверное
; Снова косвенная адресация памяти, чтобы поставить после символа новой строки "\n" 0
По сравнению с листингом от Visual C++ листинг от C++Builder имеет минимальные различия. Раньше было не так... Даже не знаю, радоваться этому или огорчаться.
Порядок индексов и указателей
Открою маленький секрет: при сложении указателя с константой большинство компиляторов на первое место помещают указатель, а на второе — константу, каким бы ни было их расположение в исходной программе. Иначе говоря, выражения a[i]
, (a+i)[0]
, *(a+i)
и *(i+a)
компилируются в один и тот же код! Даже если извратиться и написать так: (0)[i+a]
, компилятор все равно выдвинет a
на первое место. Что это — ослиное упрямство, игра случая или фича? Ответ до смешного прост — сложение указателя с константой дает указатель! Поэтому результат вычислений всегда записывается в переменную типа «указатель».
Вернемся к последнему рассмотренному примеру (combined_exp_types_cb
), применив для анализа наше новое правило:
; Копирование значений из локальных переменных в регистры
mov rax, [rbp+var_8] ; В RAX теперь указатель на строку
; Увеличение var_C на 1, а там (следовательно, теперь в регистре) значение 5
; Копирование двойного слова со знаком в четверное
movsxd rcx, edx ; теперь значение 6 в RCX
Сложение RAX и RCX. Операция сложения указывает на то, что по крайней мере один из них константа, а другой — либо константа, либо указатель.
Ага! Сумма непосредственных значений используется для косвенной адресации памяти, значит, это константа и указатель. Но кто из них кто? С большой степенью вероятности RAX
— указатель (так оно и есть), поскольку он стоит на первом месте, а RCX
— индекс, так как он стоит на втором!
Использование LEA для сложения констант
Инструкция LEA
широко используется компиляторами не только для инициализации указателей, но и для сложения констант. Поскольку внутренне представление констант и указателей идентично, результат сложения двух указателей идентичен сумме тождественных им констант. То есть LEA EBX, [EBX+0x666] == ADD EBX, 0x666
, однако по своим функциональным возможностям LEA
значительно обгоняет ADD
. Вот, например, LEA ESI, [EAX*4+EBP-0x20]
, попробуй то же самое «скормить» инструкции ADD
!
Встретив в тексте программы команду LEA
, не торопись навешивать на возвращенное ею значение ярлык «указатель»: с не меньшим успехом он может оказаться и константой! Если «подозреваемый» ни разу не используется в выражении косвенной адресации — никакой это не указатель, а самая настоящая константа!
«Визуальная» идентификация констант и указателей
Вот несколько приемов, помогающих отличить указатели от констант.
- В 64-разрядных Windows-программах указатели могут принимать ограниченный диапазон значений. Доступный процессорам регион адресного пространства начинается со смещения
0x00000000 00010000
и простирается до смещения0x000003FF FFFFFFFF
. Поэтому все непосредственные значения, меньшие0x00000000 00010000
и большие0x000003FF FFFFFFFF
, представляют собой константы, а не указатели. Исключение составляет число ноль, обозначающее нулевой указатель. Некоторые защитные механизмы непосредственно обращаются к коду операционной системы, расположенному выше адреса0x000003FF FFFFFFFF
, где начинаются владения ядра.- Если непосредственное значение смахивает на указатель, посмотри, на что он указывает. Если по данному смещению находится пролог функции или осмысленная текстовая строка, скорее всего, мы имеем дело с указателем, хотя, может быть, это всего лишь совпадение.
- Загляни в таблицу перемещаемых элементов. Если адрес «подследственного» непосредственного значения есть в таблице, это, несомненно, указатель. Беда в том, что большинство исполняемых файлов неперемещаемы и такой прием актуален лишь для исследования DLL (а DLL перемещаемы по определению).
К слову сказать, дизассемблер IDA Pro использует все три описанных способа для автоматического опознавания указателей.
ЗАКЛЮЧЕНИЕ
Как мы увидели выше, правильное определение констант и смещений зависит от многих фундаментальных факторов: от операционной системы, ее разрядности, даже от языка ассемблера, в котором отражается процессорная архитектура! Эти понятия составляют основу любой программы. А рассмотренные в статье примеры показали важность их правильной идентификации.