JustPaste.it

Хакер - Фундаментальные основы хакерства. Как идентифицировать структуры и объекты в чужой программе

hacker_frei
46041732a7b2be93b06931e6ee6980b3.png

 

https://t.me/hacker_frei

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

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

  • Идентификация структур
  • Идентификация объектов
  • Классы и объекты
  • Мой адрес — не дом и не улица!
  • Заключение

Ког­да раз­работ­чик пишет прог­рамму, он име­ет воз­можность исполь­зовать такие дос­тижения цивили­зации, как струк­туры и клас­сы. А вот ревер­серу это лишь осложня­ет жизнь: ему ведь необ­ходимо понимать, как ком­пилятор обра­баты­вает высоко­уров­невые сущ­ности и как с ними потом работа­ет про­цес­сор. О спо­собах нахож­дения в бинар­ном коде объ­ектов и струк­тур мы и погово­рим.

Пос­ле неболь­шой передыш­ки про­дол­жим сопос­тавлять дизас­сем­блер­ные лис­тинги для архи­тек­туры x86-64 и конс­трук­ции язы­ков высоко­го уров­ня (в наших при­мерах мы исполь­зуем C/C++). Этим мы занима­емся (если ты по какой‑то нелепой при­чине не читал прош­лые номера нашего жур­нала), что­бы точ­нее понять прин­цип работы прог­рамм, под­вер­гну­тых дизас­сем­бли­рова­нию, и осво­ить некото­рые инте­рес­ные при­емы реверс‑инжи­нирин­га.

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

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

Пят­надцать лет назад эпи­чес­кий труд Кри­са Кас­пер­ски «Фун­дамен­таль­ные осно­вы хакерс­тва» был нас­толь­ной кни­гой каж­дого начина­юще­го иссле­дова­теля в области компь­ютер­ной безопас­ности. Одна­ко вре­мя идет, и зна­ния, опуб­ликован­ные Кри­сом, теря­ют акту­аль­ность. Редак­торы «Хакера» попыта­лись обно­вить этот объ­емный труд и перенес­ти его из вре­мен Windows 2000 и Visual Studio 6.0 во вре­мена Windows 10 и Visual Studio 2019.

Ссыл­ки на дру­гие статьи из это­го цик­ла ищи на стра­нице авто­ра.

Об­ращаю твое вни­мание на одну деталь: с текущей статьи я перехо­жу на Visual Studio 2019. Пос­ледняя вер­сия датиру­ется 17 сен­тября и име­ет номер 16.7.5. Что­бы избе­жать воз­можных несос­тыковок, советую тебе тоже обно­вить «Сту­дию».

ИДЕНТИФИКАЦИЯ СТРУКТУР

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

4611b4304a5aab08fe29f7cf75094b97.jpg

Рас­смот­рим при­мер, демонс­три­рующий унич­тожение струк­тур на ста­дии ком­пиляции:

#include <stdio.h>

#include <string.h>

struct zzz

{

char s0[16];

int a;

float f;

};

void func(struct zzz y)

// Понятное дело, передачи структуры по значению лучше избегать,

// но здесь это сделано умышленно для демонстрации скрытого создания

// локальной переменной

{

printf("%s %x %fn", &y.s0[0], y.a, y.f);

}

int main()

{

struct zzz y;

strcpy_s(&y.s0[0], 14, "Hello,Sailor!"); // Для копирования строки

y.a = 0x666; // используется безопасная версия функции

y.f = (float)6.6; // Чтобы подавить возражение компилятора,

func(y); // указываем целевой тип

}

Ре­зуль­тат ком­пиляции это­го кода с помощью Visual Studio 2019 для плат­формы x64 дол­жен выг­лядеть так:

main proc near

; Члены структуры неотличимы от обычных локальных переменных

var_48 = xmmword ptr -48h

var_38 = qword ptr -38h

Dst = byte ptr -28h

var_18 = qword ptr -18h

var_10 = qword ptr -10h

sub rsp, 68h

mov rax, cs:__security_cookie

xor rax, rsp

mov [rsp+68h+var_10], rax

; Подготовка параметров для вызова функции

lea r8, Src ; "Hello,Sailor!"

mov edx, 0Eh ; SizeInBytes

lea rcx, [rsp+68h+Dst] ; Dst

; Вызов функции для копирования строки из сегмента данных в локальную

; переменную

call cs:__imp_strcpy_s

Сле­дующая коман­да копиру­ет одно вещес­твен­ное чис­ло, находя­щееся в млад­ших 32 битах источни­ка, — кон­стан­ту __real@40d33333 (смот­рим, чему она рав­на при объ­явле­нии в сек­ции rdata: __real@40d33333 dd 6.5999999, в фор­мате float она будет рав­на 6.6) в млад­шие 32 бита при­емни­ка — 128-бит­ного регис­тра XMM1. Напом­ню, восемь регис­тров XMM0 — XMM7 были добав­лены в рас­ширение SSE и поэто­му впер­вые появи­лись в про­цес­соре Pentium III.

movss xmm1, cs:__real@40d33333

; Помещаем указатель на строку в регистр RDX

lea rdx, [rsp+68h+var_48]

Да­лее с исполь­зовани­ем инс­трук­ции MOVUPS из рас­ширения SSE копиру­ются невыров­ненные кус­ки по 16 бит. Таким обра­зом, за раз копиру­ются сра­зу восемь сим­волов Unicode. Одна­ко количес­тво сим­волов в стро­ке впол­не может быть не крат­но вось­ми, поэто­му исполь­зует­ся имен­но эта инс­трук­ция — все осталь­ные инс­трук­ции из рас­ширения SSE опе­риру­ют с перемен­ными, выров­ненны­ми по 16-бит­ным гра­ницам памяти. В ином слу­чае они вызыва­ют исклю­чение.

movups xmm0, xmmword ptr [rsp+68h+Dst]

; В регистр RCX помещаем форматную строку для функции printf

lea rcx, _Format ; "%s %x %f\n"

; Помещаем двойное слово (значение 0x666) в переменную типа DWORD

mov dword ptr [rsp+68h+var_18], 666h ; --1

Сле­дующая коман­да копиру­ет стро­го двой­ное сло­во из памяти в регистр (у нас это XMM3). Зна­чение, сох­ранен­ное в копиру­емой области памяти: 6.599999904632568, выров­нено по гра­нице 16 бит и на самом деле рав­но 6.6. В слу­чае копиро­вания из памяти в регистр (подоб­но нашему при­меру) обну­ляет­ся стар­шее двой­ное сло­во источни­ка.

movsd xmm3, cs:__real@401a666660000000

; Помещаем значение 0x666 в 32-битный регистр

mov r8d, 666h

; Из переменной (см. метку --1) копируем двойное слово в регистр

movsd xmm2, [rsp+68h+var_18]

Да­лее учет­верен­ное сло­во (64 бит) копиру­ется из регис­тра XMM3 рас­ширения SSE в регистр обще­го наз­начения R9, добав­ленный вмес­те с рас­ширени­ем x86-64. Ведь AMD64, по сути, пред­став­ляет собой такое же рас­ширение про­цес­сорной архи­тек­туры x86, как и SSE.

movq r9, xmm3

Инс­трук­ция shufps пос­редс­твом битовой мас­ки ком­биниру­ет и перес­тавля­ет дан­ные в 32-бит­ных ком­понен­тах XMM-регис­тра. Таким обра­зом, если пред­ста­вить 0E1h в бинар­ном виде, получим 11100001b. В соот­ветс­твии с этой мас­кой про­исхо­дит тран­сфор­мация всех четырех 32-бит­ных час­тей регис­тра XMM2.

shufps xmm2, xmm2, 0E1h

; Копирование нижней 32-битной части источника в приемник

movss xmm2, xmm1

; Копирует 128 бит из регистра в переменную

movaps [rsp+68h+var_48], xmm0

; В соответствии с маской перемешивает содержимое регистра (см. выше)

shufps xmm2, xmm2, 0E1h

; Две следующие инструкции помещают значение регистра в переменные,

; находящиеся в памяти

movsd [rsp+68h+var_18], xmm2

movsd [rsp+68h+var_38], xmm2

; Все параметры находятся на своих местах, вызываем функцию printf

call printf

xor eax, eax

mov rcx, [rsp+68h+var_10]

xor rcx, rsp ; StackCookie

call __security_check_cookie

add rsp, 68h

retn

main endp

Ком­пилятор сге­нери­ровал доволь­но вити­ева­тый код со мно­жес­твом команд из рас­ширения SSE. При этом он встро­ил фун­кцию func пря­мо в main!

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

258fcce3aaf462078e65772d0da048e8.jpg

int main()

{

char s0[16];

int a;

float f;

strcpy_s(&s0[0], 14, "Hello,Sailor!");

a = 0x666;

f = (float)6.6;

printf("%s %x %fn", &s0[0], a, f);

}

И срав­ним резуль­тат ком­пиляции с пре­дыду­щим:

main proc near

Dst = byte ptr -28h

var_18 = qword ptr -18h

; Есть различие! Компилятор избавился от ненужных для выполнения переменных,

; однако от этого не становится понятнее, принадлежат переменные структуре или нет

sub rsp, 48h

mov rax, cs:__security_cookie

xor rax, rsp

mov [rsp+48h+var_18], rax

; Готовим параметры

lea r8, Src ; "Hello,Sailor!"

mov edx, 0Eh ; SizeInBytes

lea rcx, [rsp+48h+Dst] ; Dst

; Вызываем функцию копирования строки

call cs:__imp_strcpy_s

; В XMM3 помещается значение 6.599999904632568 (подробно мы говорили,

; когда разбирали предыдущий листинг)

movsd xmm3, cs:__real@401a666660000000

; Последующие инструкции продолжают готовить параметры для функции

lea rdx, [rsp+48h+Dst]

movq r9, xmm3

; В регистр RCX помещаем форматную строку для функции printf

lea rcx, _Format ; "%s %x %f\n"

; Помещаем значение 0x666 в младшие 32 бита регистра R8

mov r8d, 666h

; Вызов функции printf

call printf

xor eax, eax

mov rcx, [rsp+48h+var_18]

xor rcx, rsp ; StackCookie

call __security_check_cookie

add rsp, 48h

retn

main endp

Без вызова допол­нитель­ных фун­кций и переда­чи парамет­ров дизас­сем­блер­ный лис­тинг замет­но сок­ратил­ся. Осталь­ной код остался иден­тичным пре­дыду­щему лис­тингу.

Вы­ходит, отли­чить струк­туру от обыч­ных перемен­ных невоз­можно? Неуж­то иссле­дова­телю при­дет­ся самос­тоятель­но рас­позна­вать «родс­тво» дан­ных и свя­зывать их «брач­ными уза­ми», порой оши­баясь и неточ­но вос­про­изво­дя исходный текст прог­раммы?

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

Пос­той, но ведь точ­но так устро­ено обра­щение и к эле­мен­там мас­сива: базовый ука­затель ука­зыва­ет на начало мас­сива, к нему добав­ляет­ся сме­щение иско­мого эле­мен­та отно­ситель­но начала мас­сива (индекс эле­мен­та, умно­жен­ный на его раз­мер), резуль­тат вычис­лений и будет фак­тичес­ким ука­зате­лем на иско­мый эле­мент!

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

С дру­гой сто­роны, если прог­раммис­ту взду­мает­ся под­счи­тать зависи­мость выпито­го количес­тва пива от дня недели, он может либо выделить для уче­та мас­сив day[7], либо завес­ти струк­туру: struct week { int Monday; int Tuesday;...}. И в том и в дру­гом слу­чае сге­нери­рован­ный ком­пилято­ром код будет оди­наков, да не толь­ко код, но и смысл! В этом кон­тек­сте струк­тура неот­личима от мас­сива и физичес­ки, и логичес­ки, выбор той или иной конс­трук­ции — дело вку­са.

Так­же возь­ми себе на замет­ку, что мас­сивы, как пра­вило, длин­ны, а обра­щение к их эле­мен­там час­то соп­ровож­дает­ся матема­тичес­кими опе­раци­ями над ука­зате­лем. Далее. Обра­бот­ка эле­мен­тов мас­сива, как пра­вило, выпол­няет­ся в цик­ле, а чле­ны струк­туры по обык­новению «раз­бира­ются» инди­виду­аль­но (хотя некото­рые прог­раммис­ты поз­воля­ют себе воль­ность обра­щать­ся со струк­турой как с мас­сивом).

Еще неп­рият­нее, что язы­ки C/C++ допус­кают (если не ска­зать про­воци­руют) явное пре­обра­зова­ние типов и... Oй, а ведь в этом слу­чае при дизас­сем­бли­рова­нии не удас­тся уста­новить, име­ем ли мы дело с объ­еди­нен­ными под одну кры­шу раз­нотип­ными дан­ными (то есть струк­турой), или же это мас­сив c руч­ным пре­обра­зова­нием типа сво­их эле­мен­тов. Хотя, стро­го говоря, пос­ле подоб­ных пре­обра­зова­ний мас­сив прев­раща­ется в самую нас­тоящую струк­туру! Мас­сив по опре­деле­нию гомоге­нен и дан­ные раз­ных типов хра­нить не может.

Мо­дифи­циру­ем пре­дыду­щий при­мер, передав фун­кции не саму струк­туру, а ука­затель на нее:

...

// Подключение библиотек

...

// Объявление структуры

void func(zzz *y)

{

printf("%s %x %fn", y->s0, y->a, y->f);

}

int main()

{

zzz y;

strcpy_s(&y.s0[0], 14, "Hello,Sailor!"); // Для копирования строки

y.a = 0x666; // используется безопасная версия функции

y.f = (float)6.6;

func(&y);

}

Как видишь, изме­нения минималь­ны в срав­нении с позап­рошлым при­мером. Теперь пос­мотрим, что за код сге­нери­ровал ком­пилятор. Пос­ле бег­лого взгля­да на лис­тинг ниже уга­дыва­ется его сходс­тво с дизас­сем­блер­ным лис­тингом прог­раммы из пер­вого при­мера. Одна­ко этот получил­ся короче.

main proc near

Dst = byte ptr -28h

var_18 = dword ptr -18h

var_14 = dword ptr -14h

var_10 = qword ptr -10h

; Компилятор без зазрения совести опять запихнул функцию func внутрь main

sub rsp, 48h

mov rax, cs:__security_cookie

xor rax, rsp

mov [rsp+48h+var_10], rax

; Подготовка параметров для вызова функции

lea r8, Src ; "Hello,Sailor!"

mov edx, 0Eh ; SizeInBytes

lea rcx, [rsp+48h+Dst] ; Dst

; Вызов функции для копирования строки

call cs:__imp_strcpy_s

Ко­ман­да MOVSD копиру­ет двой­ное сло­во из памяти в регистр (в нашем слу­чае — XMM3).

movsd xmm3, cs:__real@401a666660000000

; Помещаем указатель на строку в регистр RDX

lea rdx, [rsp+48h+Dst]

Ко­пиру­ем 32-бит­ное зна­чение с пла­вающей запятой оди­нар­ной точ­ности из памяти в регистр XMM0, при этом обну­ляя его вер­хние 96 бит. Оче­вид­но, про­исхо­дит под­готов­ка парамет­ров для вызова фун­кции printf, они раз­меща­ются в регис­трах про­цес­сора.

movss xmm0, cs:__real@40d33333

; В регистр RCX помещаем форматную строку для функции printf

lea rcx, _Format ; "%s %x %f\n"

Как мы пом­ним (а если нет, надо под­нять глаз­ки вверх), в ниж­них 64 битах регис­тра XMM3 находит­ся чис­ло 6.6, поэто­му с помощью инс­трук­ции MOVQ копиру­ем эти 64 бита в регистр R9.

movq r9, xmm3

Сле­дующая инс­трук­ция копиру­ет ниж­ние 32 бита 128-бит­ного регис­тра XMM0 в область памяти — перемен­ную var_14. Как мы пом­ним, в дан­ном регис­тре находит­ся чис­ло 6.6.

movss [rsp+48h+var_14], xmm0

; Помещаем в регистр R8D значение 0x666

mov r8d, 666h

; Помещаем то же число в область памяти, другими словами —

; инициализируем переменную var_18

mov [rsp+48h+var_18], 666h

; Все параметры на своих местах,

; вызываем функцию для вывода строки на консоль

call printf

xor eax, eax

mov rcx, [rsp+48h+var_10]

xor rcx, rsp ; StackCookie

call __security_check_cookie

add rsp, 48h

retn

main endp

Ком­пилятор все кон­крет­но заоп­тимизи­ровал. И кажет­ся, вос­ста­новить изна­чаль­ную струк­туру нет никакой воз­можнос­ти. В то же вре­мя при срав­нении это­го лис­тинга с пре­дыду­щим наб­люда­ются замет­ные раз­личия. Этот стал короче и лаконич­нее, но это нис­коль­ко не помога­ет опоз­нать струк­туру. Надо хотя бы поп­робовать опре­делить началь­ные типы перемен­ных. Поэто­му попыта­емся пог­лубже раз­мотать код и обра­тим вни­мание на начало фун­кции main:

Dst = byte ptr -28h

var_18 = dword ptr -18h

var_14 = dword ptr -14h

var_10 = qword ptr -10h

Оче­вид­но, здесь объ­явля­ются четыре перемен­ные. Их про­изводный (в ском­пилиро­ван­ном виде) тип дан­ных ука­зан пос­ле зна­ка равенс­тва, далее идет сме­щение, задава­емое в бай­тах. Про­изводный тип опре­деля­ет вмес­тимость (дру­гими сло­вами, раз­мер) перемен­ной.

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

Dst db ?

...

var_18 dd ?

var_14 dd ?

var_10 dq ?

Тем не менее они опре­деля­ются в начале исполь­зующей их фун­кции (пре­дыду­щий лис­тинг). Пос­мотрим на исполь­зование перемен­ных: mov [rsp+48h+var_10], rax. Из чего сле­дует: содер­жимое регис­тра RAX записать в область памяти по адре­су RSP + 72 – 16 == RSP + 56 (в десятич­ной сис­теме). Или для при­мера рас­смот­рим дру­гую инс­трук­цию: lea rcx, [rsp+48h+Dst]. То есть в регистр RCX записать эффектив­ный адрес, ины­ми сло­вами — ука­затель на область памяти RSP + 72 – 40 == RSP + 32 (в десятич­ной сис­теме).

Как извес­тно, стек рас­тет свер­ху вниз, то есть объ­явленные поз­днее перемен­ные име­ют мень­ший адрес. Поэто­му и говорит­ся, что RSP ука­зыва­ет на вер­шину сте­ка. Но так опре­деле­но для x86 и, соот­ветс­твен­но, x86-64. Для дру­гих про­цес­сорных архи­тек­тур дела могут обсто­ять по‑дру­гому. Что­бы про­верить, куда рас­тет стек, мож­но запус­тить такую прос­тень­кую прог­рамму:

#include <stdio.h>

int main()

{

int a, b;

if (&a < &b)

printf("%s", "Stack grows upn");

else

printf("%s", "Stack grows downn");

printf("First variable adress: %pnSecond variable adress: %pn", &a, &b);

return 0;

}

Ее выпол­нение при­ведет при­мер­но к такому резуль­тату (в тво­ем слу­чае адре­са могут иметь дру­гие зна­чения).

0ff14960977428a02bc976443ab64bfa.jpg

При переза­пус­ке прог­раммы адре­са будут менять­ся, одна­ко их раз­личие сос­тавит 4 бай­та — раз­мер типа дан­ных int, при этом объ­явленная вто­рой перемен­ная будет мень­ше (вспом­ни о рос­те сте­ка).

Те­перь сно­ва взгля­нем на объ­явле­ние перемен­ных в дизас­сем­блер­ном лис­тинге:

Dst = byte ptr -28h

var_18 = dword ptr -18h

var_14 = dword ptr -14h

var_10 = qword ptr -10h

От­сюда сле­дует: var_10 = 4 бай­та — целочис­ленный тип или тип с пла­вающей запятой (так как и int, и float занима­ют в памяти 4 бай­та), var_14 — тоже 4 бай­та со все­ми вытека­ющи­ми отсю­да пос­ледс­тви­ями, var_18 — 16 байт. Получа­ется, что это мас­сив на 16 эле­мен­тов типа char, так как эле­мент пос­ледне­го занима­ет 1 байт. Конеч­но, если бы нам были неиз­вес­тны началь­ные типы дан­ных, нам бы приш­лось решать эту голово­лом­ку куда доль­ше!

Пог­ляди‑ка, обра­щения к зна­чени­ям в памяти (или для записи в память) про­исхо­дят через адрес [rsp+48h+…], даль­ше идет сме­щение опре­делен­ной перемен­ной. Таким обра­зом, мож­но сде­лать вывод: три опре­делен­ные перемен­ные вхо­дят в один кон­тей­нер. Но так как перемен­ные име­ют раз­ные типы дан­ных, этим кон­тей­нером никаким боком не может быть мас­сив, сле­дова­тель­но, это струк­тура!

Фак­тичес­ки фун­кция main выпол­няет три опе­рации: заг­ружа­ет зна­чения, пре­обра­зует их и вызыва­ет printf, пред­варитель­но раз­местив зна­чения в регис­трах про­цес­сора для переда­чи их в качес­тве парамет­ров. Как мы пом­ним, в x86-64 сог­ласно ДИП (дво­ичный интерфейс при­ложе­ний) от Microsoft пер­вые четыре парамет­ра раз­меща­ются в регис­трах. Рас­смот­рим начало получа­ющей эти парамет­ры фун­кцию printf:

printf proc near

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

mov [rsp+arg_0], rcx

mov [rsp+arg_8], rdx

mov [rsp+arg_10], r8

mov [rsp+arg_18], r9

В начале фун­кции ини­циали­зиру­ются локаль­ные перемен­ные: зна­чения извле­кают­ся из регис­тров про­цес­сора и помеща­ются в память.

ИДЕНТИФИКАЦИЯ ОБЪЕКТОВ

Объ­екты язы­ка C++ — это, по сути дела, струк­туры, сов­меща­ющие в себе дан­ные, методы их обра­бот­ки (то бишь фун­кции) и атри­буты защиты (типа publicfriend).

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

Те­перь обо всем этом под­робнее. Итак, что пред­став­ляет собой объ­ект (или экзем­пляр клас­са)?

Рас­смот­рим сле­дующий лис­тинг, демонс­три­рующий стро­ение объ­екта:

class MyClass

{

int a;

int b;

void demo_1(void);

public:

int c;

virtual void demo_2(void);

}; 

MyClass zzz;

Эк­зем­пляр клас­са MyClass «переме­лет­ся» ком­пилято­ром в сле­дующую струк­туру.

046715d5d4dce17252b5261b78200f54.jpg

Пе­ред иссле­дова­телем вста­ют сле­дующие проб­лемы: как отли­чить объ­екты от прос­тых струк­тур? Как опре­делить раз­мер объ­ектов? Как опре­делить, какая фун­кция какому объ­екту при­над­лежит? Как... Погоди, погоди, не все сра­зу! Нач­нем отве­чать на воп­росы по поряд­ку.

Во­обще, стро­го говоря, отли­чить объ­ект от струк­туры невоз­можно из‑за того, что объ­ект и есть струк­тура с чле­нами, при­ват­ными по умол­чанию. При объ­явле­нии объ­ектов мож­но поль­зовать­ся и клю­чевым сло­вом struct, и клю­чевым сло­вом class. При­чем для клас­сов, все чле­ны которых откры­ты, пред­почти­тель­нее исполь­зовать имен­но struct, так как чле­ны струк­туры уже пуб­личны по умол­чанию. Срав­ни два сле­дующих при­мера.

При­мер 1

struct MyClass

{

int x;

void demo(void);

private:

int y;

void demo_private(void);

};

При­мер 2

class MyClass

{

int y;

void demo_private(void);

public:

int x;

void demo(void);

};

Од­на запись отли­чает­ся от дру­гой лишь син­такси­чес­ки, а код, генери­руемый ком­пилято­ром, будет иден­тичен! Поэто­му с надеж­дой научить­ся раз­личать объ­екты и струк­туры сле­дует как мож­но ско­рее рас­стать­ся.

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

