Эта статья претендует на то, чтобы стать первой в серии статей о том, что делает запутанный код. Причины этого довольно просты:
- я мазохист
- Обфусцированный код мне очень интересен
Многие люди, когда видят обфусцированный код, обычно говорят «какого черта», а затем «круто», если они удосуживаются его скомпилировать/запустить. Я думаю, что запутанный код заслуживает большего внимания, это само по себе искусство, и оно стоит того, чтобы понять, как все это работает. Чтобы достичь этой цели, я буду опираться на результаты победителей 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 года и на самом деле один из самых простых, которые я когда-либо видел — следите за дальнейшими разборами.