Содержание статьи
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
Традиционно под возвращаемым функцией значением понимается то, что передает оператор return
. Однако это лишь надводная часть айсберга, не раскрывающая всей картины взаимодействия функций друг с другом. В качестве наглядной демонстрации рассмотрим типичный пример, в котором происходит возвращение значения в аргументе, переданном по ссылке:
int xdiv(int a, int b, int *c=0) { if (!b) return -1; if (c) c[0] = a % b; return a / b;}
Функция xdiv
возвращает результат целочисленного деления аргумента а
на аргумент b
, но, помимо этого, она записывает остаток в переменную с
, переданную по ссылке. Так сколько же значений вернула функция? И чем возвращение результата по ссылке хуже или «незаконнее» классического return
?
Популярные издания склонны упрощать проблему идентификации возвращенного функцией значения, рассматривая один лишь частный случай с оператором return
.
Мы же рассмотрим следующие механизмы:
- возврат значения оператором
return
; - возврат значений через аргументы, переданные по ссылке;
- возврат значений через динамическую память (кучу);
- возврат значений через глобальные переменные;
- возврат значений через флаги процессора.
Вообще‑то к этому списку не помешало бы добавить возврат значений через дисковые и проецируемые в память файлы, но это выходит за рамки обсуждаемой темы (хотя, рассматривая функцию как «черный ящик» с входом и выходом, нельзя не признать, что вывод функцией результатов своей работы в файл фактически и есть возвращаемое ею значение).
Оператор return
По общепринятому соглашению на платформе x86-64 значение, возвращаемое оператором return
, помещается в регистр RAX
(в EAX
в 32-разрядном режиме). Вещественные типы (float
, double
) — в регистр XMM0
.
А как возвращаются типы, занимающие более 8 байт? Скажем, некая функция возвращает структуру, состоящую из сотен байтов, или объект сопоставимого размера. Ни то ни другое в регистры не запихнешь!
Оказывается, если возвращаемое значение не может быть втиснуто в регистры, компилятор скрыто от программиста передает функции неявный аргумент — ссылку на локальную переменную, в которую и записывается возвращенный результат. Таким образом, функции mystuct
и void
компилируются в идентичный (или близкий к тому) код, из‑за чего «вытянуть» из машинного кода подлинный прототип невозможно!
Давай проверим наши предположения. Откомпилируй следующий код с отключенной оптимизацией в Visual C++:
#include <iostream>// Структура включает три переменные типа doublestruct mystruct { double d_var1; double d_var2; double d_var3;};mystruct MyFunc1(double a, double b, double c) { mystruct my; my.d_var1 = a; my.d_var2 = b; my.d_var3 = c; return my;}void MyFunc2(struct mystruct* my, double a, double b, double c) { my->d_var1 = a; my->d_var2 = b; my->d_var3 = c;}int main() { mystruct my; my = MyFunc1(1.001, 2.002, 3.003); std::cout << my.d_var1 << " " << my.d_var2 << " " << my.d_var3 << std::endl; MyFunc2(&my, 3.004, 5.005, 6.006); std::cout << my.d_var1 << " " << my.d_var2 << " " << my.d_var3 << std::endl;}
Теперь открой экзешник в IDA и откажись от загрузки отладочной информации, ведь при отладке в боевых условиях никакой дебажной инфы не будет.
На следующей иллюстрации я привел обе функции для сравнения в одном окне VS Code.
Хотя по объему функции различаются, выполняемые ими действия схожи. И без отладочной информации понять их различия непросто. Между тем, если приглядеться, можно обнаружить явные намеки, в какой функции происходит работа над ссылочным типом, а где выполняется возврат по значению. Смотри:
sub_140001000 proc near ; CODE XREF: main+33↓pvar_38 = qword ptr -38hvar_30 = qword ptr -30hvar_28 = qword ptr -28harg_0 = qword ptr 8arg_8 = qword ptr 10harg_10 = qword ptr 18harg_18 = qword ptr 20h; Инициализация переменных-аргументов movsd [rsp+arg_18], xmm3 movsd [rsp+arg_10], xmm2 movsd [rsp+arg_8], xmm1; В RCX первый аргумент целочисленный указатель на структуру mov [rsp+arg_0], rcx; Открываем кадр стека push rsi push rdi sub rsp, 28h; Заполнение полей структуры movsd xmm0, [rsp+38h+arg_8] movsd [rsp+38h+var_38], xmm0 movsd xmm0, [rsp+38h+arg_10] movsd [rsp+38h+var_30], xmm0 movsd xmm0, [rsp+38h+arg_18] movsd [rsp+38h+var_28], xmm0 lea rax, [rsp+38h+var_38] mov rdi, [rsp+38h+arg_0] mov rsi, rax mov ecx, 18h; Побайтно копируем структуру rep movsb; В RAX возвращаем указатель на структуру mov rax, [rsp+38h+arg_0]; Закрываем кадр стека add rsp, 28h pop rdi pop rsi retnsub_140001000 endp
В начале функции значения копируются из регистров в память. Видно, что первый аргумент целочисленный. Можно предположить, что это указатель на структуру. Остальные аргументы передаются в регистрах XMM*
, следовательно, представляют числа с плавающей запятой. После открытия кадра стека значения размещаются в смежных областях памяти, это может указывать, что они принадлежат общему контейнеру (структуре, массиву).
Далее копируется 24 байта (0x18
) или 192 бита (0xC0
). Если это число разделить на три, получим 64 бита (0x40
). Что и требовалось доказать: один элемент double
— это 64 бита, а в структуре их три. Предпоследним действием помещаем указатель на структуру в RAX
, в который будет возвращен результат. Последним действием закрываем кадр стека. В итоге напрашивается вывод, что здесь происходит возврат значения. Следовательно, прототип функции выглядит так:
mystruct Func(double a, double b, double c)
Разберем дизассемблерный листинг второй функции.
sub_140001060 proc near ; CODE XREF: main+DD↓parg_0 = qword ptr 8arg_8 = qword ptr 10harg_10 = qword ptr 18harg_18 = qword ptr 20h; Инициализация переменных-аргументов movsd [rsp+arg_18], xmm3 movsd [rsp+arg_10], xmm2 movsd [rsp+arg_8], xmm1 mov [rsp+arg_0], rcx; Модификация полей структуры mov rax, [rsp+arg_0] movsd xmm0, [rsp+arg_8] movsd qword ptr [rax], xmm0 mov rax, [rsp+arg_0] movsd xmm0, [rsp+arg_10] movsd qword ptr [rax+8], xmm0 mov rax, [rsp+arg_0] movsd xmm0, [rsp+arg_18] movsd qword ptr [rax+10h], xmm0 retnsub_140001060 endp
Начало такое же, как в прошлый раз. Но в этой функции не открывается кадр стека, что может указывать на работу с существующей структурой. Далее одно за другим выполняется замещение значений полей структуры. В конце функция ничего не возвращает. Отсюда можно вывести такой прототип:
void Func(struct mystruct* my, double a, double b, double c)
Нам удалось определить различия функций только после их глубокого анализа. Теперь взглянем на самодеятельность компилятора C++Builder.
В этом случае при беглом взгляде разница совсем незаметна. В обеих функциях присутствует кадр стека. Между тем и здесь есть зацепка: после открытия кадра стека только в MyFunc1
содержимое регистра RCX
копируется в RAX
. А в первом по логике вещей находится целочисленное значение или указатель. Также в MyFunc1
перед закрытием кадра стека указатель на область памяти RBP+var_20
копируется в регистр RAX
явно для возврата результата, следовательно, эта функция имеет такой прототип:
mystruct MyFunc1(double a, double b, double c)
Определение типа значения
Тип возвращаемого значения можно приблизительно определить по размеру регистра, в котором он возвращается. Если возвращаемое значение помещается в EAX
, можно предположить, что возвращается int
, float
или другой четырехбайтовый тип. Не исключен вариант возврата меньшего типа, например char
. Также для возврата однобайтовых типов может быть использован регистр AL
или AX
. В последнем может быть возвращено двухбайтовое значение. Восьмибайтовые типы возвращаются в регистре RAX
, при этом никто не запрещает компилятору вернуть в нем значение более мелкого типа, тут уж как повезет. На 64-битной платформе это основное средство возврата значения из функции.
Если функция при выходе явно присваивает одному из перечисленных выше регистров некоторое значение, значит, оно возвращается в вызывающую функцию. Если же эти регистры остаются неопределенными, то, скорее всего, возвращается тип void
, то есть ничто. Уточнить информацию помогает анализ вызывающей функции, а точнее, то, как она обращается с регистрами RAX [
. Логично предположить: если вызывающая функция не использует значения, оставленного вызываемой функцией в регистрах RAX [
, ее тип — void
. Но это предположение не всегда верно. Частенько программисты игнорируют возвращаемое значение, вводя исследователей в заблуждение.
Для закрепления изученного рассмотрим следующий пример, демонстрирующий механизм возвращения основных типов значений:
#include <stdio.h>// Демонстрация возвращения переменной типа char оператором returnchar char_func(char a, char b) { return a + b;}// Демонстрация возвращения переменной типа int оператором returnint int_func(int a, int b) { return a + b;}// Демонстрация возвращения переменной типа int64 оператором return__int64 int64_func(__int64 a, __int64 b) { return a + b;}// Демонстрация возвращения указателя на int оператором return// Демонстрация возвращения значения через аргументы, переданные по ссылкеint* near_func(int* a, int* b) { int* c = new int; c[0] = a[0] + b[0]; return c;}int main() { int a; int b; a = 0x666; b = 0x777; printf("%I64x\n", char_func(0x1, 0x2) + int_func(0x3, 0x4) + int64_func(0x5, 0x6) + near_func(&a, &b)[0]);}
Результат его компиляции в Microsoft Visual C++ 2019 с отключенной оптимизацией будет выглядеть так:
char char_func(char, char) proc near; Два аргумента размером в байтarg_0 = byte ptr 8arg_8 = byte ptr 10h; Инициализация переменных значениями из регистров, переданных в параметрах mov [rsp+arg_8], dl mov [rsp+arg_0], cl
Из памяти оба значения копируются в 32-битные регистры. Вместе с тем они преобразуются из типа char
в int
.
movsx eax, [rsp+arg_0]movsx ecx, [rsp+arg_8]
Сложение значений, находящихся в регистрах. Сумма накапливается в регистре EAX
. В нем также выполняется возврат результата в вызвавшую функцию.
add eax, ecx
К сожалению, достоверно определить тип возвращаемого значения невозможно. Он с равным успехом может представлять собой int
и char
, причем int
даже более вероятен, так как сумма двух char
по соображениям безопасности должна помещаться в int
, иначе возможно переполнение.
retnchar char_func(char, char) endpint int_func(int, int) proc near; Два аргумента размером в 4 байтаarg_0 = dword ptr 8arg_8 = dword ptr 10h; Инициализация переменных значениями из регистров, переданных в параметрах mov [rsp+arg_8], edx mov [rsp+arg_0], ecx; Копирование значений из памяти в 32-битные регистры mov eax, [rsp+arg_8] mov ecx, [rsp+arg_0]; Сложение значений из регистров add ecx, eax
Копирование в регистр EAX
суммы, которая будет возвращена. По всей вероятности, тип возвращаемого значения — int
.
mov eax, ecx retnint int_func(int, int) endp__int64 int64_func(__int64, __int64) proc near; Два аргумента размером в четыре слова (8 байт)arg_0 = qword ptr 8arg_8 = qword ptr 10h; Загружаем в память значения из 64-битных регистров mov [rsp+arg_8], rdx mov [rsp+arg_0], rcx; Копируем значения из памяти в более удобные регистры mov rax, [rsp+arg_8] mov rcx, [rsp+arg_0]; Выполняем суммирование add rcx, rax; Переносим результат в регистр RAX, который и будет возвращен mov rax, rcx
Следовательно, возвращаемый тип имеет размер 64 бита, то есть int64
, что и требовалось доказать.
retn__int64 int64_func(__int64, __int64) endpint * near_func(int *, int *) proc near; Две переменные размером в четыре слова (8 байт)var_18 = qword ptr -18hvar_10 = qword ptr -10h; Два аргумента размером в четыре слова (8 байт)arg_0 = qword ptr 8arg_8 = qword ptr 10h; Загружаем в память значения из 64-битных регистров mov [rsp+arg_8], rdx mov [rsp+arg_0], rcx; Открываем кадр стека sub rsp, 38h mov ecx, 4 ; size; Выделяем 4 байта из кучи call operator new(unsigned __int64); Заносим указатель на выделенную память в переменную var_10 mov [rsp+38h+var_10], rax mov rax, [rsp+38h+var_10] mov [rsp+38h+var_18], rax
Следующий витиеватый код используется, чтобы определить смещение внутри массива. Стоп! Но у нас же нет никакого массива. А как же строка c[
? Получается аж три массива, ведь компилятор обрабатывает указатели как массивы и наоборот.
mov eax, 4 imul rax, 0 mov ecx, 4 imul rcx, 0; Готовим аргументы для сложения mov rdx, [rsp+38h+arg_0] mov eax, [rdx+rax] mov rdx, [rsp+38h+arg_8]; Суммируем add eax, [rdx+rcx]; Снова определяем смещение mov ecx, 4 imul rcx, 0 mov rdx, [rsp+38h+var_18] mov [rdx+rcx], eax; Помещаем в RAX возвращаемое значение mov rax, [rsp+38h+var_18]; Закрываем кадр стека add rsp, 38h retnint * near_func(int *, int *) endpmain proc nearvar_38 = dword ptr -38hvar_30 = qword ptr -30hvar_28 = qword ptr -28hb = dword ptr -20ha = dword ptr -1Chvar_18 = qword ptr -18h; __unwind { // __GSHandlerCheck; Открываем кадр стека sub rsp, 58h mov rax, cs:__security_cookie xor rax, rsp; Инициализируем переменные mov [rsp+58h+var_18], rax; В переменную a типа int заносим значение 0x666 mov [rsp+58h+a], 666h; В переменную b типа int заносим значение 0x777 mov [rsp+58h+b], 777h; В 8-битные регистры помещаем параметры типа char перед вызовом функции mov dl, 2 ; b mov cl, 1 ; a
Вызываем функцию char_func(
. Как мы помним, у нас были сомнения в типе возвращаемого ею значения: либо int
, либо char
.
call char_func(char,char)
Расширяем возвращенное функцией значение до signed
, следовательно, она возвратила signed
.
movsx eax, al; Сохраняем результат в переменной var_38 mov [rsp+58h+var_38], eax; На этот раз помещаем параметры в 32-битные регистры, следовательно, их тип — int mov edx, 4 ; b mov ecx, 3 ; a; Вызываем функцию int_func(3,4), возвращающую значение типа int call int_func(int,int) mov ecx, [rsp+58h+var_38]; Прибавляем результат к значению переменной var_38 add ecx, eax mov eax, ecx
Преобразуем двойное слово, содержащееся в регистре EAX
, в четверное, помещаемое в регистр RAX
. Это говорит о том, что тип возвращенного функцией значения преобразуется из int
в int64
. Пока непонятно, для чего и зачем.
cdqe; Копируем расширенное четверное слово в переменную var_30 mov [rsp+58h+var_30], rax; Готовим 32-битные параметры mov edx, 6 ; b mov ecx, 5 ; a
Вызываем функцию int64_func(
, возвращающую тип int64
. Теперь становится понятно, чем вызвано расширение предыдущего результата.
call int64_func(__int64,__int64) mov rcx, [rsp+58h+var_30]; Прибавляем результат, возвращенный функцией int64_func, к четверному слову add rcx, rax mov rax, rcx; Сохраняем результат предыдущего сложения в переменной var_28 mov [rsp+58h+var_28], rax
Для передачи в качестве параметров загружаем в регистры RDX
и RCX
указатели на переменные b
и a
, значения которых определены в начале функции.
lea rdx, [rsp+58h+b] ; b lea rcx, [rsp+58h+a] ; a; Вызываем near_func call near_func(int *,int *); Определяем смещение mov ecx, 4 imul rcx, 0; Расширяем двойное слово, на которое указывают два регистра [rax+rcx], в четверное слово movsxd rax, dword ptr [rax+rcx] mov rcx, [rsp+58h+var_28]; Складываем два четверных слова add rcx, rax mov rax, rcx mov rdx, rax; Передаем формат строки вывода lea rcx, _Format ; "%I64x\n"; И, наконец, выводим результат call printf xor eax, eax mov rcx, [rsp+58h+var_18] xor rcx, rsp ; StackCookie call __security_check_cookie add rsp, 58h retnmain endp
Как мы видим, в идентификации типа значения, возвращенного оператором return
, ничего хитрого нет, все прозаично. Но не будем спешить. Рассмотрим следующий пример, демонстрирующий возвращение структуры по значению. Как ты думаешь, что именно и в каких регистрах будет возвращаться?
#include <stdio.h>#include <string.h>struct XT { char s0[4]; int x;};// Функция возвращает значение типа "структура XT" по значениюstruct XT MyFunc(const char* a, int b) { struct XT xt; strcpy_s(&xt.s0[0], 4, a); xt.x = b; return xt;}int main() { struct XT xt; xt = MyFunc("Hello, Sailor!", 0x666); printf("%s %x\n", &xt.s0[0], xt.x);}
Внимание! Не запускай откомпилированную программу. Мало того что она содержит ошибку (в буфер вместимостью четыре символа помещается строка размером в 14 символов), так еще некоторые антивирусы считают такую программу малварью.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»