byte* regionStart;
size_t regionSize;
Требуется проверить, находится ли значение указателя в пределах этого диапазона. Возможно, вашим первым побуждением будет написать так:
if (p >= regionStart && p < regionStart + regionSize)
Но гарантирует ли стандарт ожидаемое поведение этого кода?
Соответствующий пункт стандарта языка C (6.5.8 Операторы отношения)(1) гласит следующее:
Если два указателя на объект или на неполный тип ссылаются на один и тот же объект или на позицию сразу за последним элементом одного и того же массива, эти указатели равны. Если указываемые объекты являются членами одного и того же составного объекта, то указатели на члены структуры, объявленные позже, больше указателей на члены, объявленные раньше, а указатели на элементы массива с большими индексами больше указателей на элементы того же массива с меньшими индексами. Все указатели на члены одного и того же объединения равны. Если выражение P указывает на элемент массива, а выражение Q — на последний элемент того же массива, то значение указателя-выражения Q+1 больше, чем значение выражения P. Во всех остальных случаях поведение не определено.
Теперь вспомним, что язык C предназначался для работы с широким спектром архитектур, многие из которых уже стали музейными экспонатами. По этой причине он крайне консервативен в отношении выбора допустимых действий, так как необходимо оставить возможность писать программы на C для устаревших систем. (Хотя в свое время они были вполне передовыми.)
Тем не менее при выделении памяти возможно появление такого указателя, который будет удовлетворять нашему условию, хотя в действительности он не будет ссылаться на заданную область. Такое случится, например, при работе на процессоре 80286 в защищенном режиме, который использовался операционными системами Windows 3.x в стандартном режиме и OS/2 1.x.
Указатель в такой системе представляет собой 32-битное значение, состоящее из двух частей по 16 бит, — его принято записывать как XXXX:YYYY. Первая 16-битная половина (XXXX) — это «селектор», который служит для выбора сегмента памяти размером 64 Кбайт. Вторая 16-битная половина (YYYY) — «смещение», с помощью которого выбирается байт внутри сегмента, заданного первой половиной. (На самом деле этот механизм сложнее, но в рамках данного обсуждения обойдемся таким объяснением.)
Блоки памяти размером больше 64 Кбайт разбиваются на сегменты по 64 Кбайт. Для перемещения к следующему сегменту необходимо прибавить 8 к селектору текущего сегмента. Например, байт, следующий за 0101:FFFF, записывается как 0109:0000.
Но почему прибавлять надо именно 8? Почему нельзя просто увеличивать селектор на один? Дело в том, что младшие три бита селектора используются для других целей. В частности, самый младший бит селектора служит для выбора таблицы селекторов. Касаться битов 1 и 2 мы здесь не будем, так как они не имеют отношения к нашему вопросу. Для удобства просто представим, что они всегда установлены в ноль.(2)
Соответствие селекторов физическим адресам памяти описывается двумя таблицами: Глобальной таблицей дескрипторов (Global Descriptor Table; определяет сегменты памяти, общие для всех процессов) и Локальной таблицей дескрипторов (Local Descriptor Table; определяет сегменты памяти, выделенные в личное пользование конкретному процессу). Таким образом, селекторы для локальной памяти процесса — 0001, 0009, 0011, 0019 и т.д., а селекторы для глобальной памяти — 0008, 0010, 0018, 0020 и т.д. (Селектор 0000 является зарезервированным.)
Хорошо, теперь мы можем построить контрпример. Пусть regionStart = 0101:0000, а regionSize = 0x00020000. Это означает, что диапазон защищенных адресов составляет с 0101:0000 по 0101:FFFF и с 0109:0000 по 0109:FFFF. Кроме того, regionStart + regionSize = 0111:0000.
А теперь представим, что в диапазоне 0108:0000 выделяется сегмент глобальной памяти, — на то, что это глобальная память, указывает четное число в селекторе.
Заметьте, что область глобальной памяти не входит в диапазон защищенных адресов, однако значение указателя на этот участок удовлетворяет неравенству 0101:0000? 0108:0000 < 0111:0000.
Еще немного текста: Наша проверка может провалиться даже на архитектурах с плоской моделью памяти. Современные компиляторы слишком охотно оптимизируют неопределенное поведение. Обнаружив сравнение указателей, они вправе предположить, что эти указатели ссылаются на один и тот же составной объект или массив (либо на позицию за последним элементом массива), поскольку любой другой вид сравнения приводит к неопределенному поведению. В нашем случае, если regionStart указывает на начало массива или составного объекта, то корректно сравниваться с ним могут только указатели вида regionStart, regionStart + 1, regionStart + 2, ..., regionStart + regionSize. Все они удовлетворяют условию p >= regionStart и потому могут быть оптимизированы, в результате чего компилятор упрощает нашу проверку до следующего кода:
if (p < regionStart + regionSize)
Теперь условию будут удовлетворять все указатели, значение которых меньше regionStart.
(Вы можете столкнуться с этой ситуацией, если — как автор исходного вопроса, ответом на который является данная статья, — выделяете область памяти с помощью выражения regionStart = malloc(n) либо если выделенная область используется как пул preallocated объектов для быстрого доступа и нужно решить, освобождать ли указатель с помощью функции free.)
Мораль: Данный код небезопасен — даже на архитектурах с плоской моделью памяти.
Но не все так плохо: Результат преобразования указателя в целочисленный тип зависит от используемой реализации, а значит, именно она и должна описывать его поведение. Если ваша реализация предполагает получение численного значения линейного адреса объекта, на который ссылается указатель, и вы знаете, что работаете на архитектуре с плоской моделью памяти, то выходом будет сравнивать целые значения вместо указателей. Сравнение целых чисел не имеет таких ограничений, как сравнение указателей.
if ((uintptr_t)p >= (uintptr_t)regionStart &&
(uintptr_t)p < (uintptr_t)regionStart + (uintptr_t)regionSize)
Примечания:
- Обратите внимание, что «равно» и «не равно» не являются операторами отношения.
- Я знаю, что на самом деле это не так, — равными нулю я принимаю их для удобства.
(Данная статья основана на моем комментарии на StackOverflow.)
Обновлено: Уточнение: оптимизация «начала области памяти» производится только тогда, когда указатель regionStart ссылается на начало массива или составного объекта.
This is a translation of «How to check if a pointer is in a range of memory» into Russian. Click the link to see the original English version.
Комментарии (15)
Andrey2008 Автор
19.10.2017 00:06Не уверен, что получился удачный перевод: мало голосов и тихо. Поэтому для оживления приглашаю посмотреть запись нашего нового доклада: C++ CoreHard Autumn 2017, Поиск уязвимостей с использованием статического анализа кода (доклад с этой статьей никак не связан).
И заодно вопрос: Сделать какую-то диагностику в PVS-Studio на тему статьи это безумие, да?vanxant
19.10.2017 02:16Сделать какую-то диагностику в PVS-Studio на тему статьи это безумие, да?
Да)
iCpu
19.10.2017 06:57+1Похоже, вам нужно добавить целый класс диагностик "Безумие?! Нет, ЭТО СПААААРТААА!!!!!"
И сбрасывать все не прошедшие диагностику файлы в Корзину.
DmitryMe
19.10.2017 16:17Возможно, «мало» и «тихо», потому что в тексте нет реального примера «доламывания» не соответствующего Стандарту кода оптимизирующим компилятором.
SyDr
19.10.2017 09:20Может ли начальный вариант компилятор преобразовать не только к:
if (p < regionStart + regionSize)
но, также, и к (нет, ну а вдруг на целевой архитектуре < работает намного медленнее, чем !=):
if (p != regionStart + regionSize)
Ход рассужденийint in_region(int* p) { return p >= regionStart && p < regionStart + regionSize; }
можно преобразовать в (как-то так, на C я не пишу):
int in_region(int* p) { if (p == regionStart + regionSize) return 0; int* x = regionStart; while (x < regionStart + regionSize) { if (x == p) return 1; else ++x; } UndeffinedBehaviorDestroyTheWorldNotImplementedYet(); }
т.е.:
- либо p указывает на 1 елемент за пределами массива (возвращаем 0)
- либо указывает в пределах массива (возвращаем 1)
- либо указывает в любое другое место и тогда компилятор может делать что угодно, ибо испольовать <, <=, >, >= в этом случае нельзя
ну… поскольку ub в программах не существует, то можно оптимизировать до:
int in_region(int* p) { if (p == regionStart + regionSize) return 0; return 1; }
или же:
int in_region(int* p) { return p != regionStart + regionSize; }
DareDen
19.10.2017 11:21-3потому могут быть оптимизированы, в результате чего компилятор упрощает нашу проверку до следующего кода:
if (p < regionStart + regionSize)
Не оптимизирует, если компилятор не совсем идиот. Контрпример в compiler explorer.
firk
19.10.2017 14:24+1Хорошо, теперь мы можем построить контрпример. Пусть regionStart = 0101:0000, а regionSize = 0x00020000.
Пример (и весь его последующий анализ, соответственно) некорректен, в 16-бит системе не может быть regionSize больше чем 0x10000 (на самом деле и это число не влезет в 16-битный size_t, но это уже проблемы языка, а сегмент такого размера сделать всё же можно, а вот больше — нет). Селектор это индекс сегмента в весьма произвольной таблице, а не линейный номер участка памяти.
firk
19.10.2017 14:34if ((uintptr_t)p >= (uintptr_t)regionStart && (uintptr_t)p < (uintptr_t)regionStart + (uintptr_t)regionSize)
Этот вариант, в целом, хороший, но иногда может и создать проблему, если кто-то захочет использовать вместо реального regionSize какое-то абстрактное большое число (допустимость этого — отдельная тема). А ещё он может доставить проблемы уже в полностью законном случае — если конец проверяемой области памяти совпадает с концом адресного пространства (p+regionSize=0x100000000 для 32бит превратится в 0). Вариант, который ничем не уступает процитированному, но лишен указанного недостатка:
if ((uintptr_t)p >= (uintptr_t)regionStart && (uintptr_t)p - (uintptr_t)regionStart < (uintptr_t)regionSize)
А если сделать -fno-strict-overflow то можно ещё и опустить первую половину условия (но это уже некоторые могут посчитать плохим).
erwins22
19.10.2017 16:15А в стандартных библиотеках этим замарачиваются?
knstqq
20.10.2017 15:06да, в стандартных библиотеках приходится заморачиваться десятками вещей, с которыми обычный пользователь библиотеки возможно никогда не встретится: habrahabr.ru/company/yandex/blog/323972/#comment_10133110
brick_btv
Выглядит достаточно странно. Как в этом случае должны работать функции типа memset, strcpy и прочие, если нет гарантии, что проход по указателю корректно выдаст все элементы подряд?
knstqq
проход по указателю корректно выдаст все элементы подряд, именно так. Здесь нет противоречия
DmitryMe
Противоречия тут нет. Указанные функции допустимо вызывать при условии, что все подлежащие изменению/копированию данные находятся в пределах одного и того же массива, т.е. что все вычисляемые в процессе работы функции значения указателей допустимо вычислять.