Ложные убеждения о нулевых указателях

от автора

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

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

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

1. Разыменование нулевого указателя приводит к немедленному отказу программы.

Любой, кто хотя бы раз пытался разыменовать нулевой указатель в C, C++ или Rust, познакомился с STATUS_ACCESS_VIOLATION или с устрашающим сообщением Ошибка сегментации (дамп ядра), поэтому данное заблуждение не совсем безосновательно. Однако, на более высокоуровневых языках и с применением таких библиотек как Crashpad эту ошибку можно обработать, и перед отказом вывести аккуратное сообщение и обратную трассировку. Чтобы реализовать эти возможности, нужно установить векторизованный обработчик исключений под Windows и обработчик сигналов на Unix-подобных платформах.

2. Разыменование нулевого указателя в конечном итоге приводит к завершению программы.

Притом, что разыменовывать нулевой указатель Безусловно Плохо, такая ситуация ни в коем случае не является непоправимой. Векторизованные исключения и обработчики сигналов позволяют возобновить выполнение программы (может быть, не строго с того места, где она остановилась), а не обваливать выполнение процесса. Например, Go преобразует операции разыменования nil-указателей в паники. Эти случаи можно отлавливать в пользовательском коде при помощи recover. В Java такие же операции транслируются в NullPointerException, и эти исключения также можно отлавливать в пользовательском коде, как и любые другие.

В обоих случаях, если удаётся «просить прощения» (разыменовывать нулевой указатель, а затем восстанавливаться), а не «просить разрешения» (проверять, является ли указатель null, и лишь затем разыменовывать его) — это уже оптимизация. Если сравнивать все указатели с null, то выполнение программы будет лишь тормозиться в тех случаях, когда указатель не равен null, а таких случаев большинство. Напротив, сигналы обрабатываются без издержек до тех пор, пока сам сигнал не порождается, а в хорошо написанных программах сигнал выдаётся исключительно редко.

3. При разыменовании нулевого указателя всегда возникает сигнал, исключение, либо эта операция каким-то иным образом отклоняется на уровне железа.

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

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

Именно так обстоит ситуация на многих встраиваемых платформах. Там разыменование нулевого указателя так или иначе считается неопределённым поведением, поэтому, если по какой-то причине вам требуется обратиться к памяти по адресу 0, то это можно сделать двумя основными способами:

  1. Написать соответствующий код на ассемблере, поскольку в ассемблере не возникает неопределённых поведений.
  2. Если оборудование игнорирует самые верхние биты адреса, то можно обратиться по 0x80000000 (или схожему адресу) из кода C.

4. На распространённых сегодня платформах при разыменовании нулевого указателя всегда возникает сигнал, исключение, либо эта операция каким-то иным образом отклоняется на уровне железа.

В Linux поддерживается флаг специализации под названием MMAP_PAGE_ZERO, он нужен для обеспечения совместимости с программами, разработанными для System V. Программа, запущенная под setarch -Z, выполняется с адресом от 0 до 4096 (в зависимости от того, каков именно размер страницы памяти) и отображается на страницу, заполненную нулями. Можно также самостоятельно воспользоваться mmap и установить начало области памяти по адресу 0 вручную. Много лет назад этот фокус уже применялся в Wine (наряду с другими, например, пропатчивание таблицы логических дескрипторов). Благодаря такому приёму удавалось выполнять DOS-приложения без DOSBox.

В настоящее время по соображениям безопасности такая возможность по умолчанию уже не работает. Но что для одного мусор, для другого — клад. Если ядро случайно разыменует нулевой указатель именно в тот момент, когда память отображается на адрес 0, то предоставленные пользователем данные могут быть интерпретированы как структура данных ядра — и здесь открывается возможность для эксплойта. Правда, вы всё равно можете целенаправленно активировать такой режим, выполнив sudo sysctl vm.mmap_min_addr=0.

Тем не менее, существует очень современная и распространённая платформа, на которой память по-прежнему отображается на адрес 0. Речь о WebAssembly. Изоляция в пределах wasm-контейнера не требуется, поэтому проэксплуатировать брешь в безопасности при этом ничуть не проще. Так что технически разыменование нулевого указателя на этой платформе вполне работает.

5. Разыменование нулевого указателя всегда приводит к неопределённому поведению

Это непростой момент. В стандарте сказано, что да, это приводит к неопределённому поведению, но смысл этого тезиса со временем сильно изменился.

Давным-давно стандарт C считался набором рекомендаций, а не сводом правил. Тогда неопределённое поведение скорее сближалось с поведением, зависящим от реализации, а не с тёмной магией. Оптимизаторам же хватило тупости сделать так, что эту разницу перестали принимать во внимание. На большинстве платформ при разыменовании нулевого указателя код компилировался и далее выполнялся точно, как при разыменовании значения по адресу 0.

С любой точки зрения такое неопределённое поведение, каким мы понимаем его сегодня — оказывающее «жуткое дистанционное воздействие» — тогда не существовало.

Например, в компиляторе HP-UX C была опция CLI, позволявшая отобразить таблицу нулей на адрес 0, так что *(int*)NULL вернул бы 0. Были программы, работа которых опиралась на такое поведение, и для корректного выполнения в современных операционных системах их требуется либо пропатчивать, либо выполнять с флагом специализации.

Теперь мы вступаем на проклятые земли.

6. У нулевого указателя адрес 0.

Стандарт C не требует, чтобы у нулевого указателя был адрес 0. Единственное обязательное требование — (void*)x должен результировать в нулевой указатель, где x — это константа времени компиляции, равная нулю. Не составляет труда выполнять подстановку под такие паттерны во время компиляции, поэтому у нулевых указателей могут быть и иные адреса кроме 0. Аналогично, при приведении указателя к булеву значению (как в случае с if (p) и !p) нулевые указатели (null) должны результировать в false, но это не касается указателей, заполненных нулями (zero).

Это не гипотетическая ситуация: есть некоторые реальные архитектуры и интерпретаторы C, использующие ненулевые null-указатели. fullptr — это не шутка.

Если вам вдруг интересно — в Rust и других современных языках такой случай обычно не поддерживается.

7. На современных платформах у нулевого указателя адрес 0.

В таких архитектурах GPU как AMD GCN и NVIDIA Fermi 0 указывает на доступную память. Как минимум, в AMD GCN нулевой указатель представлен как -1. (Не уверена, соблюдается ли это правило в Fermi, но это было бы логично.)

8. Поскольку (void*)0 — нулевой указатель, int x = 0; (void*)x также должен быть нулевым указателем.

В int x = 0; (void*)x, x — это не константное выражение, поэтому стандарт не требует, чтобы он результировал в нулевой указатель. Приведения целых чисел к указателям во время исполнения часто не считаются операциями, поэтому добавлять if (x == 0) x = ACTUAL_NULL_POINTER_ADDRESS; к каждой операции приведения было бы очень неэффективно, а генерируя условную конструкцию с нулевым указателем при оптимизациях значений во время выполнения мы только получали бы излишние случаи несогласованности.

Очевидно, конструкция void *p; memset(&p, 0, sizeof(p)); p также не может гарантировать на выходе нулевой указатель.

9. На платформах, где нулевой указатель находится по адресу 0, объекты C не могут находиться по адресу 0.

Указатель на объект не является нулевым указателем, даже если адрес у него такой же, как у нулевого.

Если вы знаете, что такое «происхождение указателей» (pointer provenance), то вас и не должно удивлять, что в разных случаях одно и то же побитовое представление может проявлять разные свойства:

int x[1]; int y = 0; int *p = x + 1; // Это может результировать в true if (p == &y) {     // Но это приведёт к неопределённому поведению, пусть p и &y и равны     *p; }

Аналогично, объекты можно размещать по адресу 0, пусть даже указатели на них во время выполнения будут неотличимы от NULL:

int tmp = 123; // Может быть размещено по адресу 0 int *p = &tmp; // Просто указатель на 0, не происходит из нулевой константы  int *q = NULL; // Указатель является нулевым, так как происходит из нулевой константы  // у p и q будут одинаковые побитовые представления, но... int x = *p; // даёт 123 int y = *q; // неопределённое поведение

10. На платформах, где нулевой указатель имеет адрес 0, int x = 0; (void*)x — это нулевой указатель

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

int *p = (void*)0; // Обязан получаться указатель NULL int x = *p; // неопределённое поведение  int zero = 0; int *q = (void*)zero; // Некоторые компиляторы могут выдавать указатель, не поддающийся разыменованию int y = *q; // Не обязательно приводит к неопределённому поведению

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

11. На платформах, где у нулевого указателя адрес 0, int x = 0; (void*)x будет приравниваться к NULL.

Согласно документации C, указатели на объекты не равны NULL, даже, если объект находится по адресу 0. Иными словами, для того, чтобы сравнить два указателя, мало просто знать их адреса. Это один из тех редких случаев, в которых происхождение указателя влияет на выполнение программы, причём так, что не вызывает неопределённого поведения.

Соблюдаются следующие утверждения:

extern int tmp; // Допустим, это находится по адресу 0 int *p = &tmp; assert(p != NULL); // При сравнении указателя на объект и NULL они оказываются не равны  int *q = (void*)(uintptr_t)p; assert(p == q); // При полном обороте может получиться невалидный указатель, который, тем не менее, будет равен исходному assert(q != NULL); // По принципу транзитивности  int x = 0; int *r = (void*)x; // Это по-прежнему полный оборот, то, что данные не зависят от p, не имеет значения assert(r != NULL);

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

Даже если по адресу 0 никакого объекта нет, int x = 0; (void*)x всё равно может произвести такой указатель, который окажется не равен NULL при сравнении, поскольку преобразование зависит от реализации.

В Rust объекты не разрешается явно размещать по адресу 0.

12. На платформах, где null-указатель имеет адрес 0, null-указатели хранятся в виде нулей

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

Типичный пример — сегментная адресация, но более современный контекст, который также подойдёт в качестве иллюстрации — аутентификация указателей. В архитектуре ARM верхний байт указателя можно сконфигурировать так, чтобы в нём хранилась криптографическая подпись, которая бы затем верифицировалась при разыменовании. Подписью снабжаются указатели в регионах __ptr_auth, у них сигнатура сохраняется вместе с адресом. В Apple было принято не подписывать нулевые указатели, поскольку из-за этого их значения становились бы непредсказуемыми во время компиляции. Тем не менее, это решение было принято целенаправленно, а не вытекало из стандарта.

Указатели CHERI устроены ещё более странно. В CHERI наряду с привычными нам 64-разрядными адресами хранятся 128-разрядные возможности для защиты от использования после высвобождения (UAF) и выхода за границы (OOB). Любой указатель с адресом 0 считается нулевым, так что, фактически, существует различных нулевых указателей, лишь один из которых полностью заполнен нулями. Это также означает, что и проверка различных указателей на равенство может не совпадать по результату со сравнением их же двоичных представлений.

Если расширить определение указателей, так, чтобы оно охватывало и указатели членов класса, всё выглядит ещё реалистичнее. Указатели на члены, фактически, представляют собой смещения для полей (как минимум, если не учитывать методы), а 0 — допустимое смещение. Поэтому (int Class::*)nullptr обычно хранится как -1.

Заключение

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

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

Многие называют язык C «портируемым ассемблером». Я считаю, что это решительно не так. Язык C кажется приближенным к железу, но на самом деле это язык, обладающий собственной абстрактной машиной и операционной семантикой. Оптимизационные проходы, серверные компоненты для генерации кода и библиотеки должны обмениваться информацией на общем платформонезависимом языке, и в качестве такого языка не подходит «любая аппаратная прослойка». Вместо того, чтобы буквально пересказывать на C всё, что делает железо, нужно считать C более высокоуровневым языком, каким он, в сущности, и является.

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

  • Нужно ли memset эту структуру, или = {0} вполне хватит для решения задачи?
  • Почему вы приводите указатели к size_t? Попробуйте лучше uintptr_t.
  • Почему вы делаете полный оборот через целые числа? Воспользуйтесь void* в качестве нетипизированного/невыровненного типа указателя.
  • Вместо того, чтобы вручную выписывать неветвящийся код вроде (void*)((uintptr_t)p * flag), позвольте компилятору оптимизировать за вас flag? p: NULL.
  • Можно ли хранить флаги рядом с указателем, а не злоупотреблять его нижними разрядами? Если нет, то можно ли вставлять флаги с (char*)p + flags, а не (uintptr_t)p | flags?

Если у вас разыгралось паучье чутьё, то посмотрите сначала стандарт C, затем документацию вашего компилятора, потом попробуйте поговорить с разработчиками компилятора. Не рассчитывайте на то, что отсутствуют долгосрочные планы менять какие-либо поведения и определённо не увлекайтесь «верой в здравый смысл».

Если больше ничего не помогает, постарайтесь документировать ваши гипотезы. В таком случае пользователям станет проще понять пределы возможностей вашей программы, разработчикам — портировать ваше приложение на новую платформу, а вам – отлаживать неожиданные проблемы.


ссылка на оригинал статьи https://habr.com/ru/articles/890900/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *