Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #102, ИЮНЬ 2007 г.

Трюки от Крыса

Крис Касперски

Хакер, номер #102, стр. 120

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

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

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

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

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

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

int x_rand;

main()

{

srand(__TIMESTAMP__);

x_rand = rand();

}

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

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

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

foo(char *s)

{

if (*s < 'я') retu 'ты';

}

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

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

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

_TEXT:00000000 _foo proc near ; CODE XREF: _main+4?p

_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

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

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

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

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

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

for(…)

{

for (…)

{

for(…)

{

if (…) goto to_exit;

}

}

} to_exit:

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

int to_exit = 0;

for(…)

{

for(…)

{

for(…)

{

if (…)

{

to_exit = 1;

break;

}

}

if (to_exit) break;

}

if (to_exit) break;

}

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

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

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

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

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

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

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

int (*foo)();

bar()

{

// объявляем массив и заполняем его машинным кодом

char shell[]="x0Fx31xC3"; // RDTSC + RETN

// преобразуем указатель на массив в указатель на функцию foo

foo = (int(*)())shell;

// вызываем функцию foo, возвращая результат ее выполнения

// для простоты результат усекается до 32 бит, передаваемых в регистр EAX

// старшие 32 бита, помещаемые командой RDTSC в регистр EDX, мы отбрасываем

retu foo();

}

main()

{

int a;

a = bar();

}

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

Содержание
ttfb: 3.8661956787109 ms