asm-stack-hck

ассемблерные извращения — натягиваем стек\\ (черновик)

крис касперски ака мыщъх, no-email

ассемблер представляет практически неограниченную свободу для самовыражения и всевозможных извращений, что выгодно отличает его от языков высокого уровня. вот мы и воспользуемся этой возможностью, извратившись не по детски и сотворив со стеком то, о чем приплюснутый си только мечтает.

Передачу аргументов через стек можно существенно ускорить, особенно если аргументы представляют собой константу, известную еще на стадии трансляции. Классический способ передачи выглядит так:

00000000: 6869060000push000000669

00000005: 6899090000push000000999

0000000A: 6896060000push000000696

0000000F: E852060000call000000666

Листинг 1 классический способ передачи стековых аргументов

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

Усовершенствованный пример выглядит так:

.code

MOV EBP, ESP

MOV ESP, offset func_arg + 4

CALL my_func

MOV ESP, EBP

.data

func_argDD 00h, 696h, 999h, 669h

Листинг 2 оптимизированный способ передачи аргументов

И хотя размер кода после оптимизации не только не сократился, но даже увеличился (14h байт до оптимизации и 1Eh) зато мы сохранили немного стековой памяти и сократили время выполнения. Причем, чем больше аргументов передается функции, тем в более выигрышном положении оказывается оптимизированный вариант, поскольку неоптимизированный вынужден тратить на каждый аргумент один дополнительный байт!

00000000: 8BECmovebp, esp

00000002: BC66000000movesp, 000000013

00000007: E80E000000call000000666

0000000C: 8BE5movesp, ebp

0000000E: 00 00 00 00 96 06 00 00 │ 99 09 00 00 69 06 00 00

0000001E:

Листинг 3 дизассемблерный листинг оптимизированного варианта передачи аргументов

Несколько замечаний по поводу. Операционные системы семейства Windows NT (к которым принадлежит Windows 2000, Windows XP, Windows Vista, Windows Server 2003 и Windows Server Longhorn) гарантируют целостность содержимого стека выше его вершины (т. е. для адресов меньших, чем ESP), поэтому свободно переносят такие извращения безо всякого ущерба для работоспособности программы. Операционные системы семейства Windows 9x ведут себя иначе, бесцеремонно используя все, что находится выше ESP в целях «производственной необходимости», что ведет к искажению секции данных и последующему краху программы, поэтому, все, сказанное здесь, распространяется только на NT.

Рисунок 1 передача стековых аргументов напрямую без их фактической засылки в стек

Замечание номер два. Перед аргументами необходимо оставить двойное слово (а в 64-битном режиме — четвертное) для сохранения адреса возврата, при этом, секция данных, где находится это слово должна быть доступна на запись. Если же функция вызывается из одного единственного места и адрес возврата известен заранее, ничего не мешает положить его рядом с аргументами, но тогда функцию придется вызывать командой jump, а не call, что еще больше увеличивает производительность:

.code

MOV EBP, ESP

MOV ESP, offset func_arg + 4

JMP my_func

here:

MOV ESP, EBP

.data

func_argDD offset here, 696h, 999h, 669h

Листинг 4 вызов функции с предопределенным адресом возврата командой JMP

Кстати говоря, ни адрес возврата, ни аргументы функции вовсе не обязаны быть константой, известной на стадии компиляции и они могут свободно модифицироваться в любой момент командами MOV/STOS. Аналогичным образом, если аргументы хранятся в локальных переменных, то засылать их в стек необязательно! Достаточно лишь скорректировать регистр ESP таким образом, чтобы переменные-аргументы оказались на вершине (естественно, порядок размещения аргументов в памяти должен совпадать с порядком передачи аргументов, но на ассемблере, в отличии от языков высокого уровня мы можем самостоятельно выбирать нужную схему размещения переменных, так что это не проблема).

