Хакер - Фундаментальные основы хакерства. Определяем циклы в двоичном коде программы

Хакер - Фундаментальные основы хакерства. Определяем циклы в двоичном коде программы

hacker_frei

https://t.me/hacker_frei

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

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

  • Циклы с условиями в начале
  • Циклы с условием в конце
  • Циклы со счетчиком
  • Циклы с условием в середине
  • Циклы с множественными условиями выхода
  • Циклы с несколькими счетчиками
  • Идентификация continue
  • Сложные условия
  • Вложенные циклы
  • Заключение

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

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

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

Цик­лы — единс­твен­ная (за исклю­чени­ем неп­рилич­ного GOTO) конс­трук­ция язы­ков высоко­го уров­ня, име­ющая ссыл­ку «назад», то есть в область млад­ших адре­сов. Все осталь­ные виды вет­вле­ний — будь то IF — THEN — ELSE или опе­ратор мно­жес­твен­ного выбора SWITCH — всег­да нап­равле­ны «вниз», в область стар­ших адре­сов. Вследс­твие это­го изоб­ража­ющее цикл логичес­кое дерево нас­толь­ко харак­терно, что лег­ко опоз­нает­ся с пер­вого взгля­да.

Су­щес­тву­ют три основных типа цик­ла:

  1. Цик­лы с усло­вием в начале.


  1. Цик­лы с усло­вием в кон­це.


  1. Цик­лы с усло­вием в середи­не.


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

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

Та­ким обра­зом, со сто­роны тран­сля­тора впол­не дос­таточ­но под­дер­жки усло­вий одно­го типа. И дей­стви­тель­но, опе­рато­ры цик­лов whiledo и for язы­ка С/C++ работа­ют исклю­читель­но с усло­виями про­дол­жения цик­ла. Опе­ратор while язы­ка Delphi так­же работа­ет с усло­вием про­дол­жения, и исклю­чение сос­тавля­ет один лишь repeat-until, ожи­дающий усло­вие завер­шения цик­ла.

ЦИКЛЫ С УСЛОВИЯМИ В НАЧАЛЕ

Их так­же называ­ют цик­лами с пре­дус­лови­ем. В язы­ках С/C++ и Delphi под­дер­жка цик­лов с пре­дус­лови­ем обес­печива­ется опе­рато­ром while (условие), где условие — это усло­вие про­дол­жения цик­ла. То есть цикл while (a < 10) a++; выпол­няет­ся до тех пор, пока усло­вие (a>10) оста­ется истинным. Одна­ко тран­сля­тор при желании может инверти­ровать усло­вие про­дол­жения цик­ла на усло­вие его завер­шения. На плат­форме Intel 80x86 такой трюк эко­номит от одной до двух машин­ных команд.

Об­рати вни­мание: ниже при­веден цикл с усло­вием завер­шения цик­ла.

А далее — с усло­вием про­дол­жения цик­ла.

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

Цикл с усло­вием завер­шения не может быть непос­редс­твен­но отоб­ражен на опе­ратор while. Кста­ти, об этом час­то забыва­ют начина­ющие, допус­кая ошиб­ку «что вижу, то пишу»: while (a >= 10) a++. С таким усло­вием цикл вооб­ще не выпол­нится ни разу! Но как выпол­нить инверсию усло­вия и при этом гаран­тирован­но не оши­бить­ся? Казалось бы, что может быть про­ще, а вот поп­росите зна­комо­го хакера наз­вать опе­рацию, обратную «боль­ше». Очень может быть (даже навер­няка!), что отве­том будет... «мень­ше». А вот и нет, пра­виль­ный ответ «мень­ше или рав­но». Пол­ный перечень обратных опе­раций отно­шений мож­но най­ти в сле­дующей таб­лице.

ЦИКЛЫ С УСЛОВИЕМ В КОНЦЕ

Их так­же называ­ют цик­лами с пос­тусло­вием. В язы­ке С/C++ под­дер­жка цик­лов с пос­тусло­вием обес­печива­ется парой опе­рато­ров do … while, а в язы­ке Delphi — repeat … until. Цик­лы с пос­тусло­вием без каких‑либо проб­лем непос­редс­твен­но отоб­ража­ются с язы­ка высоко­го уров­ня на машин­ный код и наобо­рот. То есть, в отли­чие от цик­лов с пре­дус­лови­ем, инверсии усло­вия не про­исхо­дит.

Нап­ример, do a++; while (a<10); в общем слу­чае ком­пилиру­ется в сле­дующий код (обра­ти вни­мание: в перехо­де исполь­зовалась та же самая опе­рация отно­шения, что и в исходном цик­ле. Кра­сота, и никаких оши­бок при деком­пиляции!).

