Главное предназначение статических анализаторов – найти те ошибки, которые остались незамеченными разработчиком. И недавно команда PVS-Studio снова столкнулась с интересным примером мощи этой методики.

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

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

Предупреждение анализатора: V645 The 'strncat' function call could lead to the 'а.consoleText' buffer overflow. The bounds should not contain the size of the buffer, but a number of characters it can hold.

Фрагмент кода:

struct A
{
  char consoleText[512];
};

void foo(A a)
{
  char inputBuffer[1024];
  ....
  strncat(a.consoleText, inputBuffer, sizeof(a.consoleText) –
                                      strlen(a.consoleText) - 5);
  ....
}

Перед изучением примера давайте вспомним, что делает функция strncat:

char *strncat(
  char *strDest,
  const char *strSource,
  size_t count 
);

где:

  • 'destination' — строка-получатель;

  • 'source' — строка-источник;

  • 'count' — максимальное число символов, которое можно добавить.

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

Давайте разберёмся, как обстоят дела на самом деле. В выражении:

sizeof(a.consoleText) – strlen(a.consoleText) – 5

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

strlen(a.consoleText) = 0

Тогда результатом будет 507, и никакого переполнения не случится. О чём же тогда говорит PVS-Studio? Давайте немного углубимся во внутренние механизмы анализатора и попробуем разобраться.

В статическом анализаторе за подсчёт таких выражений отвечает механизм анализа потока данных (data-flow). В большинстве случаев, когда выражение состоит из констант времени компиляции, data-flow вернёт строгое значение выражения. В остальных случаях, как и в случае с предупреждением, он сформирует только диапазон возможных значений выражения.

В данном случае значение операнда strlen(a.consoleText) неизвестно нам на этапе компиляции. Давайте посмотрим диапазон.

Спустя несколько минут отладки мы получаем от data-flow аж 2 диапазона:

[0, 507] U [0xFFFFFFFFFFFFFFFC, 0xFFFFFFFFFFFFFFFF]

На первый взгляд, кажется, что второй диапазон лишний. Однако это не так. Просто мы забыли о том, что результатом выражения может стать отрицательное число. Например, такое может случиться при strlen(a.consoleText) = 508. В таком случае произойдет переполнение беззнакового числа, и результатом выражения будет максимальное значение результирующего типа, в данном случае — size_t.

Получается, что анализатор прав! В данном выражении к полю consoleText может добавиться гораздо большее количество символов, чем его размер, что приведёт к переполнению буфера и, как следствие, неопределённому поведению. Причина неожиданного предупреждения – то, что ложного срабатывания тут нет!

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

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Vladislav Stolyarov. One Day in the Life of PVS-Studio Developer, or How I Debugged Diagnostic That Surpassed Three Programmers.

Комментарии (33)


  1. sidorovmax
    05.07.2021 16:22
    -4

    Сделали бы сразу size_t знаковым и не было бы такого рода ошибок.
    Плюс функции strncpy и strncat провоцируют ошибки, ибо позволяют строке в итоге не оканчиваться на ноль.


    1. Maccimo
      06.07.2021 01:44
      +1

      Размер структуры в памяти не может быть отрицательным, следовательно и смысла делать size_t знаковым тоже нет. Тем паче, что от ошибки это никак бы не уберегло.


    1. dmitryrf
      07.07.2021 12:10

      Такой есть: ssize_t — Used for a count of bytes or an error indication.
      Но, как уже сказали, в данном случае это не решает проблему.


  1. Punk_Joker
    05.07.2021 16:50
    +3

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


    1. Stolyarrr Автор
      05.07.2021 16:55

      Спасибо за фидбек! Учту при написании следующих статей.


  1. Tujh
    05.07.2021 17:13

    Тут ещё и логическая ошибка, программист слишком перемудрил

    strncat(a.consoleText, inputBuffer, sizeof(a.consoleText) –
                                          strlen(a.consoleText) - 5);

    в третьей позиции должно быть:

    Maximum number of characters to be appended.

    так что sizeof(a.consoleText) было бы более чем достаточно.

    Правда это не замечание для анализатора, а для кода в целом.


    1. datacompboy
      05.07.2021 17:17
      +2

      В случае просто sizeof() туда можно будет скопировать больше чем влезет...

      Логику этого поведения мне не совсем понять (это именно сколько добавить, а не сколько всего места есть в буфере...) но таки правильно записано. ну, почти.


      1. Tujh
        05.07.2021 18:13
        +1

        был неправ


        1. datacompboy
          05.07.2021 18:15

          https://www.cplusplus.com/reference/cstring/strncat/

          /* strncat example */
          #include <stdio.h>
          #include <string.h>
          
          int main ()
          {
            char str1[20];
            char str2[20];
            strcpy (str1,"To be ");
            strcpy (str2,"or not to be");
            strncat (str1, str2, 6);
            puts (str1);
            return 0;
          }
          

          Output: "To be or not"


          1. Tujh
            05.07.2021 18:20

            лучше смотреть man

            A simple implementation of strncat() might be:
            
            char*
            strncat(char *dest, const char *src, size_t n)
            {
                size_t dest_len = strlen(dest);
                size_t i;
            
               for (i = 0 ; i < n && src[i] != '\0' ; i++)
                    dest[dest_len + i] = src[i];
                dest[dest_len + i] = '\0';
            
               return dest;
            }

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


  1. Racheengel
    05.07.2021 17:54
    -1

    Я так понимаю, что a.consoleText не обязательно zero-terminated может быть, к тому же.
    И чисто теоретически, strlen(a.consoleText) может вернуть любой мусор в этом случае.


    1. datacompboy
      05.07.2021 18:17

      Почему? strncat делает, цитирую, "Appends the first num characters of source to destination, plus a terminating null-character.". Именно из-за "plus a terminating null-character" нам и надо вычитать в третьем аргументе 1цу


      1. rat1
        05.07.2021 20:42
        +1

        Человек написал о том, что строка может быть не C-строкой, а просто массивом байт без завершения. Кто знает, что туда записали до этого?!


        1. datacompboy
          05.07.2021 20:53

          Это вопрос контракта. Использование на массиве str* функций подразумевает что данные тут предполагают 0-терминацию.


          1. rat1
            06.07.2021 00:14

            Судя по всему не заполнять массив настолько, чтобы целочисленного переполнения не произошло - это тоже вопрос контракта? Зачем нам статический анализатор? Работаем на доверии и без ошибок.


            1. datacompboy
              06.07.2021 02:32

              Вот как раз следить за тем что контракт сохранится до и после каждой из функций и нужен статан.


  1. aamonster
    05.07.2021 19:06

    А не хотите выделить подобные случаи в отдельную диагностику? "buffer overflow" тут выглядит не очень понятным, хотелось бы видеть что-то типа "третий аргумент не должен быть отрицательным" (если он отрицательный – это уже ошибка, и не надо даже смотреть, переполняется ли буфер).


    1. ciubotaru
      05.07.2021 19:46
      +3

      Так он не будет отрицательным. Там беззнаковое целое. Его переполнение (выход за верхнюю/нижнюю границу допустимых значений) это "законная" операция в Си.


      1. aamonster
        05.07.2021 21:09

        Да, действительно. Но всё равно, хоть формально и законное, по смыслу – нет. Можно диагностировать :-)


  1. valeramikhaylovsky
    05.07.2021 21:03
    +5

    Вроде статья про С++, но код на СИ. Почему не использоватьstd::string_view и std::string и STL? Сишный код это почти всегда источник багов. Современный С++ даёт возможно писать надёжный код, но этой возможностью не пользуются, даже разработчики статического анализатора для С++.


    1. technic93
      06.07.2021 00:55
      +1

      Или просто поменять тэг у статьи.


  1. technic93
    06.07.2021 00:55
    +4

    А откуда появилось магическое число пять?


  1. kunitsyn
    06.07.2021 01:16
    +1

    Здесь суть в самом предупреждении, в том, что оно не напоминает про underflow. Если оно даже запутало разработчика PVS-Studio, то что уж делать простым смертным.

    Возможно, можно как-то изменить предупреждение, чтобы оно напоминало про underflow в этом конкретном примере, но это будет очень сложно, и я даже не представляю, как это может работать с точки зрения пользователя.


  1. valeramikhaylovsky
    06.07.2021 09:53

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

    Надо подсчитать количество подстрок в строке на С++.

    Решение, которое пишут в 80% случаев:

    size_t substr_count(const char *str, const char* substr)
    {
        size_t count = 0;
        const char *p = str;
        do {
            p = strstr(p, substr);
        }
        while (p != nullptr && ++count && *(++p) != 0);
        return count;
    }
    

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

    А теперь как сделать тоже самое на С++:
    std::size_t substr_count(std::string_view text, std::string_view sub) noexcept
    {
        std::size_t t_count {0};
        for (std::size_t pos {0}; (pos = text.find(sub, pos)) != text.npos; ++t_count, pos += sub.length());
        return t_count;
    }
    

    Даже тело цикла не требуется, и вероятность получить UB резко ниже.


    1. mk2
      06.07.2021 10:00
      +4

      Количество вхождений подстроки в строку, в смысле? И видимо непересекающихся вхождений.

      То, что небольшое тело цикла упихано в for — по-моему скорее антипаттерн, не слишком удобно читается.


  1. eao197
    06.07.2021 10:13
    +1

    А как должен быть исправлен проблемный фрагмент чтобы PVS-Studio перестал диагностировать ошибку? Перед вызовом strncat нужно сделать проверку длины строки? Типа:


    if(strlen(a.consoleText)+5u < sizeof(a.consoleText))
    {
       strncat(a.consoleText, inputBuffer, sizeof(a.consoleText) –
                                          strlen(a.consoleText) - 5);
    }


    1. valeramikhaylovsky
      06.07.2021 13:38
      -1

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

      std::string concatenate(Args const&... args)
      {
          const size_t len = (0 + ... + std::string_view(args).size());
          std::string result; result.reserve(len);
          (result.append(args), ...);
          return result;
      }
      

      И работать с классом std::string, если речь о С++.


      1. eao197
        06.07.2021 13:59

        Класс std::string для строчек в ~500 байт будет использовать динамическую память, а это может быть нежелательно по тем или иным причинам. Кроме того, могут быть причины для того, чтобы оставить код в его исходном виде, не переписывая на модный и современный стандарт C++.


        1. valeramikhaylovsky
          06.07.2021 14:22
          -1

          Если нужна статическая память, то есть полиморфные аллокаторы. А причин оставлять такой вонючий код я не вижу.


          1. eao197
            06.07.2021 14:29

            Ваше мнение понятно. Позволю, однако, напомнить, что свой вопрос я задавал не вам, а разработчику PVS-Studio чтобы получить ответ из первых рук.


    1. Stolyarrr Автор
      06.07.2021 16:09
      +1

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

      После обнаружения данного срабатывания мы добавили его, как пример, в документацию V645 и там предлагаем такой вариант фикса:

      size_t textSize = strlen(a.consoleText);
      if (sizeof(a.consoleText) - textSize > 5)
      {
           strncat(a.consoleText, inputBuffer, 
                    sizeof(a.consoleText) - textSize - 5);
      }


      1. eao197
        06.07.2021 16:23

        Понятно, спасибо за ответ.


        sizeof(a.consoleText) — textSize > 5

        Какой-нибудь gcc на высоких уровнях предупреждений ругнется на сравнение unsigned с signed. Уж извините за занудство :)


        Кроме того, тут напрашивается еще вот какой момент: этот вариант можно считать надежным только в случае, если гарантируется, что в a.consoleText будет 0-символ. Если же разработчик где-то со значением consoleText ошибся и 0-символ содержится в sizeof(a.consoleText)+1u или sizeof(a.consoleText)+2, то (sizeof(a.consoleText) — textSize) приведет к переходу через ноль.


        Но стоит ли этим заморачиваться, ведь отсутствие 0-символа в a.consoleText — это явно ошибочная ситуация, которая должна предотвращаться другими способами? У меня нет однозначного ответа на этот вопрос.


        1. Stolyarrr Автор
          07.07.2021 17:50

          Спасибо за замечание по поводу сравнения, поправил в документации. По поводу отсутствия 0-символа, мне кажется, это уже совсем частный случай)