c-tricks-1Bh

сишные трюки\\ (1Bh выпуск)

крис касперски ака мыщъх, a.k.a. souriz, a.k.a. nezumi, no-email

вольности, допускаемые Си/Си++ в отношении указателей (что отличает их от Java/.NET и других «правильных» языков), обеспечивают гибкость, компактность и высокое быстродействие целевого кода. однако, подобная демократия таит в себе скрытую угрозу и всякий указатель становится источником непредсказуемых побочных эффектов, разваливающих программу!

Статический анализ отдельно взятой функции (неважно — представленный в виде исходного кода или дизассемблерного листинга), справляется только с локальными переменными, но обламывается на указателях, значение которых невозможно вычислить на стадии трансляции (т. е. указатель — не константа) и которые обрабатываются уже в 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*)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:FF000000h

2:→ FF000000h:FFFF0000h

3:→ FFFF0000h:FFFFFF00h

4:→ FFFFFF00h:FFFFFFFFh

5:→ FFFFFFFFh:FFFFFFFFh

Листинг 3 результат работы программы overlapped-pointers.c

Вот тебе и раз! Значение ячеек *arg_a и *arg_b меняется во всех четырех итерациях, образуя узор наподобие «елочки». Почему?! А все потому, что функции foo() переданы указатели на _перекрывающиеся_ (overlapped) ячейки памяти и операция присвоения меняет не только приемник (target), но и source (источник)! Теперь становится понятно, почему возникает «елочка» — раз присвоение меняет источник, то повторное присвоение даст иной результат. Точнее — может дать. А может и не дать. Тут все от содержимого источника/приемника зависит.

Вот потому статический анализ на указателях и «отдыхает».

Оправившись после «культурного шока», рассмотрим намного более сложный пример, а конкретно функцию 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*)2); 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, куда и передается управление. Вот такая замысловатая арабская вязь кода. Что тут сложного? После объяснения, конечно, ничего. Но вот сколько людей способы сказать что делает эта программа по одним лишь исходным текстам без запуска ее на выполнение?

1) , 2)
(char*)buf) + 1