Висячий указатель

undefined

Висячий указатель, а также дикий указатель — в программировании указатель, не ссылающийся на допустимый объект соответствующего типа. Это частные случаи нарушения безопасности работы с памятью. В более общем случае термин висячая ссылка (англ. dangling reference) или дикая ссылка (англ. wild reference) означает ссылку, не разрешающуюся в действительный объект.

Висячие указатели возникают при уничтожении объекта, когда объект, на который ссылается указатель, был удалён или освобождён из памяти, но сам указатель не изменён и продолжает указывать на уже недействительный участок памяти. Освобождённая память может быть перераспределена, и если программа затем разыменует (dereference) такой висячий указатель, результат может быть непредсказуемым, поскольку память уже может содержать другие данные. Если программа запишет по адресу, на который указывает висячий указатель, может произойти молчаливое повреждение не связанных с этим данных, что приводит к трудноуловимым ошибкам. Если память была перераспределена другому процессу, попытка разыменования такого указателя может вызвать ошибку сегментации (в UNIX, Linux) или общую ошибку защиты (в Windows). При наличии достаточных привилегий программа может даже повредить служебные данные распределителя памяти ядра, что приводит к нестабильности системы. В объектно-ориентированных языках со сборкой мусора висячих ссылок не возникает, так как объект уничтожается только если к нему более нет ссылок, что обеспечивается трассировкой графа памяти или подсчётом ссылок. Однако финализатор может создать новую ссылку на объект, что требует поддержки механизма воскрешения объекта, чтобы не возникла висячая ссылка.

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

Причины появления висячих указателей

Во многих языках (например, язык программирования C) удаление объекта из памяти явно или при уничтожении стекового кадра не приводит к изменению всех связанных с ним указателей. Указатель продолжает указывать на тот же участок памяти, даже если он уже используется под другие нужды.

Простейший пример:

{
    char* dp = NULL;
    // ...
    {
        char c;
        dp = &c;
    } 
    // c вышла из области видимости
    // dp теперь висячий указатель
}

Если операционная система способна обнаруживать обращения к нулевым указателям в рантайме, решение — перед выходом из внутреннего блока присвоить dp значение 0 (NULL). Другой способ — гарантировать, что dp не будет использоваться без повторной инициализации.

Другой частой причиной висячих указателей служит некорректное сочетание функций malloc() и free(): указатель становится висячим, когда блок памяти, на который он указывает, освобождается. Чтобы этого избежать, после освобождения памяти указатель следует явно обнулять — как показано ниже.

#include <stdlib.h>

void func() {
    char* dp = (char*)malloc(sizeof(char) * 10);
    // ...
    free(dp); // dp теперь висячий указатель
    dp = NULL; // dp больше не висячий
    // ... 
}

Распространённая ошибка — возвращать адрес локальной переменной, размещённой на стеке: после выхода из вызываемой функции память под эти переменные освобождается, и значения становятся мусорными.

int* func(void) {
    int num = 1234;
    // ... 
    return &num;
}

Попытки чтения по полученному указателю могут ещё некоторое время возвращать «правильное» значение (1234), но при вызове иных функций память под переменную num может быть перезаписана, и указатель перестанет работать корректно. Если требуется вернуть указатель на num, переменная должна иметь область видимости за пределами функции, например быть объявленной как static.

Причины появления «диких указателей»

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

Чаще всего это не пропуск инициализации, а её пропуск по потоку управления (например, переходом за пределы блока инициализации). Современные компиляторы обычно предупреждают о подобных случаях.

int f(int i) {
    char* dp; // dp — дикий указатель 
    static char* scp; /* scp — не дикий указатель:
                        * статические переменные инициализируются нулём
                        * и сохраняют значение между вызовами.
                        * Использование этой особенности считается плохой практикой,
                        * если не снабжено специальным комментарием. */
}

Уязвимости, связанные с висячими указателями

Как и ошибки, связанные с переполнением буфера, ошибки типа висячих или диких указателей часто приводят к появлению уязвимостей. Например, при вызове виртуальной функции возможно обращение к неподходящему адресу (например, к закладке вредоносного кода), если указатель на таблицу виртуальных функций переписан. Если указатель используется для записи, может быть повреждена другая структура данных. Даже чтение памяти через ставший висячим указатель может привести к утечкам информации (если следующий по памяти объект содержит интересные данные) или к эскалации привилегий (если невалидная память проверяется в системе безопасности). Если висячий указатель используется после освобождения памяти, но до выделения новой (без повторной инициализации), такая ошибка называется уязвимостью типа use-after-free («использование после освобождения»)[2]. Примером является уязвимость в браузерах Microsoft Internet Explorer 6–11[3], которую использовали атаки нулевого дня и устойчивые целевые угрозы[4].

Как избежать ошибок с висячими указателями

В языке C простейшая техника — реализовать модифицированную версию функции free() (или аналогичной), чтобы гарантировать сброс указателя после освобождения. Однако это не избавляет другие переменные-указатели — их копии продолжают указывать на прежний адрес.

#include <assert.h>
#include <stdlib.h>

// Безопасная версия free()
static void safeFree(void** pp) {
    // в режиме отладки аварийно завершаться, если pp равно NULL
    assert(pp);
    // free(NULL) допустима, дополнительная проверка не требуется
    free(*pp); // освобождение памяти, free(NULL) допустимо
    *pp = NULL; // сбрасываем указатель
}

int f(int i) {
    char* p = NULL;
    char* p2;
    p = (char*)malloc(1000); // получаем память
    p2 = p; // копируем указатель
    // используем память здесь
    safeFree((void**)&p); // безопасное освобождение, переменная p2 не изменится
    safeFree((void**)&p); // второй вызов не вызовет ошибку, p уже NULL
    char c = *p2; // p2 всё равно висячий, это неопределённое поведение
    return i + c;
}

Модифицированную функцию также можно использовать для гарантии, что указатель пуст перед выделением памяти:

safeFree(&p); // неизвестно, была ли память уже освобождена
p = (char*)malloc(1000); // выделение памяти

Этот подход можно обобщить макросами (например, #define XFREE(ptr) safeFree((void**)&(ptr))), создав подобие метаязыка, или вынести в отдельную библиотеку. Программистам, использующим такой подход, важно применять его во всех случаях освобождения памяти, иначе проблема сохранится. Кроме того, решение работает только в пределах одной программы или проекта и требует хорошей документации.

Среди структурированных решений — использование умных указателей в C++. Обычно они реализуют подсчёт ссылок для автоматического управления временем жизни объектов. Существуют и другие техники, включая метод надгробий (tombstone) и замков и ключей[5].

Другой подход — использовать консервативный сборщик мусора Бёма (Boehm GC), который подменяет стандартные функции выделения/освобождения памяти и полностью устраняет ошибки висячих указателей за счёт автоматического удаления объектов, а освобождение памяти явно отключается.

Ещё вариант — применять архитектуры, такие как CHERI (Capability Hardware Enhanced RISC Instructions), где указатели хранят дополнительную метаинформацию, в том числе данные о времени жизни, что предотвращает некорректные обращения по ним. Для этого требуется поддержка CPU, обеспечивающая дополнительные проверки.

В языках типа Java висячие указатели невозможны, поскольку нет функции явного освобождения памяти. Сборщик мусора освобождает память только тогда, когда объект уже недостижим.

В языке Rust система типов дополнена анализом времени жизни переменных и концепцией Resource Acquisition Is Initialization, RAII. Если не отключать эти проверки, компилятор обнаружит висячие указатели на этапе компиляции и сообщит об ошибке.

Детектирование висячих указателей

Для выявления ошибок с висячими указателями часто применяют технику: после освобождения памяти присваивают указателю значение NULL или адрес, заведомо недопустимый для работы. В большинстве языков разыменование нулевого указателя приводит к немедленному завершению программы без повреждения данных, облегчая поиск ошибки. Однако при наличии копий ситуация усложняется.

Некоторые отладчики автоматически заполняют освобождённую память специальным шаблоном, например 0xDEADBEEF (в Visual C/C++ используются 0xCC, 0xCD или 0xDD в зависимости от контекста освобождения[6]). Это предотвращает дальнейшее использование данных, делая ошибку очевидной для программиста.

Для поиска висячих указателей применяют такие инструменты, как Polyspace, TotalView (Rogue Wave), Valgrind, Mudflap[7], AddressSanitizer, а также инструменты, построенные на базе LLVM[8].

Другие средства (SoftBound, Insure++ и CheckPointer) модифицируют исходный код для сбора и отслеживания допустимых значений указателей (метаданных) с проверкой при каждом обращении.

Рабочая стратегия для небольшого набора классов — временно сделать все методы класса виртуальными: после уничтожения экземпляра указатель на таблицу виртуальных методов зануляется, и при попытке обращения произойдёт аварийное завершение программы с возможностью простого обнаружения ошибки.

В архитектуре ARM64 расширение Memory Tagging Extension (MTE; по умолчанию отключено в Linux, но может быть включено на Android 16), при обнаружении ошибок use-after-free или переполнения буфера вызывает ошибку сегментации[9][10].

Примечания

Категории