Се­год­ня мы рас­смот­рим все­воз­можные пути воз­вра­та резуль­тата из выз­ванной фун­кции в вызыва­ющую. Уме­ние опре­делять спо­собы воз­вра­щения зна­чения поз­воля­ет хакеру наибо­лее точ­но вос­ста­новить изна­чаль­ный алго­ритм взла­мыва­емой прог­раммы. А если вдо­бавок удас­тся опре­делить тип переда­ваемо­го зна­чения, то задача еще боль­ше упро­щает­ся.

Фундаментальные основы хакерства

Пят­надцать лет назад эпи­чес­кий труд Кри­са Кас­пер­ски «Фун­дамен­таль­ные осно­вы хакерс­тва» был нас­толь­ной кни­гой каж­дого начина­юще­го иссле­дова­теля в области компь­ютер­ной безопас­ности. Одна­ко вре­мя идет, и зна­ния, опуб­ликован­ные Кри­сом, теря­ют акту­аль­ность. Редак­торы «Хакера» попыта­лись обно­вить этот объ­емный труд и перенес­ти его из вре­мен 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, помеща­ется в регистр RAXEAX в 32-раз­рядном режиме). Вещес­твен­ные типы (float, double) — в регистр XMM0.

А как воз­вра­щают­ся типы, занима­ющие более 8 байт? Ска­жем, некая фун­кция воз­вра­щает струк­туру, сос­тоящую из сотен бай­тов, или объ­ект сопос­тавимо­го раз­мера. Ни то ни дру­гое в регис­тры не запих­нешь!

Ока­зыва­ется, если воз­вра­щаемое зна­чение не может быть втис­нуто в регис­тры, ком­пилятор скры­то от прог­раммис­та переда­ет фун­кции неяв­ный аргу­мент — ссыл­ку на локаль­ную перемен­ную, в которую и записы­вает­ся воз­вра­щен­ный резуль­тат. Таким обра­зом, фун­кции mystuct MyFunc(int a, int b) и void MyFunc(mystryct *my, int a, int b) ком­пилиру­ются в иден­тичный (или близ­кий к тому) код, из‑за чего «вытянуть» из машин­ного кода под­линный про­тотип невоз­можно!

Да­вай про­верим наши пред­положе­ния. Откомпи­лируй сле­дующий код с отклю­чен­ной опти­миза­цией в Visual C++:

#include <iostream>
// Структура включает три переменные типа double
struct 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
VS Code

Хо­тя по объ­ему фун­кции раз­лича­ются, выпол­няемые ими дей­ствия схо­жи. И без отла­доч­ной информа­ции понять их раз­личия неп­росто. Меж­ду тем, если приг­лядеть­ся, мож­но обна­ружить явные намеки, в какой фун­кции про­исхо­дит работа над ссы­лоч­ным типом, а где выпол­няет­ся воз­врат по зна­чению. Смот­ри:

sub_140001000 proc near ; CODE XREF: main+33↓p
var_38 = qword ptr -38h
var_30 = qword ptr -30h
var_28 = qword ptr -28h
arg_0 = qword ptr 8
arg_8 = qword ptr 10h
arg_10 = qword ptr 18h
arg_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
retn
sub_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↓p
arg_0 = qword ptr 8
arg_8 = qword ptr 10h
arg_10 = qword ptr 18h
arg_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
retn
sub_140001060 endp

На­чало такое же, как в прош­лый раз. Но в этой фун­кции не откры­вает­ся кадр сте­ка, что может ука­зывать на работу с сущес­тву­ющей струк­турой. Далее одно за дру­гим выпол­няет­ся замеще­ние зна­чений полей струк­туры. В кон­це фун­кция ничего не воз­вра­щает. Отсю­да мож­но вывес­ти такой про­тотип:

void Func(struct mystruct* my, double a, double b, double c)

Нам уда­лось опре­делить раз­личия фун­кций толь­ко пос­ле их глу­боко­го ана­лиза. Теперь взгля­нем на самоде­ятель­ность ком­пилято­ра C++Builder.

Сравнение функций MyFunc1 и MyFunc2, транслированных компилятором C++Builder 10.4
Срав­нение фун­кций 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 оператором return
char char_func(char a, char b) {
return a + b;
}
// Демонстрация возвращения переменной типа int оператором return
int 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 8
arg_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, ина­че воз­можно перепол­нение.

retn
char char_func(char, char) endp
int int_func(int, int) proc near
; Два аргумента размером в 4 байта
arg_0 = dword ptr 8
arg_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
retn
int int_func(int, int) endp
__int64 int64_func(__int64, __int64) proc near
; Два аргумента размером в четыре слова (8 байт)
arg_0 = qword ptr 8
arg_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) endp
int * near_func(int *, int *) proc near
; Две переменные размером в четыре слова (8 байт)
var_18 = qword ptr -18h
var_10 = qword ptr -10h
; Два аргумента размером в четыре слова (8 байт)
arg_0 = qword ptr 8
arg_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 *) endp
main proc near
var_38 = dword ptr -38h
var_30 = qword ptr -30h
var_28 = qword ptr -28h
b = dword ptr -20h
a = dword ptr -1Ch
var_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 ; "%I64x\n"
; И, наконец, выводим результат
call printf
xor eax, eax
mov rcx, [rsp+58h+var_18]
xor rcx, rsp ; StackCookie
call __security_check_cookie
add rsp, 58h
retn
main 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»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


Подписаться
Уведомить о
1 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии