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

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

Пят­надцать лет назад эпи­чес­кий труд Кри­са Кас­пер­ски «Фун­дамен­таль­ные осно­вы хакерс­тва» был нас­толь­ной кни­гой каж­дого начина­юще­го иссле­дова­теля в области компь­ютер­ной безопас­ности. Одна­ко вре­мя идет, и зна­ния, опуб­ликован­ные Кри­сом, теря­ют акту­аль­ность. Редак­торы «Хакера» попыта­лись обно­вить этот объ­емный труд и перенес­ти его из вре­мен 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
Ре­зуль­тат выпол­нения 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↓o
var_c = dword ptr -18h
var_a = dword ptr -14h
var_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
retn
main 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
retn
main 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
Ре­зуль­тат выпол­нения demo_minus

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

main proc near ; CODE XREF: __scrt_common_main_seh+107↓p
; DATA XREF: .pdata:ExceptionDir↓o
var_c = qword ptr -28h
var_a = qword ptr -20h
var_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
retn
main 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
retn
main endp

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

 

Идентификация оператора /

В общем слу­чае опе­ратор / тран­сли­рует­ся либо в машин­ную инс­трук­цию DIV (без­зна­ковое целочис­ленное деление), либо в IDIV (целочис­ленное деление со зна­ком), либо в DIVSS (деление вещес­твен­ных зна­чений оди­нар­ной точ­ности) или DIVSD (деление вещес­твен­ных зна­чений двой­ной точ­ности).

Продолжение доступно только участникам

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии