May 27, 2022

Хакер - Фундаментальные основы хакерства. Ищем операнды при взломе программ

https://t.me/anon_chan_by

Крис Касперски, Юрий Язев

Содержание статьи

  • Идентификация констант и смещений
  • Определение типа непосредственного операнда
  • Сложные случаи адресации или математические операции с указателями
  • Порядок индексов и указателей
  • Использование 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[])

{

static int a = 0x777;

int* b = &a;

int c = b[0];

}

Ре­зуль­тат ком­пиляции дол­жен выг­лядеть приб­лизитель­но так:

main proc near

var_1C = dword ptr -1Ch

var_18 = qword ptr -18h

var_10 = qword ptr -10h

var_8 = dword ptr -8

var_4 = dword ptr -4

; Открытие кадра стека

push rbp

; Выделение 0x20 байт для локальных переменных

sub rsp, 20h

; Кадр стека указывает на дно стека

lea rbp, [rsp+20h]

Наз­вание сме­щения unk_451110 говорит о том, что зна­чение по адре­су 451110 име­ет неоп­ределен­ный тип.

Пе­рей­дем по нему и пос­мотрим, что там находит­ся.

Так как чис­ло 0x777 не уме­щает­ся в одном бай­те, ком­пилятор раз­местил его в двух бай­тах. Сле­дова­тель­но, в RAX помеща­ется ссыл­ка на это чис­ло.

lea rax, unk_451110

Хо­тя IDA пред­ста­вила дан­ные прог­раммы так, как их при­гото­вил ком­пилятор, мы уже самос­тоятель­но опре­дели­ли, что по сме­щению unk_451110 находит­ся чис­ло, занима­ющее боль­ше одно­го бай­та. Поэто­му мы можем помочь IDA пра­виль­но отоб­разить дан­ные. Для это­го, перей­дя по сме­щению, надо нажать кла­вишу с англий­ской o, что соот­ветс­тву­ет коман­де: Edit → Operand Type → Offset → Offset (data segment). В резуль­тате сме­щение будет пере­име­нова­но, а зна­чение, на которое оно ука­зыва­ет, при­мет бла­город­ный вид: 777h. Кро­ме того, коман­да пре­обра­зова­ния неоп­ределен­ных бай­тов в дан­ные с db (один байт) изме­нит­ся на dq (восемь байт).

Ини­циали­зация локаль­ных перемен­ных:

mov [rbp+var_4], 0

mov [rbp+var_8], ecx

mov [rbp+var_10], rdx

В RAX рас­положе­на ссыл­ка на зна­чение, она копиру­ется в перемен­ную var_18. Пос­коль­ку зна­чение по ссыл­ке unk_451110 находит­ся в сег­менте дан­ных, мож­но сде­лать вывод, что var_18 — ста­тичес­кая перемен­ная.

mov [rbp+var_18], rax

Ко­пиру­ем ссыл­ку на перемен­ную в памяти и таким обра­зом получа­ем воз­можность изме­нить ссыл­ку, но не зна­чение.

mov rax, [rbp+var_18]

Заг­ружа­ем содер­жимое локаль­ной перемен­ной var_18 в регистр ECX. Отсю­да мож­но сде­лать вывод, что в RAX все‑таки ука­затель. Тог­да локаль­ная перемен­ная var_18 тоже ука­затель!

mov ecx, [rax]

Прис­ваиваем локаль­ной перемен­ной var_1C зна­чение, содер­жаще­еся в ECX. А там хра­нит­ся ука­затель на 0x777.

mov [rbp+var_1C], ecx

mov [rbp+var_4], 0

; Функция возвращает ноль

mov eax, [rbp+var_4]

; Очищаем стек

add rsp, 20h

; Закрываем кадр стека

pop rbp

retn

main endp

Черт ногу сло­мит с эти­ми ука­зате­лями! Теперь рас­смот­рим при­мер func_pointers_cb с кос­венным вызовом фун­кции (так­же ском­пилиро­ван­ный с помощью C++Builder):

int func(int a, int b)

{

return a + b;

}

