Введение
Зачем это нужно Немного теории Работа со стеком Подмена стека Создание и замена стека Пример использования Проблемы Заключение Литература |
Стек является одной из наиболее используемых и наиболее важных структур данных. Уильям Топп, Уильям Форд
Переполнение стека – одна из самых сложных ошибок, восстановление после которой практически невозможно. По существу эта ошибка считается фатальной, и единственное, что может сделать приложение, обрабатывая ее, выдать какое-либо сообщение об ошибке или записать его в лог. Никакой серьезной работы проделать невозможно, т.к. обработчик вызывается на уже «умирающем» стеке. В этой статье рассматривается, как подменить текущий стек на свой собственный. Более подробно цели описаны в следующем разделе. Все материалы относятся к операционной системе Windows 2000 и WindowsXP.
Подменять стек имеет смысл когда:
Скажу вам по секрету, что на сей труд меня сподвигло одно сообщение в форуме WinAPI (http://www.rsdn.ru/forum/?mid=144556). Смысл его в следующем: «У меня под Win2k вот этот код работает, а под XP – нет. Помогите!». Код подменял стек в обработчике исключения переполнения стека на глобальный массив большого размера и пытался выполнить какую-то операцию, требующую большого стека. Надо сказать, что обработчик выполняется на стеке, размер которого не больше одной страницы памяти, поэтому и возникает желание его заменить на более просторный.
ПРИМЕЧАНИЕ На 32-х разрядных Intel-совместимых процессорах для приложений пользовательского режима размер страницы равен 4096 байт. |
После короткого разбора, мы выяснили, что выбранный метод (подход) верный, а вот реализация была ошибочной. Я провел пару дней за отладчиком, и кое-что выяснил, относительно того, как сама система организует стек. Результаты представлены вашему вниманию.
Стек – это контейнер, работающий со своими элементами по принципу: первый пришел – последний ушел. Можно сказать по-другому: элемент уходит из стека раньше, чем пришедшие перед ним. Стек – очень удобная структура, поэтому она была реализована на аппаратном уровне еще в самых древних компьютерах. В стеке хранятся очень важные данные о контексте, в котором исполняется данный код. В нем хранятся формальные параметры процедур и функций, а также адреса возвратов. Многие машинные команды работают напрямую со стеком.
Текущая вершина стека или адрес последнего элемента хранится в регистре esp – extended stack pointer. Приставка «расширенный» появилась у него (как и у многих других регистров) когда процессоры из 16-и разрядных превратились в 32-х разрядные. Команд работы со стеком не много. Рассмотрим несколько самых популярных и часто используемых.
Эта команда уменьшает регистр esp на размер своего операнда и записывает значение операнда по адресу, находящемуся в esp.
Эта команда читает значение операнда по текущему адресу в esp и увеличивает регистр esp на размер своего операнда.
Действует также как и pop, однако операндом для нее неявно служит регистр eip – extended instruction pointer (указатель команд). С помощью этой команды вы можете изменять содержимое указателя команд, хотя явных инструкций для его изменения нет.
Стек растет сверху вниз (младшие адреса снизу). Рассмотрим следующую последовательность команд:
1: push 23h
2: push 98h
3: pop eax
4: retn
|
Суффикс h означает шестнадцатеричную запись. Регистр eax – это регистр общего назначения. Вот так будет выглядеть стек после каждой команды:
Начальное состояние стека | В стек помещено число 23h | В стек помещено число 98h | В регистр eax помещено значение 98h | Выполнен переход по адресу 23h. |
Как и у всего хорошего в этом мире, у стека есть предел или размер. То есть вы не можете бесконечно помещать в него данные. Как только он достигнет своего лимита, система сгенерирует исключение EXCEPTION_STACK_OVERFLOW. Чуть позже мы рассмотрим все подробности этой операции. По умолчанию, размер стека равен одному мегабайту. Это значение можно изменить с помощью опции линкера «/STACK». Для каждого потока система организует свой стек.
Стек располагается в виртуальном адресном пространстве процесса, соответственно он разбит на страницы и ему присущи все свойства «обыкновенной» виртуальной памяти, с которой мы работаем функциями VirtualAlloc, VirtualFree и т.д. Однако, специально для стека, имеется один флаг защиты памяти: PAGE_GUARD. Страница с таким атрибутом называется сторожевой. При обращении к ней генерируется исключение EXCEPTION_GUARD_PAGE. Как оно обрабатывается, мы также рассмотрим попозже. Изначально система не передает (commit) весь стек потоку, так как весь он может и не понадобится; передаются только первые две его страницы [1]. Количество передаваемых потоку страниц можно изменить с помощью все той же опции линкера «/STACK» или, для создаваемого вручную потока (вызовом CreateThread, CreateRemoteThread), с помощью одного из параметров.
ПРИМЕЧАНИЕ В WindowsXP, с помощью флага STACK_SIZE_PARAM_IS_A_RESERVATION, вы можете указать резервируемый размер стека. |
Для последней из передаваемых изначально станиц устанавливается флаг PAGE_GUARD. По мере разрастания дерева вызовов система передает все больше страниц стека физической памяти. Последняя страница «обычного» стека никогда не передается и всегда остается зарезервированной.
Последняя переданная страница стека всегда имеет установленный флаг PAGE_GUARD.
ПРИМЕЧАНИЕ Для полностью заполненного стека это не так. Последняя переданная страница имеет такой же атрибут, как и все остальные переданные страницы стека, а именно PAGE_READWRITE. |
Когда происходит обращение к этой странице, система генерирует исключение EXCEPTION_GUARD_PAGE.
ПРИМЕЧАНИЕ Если оно возникло при обращении к страницам стека, то система обрабатывает его сама. Во всех остальных случаях исключение передается приложению. Я не знаю, на каком уровне система определяет, что это исключение стека, но знаю, на основе каких данных она это делает. Об этом чуть позже. |
Обработчик исключения EXCEPTION_GUARD_PAGE снимает атрибут PAGE_GUARD со страницы, на которой произошла ошибка, и пытается передать следующую страницу. Если следующая страница является последней, то она не передается и обработчик генерирует EXCEPTION_STACK_OVERFLOW. Если же она не последняя – страница передается с флагами PAGE_GUARD и PAGE_READWRITE, и становится очередной сторожевой страницей стека потока.
Таким образом, система должна знать, как минимум три вещи о стеке как о структуре:
ПРИМЕЧАНИЕ На самом деле система использует следующую структуру для управления стеком:
Не знаю точно, но могу предположить, что первые два поля являются устаревшими и игнорируются. По крайней мере, в [2] и в kernel32.dll при создании потока они обнуляются. |
Адрес базы и стека можно найти в TIB – Thread Information Block (Информационный блок потока). К сожалению, я не смог найти, где хранится адрес последней зарезервированной страницы и, соответственно, как его изменить я тоже не знаю. Это может привести к кое-каким проблемам, о которых я расскажу попозже.
TIB – это структура, находящаяся в самом начале другой структуры – TEB. TEB – Thread Environment Block (блок окружения потока). Полное описание TEB мы рассматривать не будем, а вот структуру TIB привести можно. Она документирована в NTDDK. Также ее можно найти в заголовочном файле winnt.h.
typedef struct _NT_TIB { struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList; PVOID StackBase; //база стека PVOID StackLimit; //лимит стека PVOID SubSystemTib; union { PVOID FiberData; DWORD Version; }; PVOID ArbitraryUserPointer; struct _NT_TIB *Self; } NT_TIB; typedef NT_TIB *PNT_TIB; |
Второе и третье поле представляют собой базу и лимит стека соответственно. TEB, а следовательно и TIB, - структура пользовательского режима. Каждый поток обладает своим блоком окружения и информационным блоком. Базовый адрес TEB загружен в селектор, номер которого всегда хранится в сегментном регистре fs.
СОВЕТ В masm32, по умолчанию, вы не можете использовать регистр fs и gs, так как модель .flat запрещает адресацию с их использованием. Однако это можно легко обойти, если «отвязать» соответствующие регистры: assume fs:nothing. |
Линейный адрес структуры TIB, то есть базовый адрес селектора fs хранится в члене Self. Смещение этого члена от начала составляет 0x18. Зная все это, мы легко разберемся со следующей функцией.
__declspec(naked) NT_TIB& GetTIB() { __asm mov eax,fs:[18h]; //linear address of TIB__asm retn; } |
Очень простая и важная функция, которая возвращает по ссылке TIB для текущего потока.
ПРИМЕЧАНИЕ Замечу, что TEB первого или первичного потока в процессе всегда располагается по адресу 0x7ffde000. Адрес TEB следующего потока – 0x7ffdd000. Как мы видим, TEB потоков отстоят друг от друга ровно на одну страницу (4096 байт). |
Теперь, вооружившись теорией, попробуем реализовать свой стек, вернее сказать, попробуем подменить текущей стек потока на свой собственный.
И так, стек – довольно сложная структура и ограничиться одним обновлением регистра esp мы не можем. Что нужно для подмены стека?
Для восстановления стека необходимо:
Каждую из этих операций мы рассмотрим отдельно. Трудный для понимания ассемблерный код (без которого, к сожалению, обойтись трудно) снабжен подробными комментариями. Сначала я буду приводить кусок кода, а затем объяснять детально, что он делает.
Всю работу по подмене и восстановлению стека выполняют две функции:
__declspec(naked) void__stdcall SetNewStack(_stack* s,SIZE_T sz); |
и
__declspec(naked) void__stdcall RestoreStack(_stack* s); |
Вот объявление структуры _stack:
struct _stack { void* old_esp; //старое значение espvoid* old_ebp; //старое значение ebpvoid* old_stack_lim; //старое значение лимита стекаvoid* old_stack_base; //старое значение базы стекаvoid* new_stack_lim; //лимит нового стекаvoid* new_stack_base; //база нового стекаvoid* virt_base; //адрес выделенной для стека памятиvoid Alloc(SIZE_T sz); //выделение памятиvoid Free(); //освобождение памятиfriendvoid__stdcall SetNewStack(_stack* s,SIZE_T sz); friendvoid__stdcall RestoreStack(_stack* s); }; |
Выделение памяти производится в функции Alloc. Вот ее код:
//Функция создания стека //на входе: размер стека в страницах void _stack::Alloc(SIZE_T sz) { if (virt_base) Free(); SYSTEM_INFO si = {0}; GetSystemInfo(&si); //получаем размер страницы//Резервирование памяти virt_base = VirtualAlloc(0,sz*si.dwPageSize,MEM_RESERVE,PAGE_NOACCESS); //верхняя граница//оставляем «на всякий случай» три страницы new_stack_base = (LPVOID)((DWORD)virt_base+(sz-3)*si.dwPageSize); //начальный размер стека - 6 страниц new_stack_lim = (LPVOID)((DWORD)new_stack_base-si.dwPageSize*6); //передаем 10 страниц. VirtualAlloc((LPVOID)( (DWORD)new_stack_lim-si.dwPageSize), si.dwPageSize*10, //+одна сторожевая//+три резервных MEM_COMMIT,PAGE_READWRITE); //помечаем страницы как сторожевую DWORD Oldf; VirtualProtect((LPVOID)((DWORD)new_stack_lim-si.dwPageSize), si.dwPageSize,PAGE_GUARD|PAGE_READWRITE,&Oldf); new_stack_lim = (LPVOID)((DWORD)new_stack_lim + si.dwPageSize*2); } |
Опытным путем было установлено, что база стека должна указывать не на конец зарезервированной памяти, как можно было подумать, а на одну из предпоследних страниц. Т.е. обращение по адресу, находящемуся в базе стека, не должно генерировать ошибки доступа к памяти. Обращается по этому адресу, при определенных условиях, фильтр исключений библиотеки времени выполнения (__except_handler3). Он устанавливается каждый раз, когда вы используете ключевые слова __try, __except. Более подробно о реализации исключений библиотекой времени выполнения и вообще о SHE в одной из следующих статей.
Начальный размер стека устанавливается равным семи страницам, последняя из которых – сторожевая.
Лимит стека устанавливается на страницу, предшествующую сторожевой.
Здесь приводится отрывок из функции SetNewStack. Вся она будет рассмотрена далее.
s->old_stack_lim = GetTIB().StackLimit; s->old_stack_base = GetTIB().StackBase; GetTIB().StackLimit = s->new_stack_lim; GetTIB().StackBase = s->new_stack_base; __asm{ pop eax; //eip pop ebx; //_stack* s pop ecx //отчищаем стек (последний параметр) mov [ebx],esp; //s->old_esp = esp; ... |
Первые две строчки сохраняют предыдущие значения полей StackLimit и StackBase. Затем они устанавливаются новыми значениями. Сейчас фактически структура TIB находится в не согласованном состоянии, так как сам регистр esp еще не обновлен. При возникновении какой-либо ситуации, где системе потребуется информация о стеке, может произойти все что угодно. Отметим, что переключение контекста не является такой операцией.
Так как функция SetNewStack принимает два параметра, стек выглядит следующим образом:
Адрес хххх – это тот адрес, который был в регистре esp на момент вызова функции. Его-то нам и нужно сохранить. Для этого мы извлекаем из стека три значения: адрес возврата, первый и второй параметры. Так как член old_esp – первый член структуры _stack, то ее адрес и является адресом члена old_esp. Чем мы и пользуемся при создании регистра esp.
mov ecx,fs:[4]; mov esp,ecx;//esp = TIB->StackBase; mov ebp,esp; push eax; retn; |
Если вы уже забыли, по смещению 0x4 в TIB находится база стека (уже новая). Это значение мы и заносим в регистры ebp и esp. Следующие две команды предназначены для возврата из процедуры.
__declspec(naked) void__stdcall SetNewStack(_stack* s,SIZE_T sz) { __asm{ mov ebx,[esp+4]; //_stack* s mov [ebx+4],ebp; //s->old_ebp = ebp lea ebp,[esp-4]; //adjust ebp } s->Alloc(sz); s->old_stack_lim = GetTIB().StackLimit; s->old_stack_base = GetTIB().StackBase; GetTIB().StackLimit = s->new_stack_lim; GetTIB().StackBase = s->new_stack_base; __asm{ pop eax; //eip pop ebx; //_stack* s pop ecx //clear the last parameter mov [ebx],esp; //s->old_esp = esp; mov ecx,fs:[18h]; mov esp,[ecx+4]; //esp = TIB->StackBase; mov ebp,esp; push eax; retn; } } |
Так как доступ к входным параметрам осуществляется через регистр ebp, мы должны соответствующим образом его настроить. Первой командой в регистр ebx первый параметр. Затем мы сохраняем значение регистра ebp и, затем, устанавливаем его значением из регистра esp за вычетом четырех. Зачем? Дело в том, что компилятор генерирует стандартные пролог и эпилог для функции, который выглядит следующим образом:
push ebp //Prolog mov ebp,esp ... mov esp,ebp //Epilog pop ebp |
Стек после пролога выглядит так:
Для того чтобы получить доступ к первому параметру, нужно использовать регистр ebp, увеличенный на 8. Но мы в своей функции не помещали ebp в стек, так что [ebp+8] обратится ко второму параметру. Для того чтобы исправить эту ситуацию, мы и корректируем ebp.
GetTIB().StackLimit = s->old_stack_lim; GetTIB().StackBase = s->old_stack_base; |
Это код в особых комментариях не нуждается. Отметим только, что TIB снова находится в несогласованном состоянии.
__asm{ pop eax; //eip pop ebx; //_stack* s mov esp,[ebx]; //esp = s->old_esp; mov ebp,[ebx+4]; //ebp = s->old_ebp; |
Здесь, в принципе, тоже все понятно. Из стека извлекается адрес возврата и единственный параметр. Затем мы восстанавливаем регистры esp и ebp. Вроде все. Однако нам еще нужно освободить память.
push eax; //сохранение адреса возврата push ebp //сохранение ebp sub esp,4; //локальная переменная для _stack* mov [esp],ebx; //копирование _stack* в локальную переменную lea ebp,[esp-8]; //корректировка ebp для вызова s->Free() } s->Free(); //получаем this из локальной переменной__asm{ add esp,4; //удаление локальной переменной pop ebp; //восстановление ebp retn; //eax->eip } |
Этот момент является довольно сложным. Почему? Потому, что стек уже восстановлен, а адрес входной переменной лежит в старом стеке. Здесь мы вынуждены вручную строить пролог и эпилог для функции, а также создавать локальную (или автоматическую) переменную. Также нам необходимо опять провести корректировку регистра ebp, так как он будет использоваться при вызове функции Free. То есть, мы создаем локальную переменную и корректируем ebp таким образом, чтобы компилятор думал, что наша локальная переменная – это входной параметр.
После вызова функции мы удаляем локальную переменную, восстанавливаем ebp и выходим из процедуры.
__declspec(naked) void__stdcall RestoreStack(_stack* s) { __asm lea ebp,[esp-4]; //adjust ebp GetTIB().StackLimit = s->old_stack_lim; GetTIB().StackBase = s->old_stack_base; __asm{ pop eax; //eip pop ebx; //_stack* s mov esp,[ebx]; //esp = s->old_esp; mov ebp,[ebx+4]; //ebp = s->old_ebp; push eax; //push return address push ebp //save ebp sub esp,4; //local variable for _stack* mov [esp],ebx; //copy _stack* to local variable lea ebp,[esp-8]; //adjust ebp for s->Free() call } s->Free(); //getting this from local variable__asm{ add esp,4; //remove local var pop ebp; //restore ebp retn; } } |
Отмечу, что и здесь, в начале функции, мы должны скорректировать ebp, по причинам, изложенным выше.
Наш пример будет заключаться в следующем. Мы в защищенном блоке «уроним» стек. В фильтре исключений создадим новый стек и вызовем функцию, требующую большого стека. В ней распечатаем “карту” стека, чтобы убедится, что используется именно наш новый стек и используется успешно. Затем мы удалим свой стек и выйдем из фильтра. Программа тестировалась под Win2k и WindowsXP.
#include "stdafx.h" #include "_stack.h" void test(); void kill_stack() { char buf[0x1000]; buf[89] = 94; kill_stack(); } __declspec(thread) _stack s; void ShowStack(DWORD dwAdj = 0) { DWORD pBase = (DWORD)GetTIB().StackLimit; pBase += dwAdj; printf("============\tSTACK\t===============\n"); while(pBase != (DWORD)GetTIB().StackLimit-0x1000*40){ MEMORY_BASIC_INFORMATION mbi; pBase-=0x1000; VirtualQuery((LPVOID)pBase,&mbi,sizeof(mbi)); printf("%X: ",pBase); if (mbi.State == MEM_COMMIT) printf("commit "); elseif (mbi.State == MEM_RESERVE) printf("reserve "); else printf("free "); if (mbi.Protect & PAGE_READWRITE) printf("RW,"); if (mbi.Protect & PAGE_GUARD) printf("G,"); if (mbi.Protect & PAGE_NOACCESS) printf("NA,"); if (mbi.Protect & PAGE_WRITECOPY) printf("WC,"); if (mbi.Protect & PAGE_EXECUTE) printf("E,"); if (mbi.Protect & PAGE_NOCACHE) printf("NC,"); if (mbi.Protect & PAGE_READONLY) printf("R,"); if (mbi.Protect == 0) printf("none"); printf("\n"); } printf("===========================\n"); } DWORD filt() { SetNewStack(&s,200); test(); RestoreStack(&s); return EXCEPTION_EXECUTE_HANDLER; } void kill_s() { __try{ kill_stack(); } __except(filt()) { } } void test() { char buf[0x1000*43]; ShowStack(0x1000*10); } int main(int argc, char* argv[]) { kill_s(); printf("press any key for exit\n"); getch(); return 0; } |
Наиболее интересна, на мой взгляд, здесь функция ShowStack. Она перебирает страницы стека от текущего лимита на 40 страниц вглубь. Также можно задать смещение от лимита в параметре. Функция показывает адрес страницы, ее тип и флаги защиты.
Я уже упоминал, что при подмене стека вы можете столкнуться с определенными проблемами. Дело в том, что в TIB нет поля, указывающее системе адрес последней страницы стека.
ПРИМЕЧАНИЕ На самом деле это самая первая страница. Но так как стек растет в другом направлении, мы считаем ее последней. |
Эта страница должна быть всегда зарезервирована, однако при подмене стека информация о ней теряется, так что при полностью заполненном стеке она передается тоже. То есть, если обработчику исключения понадобится более одной страницы памяти стека, то он, «откушав» последнюю, обратится к следующей странице, которая уже стеку не принадлежит. Если она будет преданна кем-то еще, то данные на ней будут безвозвратно испорчены. Однако очень маловероятно, что эта страница будет передана. Скорее всего, она будет (в худшем случае) зарезервирована. Сколько я тестировал – она была свободна, так что обращение к ней вызывало простой AV.
В заключении хочу сказать, что эта работа больше теоретического характера, чем практического. Однако подмена стека может понадобиться, например, для вызова внутрипроцесным СОМ-сервером методов событийного интерфейса клиента.