По­ложе­ние без­надеж­но? Отнюдь нет! Каж­дой фун­кции — чле­ну объ­екта переда­ется неяв­ный аргу­мент — ука­затель this, ссы­лающий­ся на объ­ект, которо­му при­над­лежит дан­ная фун­кция. Экзем­пляр клас­са — это по фак­ту не сам класс, но неч­то очень тес­но с ним свя­зан­ное, поэто­му вос­ста­новить исходную струк­туру клас­сов дизас­сем­бли­руемой прог­раммы впол­не реаль­но.

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

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

При­чина в том, что ком­пилято­ру жиз­ненно необ­ходимо опре­делить ука­затель this, — нулевым, увы, this быть не может, это выз­вало бы исклю­чение при пер­вой же попыт­ке обра­щения. Да и опе­рато­ру delete надо что‑то уда­лять, а раз так, это «что‑то» надо пред­варитель­но выделить...

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

Лад­но, все это лирика, перей­дем к кон­крет­ным при­мерам.

#include <stdio.h>

class MyClass

{

public:

int x;

void demo(void);

private:

int y;

void demo_private(void);

};

void MyClass::demo_private(void)

{

printf("Privaten");

}

void MyClass::demo(void)

{

printf("MyClassn");

this->demo_private();

this->y = 0x666;

}

int main()

{

MyClass* zzz = new MyClass;

zzz->demo();

zzz->x = 0x777;

delete zzz;

}

Ес­ли сей­час взять и с ходу запус­тить ком­пиляцию, то опти­мизи­рующий ком­пилятор сно­ва в тру­ху переме­лет наш объ­ект и даже не соз­даст отдель­ные методы. Поэто­му, что­бы уви­деть магию ООП, нам при­дет­ся отклю­чить опти­миза­цию. Прог­раммис­ты ред­ко так пос­тупа­ют, тем более в наше вре­мя, ког­да ско­рость работы при­ложе­ния важ­нее его раз­мера. Но нам при­дет­ся пой­ти на эту неук­люжую улов­ку во имя зна­ний. Поэто­му, отпив пив­ка (кофе, какао — по вку­су), отклю­чи опти­миза­цию в свой­ствах про­екта в Visual Studio.

d297080afa902df800acf17dd711c0df.jpg

В ито­ге дизас­сем­бли­рован­ный лис­тинг в IDA PRO будет выг­лядеть при­мер­но так:

main proc near

var_28 = qword ptr -28h

block = qword ptr -20h

var_18 = qword ptr -18h

var_10 = qword ptr -10h

sub rsp, 48h

mov ecx, 8 ; size

Вы­деля­ем 8 байт под экзем­пляр объ­екта опе­рато­ром new. Вооб­ще‑то вов­се не факт, что память выделя­ется имен­но под объ­ект (может, тут было что‑то типа char *x = new char[8]), так что не будем счи­тать это утвер­жде­ние дог­мой, а при­мем как рабочую гипоте­зу. Даль­нейшие иссле­дова­ния покажут, что к чему.

call operator new(unsigned __int64)

mov [rsp+48h+var_18], rax

mov rax, [rsp+48h+var_18]

mov [rsp+48h+var_28], rax

Ухо‑хвост! Готовит­ся ука­затель this, который переда­ется фун­кции через регистр. Зна­чит, внут­ри RCX не что иное, как ука­затель на экзем­пляр клас­са!

mov rcx, [rsp+48h+var_28] ; this

Вот мы и доб­рались до вызова фун­кции demo — откры­ваем хвост! Пока неяс­но, что эта фун­кция дела­ет (сим­воль­ное имя дано ей для наг­ляднос­ти), тем не менее извес­тно, что она при­над­лежит экзем­пля­ру клас­са, на который ука­зыва­ет RCX. Назовем этот экзем­пляр A. Далее, пос­коль­ку фун­кция, вызыва­ющая demo (то есть фун­кция, в которой мы сей­час находим­ся), не при­над­лежит к А (она же его сама и соз­дала — не мог экзем­пляр клас­са сам «вытянуть себя за хвост»), зна­чит, фун­кция demo — это public-фун­кция. Неп­лохо для начала?

call MyClass::demo(void)

mov rax, [rsp+48h+var_28]

mov dword ptr [rax], 777h

Так‑так... Мы пом­ним, что RAX ука­зыва­ет на экзем­пляр клас­са. Тог­да выходит, что в объ­екте есть еще один public-член, и это перемен­ная типа int. Далее в остатке фун­кции содер­жится код, слу­жащий для уда­ления объ­екта из кучи, плюс эпи­лог.

mov rax, [rsp+48h+var_28]

mov [rsp+48h+block], rax

mov edx, 8 ; __formal

mov rcx, [rsp+48h+block] ; block

call operator delete(void *,unsigned __int64)

cmp [rsp+48h+block], 0

jnz short loc_14000118E

mov [rsp+48h+var_10], 0

jmp short loc_1400011A1

; ----------------------------------

loc_14000118E: ; CODE XREF: main+51↑j

mov [rsp+48h+var_28], 8123h

mov rax, [rsp+48h+var_28]

mov [rsp+48h+var_10], rax

loc_1400011A1: ; CODE XREF: main+5C↑j

xor eax, eax

add rsp, 48h

retn

main endp

По пред­варитель­ным зак­лючени­ям класс выг­лядит сле­дующим обра­зом:

class myclass

{

public:

void demo(void); // void — так как функция ничего не принимает и не возвращает

int x;

}

Вот перед нами фун­кция demo — метод объ­екта A:

public: void MyClass::demo(void) proc near

arg_0 = qword ptr 8

; Загружаем в область памяти указатель this, переданный функции через регистр

mov [rsp+arg_0], rcx

sub rsp, 28h

lea rcx, aMyclass ; "MyClass\n"

; Выводим строку на экран

call printf

mov rcx, [rsp+28h+arg_0] ; this

Оп, вот он, наш обла­датель хвос­та! Вызыва­ется еще одна фун­кция! Судя по this, это метод нашего объ­екта, при­чем, веро­ятнее все­го, име­ющий атри­бут private, пос­коль­ку вызыва­ется толь­ко из фун­кции самого объ­екта.

call MyClass::demo_private(void)

mov rax, [rsp+28h+arg_0]

mov dword ptr [rax+4], 666h

; Так, в объекте есть еще одна переменная, вероятно приватная

add rsp, 28h

retn

public: void MyClass::demo(void) endp

Тог­да, по сов­ремен­ным воз­зре­ниям, класс дол­жен выг­лядеть так:

class myclass

{

void demo_private(void);

int y;

public:

void demo(void);

int x;

}

Итак, мы не толь­ко иден­тифици­рова­ли объ­ект, но и вос­ста­нови­ли его струк­туру! Пус­кай не зас­тра­хован­ную от оши­бок (так, пред­положе­ние о при­ват­ности demo_private и у базиру­ется лишь на том, что они ни разу не вызыва­лись извне объ­екта), но все же не так ООП страш­но, как его малю­ют, и вос­ста­новить если не под­линный исходный текст прог­раммы, то хотя бы какое‑то его подобие впол­не воз­можно!

private: void MyClass::demo_private(void) proc near

; Закрытый метод demo_private — ничего интересного

arg_0 = qword ptr 8

mov [rsp+arg_0], rcx

sub rsp, 28h

lea rcx, _Format ; "Private\n"

call printf

add rsp, 28h

retn

private: void MyClass::demo_private(void) endp

Об­рати так­же вни­мание, мы не соз­даем кас­томные конс­трук­тор и дес­трук­тор, отда­вая задачу соз­дания конс­трук­тора и дес­трук­тора ком­пилято­ру. Авто­мати­чес­кое соз­дание этих методов при их отсутс­твии про­писа­но в стан­дарте язы­ка C++.

КЛАССЫ И ОБЪЕКТЫ

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

 

Ины­ми сло­вами, пусть име­ется класс А, вклю­чающий в себя фун­кции a1 и а2. Пусть соз­дано два его экзем­пля­ра — из одно­го мы вызыва­ем фун­кцию a1, из дру­гого — a2. С помощью ука­зате­ля this мы смо­жем выяс­нить лишь то, что одно­му экзем­пля­ру при­над­лежит фун­кция a1, дру­гому — a2. Но уста­новить, экзем­пля­ры это одно­го клас­са или двух раз­ных клас­сов, невоз­можно!

Де­ло осложня­ется тем, что в про­изводных клас­сах нас­леду­емые фун­кции не дуб­лиру­ются (во вся­ком слу­чае, так пос­тупа­ют «умные» ком­пилято­ры, хотя в жиз­ни слу­чает­ся вся­кое). Воз­ника­ет двуз­начность: если с одним экзем­пля­ром свя­заны фун­кции a1 и a2, а с дру­гим — a1, a2 и a3, то это могут быть либо экзем­пля­ры одно­го клас­са (прос­то из пер­вого экзем­пля­ра фун­кция a3 не вызыва­ется), либо вто­рой экзем­пляр — экзем­пляр клас­са, про­изводно­го от пер­вого. Код, сге­нери­рован­ный ком­пилято­ром, в обо­их слу­чаях будет иден­тичным! При­ходит­ся вос­ста­нав­ливать иерар­хию клас­сов по смыс­лу и наз­начению при­над­лежащих им фун­кций... Понят­ное дело, приб­лизить­ся к исходно­му коду смо­жет толь­ко ясно­видя­щий.

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

Мой адрес — не дом и не улица!

Где живут струк­туры, мас­сивы и объ­екты? Конеч­но же, в памяти! А покон­крет­нее? Кон­крет­нее — сущес­тву­ют три типа раз­мещения: в сте­ке (авто­мати­чес­кая память), сег­менте дан­ных (ста­тичес­кая память) и куче (динами­чес­кая память). И каж­дый тип со сво­им харак­тером.

 

Возь­мем стек — выделе­ние памяти неяв­ное, фак­тичес­ки про­исхо­дит на эта­пе ком­пиляции, при­чем гаран­тирован­но опре­деля­ется толь­ко общий объ­ем памяти, выделен­ный под все локаль­ные перемен­ные. А опре­делить, сколь­ко занима­ет каж­дая из них, невоз­можно в прин­ципе. Не веришь? А вот, ска­жем, пусть будет такой код: char a1[13]; char a2[17]; char a3[23]. Если ком­пилятор выров­няет мас­сивы по крат­ным адре­сам (а это дела­ют мно­гие ком­пилято­ры), то раз­ница сме­щений бли­жай­ших друг к дру­гу мас­сивов может и не быть рав­на их раз­меру. Единс­твен­ная надеж­да вос­ста­новить под­линный раз­мер — най­ти в коде про­вер­ки выходы за гра­ницы мас­сива (если они есть, а их час­то не быва­ет).

Вто­рое (самое неп­рият­ное): если один из мас­сивов не исполь­зует­ся, а толь­ко объ­явля­ется, то неоп­тимизи­рующие ком­пилято­ры (и даже некото­рые опти­мизи­рующие!) могут тем не менее отвести для него сте­ковое прос­транс­тво. Тем самым он вплот­ную прим­кнет к пре­дыду­щему мас­сиву, и гадай: то ли раз­мер мас­сива такой, то ли в его конец «вбу­хан» неис­поль­зуемый мас­сив! Ну, с мас­сивами куда бы еще ни шло, а вот со струк­турами и объ­екта­ми дела обсто­ят нам­ного хуже. Никому и в голову не при­дет помещать в прог­рамму код, отсле­жива­ющий выход за пре­делы струк­туры (объ­екта). Такое невоз­можно в прин­ципе (ну раз­ве что прог­раммист слиш­ком воль­но работа­ет с ука­зате­лями)!

Лад­но, оста­вим в сто­роне раз­мер, перей­дем к проб­лемам «раз­верс­тки» и поис­ку ука­зате­лей. Как уже говори­лось выше, если мас­сив (объ­ект, струк­тура) объ­явля­ется в непос­редс­твен­ной области видимос­ти еди­ницы тран­сля­ции, он «вспа­рыва­ется» на эта­пе ком­пиляции и обра­щение к его чле­нам выпол­няет­ся по фак­тичес­кому сме­щению, а не по базово­му ука­зате­лю. К счастью, иден­тифика­цию объ­ектов облегча­ет наличие в них ука­зате­ля на вир­туаль­ную таб­лицу, но ведь не факт, что любая таб­лица ука­зате­лей на фун­кции есть вир­туаль­ная таб­лица! Может, это прос­то мас­сив ука­зате­лей на фун­кции, опре­делен­ный самим прог­раммис­том? Вооб­ще‑то при наличии опы­та такие слу­чаи мож­но лег­ко рас­познать, но все‑таки они дос­таточ­но неп­рият­ны.

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

На­конец, объ­екты (струк­туры, мас­сивы), рас­положен­ные в куче, прос­то сказ­ка для ана­лиза! Память отво­дит фун­кция, которая явно при­нима­ет количес­тво выделя­емых бай­тов в качес­тве сво­его аргу­мен­та и воз­вра­щает ука­затель, гаран­тирован­но ука­зыва­ющий на начало экзем­пля­ра объ­екта (струк­туры, мас­сива). Раду­ет и то, что обра­щение к эле­мен­там всег­да идет через базовый ука­затель, даже если объ­явле­ние совер­шает­ся в области видимос­ти (ина­че и быть не может: фак­тичес­кие адре­са выделя­емых бло­ков динами­чес­кой памяти неиз­вес­тны на ста­дии ком­пиляции).

ЗАКЛЮЧЕНИЕ

Ар­хитек­тура x86-64, по сути, такое же рас­ширение для про­цес­сора архи­тек­туры x86, как SSE. Все мно­гооб­разие подоб­ных рас­ширений для x86, как 3DNOW, MMX, SSE, VMX, AVX и дру­гие, добав­ляют про­цес­сору допол­нитель­ные регис­тры, спо­соб­ные хра­нить боль­шие зна­чения. Вмес­те с тем про­цес­соры получа­ют новые инс­трук­ции для обра­бот­ки содер­жащих­ся в этих регис­трах дан­ных: они могут извле­кать отдель­ные сло­ва, двой­ные сло­ва, 64-раз­рядные сло­ва и более за один такт. Как мы уви­дели, сов­ремен­ный ком­пилятор от Microsoft даже для при­митив­ного кон­соль­ного при­ложе­ния, которое мы исполь­зовали для демонс­тра­ции, генери­рует код с при­месью SSE.

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

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

В кон­це семиде­сятых годов прош­лого века про­изво­дите­ли полуп­ровод­ников как раз работа­ли над про­цес­сорами, спо­соб­ными перева­рить всю кух­ню ООП, но выпущен­ный впо­пыхах для удов­летво­рения рын­ка как про­межу­точ­ное зве­но про­цес­сор 8086 показал, что упро­щение кода куда важ­нее его усложне­ния. Тем самым на про­цес­сорах, спо­соб­ных на аппа­рат­ном уров­не понимать ООП, пос­тавили крест.

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

Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei