Эта статья претендует на то, чтобы стать первой в серии статей о том, что делает запутанный код. Причины этого довольно просты:

  1. я мазохист
  2. Обфусцированный код мне очень интересен

Многие люди, когда видят обфусцированный код, обычно говорят «какого черта», а затем «круто», если они удосуживаются его скомпилировать/запустить. Я думаю, что запутанный код заслуживает большего внимания, это само по себе искусство, и оно стоит того, чтобы понять, как все это работает. Чтобы достичь этой цели, я буду опираться на результаты победителей IOCCC (IOCCC = International Obfuscated C Code Contest) и подробно объяснять, как они работают. Должен отметить, что я ни в коем случае не эксперт в C, я в основном тыкаю программу по-разному, пока не пойму, почему она делает то, что делает. Если вы обнаружите что-то неверное в моем анализе или захотите уточнить что-то, я буду рад услышать от вас! Наконец, эти сообщения могут быть немного длинными — я просто пытаюсь прояснить все , связанные с работой выбранных программ.

Имея все это в виду, давайте начнем!

Часть 1: Простые вещи

Сначала мы рассмотрим anonymous.c 1984 года. Он полностью воспроизводится ниже:

int i;main(){for(;i["]<i;++i){--i;}"];read('-'-'-',i+++"hell\
o, world!\n",'/'/'/'));}read(j,i,p){write(j/p+p,i---j,i/i);}

Я скомпилировал это в Ubuntu 16.04, используя cc --std=c89 -w <filename>, и все заработало нормально. Если вы не смогли догадаться по одной английской строке, она напечатает hello, world!. Итак, как это происходит?? Ну, во-первых, давайте рассмотрим, делает ли здесь что-нибудь препроцессор. Может показаться, что это не так, но на самом деле это так — обратная косая черта в конце первой строки называется «обратная косая черта новой строки» и является сигналом препроцессору объединить следующую строку на первую — эффективно превращая программу в одну длинную строку. После рассмотрения того, что делает препроцессор, мне нравится немного подчищать код — так что вот моя попытка (примечание: среда сломала одну длинную строку, а не я):

int i;
main(){
    for(;i["]<i;++i){--i;}"];read('-'-'-',i+++"hello, world!\n",'/'/'/'));
}
read(j,i,p){
    write(j/p+p,i---j,i/i);
}

Если вы скомпилируете это так же, как мы сделали это в первый раз, вы получите тот же результат. Прохладный. В этот момент я запустил программу strace и обнаружил, что символы выводятся на экран один за другим:

Таким образом, очевидно, что цикл for работает, и на каждой итерации он вызывает функцию «чтения», которая на самом деле вызывает системный вызов «записи». Теперь об этой функции чтения… насколько я могу судить, это функция в стиле K&R, и ее можно объявлять без типов (она просто устанавливает типы по умолчанию int). Теперь о самом вызове - у него 3 параметра. Мы сосредоточимся на первом и третьем, а ко второму вернемся позже. Первый — '-'-'-', и это на самом деле довольно просто. Поскольку символы в C представляют собой целые числа, это просто целочисленное вычитание, а поскольку значение вычитается из самого себя, это дает нам 0. Аналогично в '/'/'/' '/' делится на себя, что дает 1. Это позволяет нам упростить вызов до: read(0, i+++"hello, world!\n", 1) . Компиляция/запуск еще раз говорит нам, что ничего не изменилось.

Поскольку функция вызывается с двумя постоянными значениями, мы, вероятно, можем исключить некоторые переменные… давайте сделаем это. read определяется как:

read(j,i,p){
    write(j/p+p,i---j,i/i);
}

Мы знаем, что j равно 0, а p равно 1. Первый аргумент write равен j/p+p или 0/1+1. В C это на самом деле оценивается как 1 — сначала выполняется 0/1 (дает 0), а затем к нему добавляется 1. Третий аргумент write — это i/i, и хотя у нас нет четкого понимания второго аргумента, мы можем с уверенностью предположить, что результатом этого будет 1. Говоря о j, обратите внимание, что он висит во втором аргументе write — т.е. i---j . Было бы неплохо от него избавиться… ну, поскольку j равно 0, мы можем удалить его без проблем. В конце концов, все -0 будет просто самим собой. Зная все это, мы можем упростить функцию чтения/записи до:

read(i){
    write(1,i--,1);
}

Соответственно корректируем вызов чтения в основном цикле for. Опять же, компиляция и запуск дают нам тот же результат.

Сейчас самое время объяснить системный вызов write для тех из вас, кто не знаком с ним. Согласно справочным страницам запись принимает 3 аргумента, дескриптор файла, указатель на буфер и количество байтов, которые нужно записать из этого буфера. Если первый аргумент, дескриптор файла; равен 1, то он записывает в стандартный вывод. Итак, в нашем случае мы записываем на стандартный вывод 1 байт из того, что находится по адресу i--. Говоря о i--, декремент оценивается только после вызова write. Поскольку в этот момент функция завершается, а i является локальным (затмевает глобальный i ), мы эффективно отбрасываем уменьшенное значение — мы можем удалить уменьшение без каких-либо изменений в нашей программе. Теперь… переходим к более загадочным вещам.

Часть 2: Хитрость

Прежде чем мы пойдем дальше, вот как сейчас выглядит мой код:

int i;
main(){
    for(;i["]<i;++i){--i;}"];read(i+++"hello, world!\n"));
}
read(i){
    write(1,i,1);
}

Менее запутанно, чем оригинал, но все же немного странно. Делать осталось не так много, так что давайте приступим!

Небольшое замечание: глобальная переменная i устанавливается в 0 при запуске программы. Именно так поступают C. (Я не мог придумать подходящего места для этого факта, но об этом стоит помнить).

Первое, что я хочу изучить, это i["]<i;++i){--i;}"]; , это условие, завершающее цикл for (смеется, что). Хорошо, так как, черт возьми, это прерывает цикл? Ну, очевидно, потому что C безумен, вы можете проиндексировать массив в обратном порядке — да, вы можете сделать что-то вроде 4["12345"] и на самом деле получить '5' . Причина этого связана с массивами отношений и указателями в C. По сути, когда вы обращаетесь к массиву, например: arr[4], это то же самое, что и к do*(arr + 4) , поскольку сложение является коммутативным, C считает, что совершенно здорово делать это в обратном порядке. (Примечание: я знаю, что массивы и указатели имеют небольшие различия, но я не хочу вдаваться в подробности здесь.)

Теперь вернемся к фактическому выражению — чтобы завершить цикл for, нам нужно ложное условие или значение 0. (Каждое значение, отличное от 0, в C считается «истинным»). Когда цикл запускается, он проходит через строку "]<i;++i){--i;}" , каждый символ здесь оценивается до любого значения int, которое он имеет, и цикл продолжается. А как насчет того, когда мы пройдем конец строки? Хорошо помните, что в C строки заканчиваются нулем, поэтому в конце этой строки есть неявный нулевой байт. Этого достаточно, чтобы остановить цикл.

Если вы еще не догадались, содержимое этой строки не имеет значения, у вас может быть i["11111111111111"], и оно все равно будет работать так же. Это отвлекает, чтобы выглядеть осмысленно — важно только то, что эта строка имеет ту же длину, что и привет, мир! нить.

Итак, теперь последняя загадка, которую нужно раскрыть — как i++ + "hello, world!\n" знает, что нужно передавать только 1 символ за раз в read ? Чтобы решить эту проблему, я запустил gdb, чтобы увидеть, какие значения получает функция read. (Примечание: i увеличивается после добавления строки).

С точкой останова в строке 8 (где мы вызываем write ) мы видим:

Это заняло у меня немного времени, но в конце концов я понял, что значение i, передаваемое для чтения, является адресом указателя. Если мы переведем 4195892 в шестнадцатеричный формат, то получим: 0x400634. Давайте посмотрим, что находится по этому адресу (+ дополнительные 14 байт):

Если вы еще не поняли, вот вышеизложенное в более читабельной форме:

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

Я думаю, что о покрывает это! Надеюсь, вам было интересно прочитать это и узнать об этой программе. Этот был 1984 года и на самом деле один из самых простых, которые я когда-либо видел — следите за дальнейшими разборами.