int _tmain(int argc, _TCHAR* argv[])

{

int (*zzz) (int a, int b) = func;

// Вызов функции происходит косвенно — по указателю zzz

zzz(0x666, 0x777);

return 0;

}

Ре­зуль­тат ком­пиляции дол­жен выг­лядеть приб­лизитель­но так:

main proc near

var_1C = dword ptr -1Ch

var_18 = qword ptr -18h

var_10 = qword ptr -10h

var_8 = dword ptr -8

var_4 = dword ptr -4

; Открываем кадр стека

push rbp

; Выделяем 0x40 под локальные переменные

sub rsp, 40h

; Указатель кадра стека

lea rbp, [rsp+40h]

; В EAX заносим значение 0x666, пока непонятно для чего, но явно не для передачи

mov eax, 666h

; В R8D заносим значение 0x777

mov r8d, 777h

; Смотри! В R9 заносим указатель на функцию

lea r9, func(int,int)

; Инициализируем локальные переменные

mov [rbp+var_4], 0

mov [rbp+var_8], ecx

mov [rbp+var_10], rdx

; В var_18 помещаем указатель на функцию func

mov [rbp+var_18], r9

; Теперь ECX равна 0x666

mov ecx, eax

; а EDX — 0x777, регистры загружены и готовы для передачи параметров

mov edx, r8d

; Погляди-ка! Косвенный вызов функции!

call [rbp+var_18]

mov [rbp+var_4], 0

mov [rbp+var_1C], eax

mov eax, [rbp+var_4]

; Очищаем стек

add rsp, 40h

; Восстанавливаем регистр

pop rbp

retn

main endp

А вот и кос­венно вызыва­емая фун­кция func. Иссле­дуем ее, что­бы опре­делить тип переда­ваемых ей непос­редс­твен­ных зна­чений.

func(int, int) proc near

var_C = dword ptr -0Ch

var_8 = dword ptr -8

var_4 = dword ptr -4

IDA не опре­дели­ла аргу­мен­ты, но мы‑то зна­ем, что они есть! Сей­час, ког­да парамет­ры всег­да переда­ются через регис­тры, раз­личие меж­ду аргу­мен­тами и локаль­ными перемен­ными — чис­тая фор­маль­ность.

; Открываем кадр стека

push rbp

; Выделяем память для содержимого стека

sub rsp, 10h

; Кадр стека указывает на дно стека

lea rbp, [rsp+10h]

Прис­ваиваем зна­чение перемен­ной var_4, учи­тывая, что в регис­тре ECX переда­ется параметр, var_4 — аргу­мент:

mov [rbp+var_4], ecx

Прис­ваиваем зна­чение перемен­ной var_8, учи­тывая, что в регис­тре EDX переда­ется параметр, var_8 — аргу­мент:

mov [rbp+var_8], edx

; В ECX размещаем первое слагаемое

mov ecx, [rbp+var_4]

; Выполняем сложение с переменной, записывая сумму на место первого слагаемого

add ecx, [rbp+var_8]

; Значение суммы копируем в переменную var_C

mov [rbp+var_C], ecx

; В качестве результата возвращаем сумму

mov eax, [rbp+var_C]

; Удаляем содержимое стека

add rsp, 10h

; Закрываем кадр стека

pop rbp

retn

func(int, int) endp

Сложные случаи адресации или математические операции с указателями

C/C++ и некото­рые дру­гие язы­ки прог­рамми­рова­ния допус­кают выпол­нение над ука­зате­лями раз­личных ариф­метичес­ких опе­раций, чем серь­езно зат­рудня­ют иден­тифика­цию типов непос­редс­твен­ных опе­ран­дов. В самом деле, если бы такие опе­рации с ука­зате­лями были зап­рещены, то любая матема­тичес­кая инс­трук­ция, манипу­лиру­ющая с непос­редс­твен­ным опе­ран­дом, однознач­но ука­зыва­ла бы на его кон­стантный тип.

К счастью, даже в тех язы­ках, где это раз­решено, над ука­зате­лями выпол­няет­ся огра­ничен­ное чис­ло матема­тичес­ких опе­раций. Так, совер­шенно бес­смыс­ленно сло­жение двух ука­зате­лей, а уж тем более умно­жение или деление их друг на дру­га. Вычита­ние — дело дру­гое. Исполь­зуя тот факт, что ком­пилятор рас­полага­ет фун­кции в памяти сог­ласно поряд­ку их объ­явле­ния в прог­рамме, мож­но вычис­лить раз­мер фун­кции, отни­мая ее ука­затель от ука­зате­ля на сле­дующую фун­кцию. Такой трюк встре­чает­ся в упа­ков­щиках (рас­паков­щиках) исполня­емых фай­лов, защитах с самомо­дифи­циру­ющим­ся кодом, но в прик­ладных прог­раммах исполь­зует­ся ред­ко.

Ска­зан­ное выше отно­силось к слу­чаям «ука­затель + ука­затель». Меж­ду тем ука­затель может сочетать­ся и с кон­стан­той. При­чем такое сочета­ние нас­толь­ко популяр­но, что про­цес­соры серии 80x86 даже под­держи­вают для это­го спе­циаль­ную адре­сацию — ба­зовую. Пусть, к при­меру, име­ется ука­затель на мас­сив и индекс некото­рого эле­мен­та мас­сива. Оче­вид­но: что­бы получить зна­чение это­го эле­мен­та, необ­ходимо сло­жить ука­затель с индексом, умно­жен­ным на раз­мер эле­мен­та.

Вы­чита­ние кон­стан­ты из ука­зате­ля встре­чает­ся гораз­до реже: это­му соот­ветс­тву­ет мень­ший круг задач, и сами прог­раммис­ты избе­гают вычита­ния, пос­коль­ку оно неред­ко при­водит к серь­езным проб­лемам. Сре­ди нович­ков популя­рен сле­дующий при­ем: если им тре­бует­ся мас­сив, начина­ющий­ся с еди­ницы, они, объ­явив обыч­ный мас­сив, получа­ют на него ука­затель и... умень­шают его на еди­ницу! Эле­ган­тно, не прав­да ли?

Но подумай, что про­изой­дет, если ука­затель на мас­сив будет равен нулю. Пра­виль­но, змея уку­сит свой хвост — ука­затель ста­нет очень боль­шим положи­тель­ным чис­лом. Вооб­ще‑то под Windows NT мас­сив гаран­тирован­но не может быть раз­мещен по нулево­му сме­щению, но не сто­ит при­выкать к трю­кам, при­вязан­ным к одной плат­форме и не работа­ющим на дру­гих.

«Нор­маль­ные» язы­ки прог­рамми­рова­ния зап­реща­ют сме­шение типов, и пра­виль­но дела­ют. Сущес­тву­ет и еще одна фун­дамен­таль­ная проб­лема дизас­сем­бли­рова­ния — оп­ределе­ние типов в ком­биниро­ван­ных выраже­ниях. Рас­смот­рим сле­дующий при­мер:

MOV EAX, 0x...

MOV EBX, 0x...

ADD EAX, EBX

MOV ECX, [EAX]

Сум­ма двух непос­редс­твен­ных зна­чений здесь исполь­зует­ся для кос­венной адре­сации. Ну, положим, оба они ука­зате­лями быть не могут, исхо­дя из самых общих сооб­ражений. Навер­няка одно из непос­редс­твен­ных зна­чений — ука­затель на мас­сив (струк­туру дан­ных, объ­ект), а дру­гое — индекс в этом мас­сиве. Для сох­ранения работос­пособ­ности прог­раммы ука­затель необ­ходимо заменить сме­щени­ем мет­ки, а вот индекс при­дет­ся оста­вить без изме­нений (ведь индекс — это кон­стан­та).

Как же раз­личить, что есть что? Увы, нет уни­вер­саль­ного отве­та, а в кон­тек­сте при­веден­ного выше при­мера это и вов­се невоз­можно!

Рас­смот­рим сле­дующий при­мер, демонс­три­рующий опре­деле­ние типов в ком­биниро­ван­ных выраже­ниях (combined_exp_types):

void MyFunc(char* a, int i)

{

a[i] = '\n';

a[i + 1] = 0;

}

int main()

{

static char buff[] = "Hello,Sailor!";

MyFunc(&buff[0], 5);

}

Ре­зуль­тат ком­пиляции с помощью Microsoft Visual C++ дол­жен выг­лядеть так:

main proc near

; Выделение памяти для локальных переменных

sub rsp, 28h

; Определяем смещение — указатель на элемент в массиве

mov eax, 1

imul rax, 0

; В регистр RCX загружаем указатель на строку

lea rcx, buff ; "Hello, Sailor!"

add rcx, rax

; Указатель на строку копируется в RAX

mov rax, rcx

; Подготовка параметров:

; в регистр EDX помещается число 5 — второй параметр

mov edx, 5 ; i

; указатель на строку вновь копируется в RCX — первый параметр

mov rcx, rax ; a

; Параметры укомплектованы — вызываем функцию

call MyFunc(char *,int)

; Функция возвращает ноль

xor eax, eax

; Очистка стека

add rsp, 28h

retn

main endp

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

void MyFunc(char *, int) proc near

; Два параметра — все верно

arg_0 = qword ptr 8

arg_8 = dword ptr 10h

; Перенос значений параметров из регистров в память

mov [rsp+arg_8], edx ; Число 5

mov [rsp+arg_0], rcx ; Указатель на строку

; Копирование двойного слова со знаком в четверное

movsxd rax, [rsp+arg_8]

; Копирование четверного слова в четверное

mov rcx, [rsp+arg_0]

Сум­ма непос­редс­твен­ных зна­чений исполь­зует­ся для кос­венной адре­сации памяти, зна­чит, это кон­стан­та и ука­затель. Но кто есть кто?

mov byte ptr [rcx+rax], 0Ah

Для отве­та на этот воп­рос нам необ­ходимо понять смысл кода прог­раммы — чего же добивал­ся прог­раммист сло­жени­ем ука­зате­лей? Пред­положим, что зна­чение 5 — ука­затель. Логич­но? Да вот не очень‑то логич­но: если это ука­затель, то ука­затель на что?

Пер­вые 64 килобай­та адресно­го прос­транс­тва Windows NT заб­локиро­ваны для «отлавли­вания» нулевых и неини­циали­зиро­ван­ных ука­зате­лей. Ясно, что рав­ным пяти ука­затель быть никак не может, раз­ве что прог­раммист исполь­зовал какой‑нибудь очень извра­щен­ный трюк. А если ука­затель 0x140003038 (фак­тичес­кий адрес buff)? Выг­лядит прав­доподоб­ным легаль­ным сме­щени­ем...

Кста­ти, что там у нас рас­положе­но? Секун­дочку...

.data:0000000140003038 buff db 'Hello, Sailor!',0

Те­перь все схо­дит­ся — фун­кции передан ука­затель на стро­ку "Hello, Sailor!" (зна­чение 0x140003038) и индекс сим­вола этой стро­ки (зна­чение 5). Фун­кция сло­жила ука­затель со стро­кой и записа­ла в получен­ную ячей­ку сим­вол "\n".

Сле­дующая инс­трук­ция заносит зна­чение аргу­мен­та arg_8 в регистр EAX. Как мы уста­нови­ли, это кон­стан­та:

mov eax, [rsp+arg_8]

; Инкрементируем значение в EAX на 1

inc eax

; Преобразуем двойное слово (значение в EAX) в четверное слово (значение в RAX)

cdqe

; Помещаем в RCX значение аргумента arg_0

; Как мы выяснили, оно представляет собой указатель на строку

mov rcx, [rsp+arg_0]

Сум­ма RCX и RAX исполь­зует­ся для кос­венной адре­сации памяти, точ­нее кос­венно‑базовой, так как к ука­зате­лю при­бав­ляет­ся еще и еди­ница. В эту ячей­ку памяти заносит­ся ноль. Дру­гими сло­вами, мы про­писы­ваем ноль за сим­волом "\n".

mov byte ptr [rcx+rax], 0

retn

void MyFunc(char *, int) endp

На­ши пред­положе­ния под­твер­дились — фун­кции переда­ются ука­затель на стро­ку и индекс пер­вого «отсе­каемо­го» сим­вола стро­ки. А теперь ском­пилиру­ем тот же самый при­мер ком­пилято­ром Embarcadero C++Builder и срав­ним, чем он отли­чает­ся от Microsoft Visual C++:

; int __cdecl main(int argc, const char **argv, const char **envp)

public main

main proc near ; DATA XREF: __acrtused+29↑o

var_10 = qword ptr -10h

var_8 = dword ptr -8

var_4 = dword ptr -4

; Открытие кадра стека

push rbp

; Выделение 0х30 байт памяти для локальных переменных

sub rsp, 30h

; Копирование в RBP указателя на дно стека, ибо после вычитания получилась вершина,

; а после сложения — дно

lea rbp, [rsp+30h]

; В RAX — указатель на строку

lea rax, aHelloSailor ; "Hello, Sailor!"

; В R8D — значение 5

mov r8d, 5

; Инициализация локальных переменных...

mov [rbp+var_4], 0

; ...перебрасываем содержимое регистров в память

mov [rbp+var_8], ecx

mov [rbp+var_10], rdx

; Готовим параметры для передачи:

; в RCX копируем указатель на строку...

mov rcx, rax

; ...в EDX — значение 5

mov edx, r8d

; Вызов функции вместе с передачей параметров

call MyFunc(char *,int)

mov [rbp+var_4], 0

; Обнуляем EAX для возвращения нуля

mov eax, [rbp+var_4]

; Очищаем стек

add rsp, 30h

; Закрываем кадр стека

pop rbp

retn

main endp

; __int64 __fastcall MyFunc(char *, int)

public MyFunc(char *, int)

MyFunc(char *, int) proc near ; CODE XREF: main+2B↓p

var_C = dword ptr -0Ch

var_8 = qword ptr -8

; Открытие кадра стека

push rbp

; Выделение памяти

sub rsp, 10h

; Указатель кадра стека

lea rbp, [rsp+10h]

; Принятые параметры размещаем в локальных переменных

mov [rbp+var_8], rcx

mov [rbp+var_C], edx

; Первый параметр возвращаем в RCX

mov rcx, [rbp+var_8]

; Копирование двойного слова со знаком в четверное

movsxd rax, [rbp+var_C]

Сум­ма непос­редс­твен­ных зна­чений исполь­зует­ся для кос­венной адре­сации памяти. Этот при­ем мы уже про­ходи­ли, раз­бирая дизас­сем­блер­ный лис­тинг от Visual C++. Одна­ко воп­рос все тот же: как понять, где кон­стан­та, а где ука­затель? Как и в пре­дыду­щем слу­чае, необ­ходимо про­ана­лизи­ровать их зна­чения.

mov byte ptr [rcx+rax], 0Ah

; Копирование значений из локальных переменных в регистры

mov rax, [rbp+var_8]

mov edx, [rbp+var_C]

; Увеличение var_C на 1, а Visual C++ в этом месте использовал инструкцию inc

add edx, 1

; Копирование двойного слова со знаком в четверное

movsxd rcx, edx

; Снова косвенная адресация памяти, чтобы поставить после символа новой строки "\n" 0

mov byte ptr [rax+rcx], 0

; Восстановление стека

add rsp, 10h

; Закрытие кадра стека

pop rbp

retn

MyFunc(char *, int) endp

По срав­нению с лис­тингом от 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 теперь указатель на строку

mov edx, [rbp+var_C]

; Увеличение var_C на 1, а там (следовательно, теперь в регистре) значение 5

add edx, 1

; Копирование двойного слова со знаком в четверное

movsxd rcx, edx ; теперь значение 6 в RCX