Еще одна тонкость — «оптимизированный» вариант обладает всеми формальными атрибутами «передачи по значению», но де-факто, аргументы передаются по ссылке. То есть нет! Совсем наоборот! Аргументы передаются по _значению_ но это значение после выхода из функции сохраняет свое состояние, то есть ведет себя так, как будто бы ото было передано по ссылке. Иногда это экономит такты процессора и сокращает потребности в памяти, но иногда ведет к трудноуловимым ошибкам, лишний раз подтверждая тезис, что совершенства в мире не бывает.

И последнее: при всех этих играх со стеком следует помнить, что целый ряд API-функций требует, чтобы указатель стека был выровнен на границу 4х байт. Нарушение этого правила ведет к непредсказуемым последствиям.

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

А почему бы не подготовить кадр стека еще на стадии трансляции?! В грубом приближении это будет выглядеть так:

.code

MOV EBP, ESP

MOV ESP, offset func_arg

JMP my_func

MOV ESP, EBP

my_func:

MOV EBP,ESP

SUB ESP, offset func_locals - offset return_address

MOV ESP,EBP

RETN

.data

func_locals:

var_1DB66h

var_2DDoffset globalFlag

var_sDB«hello»,0

var_xDD0

var_yDD0

return_address:

DD 00h

func_args:

DD 696h, 999h, 669h

Листинг 5 вызов функции с заранее подготовленными аргументами и локальными переменными

В некоторых случаях достигается просто колоссальное ускорение, однако… тут есть один подводный камень — при повторном вызове функции все «инициализированные» переменные сохраняет свои _текущие_ значения и наступит полный облом. Фактически, мы добились того, что превратили локальные стековые переменные в статические! Бесспорно, _иногда_ это очень хорошо, но в 90% случав нам нужно совсем другое. Вот и устроим себе это другое с помощью REP MOVS! Подготавливаем инициализированные локальные переменные на стадии создания ассемблерной программы, а затем копируем их в кадр функции при его открытии. Это _намного_ быстрее, чем инициализировать каждую локальную переменную по отдельности командой MOV.

К тому же, кадры некоторых функций достаточно схожи между собой, что позволяет объединить несколько кадров в один! Достаточно сказать, что каждая функция нуждается в переменных, инициализированных нулями. Чтобы не делать много раз один и тот же MOV [EBP+XXh],0 лучше (и быстрее) выполнить REP STOS!

Вот в чем истинная сила ассемблера! Вот извращения, недоступные языкам высокого уровня, но… самые зверские издевательства следуют впереди!!!

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

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

Собственно говоря, для организации двух раздельных стеков нам требуется всего лишь один дополнительный регистр (который можно выделить из пула регистров общего назначения). Пусть это будет регистр EBP, указывающий на стек с локальными переменными. Собственно говоря, неправильно будет называть его стеком, поскольку в операционных системах семейства Windows стек представляет собой _особый_ регион памяти, подпираемый сверху сторожевой страницей page-guard. Мы же разместим свой стек в памяти, выделенной функцией VirtualAlloc или (если хочется оптимизации) в .BSS сеции PE-файла, выделение которой обходится очень дешевого (в плане машинного времени). Но это все детали реализации. Будем считать, что ESP указывает на нормальный стек, а EBP — на «рукотворный». Как тогда будет происходить вызов функций и передача аргументов?

А вот так:

; подготовительные операции MOVEBP, [XXX]; XXX - указатель на «рукотворный» стек MOV ESP, ESP; ;-) … ; передача аргументов функции

MOV [EBP+00h], arg_a

MOV [EBP+04h], arg_b

MOV [EBP+08h], arg_c

вызов самой функции CALLfunc …

; реализация самой функции func: ADDEBP, local_var_size; резервируем память под локальные переменные MOVECX, [EBP-local_var_size+04h]; загрузка аргумента arg_b в регистр ECX MOV ESI, [EBP-local_var_size+08h]; загрузкааргумента arg_c врегистр ESI MOVEDI, EBP; грузим в EDI указатель конец области лок. пер. SUBEDI, local_var_size; вычисляем указатель на локальный буфер ; (в данном случае он расположен по смещению 00h ; относительно фрейма) REPMOVSB; копируем arg_b байт из arg_c в лок. буффер ; делаем еще что-то полезное

RET; выходим из функции

Листинг 6 передача и использование аргументов при раздельных стеках

«Рукотворный» стек с локальными переменными и аргументами растер сверху вниз (т.е. в направлении противоположном росту обычного стека) и это неспроста. Во-первых, подсистема памяти IBMPC и операционная система Windows оптимизированы именно под такое выделение памяти и мы получаем выигрыш в производительности. Во-вторых, внизу рукотворного стека находится неинициализированная область памяти, что делает ошибки переполнения неактуальными. Затираются лишь локальные переменные текущей функции, да и то лишь те, которые лежат ниже переполняющегося буфера.

Адреса возврата хранятся вообще в другом месте и на них эти переполнения вообще не распространяются (если, конечно, «натуральный» стек расположен выше рукотворного, т.е. лежит в более младших адресах).

Основную трудность, конечно, представляет засылка аргументов в рукотворный стек. Это под MS-DOS мы могли выделить отдельный сегмент и использовать PUSH с префиксом «GS:», а под Windows приходится использовать MOV [EBP+XXh], YYYY и это при том, что адресации типа «память - память» в x86 процессорах не было и нет. В практическом плане это означает, что нам придется использовать промежуточные регистры: MOV EAX, [YYYY]/MOV [EBP+XXh], EAX. Впрочем, это можно оптимизировать, если использовать команду STOSD, занимающую в машинном представлении всего один байт и копирующую содержимое EAX в ячейку на которую указывает EDI одновременно с увеличением последнего на размер двойного слова. Стаскивать аргументы с рукотворного стека можно командой LODSD.

Окончательно расхулиганившись, можно создать целых три стека — один, «стандартный» для хранения адресов возврата, другой — для аргументов и третий для локальных переменных. Чтобы не расходовать регистры понапрасну, можно хранить указатели на вершины двух «рукотворных» стеков в оперативной памяти, загружая их то в регистр EBP, то в ESI/EDI в зависимости от того, какой из них окажется удобнее в данный конкретный момент. Падения производительности можно не опасаться. Большую часть своего времени указатели будут проводить в кэш-памяти, извлекаясь всего за один-два такта.

Естественно, все, сказанное выше, относится _только_ к нашим собственным функциям, а API-функции операционной системы таких извращений не понимают и ожидают аргументов в «стандартном» стеке. Ну… что тут можно сказать… «Персонально» для API-функций аргументы можно передать и в стандартном стеке, предварительно убедившись, что при данных аргументах функция гарантированно не вызовет переполнения (что вовсе не факт, особенно при работе с функциями из библиотеки mshtml.dll). К тому же, в 64-битной редакции Windows аргументы API-функциями в большинстве случаев передаются не через стек, а через регистры, поэтому описанная методика к ним вполне применима.

А вот как защитить от переполнения функции обычных библиотек? Самое простое решение — вызвать функции не по CALL, а по JMP, разместив адрес возврата на вершине страницы памяти, доступной только на чтение. Ниже ее будут только аргументы (доступные так же только на чтение), а вот локальные переменные, создаваемые функцией будут доступны и на чтение и на запись. Естественно, этот трюк будет работать только с теми функциями, которые не изменяют своих аргументов (а многие из них изменяют их только так), но по другому просто не получается!

Рисунок 2 реакция soft-ice на исчерпание стека (windows, к слову сказать, на это не реагирует вообще!)

__вызов функций, уже написанных на языках высокого уровня

Рисунок 3 даже такой простой отладчик как MicrosoftVisualStudioDebugger, запущенный под NT, уверенно продолжает трассировку и при нулевом значении регистра ESP