Фундаментальные основы хакерства. Практикуемся в поиске циклов при реверсе
Сегодня мы с тобой научимся находить любые типы циклов в коде реальных программ на языках высокого уровня — построенных с оптимизацией и без нее. Это шаг, с которым ты столкнешься при обратной разработке приложений. При этом определение цикла — это только полдела. Далее нужно разобраться в его механизме, выделить используемые данные и корректно декомпилировать, превратив в стройный код на языке высокого уровня. Без этого точное понимание алгоритма программы невозможно.
ЦИКЛЫ WHILE/DO
Visual C++ 2022 с отключенной оптимизацией
Для закрепления пройденного в прошлой статье материала рассмотрим несколько живых примеров. Начнем с самого простого — идентификации циклов while/do:
printf("Оператор цикла while\n");
printf("Оператор цикла do\n");
Откомпилируем этот код с помощью Visual C++ 2022 с отключенной оптимизацией.
Результат компиляции должен выглядеть примерно так:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:0000000140004018↓o
; Резервируем память для двух локальных переменных,
; Только откуда взялась вторая?
; Заносим в переменную var_18 значение 0
; Следовательно, это переменная a
Ниже следует перекрестная ссылка — loc_1400010EC
, направленная вниз. Это говорит нам о том, что перед нами начало цикла. Поскольку перекрестная ссылка направлена вниз, то переход, ссылающийся на этот адрес, будет направлен вверх!
loc_1400010EC: ; CODE XREF: main+31↓j
; Загружаем в EAX значение переменной a (var_18)
; Загружаем в var_14 значение переменной a, вот, мы нашли,
; где используется вторая переменная
; Зачем-то снова загружаем то же значение в регистр EAX
; Увеличение значения в регистре EAX на 1
; Загружаем значение из регистра EAX в переменную var_18 ("a")
; Сравниваем старое (до обновления) значение переменной a,
; ранее сохраненное в var_14, с числом 0xA
Если (var_14 >= 0xA
), делаем прыжок «вперед», непосредственно за инструкцию безусловного перехода, направленного «назад». Если выполняется прыжок «назад», значит, это цикл, а поскольку условие выхода из цикла проверяется в его начале, то это цикл с предусловием.
Для его отображения на цикл while
необходимо инвертировать условие выхода из цикла на условие продолжения цикла, другими словами, исправить >=
на <
.
Сделав это, мы получаем: while (a++ < 0xA)...
.
; заносим ссылку на строку "Оператор цикла while\n"
; Безусловный переход, направленный назад, на метку loc_1400010EC — в начало цикла,
; в область подготовки переменных для проверки
Между loc_1400010EC
и jmp short loc_1400010E
есть только одно условие выхода из цикла: jge short loc_140001113
. Значит, исходный код цикла выглядел так:
while (a++ < 0xA) printf("Оператор цикла while\n");
Далее следует начало цикла с постусловием. Однако на данном этапе мы этого еще не знаем, хотя и можем догадаться благодаря наличию перекрестной ссылки, направленной вниз.
loc_140001113: ; CODE XREF: main+23↑j
Ага, никакого условия в начале цикла не присутствует, значит, это цикл с условием в конце или в середине.
; Заносим ссылку на строку "Оператор цикла do\n"
lea rcx, byte_140002278 ; _Format
; Загружаем в EAX значение переменной var_18 ("a")
; Уменьшаем значение в EAX на 1
; Возвращаем значение из EAX в переменную a — var_18
; Сравниваем переменную a с нулем
; Если (a > 0), делаем переход в начало цикла
Поскольку условие расположено в конце цикла, это цикл do
:
do printf("Оператор цикла do\n");
Visual C++ 2022 с включенной оптимизацией
Совсем другой результат получится, если включить оптимизацию. Откомпилируем тот же самый пример с ключом /O2
(максимальная оптимизация: приоритет скорости) и посмотрим на результат, выданный компилятором:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:000000014000400C↓o
; Подготавливаем стек, ни одной локальной переменной не объявлено
; В EBX кладем число 0xA. Для чего, пока неясно
; Судя по следующей перекрестной ссылке, направленной вниз, это цикл!
loc_140001080: ; CODE XREF: main+20↓j
; Заносим в регистр RCX ссылку на строку "Оператор цикла while\n"
; Если это тело цикла, то где же предусловие?!
; Получается, что число 0xA, помещенное в EBX ранее, было начальным значением
Инструкция SUB
подобно CMP
изменяет состояние флага нуля. Если в результате вычитания получается 0, флаг нуля возводится в единицу. Следующая инструкция совершает прыжок назад, когда флаг не возведен, то есть в результате вычитания регистр RBX
не стал равен нулю.
Компилятор в порыве оптимизации превратил неэффективный цикл с предусловием в более компактный и быстрый цикл с постусловием. Имел ли он на это право? А почему нет?! Проанализировав код, компилятор понял, что этот цикл выполняется по крайней мере один раз. Следовательно, скорректировав условие продолжения, его проверку можно вынести в конец цикла.
Также в исходном тексте был инкремент счетчика цикла от нуля до 0xA
, а в подготовленном транслятором коде мы видим обратный эффект: декремент счетчика от 0xA
до нуля. Таким образом, компилятор while ((int a=0)+1) < 10) printf(...)
заменил do printf(...) while ((int a=10)-1) > 0)
.
Причем, что интересно, он не сравнивал переменную цикла с константой, а поместил константу в регистр и уменьшал его до тех пор, пока тот не стал равен нулю! Зачем? А затем, что так короче, да и работает быстрее.
Хорошо, но как нам декомпилировать этот цикл? Непосредственное отображение на язык C/C++ дает следующую инструкцию:
printf("Оператор цикла while\n");
Вполне красивый и оптимальный цикл с одной переменной.
; Значение 0xB помещаем в регистр EBX. Это подготовка к следующему циклу
; Этот код выполняется после завершения предыдущего цикла
nop word ptr [rax+rax+00000000h]
; Перекрестная ссылка, направленная вниз, говорит нам о том, что это начало цикла
loc_1400010A0: ; CODE XREF: main+40↓j
; Предусловия нет, значит, это цикл do
; Заносим в регистр RCX ссылку на строку "Оператор цикла do\n"
lea rcx, byte_140002278 ; _Format
; Уменьшаем значение, загруженное в EBX, на единицу
; Проверяем EBX на равенство нулю
; Продолжаем выполнение цикла, пока EBX > 0
Этот цикл прямиком отображается в конструкцию языка C/C++:
do { printf("Оператор цикла do\n"); }
C++Builder 10 без оптимизации
Несколько иначе обрабатывает циклы компилятор Embarcadero C++Builder 10.4. Смотри пример while-do_cb
:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; DATA XREF: __acrtused+29↑o
; Резервируем память для локальных переменных
; Помещаем в RBP указатель на дно стека
; в var_4 записывает 0, вероятно, это переменная a из исходного кода
; Еще одна переменная, изначально равная нулю, возьмем на заметку
; Ниже перекрестная ссылка, направленная вниз, значит, это начало какого-то цикла
loc_40141F: ; CODE XREF: main+3E↓j
; В начале цикла условие не обнаружено, видимо, цикл с постусловием,
; хотя не будем спешить с выводами
; В регистр EAX копируем значение из переменной var_14
; Увеличиваем значение в регистре ECX на 1
; Увеличенное значение из регистра ECX копируем в переменную var_14,
; из которой берется значение для счетчика в начале итерации
; Сравнение неувеличенного значения с 0хА
; Если это значение больше константы или равно ей,
; выполняем прыжок за пределы цикла в область старших адресов
; В случае продолжения выполнения помещаем ссылку на строку в регистр
lea rcx, aOperatorIklaWh ; "Оператор цикла while\n"
; Зачем-то сохраняем текущее значение регистра EAX в переменной var_18...
; ... и выполняем безусловный переход в начало цикла
Вот так‑то C++Builder оптимизировал код! Начальный цикл с предусловием выполнения он превратил в бесконечный цикл с условием выхода посередине (за подробностями обратись к прошлой статье)! Как мы можем декомпилировать этот цикл? Напрашивается такой вариант:
printf("Оператор цикла while\n");
Этот вариант кардинально отличается от первоначального, и я очень сомневаюсь, что в лучшую сторону! Что ж, издержки производства...
; --------------------------------
loc_401440: ; CODE XREF: main+2D↑j
; Сюда происходит переход при выходе из предыдущего цикла
; Как мы знаем, эта инструкция только переводит управление через себя
; --------------------------------
loc_401442: ; CODE XREF: main:loc_401440↑j
; Как видим, он начинается с вывода строки, нет условия, значит, цикл с постусловием
lea rcx, aOperatorIklaDo ; "Оператор цикла do\n"
Проматываем дизассемблерный листинг вверх, чтобы вспомнить, какое значение находится в регистре EAX
. Значит, в этом месте программы значение в регистре EAX
равно 0хА
. Записываем это значение в переменную var_1C
(непонятно, для каких целей, ведь в будущем она не используется). Выходит, локальную переменную a
исходной программы представляет регистровая переменная EAX
.
; Записываем в регистр EAX значение переменной var_14
; А в ней содержится значение на 1 больше, чем в EAX! То есть 0xB
; Вместо реального вычитания он прибавляет к значению в EAX -1
; Присваивает результат переменной var_14
; И сравнивает уменьшенное значение с нулем
; Если (EAX > 0), то мы прыгаем назад к началу «нового цикла»
; и осуществлению очередной итерации
Во что C++Builder превратил изначальный цикл с постусловием? В целом никаких изменений он не внес, оставив все на своих местах. И декомпилированный листинг этого цикла должен выглядеть примерно так:
printf("Оператор цикла while\n");
; В ином случае, когда (EAX <= 0), пропускаем переход
; и продолжаем выполнение кода программы
C++Builder 10 с оптимизацией
И совсем прямолинейный код транслирует C++Builder с включенной оптимизацией по скорости.
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; DATA XREF: __acrtused+29↑o
lea rsi, aOperatorIklaWh ; "Оператор цикла while"
lea rsi, aOperatorIklaDo ; "Оператор цикла do"
Без всяких ухищрений этот код десять раз выводит строку «Оператор цикла while» и одиннадцать — строку «Оператор цикла do». Быть максимально простым значит быть максимально быстрым!
Delphi 10
На закуску посмотрим, как разбирается с циклами Embarcadero Delphi 10 (пример while_repeat_d
). Немного изменим код, чтобы его проглотил компилятор:
writeln('Оператор цикла while');
writeln('Оператор цикла repeat/until');
Для удобства скомпилим с дебажной информацией, и для начала — с отключенной оптимизацией.
Посмотрим на результат в дизассемблере:
public _ZN14While_repeat_d14initializationEv
_ZN14While_repeat_d14initializationEv proc near
; DATA XREF: HEADER:0000000000400128↑o
; __unwind { // _ZN6System23_DelphiExceptionHandlerEPNS_16TExceptionRecordEyPvS2_
; Блок кода инициализации приложения
mov cs:_ZN14While_repeat_d1aE, 0
Далее идет ее сравнение с числом 0xA
. Можно подумать, что мы имеем цикл с предусловием, однако не будем торопиться с выводами. Ведь мы еще не встретили ни одной перекрестной ссылки!
cmp cs:_ZN14While_repeat_d1aE, 0Ah
; Если (a >= 0xA), пропускаем последующий цикл и переходим к следующему блоку кода
loc_428F72: ; CODE XREF: _ZN14While_repeat_d14initializationEv+60↓j
; Перекрестная ссылка говорит нам о начале цикла
; Ниже идет блок кода для вывода строки, не будем на нем подробно останавливаться...
call _ZN6System14_Write0UStringERNS_8TTextRecENS_13UnicodeStringE
call _ZN6System8_WriteLnERNS_8TTextRecE
; ... здесь он заканчивается и переменная а увеличивается на 1
add cs:_ZN14While_repeat_d1aE, 1
; Сверка получившегося значения с 0xA, значит, все-таки имеем цикл с постусловием,
; компилятор изменил цикл с предусловием, который был в исходном коде!
cmp cs:_ZN14While_repeat_d1aE, 0Ah
; Если (a < 0xA), прыгаем назад в начало цикла
loc_428FA2: ; CODE XREF: _ZN14While_repeat_d14initializationEv+30↑j
Сюда происходит переход после завершения предыдущего цикла, а также в случае, если при проверке перед входом в цикл оказывается, что переменная a
больше значения 0хА
либо равна ему.
loc_428FA3: ; CODE XREF: _ZN14While_repeat_d14initializationEv+91↓j
; Перекрестная ссылка — явный намек на начало цикла
; Он начинается с блока кода вывода строки, значит, имеем дело с циклом с постусловием
call _ZN6System14_Write0UStringERNS_8TTextRecENS_13UnicodeStringE
call _ZN6System8_WriteLnERNS_8TTextRecE
; Уменьшаем значение переменной a на 1
sub cs:_ZN14While_repeat_d1aE, 1
; Сравниваем ее значение с нулем
cmp cs:_ZN14While_repeat_d1aE, 0
; Если (a >= 0), переходим в начало цикла...
; когда переменная примет значение меньше нуля,
; выполняем блок кода завершения приложения
; --------------------------------
; --------------------------------
call _ZN6System19_UnhandledExceptionEv
; --------------------------------
loc_428FE2: ; CODE XREF: _ZN14While_repeat_d14initializationEv+98↑j
_ZN14While_repeat_d14initializationEv endp
Как оказалось, оптимизированный вариант ничем не отличается от текущего, поэтому рассматривать первый нет никакого смысла. Лучше перейдем к следующему типу циклов.
ЦИКЛЫ FOR
Visual C++ 2022 без оптимизации
Рассмотрим следующий пример for_cycle
:
printf("Оператор цикла for\n");
Результат компиляции Microsoft Visual C++ 2022 с отключенной оптимизацией будет выглядеть так:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:0000000140004018↓o
; Объявляем локальную переменную
; Резервируем для нее место в стеке
; Инициализируем переменную a нулем
; Непосредственный переход на код проверки условия продолжения цикла —
; характерный признак цикла for
; --------------------------------
loc_1400010EE: ; CODE XREF: main+2B↓j
; Загрузка в EAX значение переменной a
; Обновление переменной a. Следовательно, исходный код выглядел так: a++
loc_1400010F8: ; CODE XREF: main+C↑j
; Сравниваем переменную a со значением 0xA
; Выходим из цикла, если a >= 0xA
; Иначе продолжаем выполнение и выводим строку
; Безусловный переход в начало цикла
; --------------------------------
loc_14000110D: ; CODE XREF: main+1D↑j
Расположенная в начале цикла проверка на завершение говорит о том, что это цикл с предусловием, но непосредственно выразить его через while
не удается: мешает безусловный переход в середину цикла, минуя код инкремента переменной var_18
. Однако этот цикл с легкостью отображается на оператор for
, смотри:
for (int a = 0; a < 0xA; a++) printf("Оператор циклаfor\n");
Действительно, цикл for
сначала инициирует переменную‑счетчик, затем проверяет условие продолжения цикла (оптимизируемое компилятором в условие завершения), далее выполняет оператор цикла, модифицирует счетчик, вновь проверяет условие и так далее.
Visual C++ 2022 с применением оптимизации
А теперь задействуем оптимизацию и посмотрим, как видоизменится наш цикл:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:000000014000400C↓o
; Инициализируем регистровую переменную-счетчик
; Внимание! В исходном коде начальное значение счетчика равнялось нулю!
loc_140001080: ; CODE XREF: main+20↓j
Выполняем оператор цикла! Причем безо всяких проверок! Хитрый компилятор проанализировал код и понял, что цикл выполняется по крайней мере один раз.
Уменьшаем счетчик, хотя в исходном коде программы мы его увеличивали.
Ну правильно: sub reg, const / jnz xxx
короче, чем: INC / CMP reg, const / jnz xxx
.
Ой и мудрит компилятор! Кто же ему давал право так изменять цикл?!
А дело вот в чем: он понял, что параметр цикла в самом цикле используется только как счетчик и нет никакой разницы, увеличивается он с каждой итерацией или уменьшается.
; Переход в начало цикла, если RBX > 0
Внешне это типичный код следующего вида:
printf("Оператор цикла while\n");
Если тебя устраивает читабельность такой формы записи, оставляй ее. Если же нет, тогда код можно представить так:
for (int a = 0; a < 10; a++) printf("Оператор цикла for\n");
Постой, постой! На каком основании мы выполнили такое преобразование? А на том же самом, что и компилятор: раз параметр цикла используется только как счетчик, законна любая запись, выполняющая цикл ровно десять раз. Остается выбрать ту, которая удобнее и красивее. Никто же не будет утверждать, что for (a = 10; a > 0; a--)
более привычно, чем for (a = 0; a < 10; a++)
?
C++Builder 10
А что покажет нам товарищ C++Builder без оптимизации? Компилируем и смотрим пример for_cycle
:
main proc near ; DATA XREF: __acrtused+29↑o
; Со стула упасть можно! пять переменных для маленького цикла!
loc_40141F: ; CODE XREF: main+3D↓j
После инициализации переменных сразу следует начало цикла, где на первом месте нас ожидает проверка переменной a
. Из этого следует, что C++Builder цикл for
превратил в цикл while
с предусловием.
; Если (a >= 0xA), выходим из цикла
lea rcx, aOperatorIklaFo ; "Оператор цикла for\n"
; Копируем в EAX значение переменной a
; Увеличиваем значение в регистре на 1
; Безусловный переход в начало цикла
; --------------------------------
loc_40143F: ; CODE XREF: main+23↑j
Видно, что C++Builder 10 не дотягивает до Visual C++ 2022. Ему даже не хватило ума проверить, выполняется ли цикл хотя бы один раз, чтобы превратить цикл for
в цикл с постусловием. Вместо этого он сделал цикл с предусловием, однако заменил условие продолжения выполнения на условие прекращения!
С включенной оптимизацией C++Builder компилирует цикл for
точно так же, как циклы while
и do
. То есть выкидывает любое упоминание о цикле и просто десять раз выводит заданную строчку.
ЦИКЛЫ С УСЛОВИЕМ В СЕРЕДИНЕ
Идентификация break
Теперь настала очередь циклов с условием в середине или циклов, завершаемых вручную оператором break
. Рассмотрим следующий пример.
Visual C++ 2022 без оптимизации
Результат компиляции в Visual C++ 2022 с отключенной оптимизацией должен выглядеть так (пример cycle_break_vc
):
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:0000000140004018↓o
; Резервируем место для единственной локальной переменной
; Присваиваем переменной var_a значение 0
; Перекрестная ссылка, направленная вниз, — цикл
loc_1400010EC: ; CODE XREF: main+3E↓j
Обрати внимание: когда оптимизация отключена, компилятор транслирует безусловный цикл «слишком буквально». Так, обнулив EAX
, он педантично проверяет его значение на равенство единице, чего произойти не может! Если в кои‑то веки FALSE
будет равно TRUE
— произойдет выход из цикла.
Словом, все эти инструкции — глупый и бесполезный код цикла while (1)
!
; Если операнды одинаковые, то результат станет 0 и будет возведен флаг нуля,
; в результате чего произойдет выход из цикла
; В EAX помещаем значение переменной a
; Возвращаем значением из EAX в переменную var_a
; Сравниваем значение переменной var_a с константой 2 — пределом цикла
Переход выполняется, если (var_a <= 0x2
). Но куда ведет этот переход? Во‑первых, переход направлен вниз, то есть это уже не переход к началу цикла. Следовательно, и условие — не условие цикла, а результат компиляции конструкции IF — THEN
. Второе — переход прыгает на первую команду, следующую за безусловным jmp short loc_140001120
. А тот передает управление инструкции, следующей за командой jmp short loc_1400010EC
— безусловного перехода, направленного вверх, — в начало цикла. Следовательно, jmp short loc_140001120
осуществляет выход из цикла, а jle short loc_140001112
продолжает его выполнение.
Ниже идет переход на завершение цикла. А кто у нас завершает цикл? Ну конечно же, break
! Следовательно, окончательная декомпиляция выглядит так: if (++var_a > 0x2) break;
.
Мы инвертировали <=
в >
, так как JLE
передает управление на код продолжения цикла, а ветка THEN
в нашем случае — на break
.
; --------------------------------
; Перекрестная ссылка направлена вверх, следовательно, это не начало цикла
loc_140001112: ; CODE XREF: main+2E↑j
; Прыжок в начало цикла. Вот мы и добрались до конца цикла
Восстанавливаем исходный код:
; --------------------------------
; Сюда происходит переход в момент выхода из первого цикла. По всей видимости, это перекрестная ссылка — начало второго цикла
loc_140001120: ; CODE XREF: main+11↑j
; Уменьшаем значение в EAX: --var_a
; После возврата значения в память сравниваем значение переменной с нулем
; Если var_a >= 0, выполняем переход вниз
Оператор break
цикла do
ничем не отличается от break
цикла while
! Поэтому не будем разглагольствовать, а сразу его декомпилируем: if (var_a < 0) break;
.
; Очевидно, это break, ведущий в конец программы
; --------------------------------
loc_14000113F: ; CODE XREF: main+5B↑j
; А это проверка продолжения цикла: while (1);
loc_140001152: ; CODE XREF: main+5D↑j
Что ж, оператор break
в обоих циклах выглядит одинаково и элементарно распознается (правда, не с первого взгляда, но при отслеживании нескольких переходов — да). А вот с бесконечными циклами неоптимизирующий компилятор подкачал: полученный код проверяет условие, истинность (неистинность) которого очевидна. Интересно, как поведет себя оптимизирующий компилятор?
Visual C++ 2022 с оптимизацией
Давай откомпилируем тот же самый пример компилятором Microsoft Visual C++ 2022 с ключом /O2
(оптимизация по скорости) и посмотрим, что получится:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:000000014000400C↓o
; Объявляем и инициализируем аргумент
; printf("1-й оператор\n"); — выводим строку до начала какого-либо цикла!
; Значит, компилятор предсказал хотя бы одно выполнение итерации цикла
; Инициализация регистровых переменных:
; Записав в EBX значение 2: var_EBX = 2, очень похожее на потолок нашего цикла,
; копируем это значение в EDI: var_RDI = 2
; Перекрестная ссылка, направленная вперед. Это начало цикла
loc_140001090: ; CODE XREF: main+3C↓j
Ага! В начале цикла нет никаких проверок. Получается, оптимизирующий компилятор превратил цикл с предусловием в цикл с постусловием и предсказал еще одну выполненную итерацию. Вполне возможно, где‑то в его середине притаилось условие выхода.
Посмотри‑ка! Оказывается, итерация состоит из вывода двух строк, а перед началом цикла была выведена только одна. Значит, между командами печати находится условие выхода из цикла!
Вычитаем из RDI
число 1 (var_RDI--
)... Постой, в исходном коде в первом цикле был инкремент! Такие преобразования компилятору никто делать не запрещает. Если ему кажется, что так быстрее, его право.
Инструкция SUB
позволила не применять CMP
для сравнения значений (вот тебе и ускорение!). Если (var_RDI > 0
), переходим к началу цикла. Следовательно, выход из цикла произойдет, когда выполнится условие (var_RDI <= 0
).
Как видно, оптимизирующий компилятор выкинул никому не нужные проверки условия, упростив код и облегчив его понимание. Производим декомпиляцию первого цикла:
for (;;) // Вырожденный for представляет собой бесконечный цикл
Такой цикл явно лучше, чем while(true)
!
После конца первого цикла до начала второго вызывается первый оператор для вывода строки, точно так же, как это было в начале программы:
; Перекрестная ссылка — начало второго цикла
loc_1400010C0: ; CODE XREF: main+6B↓j
; А цикл начинаем с оператора № 2 вывода строки, подобно тому, как это было в первом цикле
; Уменьшаем значение в регистре EBX на 1: var_EBX--
; Как мы помним, в EBX находится потолок цикла, равно как было в RDI
Переход в начало цикла осуществляется, когда флаг SF == 0
. Следовательно, выход из цикла происходит, когда флаг SF == 1
, а он будет равен единице только в случае, если в результате предыдущего вычитания получилось отрицательное значение.
Все это намекает на следующий код второго цикла:
; Восстанавливаем регистры перед выходом
Для представления бесконечных циклов в декомпилированном коде мы воспользовались вырожденным for
, который не имеет ни пред-, ни постусловия, потому что дизассемблированный листинг не дал нам ни малейшего намека на присутствие цикла другого типа!
Да... тут есть над чем подумать! Компилятор нормально «переварил» первую строку цикла printf("1-й оператор\n")
, а затем «напоролся» на ветвление: if (--a < 0) break
. Хитрые парни из Microsoft знают, что для суперскалярных суперконвейерных процессоров, к которым относятся современные чипы от Intel и AMD, ветвления все равно что чертополох для Тигры. Вот и приходится компилятору исправлять ляпы программиста, что он делать в принципе не обязан, но за что ему большое человеческое спасибо!
Компилятор как бы «прокручивает», «слепляя» вызовы функций printf
и вынося ветвления в конец. Образно исполняемый код можно представить трассой, а процессор — гонщиком. Чем длиннее участок дороги без поворотов, тем быстрее его проскочит гонщик. Выносить условие из середины цикла в его конец вполне допустимо, ведь переменная, относительно которой выполняется ветвление, не модифицируется ни функцией printf
, ни какой‑либо другой. Поэтому не все ли равно, где ее проверять? Конечно же, не все равно!
К моменту, когда условие --а < 0
становится истинно, успевает выполниться первый printf
, а вот второй уже не получает управления. Вот для этого‑то компилятор и поместил код проверки условия следом за первым вызовом первой функции printf
, а затем изменил порядок вызова printf
в теле цикла. Это привело к тому, что на момент выхода из цикла по условию первый printf
выполняется на один раз больше, чем второй, так как он встречается дважды.
Ох и непросто разобраться во всей этой головоломке, а представь, дорогой друг, насколько сложно реализовать компилятор, умеющий проделывать такие фокусы!
А еще кто‑то ругает автоматическую оптимизацию. Да уж! Конечно, руками‑то можно и круче оптимизировать (особенно понимая смысл кода), но ведь эдак и мозги недолго вывихнуть! А компилятор, даже будучи стиснут со всех сторон кривым кодом программиста, за доли секунды успевает его довольно прилично окультурить!
C++Builder 10
Весьма разительно отличается тот же пример, скомпилированный с помощью Embarcadero C++Builder. При этом с включенной оптимизацией по скорости (O2), как и в прошлые разы, получается груда последовательных вызовов функций вывода строк. А вот вариант без оптимизации вполне заслуживает нашего внимания и критики:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; DATA XREF: __acrtused+29↑o
; Объявляется восемь локальных переменных!
; Инициализация некоторых из них
loc_40141F: ; CODE XREF: main+4D↓j
Все достаточно прямолинейно: вызов оператора вывода первой строки — printf("1-й оператор\n")
. Заметь, проверок нет, значит, все‑таки компилятор переделал цикл с предусловием в цикл с постусловием или условием в середине.
lea rcx, a1IOperator ; "1-й оператор\n"
; Регистру EDX присваиваем значение переменной var_14, а там у нас изначально 0,
; Увеличиваем значение в регистре на 1...
; ...и возвращаем значение в память
; Сравниваем значение var_EDX с константой 2
; Если (var_EDX <= 2), продолжаем выполнять цикл...
; ...иначе «выпрыгиваем» из первого цикла
; --------------------------------
loc_40143E: ; CODE XREF: main+3A↑j
Продолжая выполнение первого цикла, выводим вторую строку — printf("2-й оператор\n")
. Отчетливо видно, что в теле цикла, между двумя операторами вывода строк, находится условие для выхода из цикла.
lea rcx, a2IOperator ; "2-й оператор\n"
; Безусловный переход к началу первого цикла
Результирующая декомпиляция первого цикла:
; --------------------------------
loc_40144F: ; CODE XREF: main+3C↑j
; --------------------------------
loc_401451: ; CODE XREF: main:loc_40144F↑j
lea rcx, a1IOperator ; "1-й оператор\n"
; Значение переменной var_14 присваиваем регистру EDX, а в ней у нас осталось число 2:
; Если (var_EDX >= 0), продолжаем выполнение цикла
; Иначе идем в конец программы прибирать свои нули
; --------------------------------
loc_401470: ; CODE XREF: main+6C↑j
; В продолжение выполнения цикла выводим строку № 2
lea rcx, a2IOperator ; "2-й оператор\n"
; Если (ZF == 0), переходим к началу второго цикла,
; в данных условиях ZF никогда не будет равна единице, так как ((test al, 1) != 0)
; Перепрыгнув инструкцию, идем на выход
Итоговая декомпиляция второго цикла:
; --------------------------------
loc_401487: ; CODE XREF: main+6E↑j
; На выходе прибираем за собой
Компилятор C++Builder при трансляции бесконечных циклов заменяет код проверки условия продолжения цикла безусловным переходом. Но вот оптимизировать ветвления, вынося их в конец цикла так, как это делает Visual C++ 2022, он не умеет.
Идентификация continue
Теперь, после break
, рассмотрим, как компиляторы транслируют его «астральный антипод» — оператор continue
. Взглянем на следующий пример (cycle_continue
).
Visual C++ 2022 с выключенной оптимизацией
Результат работы компилятора Visual C++ 2022 с отключенной оптимизацией будет выглядеть так:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:0000000140004018↓o
; Присваиваем локальной переменной var_a значение 0
loc_1400010EC: ; CODE XREF: main+2C↓j main+3E↓j
Две перекрестные ссылки, направленные вперед, говорят о том, что это либо начало двух циклов (один из которых вложенный), либо переход в начало цикла оператором continue
.
; Загружаем в EAX значение var_a
; Загружаем в var_14 значение EAX
; Инкрементируем значение в регистре EAX
; Сравниваем значением var_a до увеличения с числом 0xA
; Выход из первого цикла (переход в начало второго цикла), если var_a >= 0xA
; Сравниваем var_a со значением 0x2
Если (var_a != 2)
, выполняется прыжок на команду, следующую за инструкцией безусловного перехода, которая направлена вверх — в начало цикла. Очень похоже на условие выхода из цикла, но не будем спешить с выводами! Вспомним, в начале цикла нам встретились две перекрестные ссылки. Безусловный переход jmp short loc_1400010EC
как раз образует одну из них. А кто «отвечает» за другую? Чтобы это узнать, необходимо проанализировать остальной код цикла.
Направленный в начало цикла безусловный переход — это либо конец цикла, либо continue
. Предположим, что это конец цикла. Тогда что же представляет собой jge short loc_140001120
? Предусловие выхода из цикла? Не похоже, в таком случае оно прыгало бы гораздо «ближе» — на метку loc_14000110E
.
А может, jge short loc_140001120
— предусловие одного цикла, а jnz short loc_14000110E
— постусловие другого, вложенного в него? Вполне возможно, но маловероятно: в этом случае постусловие представляло бы собой условие продолжения, а не завершения цикла. Поэтому с некоторой долей неуверенности мы можем принять конструкцию: CMP var_a, 2 \ JNZ loc_14000110E \ JMP loc_1400010EC
за if (a == 2) continue
.
; --------------------------------
loc_14000110E: ; CODE XREF: main+2A↑j
А вот это явно конец цикла, так как jmp short loc_1400010EC
— самая последняя ссылка на начало цикла. Итак, подытожим. Расположенное в начале цикла условие крутит этот цикл до тех пор, пока var_a < 0xA
, причем инкремент параметра цикла происходит до его сравнения. Затем следует еще одно условие, возвращающее управление в начало цикла, если var_a == 2
. Строй замыкает оператор цикла printf
и безусловный переход в его начало.
Таким образом, получается следующая картина.
Условие «ближнего» продолжения не может быть концом цикла, так как тогда условию «далекого» выхода пришлось бы выйти аж из надлежащего цикла, на что ни break
, ни другие операторы не способны. Таким образом, условие «ближнего» продолжения может быть только оператором continue
, и на языке C/C++ вся эта конструкция будет выглядеть так:
while(a++ < 10) // <- инкремент var_a и условие далекого выхода
if (a == 2) continue; // <- условие «ближнего» продолжения
printf("%x\n",var_a); // <- тело цикла
} // <- безусловный переход на начало цикла
; --------------------------------
loc_140001120: ; CODE XREF: main+23↑j main+68↓j
; Сравниваем переменную var_a с числом 0х2
; Если var_a != 2, то продолжение цикла
; Переход к коду проверки условия продолжения цикла
; Это, бесспорно, continue, и вся конструкция выглядит так:
; --------------------------------
loc_140001129: ; CODE XREF: main+45↑j
lea rcx, asc_140002254 ; "%x\n"
loc_140001139: ; CODE XREF: main+47↑j
; Обновление переменной в памяти значением из регистра
; Пока (var_a > 0), продолжать цикл
Похоже на постусловие. В таком случае исходный код должен выглядеть так:
Visual C++ 2022 с включенной оптимизацией
А теперь посмотрим, как повлияла оптимизация (/O2
) на вид циклов:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:000000014000400C↓o
loc_140001078: ; CODE XREF: main+20↓j
; Переход на метку loc_14000108D, если var_EBX == 2
; Если не ушли на метку в случае var_EBX != 2, выводим var_EBX на экран:
Следовательно, эту ветку кода можно изобразить так:
"if (var_EBX ! = 2) printf("%x\n", var_EBX);"
loc_14000108D: ; CODE XREF: main+D↑j
; Продолжение цикла, пока (var_EBX++ < 0xA)
В итоге мы можем декомпилировать цикл следующим образом:
А что, выглядит вполне читабельно, не правда ли? Ничуть не хуже, чем
loc_140001094: ; CODE XREF: main+3B↓j
; Переход на метку loc_1400010A7, если (var_EBX == 2)
; Эта ветка выполняется, лишь когда (var_EBX != 2)
loc_1400010A7: ; CODE XREF: main+27↑j
; Условие продолжения цикла — крутить, пока (var_EBX > 0)
В итоге декомпилированный цикл выглядит примерно так:
C++Builder 10 с включенной оптимизацией
Довольно интересный образец кода получается у C++Builder 10.4 с включенной оптимизацией по скорости — /O2
:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; DATA XREF: __acrtused+29↑o
; Ни одной локальной переменной, зато массовое использование регистров
; В RSI помещаем указатель на форматную строку
nop dword ptr [rax+rax+00000000h]
loc_401420: ; CODE XREF: main+2A↓j main+3C↓j
; Если (var_EDI == 2), переходим в начало цикла,
; это означает условие с continue
; Первый параметр для printf — форматная строка
; Второй параметр для printf — число для вывода
; Таким хитрым образом C++Builder инкрементирует значение регистровой переменной
; В var_EDI хранится значение счетчика до инкремента
; Прыгаем в начало цикла, пока (var_EDI < 0xA)
На основе имеющихся данных можно декомпилировать этот код:
Он получился излишне перегруженным, оптимизируем:
; Подготовка для второго цикла
nop word ptr [rax+rax+00000000h]
loc_401450: ; CODE XREF: main+5A↓j main+6C↓j
; После выполнения первого цикла в var_EAX находится максимальное значение — 0xA
; Если (var_EDI == 2), переходим в начало (второго) цикла
; Подготовка параметров для вызова printf
; На этот раз декрементируем регистровую переменную
; Переходим в начало цикла, пока (var_EDI > 1)
На основе имеющихся данных этот код можно декомпилировать таким образом:
Оптимизированный вариант ты с легкостью построишь без моей помощи.
У C++Builder получился на редкость стройный код, который приятно читать и легко понимать! Оба цикла C++Builder преобразовал в циклы с постусловием, а условие продолжения расположено в середине. Остальные компиляторы генерируют приблизительно такой же код. Общим для всех случаев будет то, что на циклах с предусловием оператор continue
практически неотличим от вложенного цикла, а на циклах с постусловием continue
эквивалентен элементарному ветвлению.
ЦИКЛЫ FOR С НЕСКОЛЬКИМИ СЧЕТЧИКАМИ
Настала очередь циклов for
, вращающих несколько счетчиков одновременно. Рассмотрим следующий пример.
for (int a = 1, b = 10; (a < 10 || b > 1); a++, b--)
Как мы помним из прошлой статьи, если поставить запятую между условиями, компилятором будет учтено только самое правое из них. Поэтому два и более условия надо соединять логическими операциями.
Дизассемблерный листинг этой программы, построенной в Visual C++ 2022 с отключенной оптимизацией, будет иметь следующий вид:
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:0000000140004018↓o
; Резервируем память для двух локальных переменных
; Инициализируем локальные переменные:
; присваиваем var_a значение 0x1
; присваиваем var_b значение 0xA
; Прыжок на код проверки условия выхода из цикла
; Это характерная черта неоптимизированных циклов for
; --------------------------------
; Перекрестная ссылка, направленная вниз, говорит о том, что это начало цикла
; А выше мы уже выяснили, что тип цикла — for
loc_1400010F6: ; CODE XREF: main+4D↓j
loc_14000110A: ; CODE XREF: main+14↑j
; Если (var_a < 0xA), прыгаем в секцию продолжения цикла и вывода строки
; Выход из цикла, если (var_b <= 0x1)
loc_140001118: ; CODE XREF: main+2F↑j
; Готовим параметры для вызова printf
; printf("%x %x\n", var_a, var_b);
; Безусловный переход в начало цикла
; --------------------------------
loc_14000112F: ; CODE XREF: main+36↑j
Как и полагается, компилятор учел оба условия. Так как между ними стоит OR
, если первое выполняется, второе проверять не имеет смысла, и программа перепрыгивает на стадию вывода строки. Если же первое условие не срабатывает, то программа обрабатывает второе условие.
Оно было инвертировано компилятором на противоположное по сравнению с исходным. Поэтому в случае его выполнения происходит выход из цикла и переход в завершающую секцию. После этого выполнение программы самотеком переходит к подготовке параметров и выводу строки. После чего посредством безусловного перехода осуществляется прыжок в начало цикла.
Этот цикл можно представить как:
if (var_a < 0xA || var_b > 0x1)
printf("%x %x\n", var_a, var_b);
Но по соображениям удобочитаемости имеет смысл скомпоновать этот код в цикл for
:
for (var_a = 1, var_b = 0xA; (a < 10 || b > 1); var_a++, var_b--)
printf("%x %x\n",var_a,var_b);
Компилятор без оптимизации вставляет безусловный переход на код проверки выхода из цикла. А как поведет себя этот же компилятор с включенной оптимизацией?
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:000000014000400C↓o
; Резервируем для него место в стеке
loc_140001082: ; CODE XREF: main+31↓j
; Если (var_EDI < 0xA), переход на метку loc_14000108C, где происходит вывод строки
; Если (var_EBX <= 1), идем на выход
loc_14000108C: ; CODE XREF: main+15↑j
; Готовим параметры, выводим строку
; Осуществляем безусловный переход в начало цикла
; --------------------------------
loc_1400010A3: ; CODE XREF: main+1A↑j
Оптимизированный код выглядит гораздо понятнее и яснее!
Остальные компиляторы, в том числе Visual C++, обрабатывают выражения инициализации и модификации счетчиков корректно в порядке их объявления в тексте программы.
ЗАКЛЮЧЕНИЕ
Циклы, подобно условным операторам, открывают перед компиляторами широкий простор для творчества. В простых случаях одни компиляторы предсказывают каждый шаг выполнения циклов и полностью отказываются от них, другие — строят заковыристые трассы, которые порой тормозят конвейер исполнения процессора. Самые лучшие на деле выправляют код программиста, предоставляя процессору оптимальный маршрут.
На этом можно поставить точку в наших изысканиях с циклами. Мы разгрызли все имеющиеся типы циклов языков C/C++ и Pascal/Delphi. Увидели, как их транслируют современные версии компиляторов этих языков программирования с включенной автоматической оптимизацией и без нее. Автоматическая оптимизация творит с кодом чудеса! Она уменьшает количество условий и переходов, ускоряя программу. Иногда после ее использования дизассемблерный код становится более понятен, чем до нее.
В работе хакера идентификация циклов играет важную роль, потому что, пропустив или неправильно поняв одну метку, можно испортить весь предварительный анализ, даже не поняв, что пропустил цикл! После этого придется начинать трудоемкий анализ всей программы с самого начала.