Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
На первый взгляд кажется, что в распознавании арифметических операций нет ничего сложного. Однако даже неоптимизирующие компиляторы используют ряд хитрых приемов, которые превращают нахождение математических операторов в головную боль. Давай изучим теорию и потренируемся на практике обнаруживать математические операции в бинарном коде программ, подготовленных разными компиляторами.
Фундаментальные основы хакерства
Пятнадцать лет назад эпический труд Криса Касперски «Фундаментальные основы хакерства» был настольной книгой каждого начинающего исследователя в области компьютерной безопасности. Однако время идет, и знания, опубликованные Крисом, теряют актуальность. Редакторы «Хакера» попытались обновить этот объемный труд и перенести его из времен Windows 2000 и Visual Studio 6.0 во времена Windows 10 и Visual Studio 2019.
Ссылки на другие статьи из этого цикла ищи на странице автора.
В общем случае оператор + транслируется либо в машинную инструкцию ADD
, «перемалывающую» целочисленные операнды, либо, с учетом наличия в процессоре поддержки SSE (а без нее процессоры уже давным‑давно не выпускаются), в инструкцию ADDSS
, обрабатывающую вещественные значения одинарной точности, и ADDSD
— двойной точности.
Оптимизирующие компиляторы могут заменять ADD xxx, 1
более компактной командой INC xxx
, а конструкцию c = a + b + const
транслировать в машинную инструкцию LEA c, [a + b + const]
. Такой трюк позволяет одним махом складывать несколько переменных, возвратив полученную сумму в любом регистре общего назначения, — необязательно в левом слагаемом, как это требует мнемоника команды ADD
. Однако LEA
не может быть непосредственно декомпилирована в оператор +, поскольку она используется не только для оптимизированного сложения (что, в общем‑то, только побочный продукт ее деятельности), но и по своему прямому назначению — для вычисления эффективного смещения.
Рассмотрим пример demo_plus, демонстрирующий использование оператора + со значениями одинарной точности:
#include <iostream>int main(){ float a = 0.7f, b = 1.4f, c; c = a + b; std::cout << c << std::endl; c = c + 0.3f; std::cout << c << std::endl;}
Результат выполнения demo_plus
Прежние версии компиляторов по умолчанию позволяли использовать неинициализированные переменные. Теперь же это не так, и компилятор обрывает трансляцию программы, встретив неинициализированную переменную. Это лишает его возможности загружать значение откуда угодно, тем самым защищая программиста от фатальных ошибок, которые могут произойти, если он забудет присвоить значение объявленной переменной.
Результат трансляции этого примера компилятором 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:ExceptionDir↓ovar_c = dword ptr -18hvar_a = dword ptr -14hvar_b = dword ptr -10h; Резервируем память для локальных переменных sub rsp, 38h; Загружаем в регистр XMM0 значение из сегмента данных только для чтения movss xmm0, cs:__real@3f333333; Перекладываем это значение из регистра в переменную var_a movss [rsp+38h+var_a], xmm0; Загружаем в регистр следующее по порядку значение movss xmm0, cs:__real@3fb33333; Перекладываем его в переменную var_b movss [rsp+38h+var_b], xmm0; Первое значение возвращаем в регистр XMM0 из переменной var_a movss xmm0, [rsp+38h+var_a]; Складываем содержимое XMM0 со значением переменной var_b addss xmm0, [rsp+38h+var_b]; Копируем сумму var_a и var_b в переменную var_c, следовательно, var_c = var_a + var_b movss [rsp+38h+var_c], xmm0; Готовим параметры для передачи оператору <<; Второй слева — переменная var_c movss xmm1, [rsp+38h+var_c]; Первый слева — формат вывода mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Собственно вызов оператора вывода строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float); Плюс вывод символа новой строки lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Загружаем в XMM0 значение переменной var_c... movss xmm0, [rsp+38h+var_c]; ...прибавляем к этому значению значение из сегмента данных только для чтения addss xmm0, cs:__real@3e99999a; Обновляем var_c: var_c = var_c + const movss [rsp+38h+var_c], xmm0; Готовим параметры для передачи оператору <<; Второй слева — переменная var_c movss xmm1, [rsp+38h+var_c]; Первый слева — формат вывода mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout; Собственно вызов оператора вывода строки call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float); Плюс вывод символа новой строки lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 38h retnmain endp
А теперь посмотрим, как будет выглядеть тот же самый пример, скомпилированный с ключом /Ox
(максимальная оптимизация):
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓o sub rsp, 28h; Как ловко! Компилятор подсчитал сумму во время компиляции; и подставил ее непосредственно для вывода; С чего бы ему идти на такие хитрости без последствий?; Значения-то представляют собой константы,; которые хранятся в сегменте данных только для чтения movss xmm1, cs:__real@40066666 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Второе для вывода значение — с ним такая же история, как с первым значением movss xmm1, cs:__real@40199999 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(float) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 28h retnmain endp
В оптимизированном варианте нет ни намека на сложение или другие арифметические операции! С возведенным флагом /O2
(максимальная оптимизация по скорости) компилятор создает точно такой же код.
Embarcadero C++Builder генерирует похожий код, а в случае оптимизации — еще хуже. Поэтому приводить результаты его труда бессмысленно — никаких новых «изюминок» они в себе не несут.
В общем случае оператор – транслируется либо в машинную инструкцию SUB
(если операнды — целочисленные значения), либо в инструкцию SUBSS
(если операнды — вещественные значения одинарной точности) или в SUBSD
(когда операнды двойной точности). Оптимизирующие компиляторы могут заменять SUB xxx, 1
более компактной командой DEC xxx
, а конструкцию SUB a, const
транслировать в ADD a, –const
, которая ничуть не компактнее и нисколько не быстрей (и та и другая укладывается в один такт). Однако хозяин (компилятор) — барин.
Покажем это на примере demo_minus, демонстрирующем использование оператора – со значениями двойной точности:
#include <iostream>int main(){ double a = 3.1, b = 1.6, c; c = a - b; std::cout << c << std::endl; c = c - 10; std::cout << c << std::endl;}
Результат выполнения demo_minus
Неоптимизированный вариант будет выглядеть приблизительно так:
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓ovar_c = qword ptr -28hvar_a = qword ptr -20hvar_b = qword ptr -18h; Резервируем память для локальных переменных sub rsp, 48h; Загружаем в регистр XMM0 значение из сегмента данных только для чтения movsd xmm0, cs:__real@4008cccccccccccd; Перекладываем это значение из регистра в переменную var_a movsd [rsp+48h+var_a], xmm0; Загружаем в регистр, заменяя имеющееся там значение следующим по порядку значением movsd xmm0, cs:__real@3ff999999999999a; Перекладываем его в переменную var_b movsd [rsp+48h+var_b], xmm0; Из переменной var_a возвращаем значение в регистр XMM0 movsd xmm0, [rsp+48h+var_a]; Вычитаем из var_a значение переменной var_b, записывая результат в XMM0 subsd xmm0, [rsp+48h+var_b]; Записываем в var_c разность var_a и var_b:; var_c = var_a — var_b movsd [rsp+48h+var_c], xmm0 movsd xmm1, [rsp+48h+var_c] mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Загружаем в регистр XMM0 значение переменной var_c — готовим к вычислению movsd xmm0, [rsp+48h+var_c]; Вычитаем из var_c значение, взятое из сегмента данных только для чтения; При этом результат записываем в регистр XMM0 subsd xmm0, cs:__real@4024000000000000; Обновляем содержимое переменной var_c:; var_c = var_c — const movsd [rsp+48h+var_c], xmm0 movsd xmm1, [rsp+48h+var_c] mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) mov rcx, rax call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 48h retnmain endp
А теперь рассмотрим оптимизированный вариант того же примера:
main proc near ; CODE XREF: __scrt_common_main_seh+107↓p ; DATA XREF: .pdata:ExceptionDir↓o sub rsp, 28h; Компилятор подсчитал разность во время трансляции и подготовил ее значение для вывода movsd xmm1, cs:__real@3ff8000000000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)); Результат второй разности также подсчитан во время компиляции movsd xmm1, cs:__real@c021000000000000 mov rcx, cs:std::basic_ostream<char,std::char_traits<char>> std::cout call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(double) mov rcx, rax lea rdx, std::endl<char,std::char_traits<char>>(std::basic_ostream<char,std::char_traits<char>> &) call cs:std::basic_ostream<char,std::char_traits<char>>::operator<<(std::basic_ostream<char,std::char_traits<char>> & (*)(std::basic_ostream<char,std::char_traits<char>> &)) xor eax, eax add rsp, 28h retnmain endp
Embarcadero C++Builder генерирует практически идентичный код без оптимизации и намного хуже с включенной оптимизацией, поэтому здесь он не рассматривается.
В общем случае оператор / транслируется либо в машинную инструкцию DIV
(беззнаковое целочисленное деление), либо в IDIV
(целочисленное деление со знаком), либо в DIVSS
(деление вещественных значений одинарной точности) или DIVSD
(деление вещественных значений двойной точности).
Источник: xakep.ru