0845_LFortran_strcat_ru/image2.png


У нас, разработчиков статического анализатора кода PVS-Studio, специфическое представление о красоте. О красоте багов. Нам нравится находить изящество в ошибках, разбираться в них, пытаться угадать, как они появились. Сейчас как раз интересный случай, когда в коде спутались понятия длины и размера.


Ошибка из проекта LFortran


Услышав о выходе очередного CppCast на тему LFortran, мы решили проверить этот самый LFortran. Проект небольшой, поэтому не знаем, наберется ли материал для классической статьи о проверке открытого проекта. Однако на глаза сразу попала ошибка, которая заслуживает написания отдельной маленькой заметки. На наш вкус, ошибка очень милая.


В проекте LFortran есть функции для конкатенации (объединения) двух строк в новом буфере.


void _lfortran_strcat(char** s1, char** s2, char** dest)
{
    int cntr = 0;
    char trmn = '\0';
    int s1_len = strlen(*s1);
    int s2_len = strlen(*s2);
    int trmn_size = strlen(&trmn);
    char* dest_char = (char*)malloc(s1_len+s2_len+trmn_size);
    for (int i = 0; i < s1_len; i++) {
        dest_char[cntr] = (*s1)[i];
        cntr++;
    }
    for (int i = 0; i < s2_len; i++) {
        dest_char[cntr] = (*s2)[i];
        cntr++;
    }
    dest_char[cntr] = trmn;
    *dest = &(dest_char[0]);
}

Если хотите, прежде чем мы разберём этот код, вы можете сами попробовать найти ошибку. Чтобы случайно не прочитать пояснение, вставлю длинную картинку :). Есть мем "длиннокот". А у нас будет "длинноединорог" :).


0845_LFortran_strcat_ru/image3.png


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


Программист, писавший код, увлёкся использованием функции strlen и использовал её даже для того, чтобы определить размер терминального нуля. Произошла путаница между размером объекта (терминального нуля) и длиной пустой строки. Это странный и неверный код. Но на наш взгляд, это красивая необычная ошибка.


Пояснение:


char trmn = '\0';
int trmn_size = strlen(&trmn);

Здесь символ trmn интерпретируется как пустая строка. Её длина нулевая. Соответственно, переменная trmn_size, название которой означает размер терминального нуля, всегда будет равна 0.


Нужно было не считать длину пустой строки, а посчитать с помощью оператора sizeof, сколько байт занимает терминальный символ. Правильный код:


void _lfortran_strcat(char** s1, char** s2, char** dest)
{
    int cntr = 0;
    char trmn = '\0';
    int s1_len = strlen(*s1);
    int s2_len = strlen(*s2);

    int trmn_size = sizeof(trmn);  // <=

    char* dest_char = (char*)malloc(s1_len+s2_len+trmn_size);
    for (int i = 0; i < s1_len; i++) {
        dest_char[cntr] = (*s1)[i];
        cntr++;
    }
    for (int i = 0; i < s2_len; i++) {
        dest_char[cntr] = (*s2)[i];
        cntr++;
    }
    dest_char[cntr] = trmn;
    *dest = &(dest_char[0]);
}

Обнаружение ошибки


Как уже было сказано, ошибка была найдена с помощью статического анализатора кода PVS-Studio. К сожалению, он не смог обнаружить ошибку, именно как выход за границу массива. Это сделать достаточно сложно. Анализ потока данных не смог сопоставить, как связан размер буфера dest_char и значение переменной cntr, увеличивающейся в циклах. Ошибка была обнаружена косвенным путём.


PVS-Studio выдал предупреждение: V742 [CWE-170, CERT-EXP37-C] Function receives an address of a 'char' type variable instead of pointer to a buffer. Inspect the first argument. lfortran_intrinsics.c 550


Очень странно считать длину строки с помощью strlen, передав ей указатель на одиночный символ. И действительно, в процессе изучения обнаруженная анализатором аномалия оказалась серьёзным багом. Статический анализ — это круто!


Продолжаем улучшать код


Выше уже было показано, как исправить ошибку в коде. Однако у кода есть и другие недостатки, на которые указывает анализатор. И было бы полезно провести дополнительный рефакторинг.


Во-первых, анализатору не нравится, что нет проверки указателя, который возвращает функция malloc. Это важно. Предупреждение: V522 [CWE-690, CERT-MEM52-CPP] There might be dereferencing of a potential null pointer 'dest_char'. Check lines: 553, 551. lfortran_intrinsics.c 553


Во-вторых, анализатор выдаёт несколько предупреждений на тему 64-битных ошибок. Код не готов к работе со строками, длина которых может быть больше INT_MAX символов. Понятно, что это экзотика, но всё равно некрасиво и потенциально опасно. Лучше использовать тип size_t вместо int.


Улучшенный вариант функции:


void _lfortran_strcat(const char** s1, const char** s2, char** dest)
{
    if (s1 == NULL || *s1 == NULL ||
        s2 == NULL || *s2 == NULL || dest == NULL)
    {
      // Какая-то обработка ошибки, уместная в данном проекте.
      ....
    }
    size_t cntr = 0;
    const char trmn = '\0';
    const size_t s1_len = strlen(*s1);
    const size_t s2_len = strlen(*s2);
    char* dest_char = (char*)malloc((s1_len+s2_len+1)*sizeof(char));
    if (dest_char == NULL)
    {
      // Какая-то обработка ошибки, уместная в данном проекте.
      ....
    }

    for (size_t i = 0; i < s1_len; i++) {
        dest_char[cntr] = (*s1)[i];
        cntr++;
    }
    for (size_t i = 0; i < s2_len; i++) {
        dest_char[cntr] = (*s2)[i];
        cntr++;
    }
    dest_char[cntr] = trmn;
    *dest = dest_char;
}

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


Дополнительные ссылки


  1. Начало коллекционирования ошибок в функциях копирования.
  2. Теперь PVS-Studio ещё лучше знает, что за зверь такой – strlen.
  3. Разработка 64-битных приложений.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Andrey Karpov. A Beautiful Error in the Implementation of the String Concatenation Function.

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


  1. qrdl
    22.07.2021 10:27
    +10

    Автор кода хотел поумничать, и нагородил лажу вместо того, чтобы идиоматично написать +1 в аргументе malloc'а. Что тут красивого?


    1. foto_shooter
      22.07.2021 10:32
      +1

      Притаившаяся ошибка. ​:)


    1. motoroller95
      22.07.2021 10:37
      +2

      много ли ума надо чтобы считать длинну однобайтовой строки?)


      1. Jogger
        22.07.2021 11:04
        +10

        Да ладно если бы это была строка! Но объевлен-то char! Внимание вопрос - сколько же char-ов влезет в один char? Не отвечайте сразу дети, подумайте!!!
        Я уж молчу что зачем-то копируют символы в цикле вместо пары memcpy, мы же здесь про ошибки, а не про оптимизацию... но блин.


        1. mSnus
          22.07.2021 12:19
          -3

          Ну, вообще-то, всё правильно, если терминатор будет из двух char-ов, этот код (с sizeof) сработает верно

          А заранее предусмотреть возможность не исследовать и переписывать код, просто поменяв константу -- очень правильное решение.


          1. tyomitch
            22.07.2021 16:51

            Что вы имеете в виду? const char trmn = '\1\2'; — это ошибка компиляции.


        1. amarao
          22.07.2021 12:34

          А что такое char? Вот в Rust - я знаю. А в Си - я, наверное, не буду умничать. Потому что мы не знаем какой именно char, и 8 там бит, или 6, или там, кто-то прихерачил половинку юникода в 16 битах...

          So подсказывает:

          char is always a byte , but it's not always an octet. A byte is the smallest addressable unit of memory (in most definitions), an octet is 8-bit unit of memory.


          1. picul
            22.07.2021 16:10
            +2

            A byte is the smallest addressable unit, так что по идее не важно, 8 в нем бит или 42 - sizeof будет 1.


            1. amarao
              22.07.2021 16:35

              В этом месте особых проблем нет. А вот каким образом char связан с буквой... Ой, давайте не будем.


              1. Ritan
                22.07.2021 22:47

                Какая разница как char связан с буквой. sizeof(char) == 1 по определению, а соответсвенно malloc(sizeof(char)) == malloc(1). Спорить тут можно разве что с тем, что new char[1] != malloc(1)


  1. neolp
    22.07.2021 10:57

    а копирование данных лучше делать memcpy(), часто это существенно эффективнее


    1. qw1
      22.07.2021 16:45

      Эффективнее только для длинных строк.
      Зачастую в memcpy проверяется куча граничных случаев: насколько выровнен источник, насколько приёмник, середину копируем 16-байтным кусками через SSE2. При копировании типичной строки из 5 символов, эти проверки сожрут всю оптимизацию.


  1. picul
    22.07.2021 11:04

    Думаю в библиотечной функции добавленные "улучшающие" проверки неуместны, так как теперь они будут выполняться даже тогда, когда они не нужны (а чаще всего они действительно не нужны). Разве что проверку результата malloc'а можно оставить, хотя и она в обычной ситуации никогда не сработает.


  1. kunix
    22.07.2021 11:26
    +6

    Во-вторых, анализатор выдаёт несколько предупреждений на тему 64-битных ошибок.

    Ну по-хорошему, надо еще детектить целочисленное переполнение, в результате которого, теоретически, будет выдан буфер меньшего размера со всеми вытекающими.
    char* dest_char = (char*)malloc(s1_len+s2_len+trmn_size);

    Когда-то в Android было много багов при вызовах вида new T[count], где count контролировался извне. Местечковый урезанный libc (bionic) честно вызывал malloc(sizeof(T)*count), умножение переполнялась, на выходе красивое переполнение кучи.


  1. mSnus
    22.07.2021 12:18

    del


  1. atd
    22.07.2021 15:04
    +2

    Меня другое смущает: если мы делаем +sizeof('\0') вместо +1, то значит мы не уверены, что у нас sizeof(char) == 1, а если мы в этом не уверены, то маллок нам выделит недостаточно памяти даже в «исправленном» случае. Верно было бы писать что-то вроде malloc((len1+len2+1)*sizeof(char))


    1. tyomitch
      22.07.2021 16:57

      Стандарт гарантирует, что sizeof(char)==1 на любой архитектуре, независимо от числа битов в char.


    1. Andrey2008 Автор
      22.07.2021 16:59

      Согласен. Поменял в статье.


      1. tyomitch
        22.07.2021 17:04
        +4

        И зря.
        Пункт 6.5.3.4 стандарта Си:

        4. When sizeof is applied to an operand that has type char, unsigned char, or signed char, (or a qualified version thereof) the result is 1.


        1. Andrey2008 Автор
          22.07.2021 17:14

          Почему зря? Мне кажется, такой вариант смотрится лучше: (len1+len2+1)*sizeof(char). А до этого смешивалось понятие длины строк и размера терминально символа.


          1. tyomitch
            22.07.2021 17:41
            +1

            Лучше чем len1+len2+sizeof(char) - согласен.

            Чем len1+len2+1 - разве лучше?


            1. Andrey2008 Автор
              22.07.2021 17:48
              +1

              Чисто теоретически, легче будет переделать на wchar_t.


              1. thesanone
                24.07.2021 12:15

                Писать код на будущее, очень частая ошибка, а если потом понадобиться функция конкатенации wchar_t, то лучше новую функцию для этого написать


  1. gameplayer55055
    22.07.2021 17:47

    А зачем считать размер нуль терминатора?

    Типо если подсунут жирный char?


    1. Andrey2008 Автор
      22.07.2021 19:27

      Да. Но как уже отметили, тогда уж надо ещё и умножение длины на sizeof для красоты писать.


  1. GCU
    24.07.2021 01:13
    +2

    Очень коварная ошибка.

    Учитывая что malloc выровнен блоками по 8 байт, вероятность что вылезут из блока лишь 1/8. Но поскольку память распределяется страницами по 4КБ, ошибка доступа будет лишь при выходе из последнего блока страницы, вероятность что блок последний 1/512.

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