Наши продукты

Все выпуски в PDF →
  • Годовая подписка на Xakep.ru

    Все статьи, PDF-архив, скидки и другие бонусы

    6000₽ в год

    Подписаться
  • Ежеквартальный «Хакер» #4

    Печатный выпуск за август–ноябрь 2026 года

    2000₽

    Заказать
  • Ежеквартальный «Хакер» #3

    Печатный выпуск за май–июль 2026 года

    2000₽

    Заказать
  • Спецвыпуск «Хакера» #4

    Избранные материалы 2021–2022 годов на бумаге

    2000₽

    Заказать
Се­год­ня мы раз­берем стран­ную про­вер­ку дли­ны стро­ки в неиз­вес­тном бинар­нике C++ и выяс­ним, как без­зна­ковая ариф­метика меня­ет ее смысл. Вос­ста­новим логику по инс­трук­циям SUB, CMP и JBE, прос­ледим за регис­тра­ми и фла­гами про­цес­сора в GDB, а затем под­твер­дим выводы в Ghidra. Заод­но раз­берем­ся с перепол­нени­ем, допол­нитель­ным кодом и осо­бен­ностя­ми условных перехо­дов x86-64.

Ког­да Ghidra показы­вает 5 < length - 5, хочет­ся сра­зу перевес­ти усло­вие на при­выч­ный язык. Но одна стро­ка псев­докода не рас­ска­жет, что про­изош­ло с чис­лом при вычита­нии и куда ведет переход. Прос­ледим зна­чение от поля std::string до инс­трук­ций SUB, CMP и JBE, про­верим фла­ги в GDB и запишем под­твержден­ный вывод в Ghidra.

Для это­го мы возь­мем кряк­ми Getting Started Keygen от авто­ра Mazzotti — неболь­шую прог­рамму‑голово­лом­ку для Linux x86-64, написан­ную на C++. Она про­сит ввес­ти стро­ку, затем чис­ло и, если чис­ло сов­пада­ет с резуль­татом хеш‑фун­кции, поз­драв­ляет с победой. Исходно­го кода нет, отла­доч­ных сим­волов тоже. В тво­их руках толь­ко бинар­ник, Ghidra и GDB.

В статье «Байт за бай­том. Учим­ся вос­ста­нав­ливать струк­туры дан­ных с помощью Ghidra и GDB» мы замети­ли, что деком­пилятор показы­вает в main три перемен­ные: local_78, local_70 и local_68. Они лежали рядом на сте­ке и учас­тво­вали в чте­нии стро­ки через std::operator>>. Мы пред­положи­ли, что это час­ти одно­го объ­екта std::string, а затем про­вери­ли гипоте­зу в GDB.

Для про­вер­ки мы написа­ли на Python скрипт, который запус­кал прог­рамму со стро­ками раз­ной дли­ны и счи­тывал зна­чения из памяти через GDB. Серия из ста запус­ков показа­ла изме­нение емкости 30 — 60 — 120; допол­нитель­ная про­вер­ка под­твер­дила зна­чение 240. Мы так­же уви­дели, что стро­ки дли­ной до 15 сим­волов хра­нят­ся внут­ри объ­екта, а начиная с 16 сим­волов исполь­зует­ся память в куче. Так ведет себя при­менен­ная здесь реали­зация std::string из libstdc++. По резуль­татам экспе­римен­та мы вос­ста­нови­ли струк­туру std::string, наз­вали ее std_string и при­мени­ли в Ghidra.

Ес­ли ты не читал пре­дыду­щую статью, для даль­нейше­го раз­бора дос­таточ­но знать глав­ное: пос­ле реконс­трук­ции у нас появи­лось поле _string_length, содер­жащее дли­ну вве­ден­ной стро­ки. В вос­ста­нов­ленной струк­туре мы задали ему тип unsigned long. В иссле­дуемой сбор­ке он занима­ет 8 байт и хра­нит толь­ко неот­рицатель­ные зна­чения. Это резуль­тат нашего ана­лиза, а не извес­тное нам объ­явле­ние из исходно­го кода — исходни­ков, напоми­наю, у нас нет.

Те­перь пос­мотри на псев­докод фун­кции main пос­ле реконс­трук­ции струк­туры:

