Сообщений 7    Оценка 105        Оценить  
Система Orphus

Как подменить стек?

Автор: Алексей Ширшов
The RSDN Group

Источник: RSDN Magazine #3
Опубликовано: 01.04.2003
Исправлено: 10.12.2016
Версия текста: 1.0.1
Введение
Зачем это нужно
Немного теории
Команда PUSH
Команда POP
Команда RETN
Пример
Размер стека
Организация стека
Работа со стеком
Информационный блок потока
Подмена стека
Создание и замена стека
Выделение памяти под стек
Восстановление стека
Пример использования
Проблемы
Заключение
Литература

Стек является одной из наиболее используемых
и наиболее важных структур данных.
Уильям Топп, Уильям Форд

Исходный код – 1.4K

Введение

Переполнение стека – одна из самых сложных ошибок, восстановление после которой практически невозможно. По существу эта ошибка считается фатальной, и единственное, что может сделать приложение, обрабатывая ее, выдать какое-либо сообщение об ошибке или записать его в лог. Никакой серьезной работы проделать невозможно, т.к. обработчик вызывается на уже «умирающем» стеке. В этой статье рассматривается, как подменить текущий стек на свой собственный. Более подробно цели описаны в следующем разделе. Все материалы относятся к операционной системе Windows 2000 и WindowsXP.

Зачем это нужно

Подменять стек имеет смысл когда:

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

Скажу вам по секрету, что на сей труд меня сподвигло одно сообщение в форуме WinAPI (http://www.rsdn.ru/forum/?mid=144556). Смысл его в следующем: «У меня под Win2k вот этот код работает, а под XP – нет. Помогите!». Код подменял стек в обработчике исключения переполнения стека на глобальный массив большого размера и пытался выполнить какую-то операцию, требующую большого стека. Надо сказать, что обработчик выполняется на стеке, размер которого не больше одной страницы памяти, поэтому и возникает желание его заменить на более просторный.

ПРИМЕЧАНИЕ

На 32-х разрядных Intel-совместимых процессорах для приложений пользовательского режима размер страницы равен 4096 байт.

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

Немного теории

Стек – это контейнер, работающий со своими элементами по принципу: первый пришел – последний ушел. Можно сказать по-другому: элемент уходит из стека раньше, чем пришедшие перед ним. Стек – очень удобная структура, поэтому она была реализована на аппаратном уровне еще в самых древних компьютерах. В стеке хранятся очень важные данные о контексте, в котором исполняется данный код. В нем хранятся формальные параметры процедур и функций, а также адреса возвратов. Многие машинные команды работают напрямую со стеком.

Текущая вершина стека или адрес последнего элемента хранится в регистре esp – extended stack pointer. Приставка «расширенный» появилась у него (как и у многих других регистров) когда процессоры из 16-и разрядных превратились в 32-х разрядные. Команд работы со стеком не много. Рассмотрим несколько самых популярных и часто используемых.

Команда PUSH

Эта команда уменьшает регистр esp на размер своего операнда и записывает значение операнда по адресу, находящемуся в esp.

Команда POP

Эта команда читает значение операнда по текущему адресу в esp и увеличивает регистр esp на размер своего операнда.

Команда RETN

Действует также как и 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, и становится очередной сторожевой страницей стека потока.

Таким образом, система должна знать, как минимум три вещи о стеке как о структуре:

ПРИМЕЧАНИЕ

На самом деле система использует следующую структуру для управления стеком:

        typedef
        struct _USER_STACK {
PVOID FixedStackBase; //база стека фиксированного размера
PVOID FixedStackLimit; //лимит стека фиксированного размера
PVOID ExpandableStackBase; //база расширяемого стека
PVOID ExpandableStackLimit; //лимит расширяемого стека
PVOID ExpandableStackBottom; //адрес последней зарезервированной страницы//расширяемого стека
} USER_STACK, *PUSER_STACK;

Не знаю точно, но могу предположить, что первые два поля являются устаревшими и игнорируются. По крайней мере, в [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 мы не можем. Что нужно для подмены стека?

  1. Зарезервировать необходимый объем адресного пространства, в котором будет находиться стек и передать последние несколько страниц (напомню, что система передает две).
  2. Сохранить значения полей StackBase и StackLimit структуры NT_TIB в локальной памяти потока (TLS – Thread Local Storage). Дело в том, что мы не можем сохранять эти значения в глобальных переменных или в стеке. Также мы не можем более двух раз вложено подменять стек в том же самом потоке. Это ограничение довольно просто обойти, если создать свою собственную структуру данных для хранения этих значений. Также необходимо сохранить значения некоторых регистров. Более подробно см. далее.
  3. Изменить поля StackBase и StackLimit структуры NT_TIB.
  4. Изменить регистр esp.

Для восстановления стека необходимо:

  1. Восстановить старые значения полей StackBase и StackLimit структуры NT_TIB.
  2. Восстановить регистр esp.
  3. Освободить занятую под стек память.

Каждую из этих операций мы рассмотрим отдельно. Трудный для понимания ассемблерный код (без которого, к сожалению, обойтись трудно) снабжен подробными комментариями. Сначала я буду приводить кусок кода, а затем объяснять детально, что он делает.

Всю работу по подмене и восстановлению стека выполняют две функции:

      __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 в одной из следующих статей.

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

Лимит стека устанавливается на страницу, предшествующую сторожевой.

Сохранение предыдущего состояния и изменение полей TIB

Здесь приводится отрывок из функции 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.

Изменение регистра esp и выход из функции SetNewStack

mov ecx,fs:[4];
mov esp,ecx;//esp = TIB->StackBase;
mov ebp,esp;
push eax;
retn;

Если вы уже забыли, по смещению 0x4 в TIB находится база стека (уже новая). Это значение мы и заносим в регистры ebp и esp. Следующие две команды предназначены для возврата из процедуры.

Полный текст функции SetNewStack

          __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.

Восстановление стека

Восстановление TIB

    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. Вроде все. Однако нам еще нужно освободить память.

Освобождение памяти и выход из функции RestoreStack

    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 и выходим из процедуры.

Полный текст функции RestoreStack

          __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.

Заключение

В заключении хочу сказать, что эта работа больше теоретического характера, чем практического. Однако подмена стека может понадобиться, например, для вызова внутрипроцесным СОМ-сервером методов событийного интерфейса клиента.

Литература

  1. Джеффри Рихтер, Windows для профессионалов
  2. Гарии Наббет, Native API


Эта статья опубликована в журнале RSDN Magazine #3. Информацию о журнале можно найти здесь
    Сообщений 7    Оценка 105        Оценить