Skip to content

Latest commit

 

History

History
184 lines (144 loc) · 9.32 KB

nullptr_dereference.md

File metadata and controls

184 lines (144 loc) · 9.32 KB

Разыменование нулевых указателей.

Самая крутая ошибка с самыми жуткими последствиями. null вообще называют ошибкой на миллиард долларов. От них страдает куча кода, на самых разных языках программирования. Но если в условной Java при обращении по null-ссылке вы получите исключение с вполне предсказуемыми последствиями (ну, упало и упало), то в великом и ужасном C++, а также в C за вами придет неопределенное поведение. И оно будет действительно неопределенным!

Но для начала, конечно, надо отметить, что, после всех обсуждений туманных формулировок стандарта, в настоящее время есть некоторое соглашение, что все-таки не сама по себе конструкция *p, где p — нулевой указатель, вызывает неопределенное поведение. А lvalue-to-rvalue преобразование. Ну или менее формально, кратко и не совсем правильно: пока нет чтения или записи значения по этому самому нулевому адресу — все нормально.

Так, сейчас совершенно законно вы можете вызвать статические методы класса через nullptr.

struct S {
    static void foo() {};
};

S *p = nullptr;
p->foo();

А также можно писать вот такую ерунду

S* p = nullptr;
*p; 

Причем эту ерунду можно писать только в C++. В C это безобразие все-таки запретили (см. 6.5.3.2, сноска 104). И в C применять оператор разыменования к невалидным и нулевым указателями нельзя нигде. А у C++ свой особый путь. И эти странные примеры собираются в constexpr контексте (напоминаю, в нем запрещено UB и компилятор проверяет).

Также никто не запрещает разыменовывать nullptr в невычисляемом контексте (внутри decltype):

#define LVALUE(T) (*static_cast<T*>(nullptr))

struct S {
    int foo() { return 1; };
};

using val_t = decltype(LVALUE(S).foo());

Но, несмотря на то что так делать можно, совершенно не значит, что так делать нужно. Потому что последствия от разыменования nullptr там, где этого делать нельзя, могут быть печальными. Лезвие тонкое, острое, можно легко оступиться и что-нибудь взорвать.

Если разыменовать nullptr, может быть исполнен код, который никак не вызывался:

#include <cstdlib>

typedef int (*Function)();

static Function Do = nullptr;

static int EraseAll() {
  return system("rm -rf /");
}

void NeverCalled() {
  Do = EraseAll;  
}

int main() {
  return Do();
}

Компилятор обнаруживает разыменование nullptr (вызов функции Do). Это неопределенное поведение. Такого быть не может. Компилятор находит, что есть одно место, где этому указателю присваивается ненулевое значение. И раз нуля быть не может, то, значит, именно это значение он и использует. Как результат — исполняется код функции, которую мы не вызывали.

Или вот совершенно дурная программа.

void run(int* ptr) {
    int x = *ptr;
    if (!ptr) {
        printf("Null!\n");
        return;
    }
    *ptr = x;
}

int main() {
  int x = 0;
  scanf("%d", &x);  
  run(x == 0 ? nullptr : &x);
}

Из-за разыменования указателя ptr, проверка на nullptr после разыменования может быть удалена.

Вы, конечно же, почти наверняка никогда не напишете такой странный код. Но что если разыменование указателя будет спрятано за вызовом функции?

void run(int* ptr) {
    try_do_something(ptr); // если функция разыменует указатель, 
                           // и оптимизатор это увидит, проверка ниже
                           // может быть удалена
    if (!ptr) {
        printf("Null!\n");
        return;
    }
    *ptr = x;
}

Такая ситуация уже куда ближе к реальности.

В стандартной библиотеке C, например, есть функции, от которых можно было бы, по неопытности, ожидать проверки на nullptr, но они этого не делают.

strlen, strcmp, другие строковые функции, а в C++ еще конструктор std::string(const char*) — их вызов с nullptr в качестве аргумента ведет к неопределенному поведению (и удалению нижерасположенных проверок, если вам не повезет).

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

int main(int argc, char **argv) {
      char *string = NULL;
      int length = 0;
      if (argc > 1) {
          string = argv[1];
          length = strlen(string);
          if (length >= LENGTH) exit(1);
      }

      char buffer[LENGTH];
      memcpy(buffer, string, length); // при передаче nullptr
                                      // length будет нулевым,
                                      // но это не спасает от UB
      buffer[length] = 0;

      if (string == NULL) {
          printf("String is null, so cancel the launch.\n");
      } else {
          printf("String is not null, so launch the missiles!\n");
      }
}

На одних и тех же входных данных (вернее, их отсутствии), этот код завершается с разными результатами в зависимости от компилятора и уровня оптимизаций.

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

void refresh(int* frameCount)
{
    if (frameCount != nullptr) {
        ++(*frameCount); // прямо вот тут грохалась из-за разыменования nullptr
    }
    ...
}

просто потому что где-то совершенно в не связанном с ней классе написали:

class refarray {
public:
    refarray(int length)
    {
        m_array = new int*[length];
        for (int i = 0; i < length; i++) {
            m_array[i] = nullptr;
        }
    }

    int& operator[](int i)
    {
        // разыменование указателя без проверки на null
        return *m_array[i];
    }
private:
    int** m_array;
};

И вызвали функцию так:

refresh(&(some_refarray[0]));

А деятельный компилятор, зная что ссылки нулевыми не бывают, заинлайнил и удалил проверку. Здорово, неправда ли?

Не забывайте проверять на nullptr. Иначе оно взорвется.

Полезные ссылки

  1. https://habr.com/ru/company/pvs-studio/blog/250701/
  2. https://habr.com/ru/post/513058/
  3. https://news.ycombinator.com/item?id=12002746