Як вказівники на згадку викликають переповнення буфера C

1

Щоб по-справжньому зрозуміти, чому вказівники поводяться саме так, потрібно перестати мислити абстрактними змінними та почати візуалізувати апаратне забезпечення. Це проста модель, але є фундаментом для всього: від сегфолтів (падіння програми через порушення доступу до пам’яті) до вразливостей безпеки. Якщо ви не впевнені в основах роботи з бітами, байтами та словами, вам, ймовірно, варто зупинитися тут та освіжити ці знання. Подальше пояснення передбачає, що ви знаєте різницю між байтом та бітом.

У вашому комп’ютері є пам’ять, або ОЗУ (оперативний пристрій). У вас може бути встановлено 16, 32 або 64 гігабайти такої пам’яті. Це не просто сховище; це робочий простір. У ньому зберігається код, який в даний момент виконує процесор (CPU), і дані, з якими ці програми працюють у цю саму секунду. Подайте ОЗУ як величезний лінійний масив байтів.

Кожен байт у цьому масиві має свою адресу. Перший байт має адресу 0. Наступний – 1. Потім 2, 3 і так далі. Ці адреси працюють так само, як індекси в стандартному масиві програмування. Оскільки процесор може миттєво перейти до будь-якої адреси, пам’ять і називається оперативною (random access memory). Їй не потрібно читати дані послідовно. Вона може отримати будь-які дані в будь-який момент. Коли компілятор потрібно зберегти більший тип даних, він просто виділяє блок суміжних байтів. Наприклад, стандартне число з плаваючою комою (float) зазвичай займає 4 суміжні байти.

Давайте подивимося на глобальне оголошення в C:

`

Вам це змінна з ім’ям f, що зберігає значення типу float. Для комп’ютера це команда зарезервувати 4 конкретні байти в масиві пам’яті. Припустимо, компілятор призначає ці байти в комірку пам’яті з адресою 248440.

Коли ви пишете:

`

Компілятор не думає “оновити змінну f”. Він вважає «завантажити значення 3.14 за адресою пам’яті 248 440». Абстракція дуже тонка. Під капотом все зводиться до адрес і значень, що знаходяться за цими адресами.

Така механічна природа пам’яті призводить до деяких неприємних побічних ефектів. Розглянемо цей фрагмент коду на C:

`

Ви можете очікувати, що s і t будуть містити 0:0, 1:1, 2:2, 3:3. А u має залишитися рівним 0. Натомість висновок виглядає так:

`

Зачекайте. Що трапилося з t[0]? І чому u тепер дорівнює 5?

Проблема полягає в класичному переповненні буфера. Уважно подивіться умову циклу: i<=4. Масив t оголошений як t[4], що означає, що він має індекси 0, 1, 2 та 3. Коли i досягає 4, код записує дані в t[4]. Цей індекс немає у виділеному просторі для t. Запис відбувається однією елемент поза масиву.

Оскільки пам'ять є безперервною, комп'ютер розміщує s, t і u прямо поруч один з одним у купі (heap) або стеку (stack).

Коли ви записуєте дані за межі масиву, програма не падає негайно. Ви перезаписуєте те, що знаходиться в комірці пам'яті, що йде за масивом.

У даному конкретному випадку запис в t[4] перезаписує комірку пам'яті, в якій зберігається u (яка спочатку дорівнювала 0). Але чому u

Запис у s[4]. Цей індекс немає. Але комп'ютеру все одно. Він не перевіряє, чи не настаєте ви собі на ноги. Він просто обчислює адресу. І це адреса виявляється адресою t[0].

Ви думаєте, що записуєте дані в s. Насправді ви записуєте їх у t. Система виконує команду, не моргнувши оком. Логіка порушена. Програма ушкоджує дані. Все тихо, доки не стане не тихо.

Тепер спробуйте щось гірше.

`s[1000000] = 5;

Ви записуєте на згадку, якої ваша програма не володіє. Наслідки повністю залежать від операційної системи, яку ви використовуєте. У захищених системах, таких як UNIX, Windows NT або Windows 98, ОС негайно виявляє це порушення. Вона завершує програму. Жорстко. Вона захищає решту системи від помилки.

Старі системи, такі як Windows 3.1 або класична Mac OS, не мають такої розкоші. Вони не знають, що ви робите. В результаті ви перезаписуєте код або змінні іншої програми. Підсумок? Збій. Аварійне завершення. Або ще гірше системний збій, який здається випадковим, але насправді є прямим результатом неконтрольованого доступу до пам'яті.

У пам'яті змінні i, s, t та u розташовані поруч, за конкретними адресами. Вони — сусіди. Якщо ви вийдете за межі однієї змінної, ви торкнетесь наступної. Комп'ютер робить те, що ви сказали. Він просто не дбає про те, що ви діяли з добрими намірами.

Небезпека неконтрольованого доступу до масивів

C та C++ не виконують перевірку діапазонів. Вони вважають, що ви знаєте, що робите. Це означає, що ви повинні уважно стежити за діапазонами масивів. Якщо читати або записувати дані за межами кордонів, поведінка програми буде помилковою. Невизначеним. Небезпечним.

Саме через відсутність безпеки помилки неініціалізованих покажчиків так часто зустрічаються в C. Розглянемо цей код:

`

Компілятор виводить значення p та адресу i. Спочатку p містить сміття чи нуль. Адреса i зазвичай є великою кількістю. Наприклад, ви можете побачити:

`

Після p = &i, p містить адресу i. Просто.

Тепер подивіться на це:

`

Тут виводиться значення, яке вказує p. Але p не ініціалізований. Він вказує на адресу 0 або якусь випадкову ділянку пам'яті. Результатом майже завжди стає "сегментаційна помилка". Помилка часу виконання. Ви намагаєтеся отримати доступ до пам'яті, яка вам не належить.

Вказівники під новим кутом

Як тільки ви зрозумієте ці ризики, ставлення до покажчиків зміниться. Вони не просто змінні. Це прямі адреси осередків пам'яті.

Візьмемо цю програму:

`

Ось що тут відбувається:

Як вказівники зберігають адреси пам'яті

Коли ви повідомляєте змінну цілого типу, наприклад i, вона займає 4 байти в оперативній пам'яті (RAM). Покажчик, назвемо його p, також займає 4 байти. Цей розмір зберігається для переважної більшості сучасних систем, де адреси пам'яті мають довжину 32 біти. Індустрія повільно переходить до 64-бітової адресації, але поки що 4-байтовий розмір залишається стандартом.

Уявіть, що i це будинок. Він має конкретну адресу на вулиці, скажімо, 248 440. Покажчик p — це не сам будинок. Це аркуш паперу, на якому написана ця адреса. Коли ви виконуєте привласнення p = &i, ви записуєте число 248440 на цей листок.

Ця відмінність є критично важливою для розуміння того, як працюють покажчики в C. Вказівник не зберігає значення i. Він зберігає місце, де знаходиться i. Якщо ви розіменуєте p (використовуючи p ), ви звертаєтесь до цієї адреси та читаєте значення. У цьому сенсі p та i функціонально ідентичні. Вони вказують на ті самі дані.

Перегляд адреси в коді

Ви можете перевірити цю поведінку за допомогою простого оператора виводу.

printf("%d", p);

Ця команда не виводить значення i. Вона виводить число записане на листку. Результатом є фактична адреса пам'яті змінної i.

Чому це важливо? Тому що це дозволяє надсилати адреси, а не копії даних. Ви можете безпосередньо маніпулювати пам'яттю. Ви можете пов'язувати структури один з одним. Ви можете ефективно керувати пам'яттю в купі (heap). Покажчик – це посилання. Змінна – це вміст. Плутанина між ними призводить до помилок. Розуміння відмінностей дає вам контроль.

Деякі розробники ставляться до покажчиків як до чогось магічного. Це негаразд. Це просто числа. Адреси пам'яті – це цілі числа. Ставтеся до них відповідно.

попередня статтяПерестаньте витрачати гроші на послуги масової подачі URL для SEO