Сло­жение RAX и RCX. Опе­рация сло­жения ука­зыва­ет на то, что по край­ней мере один из них кон­стан­та, а дру­гой — либо кон­стан­та, либо ука­затель.

mov byte ptr [rax+rcx], 0

Ага! Сум­ма непос­редс­твен­ных зна­чений исполь­зует­ся для кос­венной адре­сации памяти, зна­чит, это кон­стан­та и ука­затель. Но кто из них кто? С боль­шой сте­пенью веро­ятности RAX — ука­затель (так оно и есть), пос­коль­ку он сто­ит на пер­вом мес­те, а RCX — индекс, так как он сто­ит на вто­ром!

Использование LEA для сложения констант

Инс­трук­ция LEA широко исполь­зует­ся ком­пилято­рами не толь­ко для ини­циали­зации ука­зате­лей, но и для сло­жения кон­стант. Пос­коль­ку внут­ренне пред­став­ление кон­стант и ука­зате­лей иден­тично, резуль­тат сло­жения двух ука­зате­лей иден­тичен сум­ме тож­дес­твен­ных им кон­стант. То есть LEA EBX, [EBX+0x666] == ADD EBX, 0x666, одна­ко по сво­им фун­кци­ональ­ным воз­можнос­тям LEA зна­читель­но обго­няет ADD. Вот, нап­ример, LEA ESI, [EAX*4+EBP-0x20], поп­робуй то же самое «скор­мить» инс­трук­ции ADD!

Встре­тив в тек­сте прог­раммы коман­ду LEA, не торопись навеши­вать на воз­вра­щен­ное ею зна­чение ярлык «ука­затель»: с не мень­шим успе­хом он может ока­зать­ся и кон­стан­той! Если «подоз­рева­емый» ни разу не исполь­зует­ся в выраже­нии кос­венной адре­сации — никакой это не ука­затель, а самая нас­тоящая кон­стан­та!

«Визуальная» идентификация констант и указателей

Вот нес­коль­ко при­емов, помога­ющих отли­чить ука­зате­ли от кон­стант.

  1. В 64-раз­рядных Windows-прог­раммах ука­зате­ли могут при­нимать огра­ничен­ный диапа­зон зна­чений. Дос­тупный про­цес­сорам реги­он адресно­го прос­транс­тва начина­ется со сме­щения
  2. 0x00000000 00010000 и прос­тира­ется до сме­щения 0x000003FF FFFFFFFF. Поэто­му все непос­редс­твен­ные зна­чения, мень­шие 0x00000000 00010000 и боль­шие 0x000003FF FFFFFFFF, пред­став­ляют собой кон­стан­ты, а не ука­зате­ли. Исклю­чение сос­тавля­ет чис­ло ноль, обоз­нача­ющее нулевой ука­затель. Некото­рые защит­ные механиз­мы непос­редс­твен­но обра­щают­ся к коду опе­раци­онной сис­темы, рас­положен­ному выше адре­са 0x000003FF FFFFFFFF, где начина­ются вла­дения ядра.
  3. Ес­ли непос­редс­твен­ное зна­чение сма­хива­ет на ука­затель, пос­мотри, на что он ука­зыва­ет. Если по дан­ному сме­щению находит­ся про­лог фун­кции или осмыслен­ная тек­сто­вая стро­ка, ско­рее все­го, мы име­ем дело с ука­зате­лем, хотя, может быть, это все­го лишь сов­падение.
  4. Заг­ляни в таб­лицу переме­щаемых эле­мен­тов. Если адрес «под­следс­твен­ного» непос­редс­твен­ного зна­чения есть в таб­лице, это, несом­ненно, ука­затель. Беда в том, что боль­шинс­тво исполня­емых фай­лов непере­меща­емы и такой при­ем акту­ален лишь для иссле­дова­ния DLL (а DLL переме­щаемы по опре­деле­нию).

К сло­ву ска­зать, дизас­сем­блер IDA Pro исполь­зует все три опи­сан­ных спо­соба для авто­мати­чес­кого опоз­навания ука­зате­лей.

ЗАКЛЮЧЕНИЕ

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

https://t.me/anon_chan_by