undefined8 main(void)
{
// ...
std_string enter_string;
// ...
enter_string._string_ptr = (char *)&enter_string._string_storage;
enter_string._string_storage._string_buf[0] = '\0';
enter_string._string_length = 0;
poVar2 = std::operator<<((ostream *)std::cout,"Enter a string of characters (no spaces): ");
// ...
std::operator>>((istream *)std::cin,(string *)&enter_string);
if (5 < enter_string._string_length - 5) {
std::operator<<((ostream *)std::cout,"Bro, what are you trying to do?");
FUN_00101430();
exit(0);
}
poVar2 = std::operator<<((ostream *)std::cout,"Enter correct number (no spaces): ");
// ...
}

Прог­рамма чита­ет стро­ку, а затем про­веря­ет ее дли­ну. Если дли­на не в пра­виль­ном диапа­зоне, прог­рамма выводит сооб­щение «Bro, what are you trying to do?» и завер­шает­ся. Теперь пос­мотри на конс­трук­цию if и усло­вие в ней:

if (5 < enter_string._string_length - 5) { ... }

До реконс­трук­ции струк­туры Ghidra показы­вала про­вер­ку 5 < local_70 - 5U, а пос­ле реконс­трук­ции вмес­то local_70 появи­лось поле _string_length. Суф­фикс U сооб­щает тип литера­ла, но смысл все­го вычита­ния зависит и от типа поля.

Сна­чала про­верим без­зна­ковое вычита­ние на отдель­ной прог­рамме. Потом вер­немся к кряк­ми: сопос­тавим псев­докод с SUB, CMP и JBE, прой­дем учас­ток в GDB и сох­раним вывод в Ghidra. Так стран­ное усло­вие прев­ратит­ся в про­вер­ку с понят­ными гра­ница­ми и вет­ками.

 

Беззнаковая арифметика

Итак, перед тобой усло­вие:

if (5 < enter_string._string_length - 5) {
std::operator<<((ostream *)std::cout,"Bro, what are you trying to do?");
FUN_00101430();
exit(0);
}

На­пом­ню, что до реконс­трук­ции струк­туры std::string наше усло­вие выг­лядело так:

if (5 < local_70 - 5U) { ... }

В преж­нем лис­тинге поле дли­ны обоз­началось local_70. Пос­ле реконс­трук­ции объ­екта мы наз­вали его _string_length. Вот струк­тура, с которой даль­ше мы будем читать усло­вие:

#include <cstddef>
struct std_string {
char* _string_ptr; // Offset 0x00, указатель на данные (8 байт)
unsigned long _string_length; // Offset 0x08, длина строки (8 байт)
union union_string_storage {
char _string_buf[16]; // SSO-буфер для коротких строк (offset 0x10)
unsigned long _string_capacity; // Емкость кучи для длинных строк (offset 0x10)
} _string_storage;
};
// Общий размер структуры — 32 байта (0x20)
static_assert(sizeof(std_string) == 32, "std_string must be 32 bytes");

Пос­ле реконс­трук­ции струк­туры мы задали полю _string_length тип unsigned long. В иссле­дуемой 64-бит­ной сбор­ке он занима­ет 8 байт и хра­нит зна­чения от 0 до 2^64 - 1, то есть до 18446744073709551615. Поэто­му вычита­ние _string_length - 5 нуж­но раз­бирать как без­зна­ковую ариф­метику.

Те­перь про­верим вычита­ние с вос­ста­нов­ленным типом поля. Возь­мем неболь­шое зна­чение, выч­тем 5 и пос­мотрим, что получит­ся. Затем сопос­тавим резуль­тат с машин­ным кодом и наб­людени­ями в GDB.

 

Эксперимент с test_unsigned.cpp

От­крой редак­тор и напиши прос­тую прог­рамму test_unsigned.cpp, которая пов­торит такое же усло­вие, которое ты видел в деком­пилиро­ван­ном псев­докоде:

if (5 < enter_string._string_length - 5) { ... }

Пе­репи­ши исходный код test_unsigned.cpp:

#include <iostream>
#include <string>
int main() {
unsigned long num;
std::cout << "Enter a number: ";
do {
std::cin >> num;
if (5 < num - 5) { // При TRUE длина выходит за диапазон 5–10
std::cout << "Condition TRUE! (5 < num - 5)" << std::endl;
}
else {
std::cout << "Condition FALSE! (5 >= num - 5)" << std::endl;
}
std::cout << "num = " << num << std::endl;
std::cout << "num - 5 = " << (num - 5) << std::endl;
} while (num != 0);
return 0;
}

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

Я исполь­зую тип unsigned long — такой же, как у поля _string_length в вос­ста­нов­ленной струк­туре std_string. Это важ­но: от типа перемен­ной зависит, как вычис­ляет­ся вычита­ние.

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

g++ -O0 -o test_unsigned test_unsigned.cpp

Я собираю при­мер с -O0, что­бы изме­нения кода при опти­миза­ции не мешали сопос­тавлять исходное усло­вие с лис­тингом. Это не гаран­тия кон­крет­ной пос­ледова­тель­нос­ти инс­трук­ций: ее все рав­но при­дет­ся про­верить в получив­шемся бинар­нике. С -O2 или -O3 ком­пилятор может с боль­шей веро­ятностью перес­тро­ить срав­нение и вет­ки.

Для инте­реса пос­мотри на псев­докод test_unsigned в Ghidra. Код усло­вия может отли­чать­ся от кряк­ми и быть, нап­ример, таким:

if (local_10 - 5 < 6) {
// FALSE
} else {
// TRUE
}

Ghidra пред­ста­вила усло­вие в инверти­рован­ной фор­ме и поменя­ла мес­тами бло­ки TRUE и FALSE. Логика от это­го не изме­нилась: при num - 5 < 6 выпол­няет­ся блок FALSE, в осталь­ных слу­чаях — TRUE. Само рас­положе­ние веток в машин­ном коде мы опре­делим по дизас­сем­блер­ному лис­тингу. Пока запом­ни: запись усло­вия в деком­пилято­ре может отли­чать­ся от той, которую ты написал в C++, даже ког­да обе записи опи­сыва­ют одно поведе­ние.

За­пус­ти прог­рамму и вво­ди чис­ла, ими­тируя дли­ну стро­ки. Ниже показа­ны фраг­менты отдель­ных запус­ков с раз­ными зна­чени­ями. Внут­ри одно­го запус­ка приг­лашение Enter a number: появ­ляет­ся толь­ко один раз; сле­дующие чис­ла мож­но вво­дить без пов­торно­го приг­лашения.

Enter a number: 1
Condition TRUE! (5 < num - 5)
num = 1
num - 5 = 18446744073709551612
...
Enter a number: 4
Condition TRUE! (5 < num - 5)
num = 4
num - 5 = 18446744073709551615
Enter a number: 5
Condition FALSE! (5 >= num - 5)
num = 5
num - 5 = 0
...
Enter a number: 10
Condition FALSE! (5 >= num - 5)
num = 10
num - 5 = 5
Enter a number: 11
Condition TRUE! (5 < num - 5)
num = 11
num - 5 = 6
...

Что ты здесь видишь? При вво­де чисел от 1 до 4 усло­вие истинно, от 5 до 10 — лож­но, а от 11 — сно­ва истинно. В кряк­ми на мес­те num сто­ит дли­на вве­ден­ной стро­ки. Ког­да усло­вие истинно, прог­рамма выводит «Bro, what are you trying to do?» и завер­шает работу. При дли­не от 5 до 10 про­вер­ка лож­на, поэто­му выпол­нение про­дол­жает­ся и прог­рамма про­сит ввес­ти чис­ло. Но отку­да берут­ся огромные зна­чения в стро­ке num - 5 для чисел мень­ше пяти? Раз­берем­ся с без­зна­ковым вычита­нием.

Для 0 усло­вие тоже истинно: резуль­тат без­зна­ково­го вычита­ния 0 - 5 ока­зыва­ется в вер­хней час­ти диапа­зона. В нашей экспе­римен­таль­ной прог­рамме этот резуль­тат выводит­ся, пос­ле чего цикл завер­шает­ся из‑за while (num != 0).

Для unsigned long выраже­ние 4 - 5 дало 18446744073709551615. Пос­мотрим на биты это­го резуль­тата: так ста­нет вид­но, почему та же пос­ледова­тель­ность в зна­ковой интер­пре­тации озна­чает -1.

 

Дополнительный код (Two’s Complement)

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

Од­но зна­чение мож­но опи­сать на двух уров­нях. На уров­не C++ перемен­ная име­ет тип unsigned long и вычита­ние под­чиня­ется пра­вилам без­зна­ковой ариф­метики. На уров­не машин­ного кода в регис­тре находят­ся биты резуль­тата. Отдель­ной мет­ки «зна­ковое» или «без­зна­ковое» рядом с ними нет. Если сле­дующая инс­трук­ция про­веря­ет фла­ги пос­ле вычита­ния, условный переход опре­деля­ет, какую семан­тику срав­нения исполь­зует прог­рамма.

Нап­ример, такие инс­трук­ции, как SUB и CMP, изме­няют фла­ги сос­тояния про­цес­сора. Без­зна­ковые и зна­ковые условные перехо­ды исполь­зуют раз­ные ком­бинации этих фла­гов, поэто­му одна и та же пос­ледова­тель­ность битов может учас­тво­вать как в без­зна­ковом, так и в зна­ковом срав­нении.

Ког­да ты пишешь на C++

unsigned long a = 4;
unsigned long b = 5;
unsigned long c = a - b;

ком­пилятор не записы­вает в SUB отдель­ный приз­нак зна­ково­го типа. Инс­трук­ция выпол­няет вычита­ние задан­ной раз­ряднос­ти и уста­нав­лива­ет фла­ги. Даль­нейший код может читать биты резуль­тата из регис­тра или про­верять фла­ги условным перехо­дом. В нашем при­мере JBE не чита­ет RAX: он про­веря­ет CF и ZF, уста­нов­ленные пред­шес­тву­ющей инс­трук­цией CMP.

Од­на и та же пос­ледова­тель­ность битов может интер­пре­тиро­вать­ся по‑раз­ному в зависи­мос­ти от того, какой условный переход про­веря­ет уста­нов­ленные фла­ги. Без­зна­ковые перехо­ды (JB, JBE, JA, JAE) исполь­зуют CF, а JBE и JA допол­нитель­но про­веря­ют ZF. Переход JB про­веря­ет CF=1, JBE — CF=1 или ZF=1, JA — CF=0 и ZF=0, а JAE — CF=0. Зна­ковые перехо­ды (JL, JLE, JG, JGE) исполь­зуют SF и OF, а JLE и JG допол­нитель­но учи­тыва­ют ZF.

Ес­ли в регис­тре толь­ко биты, как вооб­ще получа­ется отри­цатель­ное чис­ло? Через допол­нитель­ный код (Two’s Complement) — спо­соб пред­став­ления зна­ковых целых чисел, при котором стар­ший бит опре­деля­ет знак: 0 — неот­рицатель­ное чис­ло, вклю­чая ноль; 1 — отри­цатель­ное. В ариф­метике фик­сирован­ной раз­ряднос­ти вычита­ние a - b мож­но реали­зовать как сло­жение a + (-b). Здесь нет «отри­цатель­ного нуля» — в отли­чие от более ста­рых спо­собов пред­став­ления (пря­мого и обратно­го кода), где ноль имел два пред­став­ления: +0 и −0.

Про­ще говоря, что­бы получить -5, нуж­но взять чис­ло 5 в дво­ичном виде (00000101). Затем ты инверти­руешь все биты и получа­ешь 11111010. Далее при­бав­ляешь еди­ницу. В резуль­тате ты получишь чис­ло 11111011 — это -5 в 8-бит­ном допол­нитель­ном коде. Если про­читать его как без­зна­ковое, получит­ся 251.

Да­вай нем­ного пос­чита­ем для зак­репле­ния. Возь­ми лист бумаги и вычис­ли 4 - 5 для 8-бит­ного без­зна­ково­го целого.

Вычитание 4 − 5 в восьми битах: результат — 11111111
Вы­чита­ние 4 − 5 в вось­ми битах: резуль­тат — 11111111

Ре­зуль­тат 11111111 — это -1, если читать как зна­ковое, и 255, если читать как без­зна­ковое зна­чение.

Ана­логич­но пос­читай резуль­тат 4 - 5 для 64-бит­ного unsigned long, который мы исполь­зовали в экспе­римен­те:

Вычитание 4 − 5 в 64 битах: результат — 0xFFFFFFFFFFFFFFFF
Вы­чита­ние 4 − 5 в 64 битах: резуль­тат — 0xFFFFFFFFFFFFFFFF

Ре­зуль­тат 0xFFFFFFFFFFFFFFFF — это -1 в зна­ковом виде и 18446744073709551615 в без­зна­ковом.

Ког­да ты видишь в GDB, что в регис­тре лежит 0xFFFFFFFFFFFFFFFF, не спе­ши решать, что это «минус один» или «огромное чис­ло». Это одна и та же пос­ледова­тель­ность битов, а ее интер­пре­тация зависит от кон­тек­ста. Если пос­ле опе­рации выпол­няет­ся без­зна­ковый переход вро­де JBE, нуж­но смот­реть на фла­ги CF и ZF, которые опре­деля­ют резуль­тат это­го перехо­да. Для зна­ково­го перехо­да вро­де JLE исполь­зуют­ся дру­гие фла­ги: SF, OF и ZF.

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

Те­перь пос­мотрим, что про­исхо­дит при вычита­нии боль­шего чис­ла из мень­шего в без­зна­ковой ариф­метике и почему резуль­тат «завора­чива­ется» через ноль.

 

Беззнаковое переполнение (wrap-around)

В пре­дыду­щем опы­те зна­чение 4 пос­ле вычита­ния 5 ока­залось у вер­хней гра­ницы диапа­зона unsigned long. Раз­берем, как получа­ется этот резуль­тат.

Нап­ример, если перемен­ная num типа unsigned long содер­жит 4, то выраже­ние num - 5 дает 18446744073709551615. Ког­да из без­зна­ково­го чис­ла вычита­ют зна­чение, которое боль­ше самого чис­ла, резуль­тат выходит за ниж­нюю гра­ницу диапа­зона. Для без­зна­ковых типов ариф­метика выпол­няет­ся по модулю 2^N, где N — количес­тво битов типа. Поэто­му резуль­тат не ста­новит­ся отри­цатель­ным: он «завора­чива­ется» через ноль и ока­зыва­ется в вер­хней час­ти дос­тупно­го диапа­зона. Для нашего 64-бит­ного типа это ариф­метика по модулю 2^64.

Рас­смот­рим для наг­ляднос­ти ста­рый механи­чес­кий счет­чик элек­тро­энер­гии: у него все­го пять цифр, и пос­ле 99999 он сно­ва показы­вает 00000. Если счет­чик сто­ит на 00003, а ты «отма­тыва­ешь» его на пять еди­ниц назад, он не покажет -2. Он покажет 99998, потому что про­шел через нулевое зна­чение.

Аналогия с пятизначным счетчиком: от 00003 на пять делений назад к 99998
Ана­логия с пятиз­начным счет­чиком: от 00003 на пять делений назад к 99998

Этот при­мер хорошо показы­вает прин­цип перепол­нения (wrap-around): диапа­зон зна­чений зам­кнут в коль­цо. Если дви­гать­ся ниже минималь­ного зна­чения, ты не оста­нав­лива­ешь­ся на нуле, а перехо­дишь к мак­сималь­ному зна­чению диапа­зона и про­дол­жаешь отсчет отту­да.

В дво­ичной ариф­метике про­исхо­дит такое же «завора­чива­ние». Возь­мем для прос­тоты 8-бит­ное без­зна­ковое чис­ло. Его диапа­зон — от 0 до 255. Если из 3 вычесть 5, матема­тичес­кий резуль­тат равен -2, но отри­цатель­ное зна­чение для без­зна­ково­го типа пред­ста­вить нель­зя.

Про­цес­сор не получа­ет сна­чала «нас­тоящее» отри­цатель­ное чис­ло -2, а затем пре­обра­зовы­вает его в 254. При работе с фик­сирован­ной раз­рядностью опе­рация выпол­няет­ся над дос­тупным количес­твом битов, поэто­му пос­ле вычита­ния 3 - 5 получа­ются биты 11111110. Уже даль­нейшая интер­пре­тация этих битов опре­деля­ет, уви­дим мы 254 как без­зна­ковое зна­чение или -2 как зна­ковое.

В дво­ичном виде это выг­лядит так.

Вычитание 3 − 5 в восьми битах: 11111110, или 254 без знака
Вы­чита­ние 3 − 5 в вось­ми битах: 11111110, или 254 без зна­ка

По­лучив­шиеся 11111110 — это 254, если интер­пре­тиро­вать биты как без­зна­ковое зна­чение. Те же биты в зна­ковом 8-бит­ном допол­нитель­ном коде соот­ветс­тву­ют -2.

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

 

Флаги процессора: как CPU принимает решение

При без­зна­ковом вычита­нии одних битов резуль­тата нам мало: нуж­но понять, получил­ся ли ноль и был ли заем (так называ­ют ситу­ацию, ког­да умень­шаемое было мень­ше вычита­емо­го и резуль­тат вышел за ниж­нюю гра­ницу диапа­зона). SUB и CMP записы­вают эту информа­цию во фла­ги регис­тра RFLAGS; затем условный переход про­веря­ет нуж­ные ему фла­ги. Для нашего JBE это CF и ZF. Преж­де чем прос­ледить их в GDB, раз­берем­ся с раз­мером регис­тров и опе­ран­дов.

Расположение флагов в RFLAGS: флаги состояния, управления и системные флаги
Рас­положе­ние фла­гов в RFLAGS: фла­ги сос­тояния, управле­ния и сис­темные фла­ги

Сна­чала пос­мотрим на регис­тры, с которы­ми работа­ют инс­трук­ции кряк­ми.

 

Размеры регистров

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

От­дель­но сто­ит упо­мянуть регистр фла­гов. RFLAGS — 64-бит­ный регистр. В 64-бит­ном режиме его млад­шие 32 бита соот­ветс­тву­ют EFLAGS, а млад­шие 16 бит — FLAGS. Все нуж­ные нам фла­ги находят­ся в млад­ших битах RFLAGS.

Младшие биты RFLAGS, доступные также через EFLAGS и FLAGS
Млад­шие биты RFLAGS, дос­тупные так­же через EFLAGS и FLAGS

В архи­тек­туре x86-64 регис­тры име­ют раз­ные раз­меры. Один и тот же регистр мож­но адре­совать по‑раз­ному в зависи­мос­ти от того, сколь­ко битов мы хотим исполь­зовать.

Части регистра RAX: EAX, AX, AH и AL
Час­ти регис­тра RAX: EAX, AX, AH и AL

Обоз­начения RAX, EAX, AX и AL — это раз­ные спо­собы обра­щения к час­тям одно­го и того же регис­тра. EAX — млад­шие 32 бита RAX, AX — млад­шие 16 бит, а AL — млад­шие 8 бит.

Нап­ример, если RAX = 0x1122334455667788, то регистр будет выг­лядеть так.

Одно значение RAX и значения его младших частей
Од­но зна­чение RAX и зна­чения его млад­ших час­тей

В коде кряк­ми ты уви­дишь RAX — 64-бит­ный регистр. Имен­но туда инс­трук­ция MOV RAX, [RSP + 0x18] заг­ружа­ет дли­ну стро­ки — 8-бай­товое зна­чение, которо­му мы наз­начили тип unsigned long в вос­ста­нов­ленной струк­туре.

Ши­рина опе­ран­да вли­яет на то, что оста­нет­ся в RAX пос­ле записи. MOV EAX, 0 обну­лит весь RAX: запись в его млад­шие 32 бита авто­мати­чес­ки обну­ляет стар­шие 32. MOV AX, 0 изме­нит толь­ко млад­шие 16 бит, а стар­шие 48 сох­ранит. Поэто­му в лис­тинге обра­щай вни­мание, в какую часть регис­тра пишет инс­трук­ция.

Ког­да Ghidra в одном мес­те пишет RAX, а в дру­гом — EAX, это не два раз­ных регис­тра, а два спо­соба обра­щения к одно­му и тому же регис­тру. Если ты видишь MOV EAX, 0, это не зна­чит, что где‑то отдель­но живет RAX, который не обну­лил­ся. Инс­трук­ция записы­вает зна­чение в EAX, а при записи в 32-бит­ный регистр вер­хние 32 бита соот­ветс­тву­юще­го RAX авто­мати­чес­ки обну­ляют­ся. Поэто­му пос­ле MOV EAX, 0 весь RAX содер­жит ноль.

 

Флаги состояния (status flags)

Пос­мотрим на фла­ги, которые меня­ют ариф­метичес­кие инс­трук­ции. Для нашего JBE понадо­бят­ся CF и ZF, а осталь­ные помогут про­читать пол­ный вывод GDB пос­ле каж­дого шага.

Ас­сем­блер­ная инс­трук­ция SUB выпол­няет вычита­ние и записы­вает получив­ший­ся резуль­тат в пер­вый опе­ранд, одновре­мен­но уста­нав­ливая фла­ги OF, SF, ZF, AF, PF и CF в RFLAGS. CMP выпол­няет такое же вычита­ние для уста­нов­ки этих фла­гов, но саму раз­ность не сох­раня­ет. В нашем фраг­менте кода получив­шееся сос­тояние фла­гов затем про­веря­ет JBE.

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

ФлагНаз­ваниеКог­да уста­нов­лен
CFCarry Flag (флаг перено­са)При вычита­нии был заем. Для без­зна­ковых чисел это озна­чает, что умень­шаемое было мень­ше вычита­емо­го и резуль­тат вышел за ниж­нюю гра­ницу диапа­зона
PFParity Flag (флаг чет­ности)Млад­ший байт резуль­тата содер­жит чет­ное чис­ло еди­нич­ных битов
AFAuxiliary Carry Flag (вспо­мога­тель­ный флаг перено­са)При вычита­нии про­изо­шел заем через гра­ницу млад­шего полубай­та — меж­ду битами 3 и 4. Исполь­зует­ся, в час­тнос­ти, при опе­раци­ях с BCD-чис­лами
ZFZero Flag (флаг нуля)Ре­зуль­тат равен нулю
SFSign Flag (флаг зна­ка)Стар­ший бит резуль­тата равен 1
OFOverflow Flag (флаг перепол­нения)Про­изош­ло зна­ковое перепол­нение: матема­тичес­кий резуль­тат не помеща­ется в зна­ковый тип соот­ветс­тву­юще­го раз­мера
Флаги состояния в RFLAGS: положение CF, PF, AF, ZF, SF и OF
Фла­ги сос­тояния в RFLAGS: положе­ние CF, PF, AF, ZF, SF и OF

Те­бе нуж­но запом­нить, что для без­зна­ковых срав­нений важен флаг CF (и иног­да ZF), а для зна­ковых — ком­бинация SF и OF (и иног­да ZF). Имен­но поэто­му в ассем­бле­ре сущес­тву­ют раз­ные инс­трук­ции условно­го перехо­да: одни исполь­зуют CF и ZF (нап­ример, JA, JBE), дру­гие — SF и OF (нап­ример, JG, JL). Так, JBE выпол­няет переход, если CF=1 или ZF=1, а JA — если CF=0 и ZF=0. Под­робную таб­лицу со все­ми перехо­дами мы раз­берем поз­днее.

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

 

CMP — вычитание без сохранения результата

CMP — это инс­трук­ция срав­нения, которая для уста­нов­ки фла­гов выпол­няет такое же вычита­ние, что и SUB, но с одним отли­чием: резуль­тат не сох­раня­ется в регистр. Обновля­ются толь­ко фла­ги.

CMP a, b ; Вычисляет a - b, устанавливает флаги, но не записывает результат

С точ­ки зре­ния вычис­ления фла­гов это соот­ветс­тву­ет

SUB a, b ; a = a - b, устанавливает флаги

Пос­ле CMP регистр a оста­ется преж­ним, а пос­ле SUB меня­ется. При оди­нако­вых опе­ран­дах фла­ги в обо­их слу­чаях уста­нав­лива­ются оди­нако­во.

SUB вычита­ет 5 из дли­ны и сох­раня­ет length - 5 в RAX. Затем CMP срав­нива­ет это зна­чение с 5; обе инс­трук­ции меня­ют фла­ги, но JBE про­веря­ет сос­тояние, оставлен­ное пос­ледней из них — инс­трук­цией CMP. Если убрать SUB и оста­вить толь­ко CMP RAX, 5, срав­нение пой­дет с исходной дли­ной и про­вер­ка диапа­зона изме­нит­ся.

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

Поз­наком­лю тебя с ариф­метичес­кими и логичес­кими инс­трук­циями ассем­бле­ра, которые обновля­ют фла­ги:

  • ADD, SUB, ADC, SBB — сло­жение и вычита­ние;
  • CMP, TEST — выпол­няют вычис­ление для уста­нов­ки фла­гов, но не сох­раня­ют резуль­тат;
  • AND, OR, XOR — логичес­кие опе­рации;
  • INC, DEC — инкре­мент и дек­ремент. В x86 они обновля­ют OF, SF, ZF, AF и PF, но намерен­но не изме­няют CF.

Есть и инс­трук­ции, которые выпол­няют вычис­ления, но фла­ги не изме­няют. Нап­ример, NOT выпол­няет побито­вое отри­цание, но не обновля­ет фла­ги.

А вот пересы­лоч­ные и управля­ющие инс­трук­ции фла­ги не обновля­ют:

  • MOV, LEA, XCHG — пересыл­ка дан­ных;
  • PUSH, POP — работа со сте­ком;
  • JMP, CALL, RET — управле­ние потоком выпол­нения;
  • NOP — ничего не дела­ет.

По­это­му MOV RAX, length не может исполь­зовать­ся для уста­нов­ки фла­гов. MOV прос­то копиру­ет зна­чение и не изме­няет ни CF, ни ZF, ни SF. Эти фла­ги оста­ются в том сос­тоянии, которое им задала пре­дыду­щая инс­трук­ция, изме­няющая их.

Та­ким обра­зом, для успешно­го решения нашей задачи про­веряй, что вычис­ляет инс­трук­ция и какие фла­ги она изме­няет. MOV может перенес­ти зна­чение в регистр, но сос­тояние RFLAGS при этом оста­нет­ся преж­ним. А вот SUB и CMP уста­нав­лива­ют фла­ги по резуль­тату вычита­ния — имен­но их сос­тояние затем про­веря­ет условный переход JBE.

Что­бы уви­деть, как все это работа­ет вмес­те, вер­немся к тому мес­ту в кряк­ми, с которо­го мы начали раз­бор.

 

Возвращаемся к крякми: SUB — CMP — JBE

В нашем коде SUB нужен для получе­ния зна­чения length - 5, которое затем исполь­зует­ся как пер­вый опе­ранд сле­дующе­го срав­нения. SUB RAX, 0x5 сох­раня­ет это зна­чение в RAX, пос­ле чего CMP RAX, 0x5 уста­нав­лива­ет фла­ги сос­тояния, на которые ори­енти­рует­ся JBE.

Пос­мотри на ассем­блер­ный код кряк­ми в этом мес­те. Стрел­кой показан условный переход, который выпол­няет­ся, ког­да дли­на вве­ден­ного сло­ва находит­ся в диапа­зоне [5; 10].

Участок крякми в Ghidra: после JBE ветка отказа завершается вызовом exit
Учас­ток кряк­ми в Ghidra: пос­ле JBE вет­ка отка­за завер­шает­ся вызовом exit

На схе­ме вид­но три инс­трук­ции: SUB вычита­ет 5 из дли­ны стро­ки, CMP срав­нива­ет резуль­тат с 5, а JBE реша­ет, куда идти даль­ше. JBE — это Jump if Below or Equal (прыг­нуть, если ниже или рав­но). Да, имен­но ниже, а не мень­ше — такая тер­миноло­гия, к которой мы еще вер­немся в даль­нейшем. Это условный переход для без­зна­ково­го срав­нения: он выпол­няет­ся, если CF=1 или ZF=1.

Ес­ли усло­вие JBE истинно, мы попада­ем на LAB_0010128a (Enter correct number). Если лож­но, выпол­няем сле­дующую инс­трук­цию по адре­су 0010126c (Bro, what are you trying to do?).

У тебя может воз­никнуть воп­рос, почему при лож­ном JBE мы не доходим до LAB_0010128a, выпол­няя инс­трук­ции по поряд­ку. Ведь LAB_0010128a рас­положен ниже по адре­су.

От­вет находит­ся меж­ду адре­сами 0010126c и 0010128a — там сто­ит вызов exit(0). Этот вызов завер­шает прог­рамму насов­сем. Никакие инс­трук­ции пос­ле него — вклю­чая 0010128a — выпол­нены не будут. Поэто­му при лож­ном JBE прог­рамма выводит нас­мешли­вое сооб­щение и завер­шает­ся, а при истинном пры­гает на 0010128a и про­сит чис­ло.

Мы разоб­рали, как прог­рамма при­нима­ет решение на уров­не инс­трук­ций SUB — CMP — JBE. Теперь про­верим этот раз­бор непос­редс­твен­но во вре­мя выпол­нения с помощью GDB. Сна­чала раз­берем­ся, как сопос­тавить адре­са из Ghidra с адре­сами в памяти, и нас­тро­им GDB на при­выч­ный нам Intel-син­таксис. Пос­ле это­го оста­новим­ся перед SUB и пошаго­во прос­ледим, как меня­ются RAX и фла­ги пос­ле SUB и CMP.

 

Прогон GDB для test

По­ра запус­тить прог­рамму под отладчи­ком. Но преж­де чем ста­вить пер­вую точ­ку оста­нова, нам нуж­но разоб­рать­ся с пароч­кой нюан­сов, которые могут тебя сбить с намечен­ного пути: почему адре­са в GDB отли­чают­ся от адре­сов в Ghidra и как пра­виль­но ука­зать адрес в коман­де break.

PIE и ASLR

Ты уже зна­ешь, что адре­са в GDB отли­чают­ся от адре­сов в Ghidra. При­чина в том, что наша кряк­ми соб­рана как PIE-бинар­ник и при запус­ке с ASLR может заг­ружать­ся по раз­ным базовым адре­сам. Поэто­му адрес из Ghidra нуж­но перес­читать с уче­том реаль­ного адре­са заг­рузки.

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

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Присоединяйся к сообществу «Xakep.ru»!

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

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

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

    Подписаться

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