Срав­ним код цик­ла с пос­тусло­вием и код цик­ла с пре­дус­лови­ем. Не прав­да ли, цикл с усло­вием в кон­це ком­пак­тнее и быс­трее? Некото­рые ком­пилято­ры (нап­ример, Microsoft Visual C++) уме­ют тран­сли­ровать цик­лы с пре­дус­лови­ем в цик­лы с пос­тусло­вием. На пер­вый взгляд, это вопи­ющая самоде­ятель­ность ком­пилято­ра — если прог­раммист хочет про­верять усло­вие в начале, то какое пра­во име­ет тран­сля­тор ста­вить его в кон­це?

На самом же деле раз­ница меж­ду «до» и «пос­ле» не столь зна­читель­на. Если ком­пилятор уве­рен, что цикл выпол­няет­ся хотя бы один раз, то он впра­ве выпол­нять про­вер­ку ког­да угод­но. Разуме­ется, при этом необ­ходимо нес­коль­ко скор­ректи­ровать усло­вие про­вер­ки: while (a<b) не экви­вален­тно do ... while (a<b), так как в пер­вом слу­чае при (a == b) уже про­исхо­дит выход из цик­ла, а во вто­ром — цикл выпол­няет еще одну ите­рацию. Одна­ко этой беде лег­ко помочь: уве­личим а на еди­ницу (do ... while ((a+1)<b)) или выч­тем эту еди­ницу из b (do ... while (ab-1))), и... теперь все будет работать!

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

ЦИКЛЫ СО СЧЕТЧИКОМ

Цик­лы со счет­чиком (for) не явля­ются самос­тоятель­ным типом цик­лов, а пред­став­ляют собой все­го лишь син­такси­чес­кую раз­новид­ность цик­лов с пре­дус­лови­ем. В самом деле, for (a = 0; a < 10; a++) в пер­вом приб­лижении то же самое, что и

a = 0;

while (a < 10) {

...

a++;

}

Од­нако резуль­таты ком­пиляции двух этих конс­трук­ций необя­затель­но будут иден­тичны!

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

Не­пос­редс­твен­ный пры­жок вниз может быть резуль­татом ком­пиляции и цик­ла for, и опе­рато­ра GOTO, но GOTO сей­час не в моде и исполь­зует­ся край­не ред­ко, а без него опе­ратор условно­го перехо­да IF — THEN не может прыг­нуть непос­редс­твен­но в середи­ну цик­ла while! Выходит, изо всех «кан­дидатов» оста­ется толь­ко цикл for.

Не­кото­рые осо­бо прод­винутые ком­пилято­ры (Microsoft Visual C++, Embarcadero C++Builder) пос­тупа­ют хит­рее: ана­лизи­руя код, они еще на ста­дии ком­пиляции пыта­ются опре­делить, выпол­няет­ся ли дан­ный цикл хотя бы один раз, и, если видят, что он дей­стви­тель­но выпол­няет­ся, прев­раща­ют for в типич­ный цикл с пос­тусло­вием.

На­конец, самые совер­шенные ком­пилято­ры (из которых мож­но наз­вать один лишь Microsoft Visual C++) могут даже заменять цик­лы с при­раще­нием на цик­лы с убы­вани­ем при усло­вии, что параметр цик­ла не исполь­зует­ся опе­рато­рами цик­ла, а лишь прок­ручива­ет цикл опре­делен­ное чис­ло раз. Зачем это ком­пилято­ру? Ока­зыва­ется, цик­лы с убы­вани­ем гораз­до короче — одно­бай­товая инс­трук­ция DEC не толь­ко умень­шает опе­ранд, но и выс­тавля­ет Zero-флаг при дос­тижении нуля. В резуль­тате необ­ходимость в коман­де CMP A, xxx отпа­дает авто­мати­чес­ки.

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

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

Ска­жем так: если логика иссле­дуемо­го цик­ла син­такси­чес­ки удоб­нее выража­ется через опе­ратор for, то и выражай ее через for! В про­тив­ном слу­чае исполь­зуй while или do (repeat\until) для цик­лов с пред- и пос­тусло­вием соот­ветс­твен­но.

И в зак­лючение пара слов о «кас­три­рован­ных» цик­лах. Язык C/C++ поз­воля­ет опус­тить ини­циали­зацию перемен­ной цик­ла, усло­вие выхода из него, опе­ратор при­раще­ния перемен­ной или все это вмес­те. При этом for вырож­дает­ся во while и ста­новит­ся прак­тичес­ки неот­личимым от него. 

ЦИКЛЫ С УСЛОВИЕМ В СЕРЕДИНЕ

По­пуляр­ные язы­ки высоко­го уров­ня непос­редс­твен­но не под­держи­вают цик­лы с усло­вием в середи­не, хотя необ­ходимость в них воз­ника­ет доволь­но час­то. Поэто­му прог­раммис­ты их реали­зуют на осно­ве уже име­ющих­ся цик­лов while (while\do) и опе­рато­ра выхода из цик­ла break. Нап­ример, так.

Ком­пилятор (если он не сов­сем осёл) раз­ворачи­вает бес­конеч­ный цикл в безус­ловный переход JMP, нап­равлен­ный, естес­твен­но, назад (ослы генери­руют код вро­де MOV EAX, 1\CMP EAX,1\JZ repeat). Нап­равлен­ный назад безус­ловный переход весь­ма харак­терен — за исклю­чени­ем бес­конеч­ного цик­ла, его может порож­дать один лишь опе­ратор GOTO. А раз у нас есть бес­конеч­ный цикл, то усло­вие его завер­шения может находить­ся лишь в середи­не это­го цик­ла (слож­ные слу­чаи мно­гопо­точ­ных защит, модифи­циру­ющих из сосед­него потока безус­ловный переход в NOP, мы пока не рас­смат­рива­ем). Оста­ется про­чесать тело цик­ла и най­ти это самое усло­вие.

Сде­лать это будет нет­рудно — опе­ратор break тран­сли­рует­ся в переход на пер­вую коман­ду, сле­дующую за JMP repeat, а сам break получа­ет управле­ние от вет­ки IF (условие) — THEN — [ELSE]. Усло­вие ее сра­баты­вания и будет иско­мым усло­вием завер­шения цик­ла. Вот, собс­твен­но, и все.

ЦИКЛЫ С МНОЖЕСТВЕННЫМИ УСЛОВИЯМИ ВЫХОДА

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

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

ЦИКЛЫ С НЕСКОЛЬКИМИ СЧЕТЧИКАМИ

Опе­ратор «запятая» язы­ка С/C++ поз­воля­ет выпол­нять мно­жес­твен­ную ини­циали­зацию и модифи­кацию счет­чиков цик­ла for. Нап­ример: for (int a = 0, b = 10; a != b; a++, b--)... А как нас­чет нес­коль­ких усло­вий завер­шения? Подав­ляющее боль­шинс­тво учеб­ников по прог­рамми­рова­нию на этот счет хра­нят гро­бовое мол­чание.

С помощью Embarcadero C++Builder 10.4 поп­робу­ем ском­пилиро­вать сле­дующий код (при­мер some_counters_cb):

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

for (int a = 0, b = 10; a < 10, b > 5; a++, b--)

std::cout << a << " | " << b << std::endl;

return 0;

}

Он будет бла­гопо­луч­но «прог­лочен» ком­пилято­ром, одна­ко в области пре­дуп­режде­ний выведет

cod1.cpp(15,27): warning W7379: relational comparison result unused

Ре­зуль­тат выпол­нения при­мера some_counters_cb

Пре­дуп­режде­ние он вывел, а ском­пилиро­вал‑то все рав­но неп­равиль­но! Логичес­кое усло­вие (a1, a2, a3, ... ,an) лишено смыс­ла, и ком­пилято­ры без малей­ших колеба­ний и заз­рений совес­ти отбро­сят все, кро­ме самого пра­вого выраже­ния an. Оно‑то и будет еди­нолич­но опре­делять усло­вие про­дол­жения цик­ла.

От­компи­лиру­ем этот же код в Microsoft Visual C++ 2022. Он выведет более осмыслен­ное сооб­щение: «Пре­дуп­режде­ние C6319: Исполь­зование опе­рато­ра „запятая“ в про­веря­емом выраже­нии при­водит к тому, что левый аргу­мент будет про­пущен, если у него нет побоч­ных эффектов».

Та­кое объ­ясне­ние более понят­но. Если усло­вие про­дол­жения цик­ла зависит от нес­коль­ких перемен­ных, то их срав­нения сле­дует объ­еди­нить в одно выраже­ние пос­редс­твом логичес­ких опе­раций ORAND и дру­гих. Нап­ример: for (a=0, b=10; (a > 0 && b < 10); a++, b--) — цикл пре­рыва­ется сра­зу же, как толь­ко одно из двух усло­вий ста­нет лож­но; for (a=0, b=10; (a > 0 || b < 10); a++, b--) — цикл про­дол­жает­ся до тех пор, пока истинно хотя бы одно усло­вие из двух.

Для успо­коения совес­ти модифи­циру­ем код нашего при­мера, ском­пилиру­ем его и пос­мотрим на резуль­тат:

int main() {

for (int a = 0, b = 10; (a < 10 || b > 5); a++, b--)

std::cout << a << " | " << b << std::endl;

}

Ре­зуль­тат выпол­нения при­мера some_counters

В осталь­ном же цик­лы с нес­коль­кими счет­чиками тран­сли­руют­ся ана­логич­но цик­лам с одним счет­чиком, за исклю­чени­ем того, что ини­циали­зиру­ется и модифи­циру­ется не одна, а сра­зу нес­коль­ко перемен­ных.

ИДЕНТИФИКАЦИЯ CONTINUE

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

Нап­ример:

while (a++ < 10)

if (a == 2)

continue;

...

Ком­пилиру­ется приб­лизитель­но так.

СЛОЖНЫЕ УСЛОВИЯ

До сих пор, говоря об усло­виях завер­шения и про­дол­жения цик­ла, мы рас­смат­ривали лишь эле­мен­тарные усло­вия отно­шения, в то вре­мя как прак­тичес­ки все язы­ки высоко­го уров­ня допус­кают исполь­зование сос­тавных усло­вий. Одна­ко сос­тавные усло­вия мож­но схе­матич­но изоб­разить в виде абс­трак­тно­го «чер­ного ящи­ка» с вхо­дом/выходом и логичес­ким дво­ичным деревом внут­ри. Пос­тро­ение и реконс­трук­ция логичес­ких деревь­ев под­робно рас­смат­рива­ются в раз­деле «Иден­тифика­ция IF — THEN — ELSE», здесь же нас инте­ресу­ет орга­низа­ция цик­лов, а не сами усло­вия.

Вложенные циклы

Цик­лы, понят­ное дело, могут быть и вло­жен­ными. Казалось бы, какие проб­лемы? Начало каж­дого цик­ла надеж­но опре­деля­ется по перек­рес­тной ссыл­ке, нап­равлен­ной вниз. Конец цик­ла — условный или безус­ловный переход на его начало. У каж­дого цик­ла толь­ко одно начало и толь­ко один конец (хотя усло­вий выхода может быть сколь­ко угод­но, но это дру­гое дело). При­чем цик­лы не могут пересе­кать­ся; если меж­ду началом и кон­цом одно­го цик­ла встре­чает­ся начало дру­гого цик­ла, то этот цикл — вло­жен­ный.

Но не все так прос­то: тут есть два под­водных кам­ня. Пер­вый: опе­ратор continue в цик­лах с пре­дус­лови­ем, вто­рой — слож­ные усло­вия про­дол­жения цик­ла с пос­тусло­вием. Рас­смот­рим их под­робнее.

Пос­коль­ку в цик­лах с пре­дус­лови­ем опе­ратор continue тран­сли­рует­ся в безус­ловный переход, нап­равлен­ный «вверх», он ста­новит­ся прак­тичес­ки неот­личим от кон­ца цик­ла. Смот­ри:

while (условие1) {

...

if (условие2) continue;

...

}

Тран­сли­рует­ся в сле­дующий код.

Два кон­ца и два начала впол­не напоми­нают два цик­ла, один из которых вло­жен в дру­гой. Прав­да, начала обо­их цик­лов сов­мещены, но ведь может же такое быть, если в цикл с пос­тусло­вием вло­жен цикл с пре­дус­лови­ем? На пер­вый взгляд кажет­ся, что да, но если подумать, то... ай‑ай‑ай! А ведь ус­ловие1 выхода из цик­ла пры­гает аж за вто­рой конец! Если это пре­дус­ловие вло­жен­ного цик­ла, то оно пры­гало бы за пер­вый конец. А если ус­ловие1 — пре­дус­ловие материн­ско­го цик­ла, то конец вло­жен­ного цик­ла не смог бы передать на него управле­ние. Выходит, это не два цик­ла, а один. А пер­вый «конец» — резуль­тат тран­сля­ции опе­рато­ра continue.

С раз­бором слож­ных усло­вий про­дол­жения цик­ла с пос­тусло­вием дела обсто­ят еще луч­ше. Рас­смот­рим такой при­мер:

do {

...

} while(условие1 || условие2);

Ну чем не

do {

do {

...

} while(условие1)

} while(условие2)

Стро­го говоря, пред­ложен­ный вари­ант логичес­ки верен, но син­такси­чес­ки нек­расив. Материн­ский цикл кру­тит в сво­ем теле один лишь вло­жен­ный цикл и не содер­жит никаких дру­гих опе­рато­ров. Так зачем он тог­да, спра­шива­ется, нужен? Сле­дует объ­еди­нить его с вло­жен­ным цик­лом!

ЗАКЛЮЧЕНИЕ

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

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





Report Page