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

Трюки от Крыса 20h: юбилейный выпуск

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




Наверное, все слышали о языке brainfuck, программирование на котором представляет мазохизм в стиле «хрен напишешь» и «хрен прочтешь». Однако Си с его гибким синтаксисом и мощным препроцессором позволяет трахать мозги не только секса ради, но и для дела. Обфускация исходных текстов – это конкретная тема, которой и посвящен юбилейный выпуск трюков.

Препроцессор

Стандартный препроцессор языка Си, конечно, не такой мощный как, например, у MASM'а, но для большинства повседневных задач его вполне хватает. Макросы — великая вещь, но если шагнуть дальше, то можно полностью переопределить синтаксис языка. И писать программу… стихами. Пускай потом коллеги разбираются, откуда это взялось и что оно здесь делает.

Классический пример подобного творчества приведен ниже:

Программа на языке Си

Twas the night before Christmas
And all through the house
Not a creature was stirring
Not even a mouse
The stockings were hung
By the chimney with care
In hopes that Saint Nicholas
Soon would be there

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

Определения лексем

#define Twas int
#define the
#define night main()
#define before {
#define Christmas int number, rightDigit, sign = 0;
#define And
#define all printf("Enter your number: ");
#define through scanf("%d", &number);
#define house if (number < 0)
#define Not
#define a
#define creature {
#define was number = -number;
#define stirring sign = 1;
#define even }
#define mouse do
#define The {
#define stockings rightDigit
#define were = number
#define hung %
#define By 10;
#define chimney printf("%d", rightDigit);
#define with number /=
#define care 10;
#define In }
#define hopes while
#define that (number);
#define Saint if (sign)
#define Nicholas puts("-");
#define Soon else
#define would putchar('\n');
#define be return 0;
#define there }

Собрав оба листинга воедино и откомпилировав файл, мы получаем вполне работоспособную программу, запрашивающую число и выводящую его в обратном порядке. Как видно, трансляция сишного кода в стихи осуществлялась тривиальной контекстной заменой, причем, часто употребляемые слова английского языка (and, a, the) определены через пробел. Короче, идея понятна. Проблема в том, что практически все компиляторы позволяют вывести результат работы препроцессора в файл (у MS VC за это отвечает ключ /P), проанализировать который не составит никакого труда. Чтобы трахнуть мозги (мы же хотим трахнуться, правда?), необходимо использовать более продвинутые методики. Например, хитрые математические алгоритмы (типа сходящихся рядов), реализованные в виде развернутых циклов. Заслуга препроцессора в том, что он позволяет представить эти ряды в псевдографической форме, высаживающей хакеров на реальную измену.
Программа, приведенная ниже, вычисляет число «пи» – тем точнее, чем больше «круг». Она не только компилируется, но еще и работает! Однако, понять, как она работает, невозможно ни путем анализа исходного кода, ни сохранением препроцессорного вывода в отдельном файле, ни даже дизассемблированием откомпилированного файла.

Это не шутка! Программа для расчета числа «пи». Да-да, именно расчета, а не вывода готовой константы посредством сопроцессора. Чем больше круг — тем выше точность расчетов.

/*
* Program to compute an approximation of pi
* by Brian Westley, 1988
*/
#define _ 0xF<00? --F<00||--F-OO--:-F<00||--F-OO--;
int F=00,OO=00;
main(){F_OO();printf("%1.3f\n",4.*-F/OO/OO);}F_OO()
{
_-_-_-_
_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_-_-_-_-_
_-_-_-_-_-_-_-_
_-_-_-_
}

Фундамент языка

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

Программа, изображенная ниже, похожа на первоапрельскую шутку, которой она по сути дела и является. Код исправно компилируется и работает, выводя на экран число «минус два», а если последнюю строку заменить на «1 -+- + -+- 1», мы получим… ноль.

Графитти на чистом Си

foo(int a){printf("%d\n",a);};main(){foo(

+- -+- -+- -+
////////////.!
////////////./!
////////////.//!
////////////.///!
+ - -+- -+- +////!
!!!!!~~!!!!!!////!
!!!!~~!~!!!!!////.
!!!~~~~~~!!!!/////
!!~~!~~!~~!!!////
!~~!!~~!!~~!!///
~~!!!~~!!!~~!//
1 -+- - -+- 1
);
}

И в чем прикол? А в том, что никакой программист не сможет быстро сказать, какой результат мы получим на выходе. К тому же, подобные «художества» автоматически трактуются человеком как комментарии, что позволяет скрыть часть функционала. Не говоря уже о том, что после наведения «порядка» и удаления всех «ненужных» комментариев программа просто развалится со всеми вытекающими отсюда…

Реальный хардкор

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

Программа-головоломка (это только с виду она простая…)

/*
* HELLO WORLD program
* by Jack Applin and Robert Heckendorn, 1985
*/
main(v,c)char**c;{for(v[c++]="Hello, world!\n)";
(!!c)[*c]&&(v--||--c&&execlp(*c,*c,c[!!c]+!!c,!c));
**c=!c)write(!!*c,*c,!!**c);}

Вопрос на засыпку — что это такое, как оно работает и что делает? Если кто думает, что программа выводит строку «Hello, world», он глубоко ошибается. Благодаря коварному багу, на экране отображается только «Hello», а «world!» появляется только после удаления пробела между запятой и миром.

Кстати говоря, 128 байт исходного текста транслируются оптимизирующим компилятором Microsoft Visual C++ в 128 байт машинного кода. Мистика! Совпадение размеров, разумеется, чистая случайность (в 1985 году, где создавалась эта программа, никаких «визуалов» вообще не существовало), но черной магии здесь и без того хватает. Концентрация языковых «извращений» просто поражает! Начнем с того, что программа рекурсивно вызывает сама себя посредством документированной, но малораспространенной функции execlp(). Это серьезно затрудняет отладку, поскольку отладчики на работу с дочерними процессами как-то не рассчитаны, и пошаговая остановка тихо кончается, не достигая цели. Точки останова, установленные в материнском процессе, на дочернее потомство, естественно, не распространяются. Заниматься оплодотворением приходится самостоятельно, прибегая к рукоблудному методу инъекции бряков непосредственно в тело исполняемого файла, что съедает все свободное и несвободное время, без всякой надежды на скорый успех.

Во многих случаях обфускация снимается декомпиляцией двоичного файла в Си-код, повторной компиляцией декомпилированного файла с максимальным уровнем оптимизации и последующей финальной декомпиляцией. В качестве декомпилятора обычно используется Hex-Rays, который люди берут так же, где и саму ИДУ – не потому, что Hex-Rays хорош, а потому, что другие декомпиляторы еще хуже.

Однако, в нашем случае Hex-Rays генерирует кривой код, повторная компиляция которого разваливает программу. Причем, понять, где она валится, практически невозможно – алгоритм в целом тривиален, но при переходе на частности выясняется, что любая конкретно взятая операция (или даже группа операций) в изоляции от остальных лишена смысла, и декомпозиция идет лесом. Программа представляет собой монолитное сооружение, своеобразный магический кристалл с множеством граней, многократно отражающихся друг в друге, и совокупность этих отражений реализует алгоритм, расшифровав который мы узнаем, что программа считает первый аргумент командой строки, укорачивает его на один символ и выводит результат на экран. Поскольку пробел трактуется как разделитель аргументов, то оригинальный вариант программы оказывается неработоспособен, и нам необходимо либо удалить пробел после запятой, либо заключить строку в кавычки, либо сделать и то, и другое сразу:

Результат работы оптимизирующего компилятора с последующей декомпиляцией Hex-Rays – те же яйца, только в профиль

int __cdecl main(int argc, const char **argv, const char *envp)
{
int result; int v4; bool v5; char **v6; int v7; v4 = argc;
argv[argc] = "Hello, world!\n)"; v6 = (char **)(argv + 1);

result = argv + 1 != 0; v5 = argv + 1 != 0;
if ( (*(argv + 1))[result] )
{
do
{
v7 = v4--; if ( !v7 )
{
--v6; if ( !v6 ) break;
v5 = 1; result = _execlp(*v6, (char)*v6);
if ( !result ) break;
} _write(*v6 != 0, *v6, **v6 != 0);
result = (int)*v6; **v6 = v6 == 0;
} while ( (*v6)[v5] );
} return result;
}

Содержание