Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Сегодня мы рассмотрим всевозможные пути возврата результата из вызванной функции в вызывающую. Умение определять способы возвращения значения позволяет хакеру наиболее точно восстановить изначальный алгоритм взламываемой программы. А если вдобавок удастся определить тип передаваемого значения, то задача еще больше упрощается.
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен 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
;
Вообще‑то к этому списку не помешало бы добавить возврат значений через дисковые и проецируемые в память файлы, но это выходит за рамки обсуждаемой темы (хотя, рассматривая функцию как «черный ящик» с входом и выходом, нельзя не признать, что вывод функцией результатов своей работы в файл фактически и есть возвращаемое ею значение).
По общепринятому соглашению на платформе x86-64 значение, возвращаемое оператором return
, помещается в регистр RAX
(в EAX
в 32-разрядном режиме). Вещественные типы (float
, double
) — в регистр XMM0
.
А как возвращаются типы, занимающие более 8 байт? Скажем, некая функция возвращает структуру, состоящую из сотен байтов, или объект сопоставимого размера. Ни то ни другое в регистры не запихнешь!
Оказывается, если возвращаемое значение не может быть втиснуто в регистры, компилятор скрыто от программиста передает функции неявный аргумент — ссылку на локальную переменную, в которую и записывается возвращенный результат. Таким образом, функции mystuct MyFunc(int a, int b)
и void MyFunc(mystryct *my, int a, int b)
компилируются в идентичный (или близкий к тому) код, из‑за чего «вытянуть» из машинного кода подлинный прототип невозможно!
Давай проверим наши предположения. Откомпилируй следующий код с отключенной оптимизацией в 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.
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 mov [rsp+arg_0], rcx ; В 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 ; Побайтно копируем структуру mov rax, [rsp+38h+arg_0] ; В RAX возвращаем указатель на структуру; Закрываем кадр стека 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 и MyFunc2, транслированных компилятором C++Builder 10.4
В этом случае при беглом взгляде разница совсем незаметна. В обеих функциях присутствует кадр стека. Между тем и здесь есть зацепка: после открытия кадра стека только в 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 [EAX]
. Логично предположить: если вызывающая функция не использует значения, оставленного вызываемой функцией в регистрах RAX [EAX]
, ее тип — 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("%I64xn", 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[0] = a[0] + b[0];
? Получается аж три массива, ведь компилятор обрабатывает указатели как массивы и наоборот.
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 retn int * 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(1,2)
. Как мы помним, у нас были сомнения в типе возвращаемого ею значения: либо int
, либо char
.
call char_func(char,char)
Расширяем возвращенное функцией значение до signed int
, следовательно, она возвратила signed char
.
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(5,6)
, возвращающую тип 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 ; "%I64xn"; И, наконец, выводим результат 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 %xn", &xt.s0[0], xt.x);}
Внимание! Не запускай откомпилированную программу. Мало того что она содержит ошибку (в буфер вместимостью четыре символа помещается строка размером в 14 символов), так еще некоторые антивирусы считают такую программу малварью.
Источник: xakep.ru