Разыменование нулевых указателей больше не проблема

    image

    Дефект, который справедливо считается «чумой» современного программирования преодолим. Предлагаем ознакомиться с переводом статьи Бертрана Мейера, французского учёного, создателя языка программирования Eiffel, приглашенного профессора и руководителя Лаборатории программной инженерии Университета Иннополис. Оригинал статьи опубликован в журнале Сommunications of the ACM.

    Код имеет большое значение — об этом мы говорили в предыдущей статье. Языки программирования играют не менее важную роль. Несмотря на то, что Eiffel больше известен принципами Design by Contract («проектирование по контракту»), они являются лишь частью систематического проектирования, основная цель которого — помочь разработчикам реализовать максимум своих возможностей и устранить из кода источники сбоев и ошибок.

    Говоря об источниках сбоев, стоит упомянуть разыменование нулевого указателя — дефект, который справедливо считается «чумой» современного программирования. Данный термин обозначает явление, которое происходит, когда вы делаете вызов x.f, означающий «применить компонент f (доступ к полю или операции) к объекту, на который ссылается x». Если ваша задача — определить значащие структуры данных, необходимо разрешить использование значения null, также известного как Nil или Void, как одного из возможных значений ссылочных переменных (например, для завершения связанных структур данных: поле «next» последнего элемента списка должно быть нулевым, чтобы указать, что следующий элемент отсутствует). Далее следует убедиться, что вызов x.f никогда не применяется к нулевому значению x, поскольку в этом случае отсутствует объект, к которому применяется f.

    Эта проблема довольно актуальна для объектно-ориентированных языков, в которых вызовы вида x.f являются ключевым механизмом. Риск неопределённого поведения возникает при каждом использовании этого механизма (сколько миллиардов таких случаев уже произошло к тому моменту, как вы прочитаете эту статью?). Компиляторы для большинства языков программирования улавливают другие ошибки подобной природы — в частности, ошибки, связанные с типом объявления, например, когда переменной присваивается неверное значение. Тем не менее, компиляторы не могут предотвратить разыменование нулевого указателя.

    Разыменование нулевого указателя — основная уязвимость, которая ставит под угрозу реализацию большинства современных программ. По мнению Тони Хоара, разыменование нулевого указателя — «ошибка на миллиард долларов». И это нисколько не преувеличение. Александр Когтенков в своей кандидатской диссертации исследовал дефекты, связанные с разыменованием нулевого указателя, основываясь на базе данных типичных уязвимостей и рисков (CVE), в которой содержится информация об Интернет-атаках. Результаты исследования представлены в графике, где отображается общее количество атак в год.

    image

    За числом атак стоят пугающие реальные случаи. Исходя из описания уязвимостей CVE-2016-9113: Разыменование указателя NULL в функции imagetobmp модуля convertbmp.c:980 OpenJPEG 2.1.2 image->comps[0].data не присваивает значение после иницализации (NULL). Резульетат — отказ в обслуживании.

    Да, это случай со стандартом JPEG. Постарайтесь не думать об этом, загружая свои фото в сеть. Всего за один месяц (ноябрь 2016) в базе данных системы были зафиксированы уязвимости, связанные с разыменованием нулевого указателя, повлиявшие на продукты Gotha в ИТ-индустрии, начиная от Google и Microsoft («теоретически, кто угодно мог обрушить сервер, смастерив всего один «специальный» пакет данных») до Red Hat и Cisco. Компания NVIDIA прокомментировала это так: Продукты NVIDIA Quadro, NVS и Ge-Force, а также NVIDIA Windows GPU Display Driver R340 версии до 342.00 и R375 версии до 375.63 обнаружили уязвимость в драйвере (nvlddmkm.sys), где разыменование указателя NULL, вызванное вводом недопустимого пользователя, может привести к отказу в обслуживании или потенциальной эскалации привилегий.

    Люди часто жалуются, что безопасность и Интернет — вещи несовместимые. Но что, если проблема не только в проектировании (Стек протоколов TCP/IP прекрасно функционирует), а в языках программирования, которые используются для написания средств реализации этих протоколов?

    Что касается языка программирования Eiffel, мы решили, что пора решить эту проблему. Ранее мы устранили небезопасные преобразования типа с помощью системы типов, избавились от ошибок управления памятью с помощью сборки мусора, от дефектов data race — с помощью механизма SCOOP. Пришло время решить проблему разыменования нулевых указателей. Теперь в Eiffel нет проблемы небезопасных вызовов – разыменование нулевого указателя здесь в принципе невозможно. Принимая вашу программу, компилятор гарантирует, что при каждом исполнении вызова x.f, переменная x будет ссылаться на конкретный объект, который реально существует.

    Как нам это удалось? В этой статье мы не станем подробно описывать, как предотвратить разыменование нулевых указателей, ограничившись ссылкой на ресурс с документацией. Отметим также, что механизм постоянно совершенствуется. В настоящей статье мы расскажем об основных идеях. Оригинальная статья по данной теме стала основным докладом на Европейской конференции по объектно-ориентированному программированию (ECOOP) в 2005 году. Несколько лет спустя, пересматривая исходное решение в одной из статей, я писал: «На разработку, усовершенствование и описание технологии void safety, основанной на механизме защиты от разыменования нулевых указателей, ушло несколько недель. Инженерная работа заняла четыре года».

    Это звучало оптимистично. Семь лет спустя «инженерные работы» продолжались. И дело не в защите нулевых указателей от разыменования — механизм изначально был достаточно обоснован теоретически. Целью затянувшейся доработки концепции было облегчить работу программистов. Любой механизм, лишённый багов, например, статическая типизация, обеспечивает надёжную защиту и безопасность, по следующей формуле: «запретить вредоносные схемы (иначе дефектов не избежать), сохранив полезные (иначе избавиться от дефектов было бы слишком просто — достаточно удалить все программы!), при этом не меняя принцип их работы». Так называемые «инженерные работы» включают подробный статический анализ, благодаря которому компилятор принимает безопасные типы, которые были бы отвергнуты более упрощённым решением.
    На практике, сложность оптимизации решений по защите от разыменования нулевых указателей в большей степени связана с инициализацией объектов. Детали механизма можно постоянно совершенствовать, но сама идея проста: механизм основывается на объявлении типов и статическом анализе.

    Система защиты от разыменования нулевых указателей разграничивает «прикрепленные» (attached) и «открепляемые» (detachable) типы. Если вы типизируете переменную р1 конкретным типом (например, PERSON), она никогда не будет нулевой — её значение всегда будет ссылкой на объект данного типа, т.е. переменная р1 является «прикреплённой». Это по умолчанию. Если вы хотите, чтобы переменная р2 приняла нулевое значение, обозначьте её как «открепляемую» — detachable PERSON. Простые механизмы компиляции поддерживают это разграничение: можно присвоить р1 в р2, но не наоборот. Таким образом, «прикреплённое» выражение является верным: во время выполнения программы значение р1 всегда будет ненулевым. Компилятор формально гарантирует это.

    При статическом анализе таких гарантий гораздо больше, причём это не требует каких-то усилий со стороны программистов, если код безопасен. Например, если фрагмент кода выглядит так:
    if p2 /= Void then p2.f end, мы знаем, что всё в порядке (при определённых условиях. В многопоточном программировании, например, важно, чтобы параллельный поток не обнулил переменную р2 в промежуток между ее проверкой и применением f. Это предусмотрено правилами).

    Разумеется, реальное определение механизма не гарантирует, что компилятор распознает безопасные случаи и отклонит небезопасные. Мы не можем просто доверить безопасность программы программному инструменту (даже таким инструментам с открытым исходным кодом, как компиляторы Eiffel). Кроме того, есть нечто большее, чем просто компилятор. Определение void safety использует ряд простых и понятных правил, известных как сертифицированные шаблоны прикрепления (CAPs), которые компиляторы должны соблюдать. Предыдущий пример иллюстрирует один из таких сертифицированных шаблонов. Формальная модель, подкрепленная механизированными доказательствами (с помощью инструмента Isabelle/HOL), даёт весомые доказательства обоснованности этих правил, включая деликатные вопросы, связанные с инициализацией.

    Технология void safety существует уже несколько лет, и те, кто её использовал, не хотят возвращаться к прежним методам безопасности нулевых указателей. Создание безопасного кода быстро становится привычным делом.

    А вы уверены, что ваш код защищён от разыменования нулевых указателей?
    Innopolis University
    66,59
    Российский ИТ-вуз
    Поделиться публикацией

    Комментарии 21

      +2

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

        0
        Грамотное решение проблемы нулевых указателей — использование алгебраических типов данных.

        Когда мы имеем вместо void* func() что-то вида fn func() -> Enum_Option, то мы дальше предоставляем программисту простую возможность обработать 'Err' (кодирование которого и стало причиной появления NULL), и возможность безопасно вынуть значение из 'Ok' посредством исчерпывающего pattern matching'а.

        Впервые оно появилось в заумных ML'ных языках, но сейчас оно дошло до мейнстрима и его можно использовать не взрывая себе мозг монадками.
          0

          Да и раньше монадок не нужно было, аппликативных функторов достаточно.


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

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

              Формализация и понимание нижележащих абстракций — оно, в общем, не зря.

            0
            Напишите «что-то вида» на с\с++. Если уж сравнивать примеры кода то хотябы со схожими языками.
              0
              Ну, я его только учу, до реального кода пока далеко. Но мои эксперименты показали, что в работе с аллокатором он (Rust) существенно обгоняет Go и находится на одном и том же уровне с Сишным кодом (при использовании одинаковых структур данных, потому что наивный тест на сишном string.h прососал даже python'у из-за неэффективного strlen).
                0
                На плюсах вы можете возвращять не голый указатель а обертку, которая может защищать от обращения к нулевому указателю.
                  0
                  Главная проблема в том, что в С++ — МОЖНО. Можно почти всё. В том числе стрелять себе в ногу без предупреждения по модели С. В этом смысле совместимость с С — фатальна для языка, т.к. компилятор не имеет возможности защитить человека от ошибок, и уровень дисциплины что в С, что в С++ оказывается одинаковый.
                0

                А смысл? На C++ вам система типов языка мало что гарантирует.

                  0
                  C++ строго типизируемый, о чём вы?
                    +1
                    C void*, вообще указателями, неявными преобразованиями, отсутствием понятия чистых функций и прочей радостью?
              +1

              Rust?

                +2
                Тоже об этом подумал. К тому же это явно более зрелый язык.

                > избавились от ошибок управления памятью с помощью сборки мусора

                Особенно смешно читать вот это.
                  +1
                  Вот вот. Сборка мусора — отдельный источник проблем в совсем неожиданных местах
                    –1

                    Например?

                      0
                      Ну, например запуск сбора мусора в неподходящий момент или нехватка памяти, которая в нужный момент не была освобождена сборщиком. Счетчик ссылок, например как в Objective C мне кажется лучшим решением
                        +1

                        Счётчик ссылок не очень хорошо работает с циклами, приходится извращаться.


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

                          0
                          >Счётчик ссылок не очень хорошо работает с циклами, приходится извращаться.
                          А чем плох счетчик ссылок с циклами?

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

                          Вот кстати еще против аргумент сбора мусора: данные можно организовать так, что будет утечка памяти, сборщик мусора ее не освободит

                            0
                            А чем плох счетчик ссылок с циклами?

                            Тем, что если у вас A хранит в себе shared_ptr<B>, который хранит в себе shared_ptr<A> на исходный A, то они не удалятся никогда. Вам нужно либо руками в точке разрушения очищать один из указателей, либо обмазываться weak_ptr'ами.


                            Но вы можете явно на это влиять, может не очень красиво, костыльно, но можно.

                            Это да. Но костыльно и не очень красиво, а также не очень поддерживаемо, согласитесь.


                            На GC тоже можно влиять. В ghc 8.2 вон, например, compact regions завезли.


                            Вот кстати еще против аргумент сбора мусора: данные можно организовать так, что будет утечка памяти, сборщик мусора ее не освободит

                            Ну, можно и кеш какой-нибудь не очищать, да, будет семантическая утечка, верно. GC не панацея и не серебряная пуля ни в коем случае.


                            Тут примерно как со строгой статической типизацией: тайпчекер, который переносит управление ресурсами в компил-тайм, отвергнет больше семантически корректных программ, но даст вам больше уверенности, что неотвергнутые программы корректны.

                +1
                разыменование нулевого указателя — дефект, который справедливо считается «чумой» современного программирования

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

                перегрузка фунции заслуженно считается холокостом современного программирования

                ну а серьезно, ну не нравятся указатели, испльзуйте референсы.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое