Различия

Здесь показаны различия между двумя версиями данной страницы.

Ссылка на это сравнение

articles:c-tricks-1bh [2017/09/05 02:55] (текущий)
Строка 1: Строка 1:
 +====== c-tricks-1Bh ======
 +<​sub>​{{c-tricks-1Bh.odt|Original file}}</​sub>​
 +
 +====== сишные трюки\\ (1Bh выпуск) ======
 +
 +крис касперски ака мыщъх, a.k.a. souriz, a.k.a. nezumi, no-email
 +
 +**вольности,​ допускаемые Си/Си++ в отношении указателей (что отличает их от ****Java/​.NET ****и других "​правильных"​ языков),​ обеспечивают гибкость,​ компактность и высокое быстродействие целевого кода. однако,​ подобная демократия таит в себе скрытую угрозу и всякий указатель становится источником непредсказуемых побочных эффектов,​ разваливающих программу!**
 +
 +===== #1 – в одну реку нельзя вступить дважды! =====
 +
 +Статический анализ отдельно взятой функции (неважно — представленный в виде исходного кода или дизассемблерного листинга),​ справляется только с локальными переменными,​ но обламывается на указателях,​ значение которых невозможно вычислить на стадии трансляции (т. е. указатель — не константа) и которые обрабатываются уже в run-time (т.е. в ходе выполнения программы).
 +
 +Всякий неконстантный указатель способен драматически менять логику работы не только отдельно взятой анализируемой функции,​ но даже и всей программы в целом! Разобраться — что же действительно делает тот или иной указатель можно только с помощью отладчика или… статической трассировки _всего_ исходного текста,​ что фактически равносильно исполнению программы на эмулирующем отладчике.
 +
 +Начинающие хакеры недооценивают коварство указателей,​ за что потом расплачиваются изматывающей отладкой,​ отнимающей намного больше времени,​ чем кодирование. Хотите наглядный пример?​ Пожалуйста!
 +
 +foo(int *arg_a, int *arg_b)
 +
 +{
 +
 +printf("​1:​->​ %08Xh:​%08Xh\n",​ *arg_a, *arg_b);
 +
 +
 +
 +***arg_a = *arg_b;​********//​ (1)**
 +
 +printf("​2:​->​ %08Xh:​%08Xh\n",​ *arg_a, *arg_b);
 +
 +
 +
 +***arg_a = *arg_b;​****//​ (2) - ****может иметь другое действие,​ чем ****(1)**
 +
 +printf("​3:​->​ %08Xh:​%08Xh\n",​ *arg_a, *arg_b);
 +
 +
 +
 +***arg_a = *arg_b;​****//​ (3) - ****может иметь другое действие,​ чем ****(1, 2)**
 +
 +printf("​4:​->​ %08Xh:​%08Xh\n",​ *arg_a, *arg_b);
 +
 +
 +
 +***arg_a = *arg_b;​****//​ (4) - ****может иметь другое действие,​ чем ****(1, 2, 3)**
 +
 +printf("​5:​->​ %08Xh:​%08Xh\n",​ *arg_a, *arg_b);
 +
 +}
 +
 +Листинг 1 исходный код загадочной функции
 +
 +Такая простая функция foo() — всего четыре команды *arg_a = *arg_b (отладочные вызовы printf – не в счет). Разве не очевидно,​ что здесь происходит копирование ячейки *arg_bв ячейку *arg_a, для "​надежности"​ повторяемое четыре раза? Тогда почему же оптимизирующие компиляторы (например,​ MS VC) даже на максимальном уровне оптимизации не выкидывают вторую и все последующие операции присвоения?​! А они же их не выкидывают,​ в чем легко убедиться,​ заглянув в дизассемблерный листинг.
 +
 +Предположение,​ что все команды "​*arg_a = *arg_b"​ идентичны — ошибочно и базируется на неявном допущении,​ что arg_a и arg_b указывают на _различные_ ячейки,​ чего нам никто не гарантирует,​ и что никаким боком не вытекает из анализа самой функции foo(), принимающей указатели arg_a и arg_b как аргументы. Понять,​ что же действительно здесь происходит можно только обратившись к материнской функции,​ которая в данном случае выглядит так:
 +
 +int buf[3]={0,​-1,​0};​
 +
 +main()
 +
 +{
 +
 +foo(buf, (int*)(((char*)buf) + 1));
 +
 +}
 +
 +Листинг 2 хитрый вызов функции foo() в программе overlapped-pointers.c
 +
 +Компилируем программу из командной строки как обычно (cl.exe overlapped-pointers.c),​ запускаем и смотрим результат. //По многочисленным просьбам читателей,​ не осилившими ////readme ////к ////​Microsoft Visual Studio//// или запускающими vcvars32.bat из ////​FAR'////​а,​ а не из отдельного ////​cmd.exe////,​ мыщъх решил снабжать каждый приводимый листинг ////​.dsw/​.dsp ////​проектами,​ упрощающими сборку программы до предела (клавиша ////<​F7>​ ////в Студии).//​
 +
 +Но мы отвлеклись. Вернемся к обсуждению полученного вывода. А вывод намного интереснее,​ чем это было можно предположить из анализа исходного текста:​
 +
 +1:-> 00000000h:​**FF**000000h
 +
 +2:-> **FF**000000h:​**FFFF**0000h
 +
 +3:-> **FFFF**0000h:​**FFFFFF**00h
 +
 +4:-> **FFFFFF**00h:​**FFFFFFFF**h
 +
 +5:-> **FFFFFFFF**h:​**FFFFFFFF**h
 +
 +Листинг 3 результат работы программы overlapped-pointers.c
 +
 +Вот тебе и раз! Значение ячеек *arg_a и *arg_b меняется во всех четырех итерациях,​ образуя узор наподобие "​елочки"​. Почему?​! А все потому,​ что функции foo() переданы указатели на _перекрывающиеся_ (overlapped) ячейки памяти и операция присвоения меняет не только приемник (target), но и source (источник)! Теперь становится понятно,​ почему возникает "​елочка"​ — раз присвоение меняет источник,​ то повторное присвоение даст иной результат. Точнее — может дать. А может и не дать. Тут все от содержимого источника/​приемника зависит.
 +
 +Вот потому статический анализ на указателях и "​отдыхает"​.
 +
 +===== #​2 –хардкорные извраты с адресом возврата =====
 +
 +Оправившись после "​культурного шока",​ рассмотрим намного более сложный пример,​ а конкретно функцию baz(), состоящую всего из одной операции "​*ret_addr = arg_a",​ задействущую один-единственный указатель. Ну и какого подвоха от нее можно ожидать?​! Да любого! Это же указатель! И писать он может в любую, абсолютную любую ячейку памяти,​ куда только разрешена запись,​ например,​ подменять адрес возврата из функции,​ что используется для скрытой передачи управления многими защитными механизмами или же представляет собой грязный "​хак",​ вставленный сотрудником,​ который не хочет, чтобы коллеги понимали как работает написанный им код.
 +
 +Собственно говоря,​ сама функция baz() не представляет ничего интересного и все трюкачество сосредоточено в вызывающем коде, который в простейшем случае выглядит так (см. листинг 2). Вопрос:​ что выводит это программа на экран? Даже динамический анализ с отладчиком в руках требует напряжения мозговых извилин и знания кучи особенностей языка. Хинт: данный пример не закладывается на конкретный компилятор и сохраняет свою работоспособность даже при портировании на другие 32-битные системы. То есть, с формальной точки зрения это не такой уж и грязных хак (//​**примечание**////:​ для упрощения кода, в программе использована ассемблерная вставка,​ но при желании,​ данный пример можно реализовать и на чистом Си//).
 +
 +// stdcall, since we need to blow up the args
 +
 +__stdcall bar (int arg_a)
 +
 +{
 +
 +static int count;
 +
 +printf("​%X:​->​ %08Xh:hello bar\n",​++count,​ arg_a);
 +
 +}
 +
 +// cdecl, since we don't want to blow up the args
 +
 +__cdecl baz (int arg_a,int *ret_addr)
 +
 +{
 +
 +*ret_addr = arg_a;
 +
 +}
 +
 +main()
 +
 +{
 +
 +foo(buf, (int*)(((char*)buf) + 1));
 +
 +
 +
 +__asm
 +
 +{
 +
 +push eax; for bar.RETN 4 (second pass, dummy arg)
 +
 +push offset next; for bar.RETN 4 (second pass, jump to next)
 +
 +mov  eax, esp; calculate the pointer to...
 +
 +sub  eax, 0Ch; ...the return address of baz
 +
 +push eax; for baz.ret_addr AND bar.RET 4 (dummy arg)
 +
 +push offset **bar**; for baz.arg_a ​ AND bar.RET 4 (jump to itself)
 +
 +call baz; go-go bar baz :-)
 +
 +next:; don't need SUB ESP,XX - stack is ok due to RET4
 +
 +}
 +
 +Листинг 4 исходный код программы со скрытой подменой адреса возврата
 +
 +Компилируем программу так же как и раньше (для экономии места она реализована все в том же файле overlapped-pointers.c),​ и смотрим на результат ее выполнения:​
 +
 +1:-> 0012FF60h:​hello bar
 +
 +2:-> 00000019h:​hello bar
 +
 +Листинг 5 функция bar() вызывается _дважды_
 +
 +К тому, что после завершения baz() вызывается функция bar(),мы морально подготовлены (это вытекает из названия указателя ret_addr и явной засылке адреса bar командой push offset bar),​ но вот тот факт, что bar() вызывается дважды — уже сюрприз! Говорю же, что не баг, а заранее просчитанный ход, который очень трудно распознать даже матерым программистам.
 +
 +Отладчик покажет полную картину происходящего,​ а чтобы не сбиться с пути мыщъх дает несколько хинтов. Функция main() готовит стек, засовывая в него незначимый аргумент-пустышку (dummy arg), за которым следует адрес выхода из функции (смещение метки next). Далее засылается тщательно рассчитанное смещение адреса возврата из baz(), передаваемое как аргумент ret_addr и указатель на bar (аргумент arg_a).
 +
 +И вот происходит вызов функции baz(), с форсированной спецификацией cdecl-соглашения,​ определяющего порядок засылки аргументов в стек и снимающей с baz() обязанности по вычистке аргументов из стека после завершения. Команда "​*ret_addr = arg_a;"​ подменяет адрес возврата из baz() заменяя его указателем на функцию bar(), которая и вызывается при завершении baz(), причем стек остается в том же самом состоянии,​ каким он был на момент вызова baz(), т.е. с двумя аргументами:​ указателем на адрес возврата из baz(), ну теперь уже bar(), и адресом самой функции bar(), которая,​ между прочим (и это очень важно!) форсирована на stdcall-соглашение,​ что обязывает ее вычищать аргументы из стека по завершению.
 +
 +При первом выполнении функции bar() она выводит аргумент arg_a (указатель на адрес возврата),​ трактуя второй аргумент как адрес возврата в материнскую функцию,​ которой в данном случае… является сама bar(), указатель на которую следует за arg_a. Следовательно,​ при выходе из функции bar() она выталкивает arg_a из стека вместе с адресом возврата на саму себя, в результате чего происходит ее повторный вызов, но теперь на вершине стека — фиктивный аргумент-пустышка и указатель на метку next, куда и передается управление.
 +
 +Вот такая замысловатая арабская вязь кода. Что тут сложного?​ После объяснения,​ конечно,​ ничего. Но вот сколько людей способы сказать что делает эта программа по одним лишь исходным текстам без запуска ее на выполнение?​
 +
 +