С-шные трюки от мыщъх'а (выпуск 0Dh)

Автор: (c)Крис Касперски ака мыщъх

Совет 1: Вычисление rand() на стадии компиляции

Препроцессор в Си - великая вещь, однако его возможности существенно ограничены и зачастую, чтобы осуществить задуманное, приходится извращаться не по-детски. Достаточно часто программистам требуется получить случайное число, уникальное для каждого билда, но не меняющееся от запуска программы к запуску. Существует множество решений этой проблемы. В частности, линкер ulink использует штамп времени, содержащийся в заголовке PE-файла, однако этот способ системно-зависимый и, что самое неприятное, неработающий на некоторых UNIX-подобных осях, где ELF-заголовок вообще не проецируется на адресное пространство процесса.

Некоторые программисты поступают так - подключают включаемый файл x-file.h директивой '#include "x-file.h"', создают простую утилиту на Си, генерирующую x-file.h следующего содержания: "#define X_RAND 0xXXXXXXXX", где 0xXXXXXXXX - случайное число, возвращаемое функцией rand(), а в makefile-файл вставляют команды компиляции этой вспомогательной утилиты, ее линковку и запуск. Затем, после создания x-file.h можно собирать файл проекта. Достоинство этого трюка в его переносимости, а недостаток - в излишней громоздкости. Сгенерировать уникальное для каждого билда число можно и проще!

Компиляторы, придерживающиеся ANSI Си, имеют в своем "словарном запасе" макросы __DATE__ и __TIME__, возвращающие дату и время компиляции файла, соответственно. Оба значения представлены в строковом формате, от которого приходится избавляться путем вычисления хеш-суммы по алгоритму CRC32 или любому другому. Для достижения большей случайности полученное число можно передать функции srand() с последующим вызовом rand().

Как вариант, можно использовать макрос __TIMESTAMP__, возвращающий штамп даты/времени последней модификации компилируемого файла в виде 32-битного целого, что изваляет нас от необходимости вычисления CRC32, однако если файл, содержащий __TIMESTAMP__ не будет изменен, мы получим то же самое число, что и в предыдущем билде. В некоторых случаях это неприемлемо, в некоторых - напротив, даже очень желательно (т.е. сгенерированное число изменяется только в случае изменения файла).

int x_rand;
main()
{
        srand(__TIMESTAMP__);
        x_rand = rand();
        ...
}

Листинг 1. Использование макроса __TIMESTAMP__ для генерации случайного числа, уникального для каждого билда.

Совет 2: Строковые литералы и тип char

Рассмотрим следующий код (см. листинг 2), вполне типичный для начинающих. Что в нем неправильно?

foo(char *s)
{
        if (*s < 'я')
                return "ты";
}

Листинг 2. Пример неправильного использования char*, неявно закладывающийся на особенности поведения компилятора.

Опытным программистам известно, что стандарт ANSI Cи позволяет компиляторам самостоятельно решать, должен ли тип char быть знаковым или нет, поэтому если число укладывается в диапазон [0...127], мы вправе использовать char - программа будет работать, независимо от наличия знака. В противном случае следует явно специфицировать тип, указывая перед char каким ему быть - signed или unsigned.

Компилятор Microsoft Visual C++ по умолчанию всегда выбирает unsigned char, поэтому данная программа будет работать правильно, однако стоит откомпилировать ее с помощью Borland C++, как все изменится и мы получим совершенно неожиданный результат. Компилятор по умолчанию устанавливает char в signed, в результате чего строковый литерал 'я' превращается в число -17 и условие (*s < 'я') окажется в косяках, что наглядно подтверждает дизассемблерный листинг, приведенный ниже:

_TEXT:00000000 _foo     proc near   ; CODE XREF: _main+4vp
_TEXT:00000000
_TEXT:00000000 arg_0    = dword ptr  8
_TEXT:00000000
_TEXT:00000000          push  ebp
_TEXT:00000001          mov   ebp, esp
_TEXT:00000003          mov   eax, [ebp+arg_0]
_TEXT:00000006          cmp   byte ptr [eax], -17  ; 'я'
_TEXT:00000009          jge   short loc_20         ; знаковое сравнение!
_TEXT:0000000B          mov   eax, 0EBE2h
_TEXT:00000010
_TEXT:00000010 loc_20:  ; CODE XREF: _foo+9^j
_TEXT:00000010          pop   ebp
_TEXT:00000011          retn
_TEXT:00000011 _foo     endp

Листинг 3. Результат работы компилятора Borland C++ - переменная char *s трактуется как signed char*.

Самое подлое коварство данной проблемы заключается в том, что она не проявляется на английских программах, поскольку символы английского алфавита сосредоточены в первой половине ASCII-таблицы и потому знака "минус" в них просто не возникает! А вот после русификации (или работе с русскими файлами данных) она неожиданно вылезает в самых разных местах, разваливая программу и порождая трудноуловимые баги.

Совет 3: Выход из нескольких циклов сразу

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

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

for (...)
{
        for (...)
        {
                for (...)
                {
                        if (...)
                                goto to_exit;
                }
        }
} to_exit:

Листинг 4. Выход из трех циклов c использованием оператора goto.

int to_exit = 0;
for (...)
{
        for (...)
        {
                for (...)
                {
                        if (...)
                        {
                                to_exit = 1;
                                break;
                        }
                }
                if (to_exit) break;
        }
        if (to_exit) break;
}

Листинг 5. Выход из трех циклов без использования оператора goto.

Не кажется ли вам, что листинг 4 намного более нагляден и в нем гораздо труднее совершить ошибку, чем в "идеологически правильном" листинге 5? Увы! В некоторых случаях использование goto строго запрещено принятыми корпоративными правилами кодирования, против которых не попрешь. Вот такая, значит, бюрократия.

Совет 4: Переносимые ассемблерные вставки

Когда возможностей, предоставляемых языком Си, оказывается недостаточно (например, требуется прочитать значение регистра - счетчика команд), программисты обычно прибегают к ассемблерным вставкам.

Проблема в том, что способ оформления ассемблерных вставок не стандартизован и каждый компилятор делает это по-своему. К тому же, даже в рамках x86-процессоров существует, как минимум, два ассемблерных синтаксиса - Intel (поддерживаемый Windows-компиляторами) и AT&T, поддерживаемый, например, GCC.

Одно из решений состоит в переводе ассемблерной вставки в машинный код (что очень удобно делать в Hiew) с последующим размещением ее в локальном массиве, указатель на который преобразуется в указатель на функцию, запускаемую на выполнение с передачей аргументов через стек по тому или иному соглашению.

Практический пример использования такого трюка на практике приведен ниже:

int (*foo)();

bar()
{
        // Объявляем массив и заполняем его машинным кодом
        char shell[] = "\x0F\x31"; // RDTSC

        // Преобразуем указатель на массив в указатель на функцию foo
        foo = (int(*)())shell;

        // Вызываем функцию foo, возвращая результат ее выполнения
        // Для простоты результат усекается до 32-бит, передаваемых в регистр EAX
        // Старшие 32-бита, помещаемые командой RDTSC в регистр EDX, мы отбрасываем

        return foo();
}

main()
{
        int a;
        a = bar();
}

Листинг 6. Пример вставки машинного кода в С-программу.

Единственный существенный недостаток данного метода в том, что на осях с неисполняемым стеком он не работает и приходится вызывать системно-зависимые функции для установки соответствующих атрибутов доступа к памяти - VirtualProtect() на Windows и mprotect() на UNIX, вокруг которых приходится делать свои "обертки" (они же "врапперы", от английского "wrapper"), вызываемые перед передачей управления на функции foo(), но и в этом случае у нас нет никаких гарантий, что ось позволит выполнить код. В частности, некоторые UNIX-подобные системы на процессорах, не поддерживающих биты NX/XD (атрибуты исполнения кода на уровне страниц), размещают стек в области памяти, управляемой селектором, устанавливающим права доступа только на чтение/запись (без возможности исполнения) и потому игнорирующие вызов mprotect(..., PROT_EXEC).