Ассемблерные извращения— натягиваем стек (черновик)
крис касперски ака мыщъх, no-email
ассемблер представляет практически неограниченную свободу для самовыражения и всевозможных извращений, что выгодно отличает его от языков высокого уровня. вот мы и воспользуемся этой возможностью, извратившись не по детски и сотворив со стеком то, о чем приплюснутый си только мечтает.
классический способ передачи стековых аргументов
Довольно расточительное (в плане процессорных тактов) решение, особенно если функция вызывается многократно. При этом операнды команды PUSH перегоняются из секции .text (находящейся в кодовой кэш-памяти первого уровня) в область стека, находящейся в кэш-памяти данных. Ну и на хрена гонять их туда и обратно, когда аргументы можно использовать непосредственно по месту хранения.
Усовершенствованный пример выглядит так:
.code
MOV EBP, ESP
MOV ESP, offset func_arg + 4
CALL my_func
MOV ESP, EBP
.data
func_arg DD 00h, 696h, 999h, 669h
вызов функции с заранее подготовленными аргументами и локальными переменными
В некоторых случаях достигается просто колоссальное ускорение, однако… тут есть один подводный камень — при повторном вызове функции все "инициализированные" переменные сохраняет свои _текущие_ значения и наступит полный облом. Фактически, мы добились того, что превратили локальные стековые переменные в статические! Бесспорно, _иногда_ это очень хорошо, но в 90% случав нам нужно совсем другое. Вот и устроим себе это другое с помощью REP MOVS! Подготавливаем инициализированные локальные переменные на стадии создания ассемблерной программы, а затем копируем их в кадр функции при его открытии. Это _намного_ быстрее, чем инициализировать каждую локальную переменную по отдельности командой MOV.
К тому же, кадры некоторых функций достаточно схожи между собой, что позволяет объединить несколько кадров в один! Достаточно сказать, что каждая функция нуждается в переменных, инициализированных нулями. Чтобы не делать много раз один и тот же MOV [EBP+XXh],0 лучше (и быстрее) выполнить REP STOS!
Вот в чем истинная сила ассемблера! Вот извращения, недоступные языкам высокого уровня, но… самые зверские издевательства следуют впереди!!!
передача и использование аргументов при раздельных стеках
"Рукотворный" стек с локальными переменными и аргументами растер сверху вниз (т.е. в направлении противоположном росту обычного стека) и это неспроста. Во-первых, подсистема памяти IBM PC и операционная система 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, разместив адрес возврата на вершине страницы памяти, доступной только на чтение. Ниже ее будут только аргументы (доступные так же только на чтение), а вот локальные переменные, создаваемые функцией будут доступны и на чтение и на запись. Естественно, этот трюк будет работать только с теми функциями, которые не изменяют своих аргументов (а многие из них изменяют их только так), но по другому просто не получается!
оптимизированный способ передачи аргументов
И хотя размер кода после оптимизации не только не сократился, но даже увеличился (14h байт до оптимизации и 1Eh) зато мы сохранили немного стековой памяти и сократили время выполнения. Причем, чем больше аргументов передается функции, тем в более выигрышном положении оказывается оптимизированный вариант, поскольку неоптимизированный вынужден тратить на каждый аргумент один дополнительный байт!
00000000: 8BEC mov ebp, esp
00000002: BC66000000 mov esp, 000000013
00000007: E80E000000 call 000000666
0000000C: 8BE5 mov esp, ebp
…
0000000E: 00 00 00 00 96 06 00 00 ¦ 99 09 00 00 69 06 00 00
0000001E:
дизассемблерный листинг оптимизированного варианта передачи аргументов
Несколько замечаний по поводу. Операционные системы семейства Windows NT (к которым принадлежит Windows 2000, Windows XP, Windows Vista, Windows Server 2003 и Windows Server Longhorn) гарантируют целостность содержимого стека выше его вершины (т. е. для адресов меньших, чем ESP), поэтому свободно переносят такие извращения безо всякого ущерба для работоспособности программы. Операционные системы семейства Windows 9x ведут себя иначе, бесцеремонно используя все, что находится выше ESP в целях "производственной необходимости", что ведет к искажению секции данных и последующему краху программы, поэтому, все, сказанное здесь, распространяется только на NT.
вызов функции с предопределенным адресом возврата командой 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_1 DB 66h
var_2 DD offset globalFlag
var_s DB "hello",0
var_x DD 0
var_y DD 0
return_address:
DD 00h
func_args:
DD 696h, 999h, 669h
реакция soft-ice на
__вызов функций, уже написанных на языках высокого уровня
передача стековых аргументов напрямую без их фактической засылки в стек
Замечание номер два. Перед аргументами необходимо оставить двойное слово (а в 64-битном режиме — четвертное) для сохранения адреса возврата, при этом, секция данных, где находится это слово должна быть доступна на запись. Если же функция вызывается из одного единственного места и адрес возврата известен заранее, ничего не мешает положить его рядом с аргументами, но тогда функцию придется вызывать командой jump, а не call, что еще больше увеличивает производительность:
.code
MOV EBP, ESP
MOV ESP, offset func_arg + 4
JMP my_func
here:
MOV ESP, EBP
.data
func_arg DD offset here, 696h, 999h, 669h
Турбопередача стековых аргументов
Передачу аргументов через стек можно существенно ускорить, особенно если аргументы представляют собой константу, известную еще на стадии трансляции. Классический способ передачи выглядит так:
00000000: 6869060000 push 000000669
00000005: 6899090000 push 000000999
0000000A: 6896060000 push 000000696
0000000F: E852060000 call 000000666
Защита адреса возврата от переполнения
Проблема переполняющихся буферов породило огромное количество червей, открыв безграничный простор для хакерских атак, но, несмотря на все ухищрения, предпринятые как со стороны производителей компиляторов, так и со стороны разработчиков операционных систем, проблем остается нерешенной и посей день.
Ассемблер предоставляет по меньшей мере два надежных механизма, до которых еще не компиляторы "додумались". Первое и самое простое— это _два_ стека: один для хранения адресов возврата, другой: для передачи аргументов и локальных переменных. Кстати говоря, существуют процессорные архитектуры, в которых этот механизм реализован изначально, но x86 семейство к ним увы не относятся, поэтому приходится брать в лапы напильник и точить. Или торчать? Неееет, торчать мы будем потом, когда забьем косяк, а пока лучше поточим.
Собственно говоря, для организации двух раздельных стеков нам требуется всего лишь один дополнительный регистр (который можно выделить из пула регистров общего назначения). Пусть это будет регистр EBP, указывающий на стек с локальными переменными. Собственно говоря, неправильно будет называть его стеком, поскольку в операционных системах семейства Windows стек представляет собой _особый_ регион памяти, подпираемый сверху сторожевой страницей page-guard. Мы же разместим свой стек в памяти, выделенной функцией VirtualAlloc или (если хочется оптимизации) в .BSS сеции PE-файла, выделение которой обходится очень дешевого (в плане машинного времени). Но это все детали реализации. Будем считать, что ESP указывает на нормальный стек, а EBP — на "рукотворный". Как тогда будет происходить вызов функций и передача аргументов?
А вот так:
; // подготовительные операции
MOV EBP, [XXX] ; XXX
- указатель на "рукотворный" стек
MOV ESP, ESP ; ;-)
…
; // передача аргументов функции
MOV
[EBP+00h], arg_a
MOV [EBP+04h], arg_b
MOV [EBP+08h], arg_c
// вызов самой функции
CALL func
…
// ================================================================================
; // реализация самой функции
func:
ADD EBP, local_var_size ; резервируем память под локальные переменные
MOV ECX, [EBP-local_var_size+04h] ; загрузка аргумента arg_b
в регистр ECX
MOV ESI, [EBP-local_var_size+08h] ; загрузка
аргумента
arg_c в регистр ESI
MOV EDI, EBP ; грузим в EDI
указатель конец области лок. пер.
SUB EDI, local_var_size ; вычисляем указатель на локальный буфер
; (в данном случае он расположен по смещению 00h
; относительно фрейма)
REP MOVSB ; копируем arg_b байт из arg_c
в лок. буффер
; // делаем еще что-то полезное
RET ; выходим из функции