среда, 30 августа 2017 г.

Работа с памятью в SourcePawn

Вступление

Доброго времени суток всем. Не так давно я решил заняться собственным сервером Team Fortress 2, и для этого мне понадобилось написание плагинов с помощью SourcePawn. Я начал изучать этот язык относительно недавно и в целом он мне нравится, за исключением некоторых моментов, о которых я бы хотел поговорить сегодня, а также обсудить решения для них.
Если вы не хотите читать философские рассуждения на тему ограниченности языка, то я рекомендую пропустить данный раздел и сразу перейти к следующему.

Один из этих моментов - ограничение в использовании адресного пространства процесса srcds. Увы, но мы можем работать только с библиотеками server и engine. Ну, этого, по идее, должно хватить, так как основная работа движка происходит именно в этих модулях. Однако, не поймите меня неправильно, но я не люблю чувствовать себя ограниченным при работе с памятью, хех. Тем не менее, с этим всё же можно смириться, думал я первое время.

Однако то, что мне действительно чертовски не понравилось, так это отсутствие возможности "смешивания" памяти плагина и сервера. То есть если я захочу внедрить указатель на какой-либо участок памяти своего плагина в любую библиотеку процесса или сам процесс srcds - я буду вынужден воздержаться, поскольку во-первых, получать нативные адреса собственных переменных или функций плагина невозможно, а во-вторых, работать я могу только с, опять же, server или engine. Даже если бы у меня была возможность получения адресов с помощью "#emit", то получал бы я отнюдь не адреса относительно начала памяти процесса (нуля), а относительно специальной памяти плагина. Более того, в самом плагине нельзя выделить память процесса.

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

Я практически уверен в том, что кто-то скажет, что для реализации подобного функционала можно воспользоваться native функциями, и я буду вынужден согласиться. С помощью native можно реализовать всё, чем я был недоволен до этого, однако, я предпочитаю не таскать за своими проектами огромное количество модульных зависимостей, а native функции заставят вас это делать. Поэтому все решения проблем я буду воспроизводить с использованием чистого SourcePawn кода, который можно будет аккуратно завернуть в include файл.

Начнем же.

Что мы имеем?

Чтобы найти какой-то паттерн кода с целью произвести его патчинг, нам дают возможность использовать специальную вещь - gamedata. Это файл, в котором вы указываете паттерн для поиска, модуль, операционную систему и оффсет. Достаточно полезная вещь.

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

Еще одна самая главная особенность чистого языка - функция SDKCall(), которая была разработана для вызовов функций классов, как ни странно.

Вот и всё. Эти вещи позволят нам сделать SourcePawn намного мощнее в плане работы с памятью.

Получение версии Windows

Для начала начнем с самого простого - попробуем получить версию нашей операционной системы. Как это сделать, не прибегая к native? Очень просто! Следите за руками.

В ОС Windows есть особое место в памяти, адрес которого статичен еще со времен Windows 2000. Это место представляет собой структуру KUSER_SHARED_DATA, которая как раз-таки и содержит необходимую для нас информацию. Адрес данной структуры - 0x7FFE0000. Давайте на основе имеющейся информации напишем функцию для получения версии ОС.

stock void GetWindowsVersion(int& iMajorVer, int& iMinorVer)

{

    Address pUserSharedData = view_as<Address>(0x7FFE0000);

   

    /* ((KUSER_SHARED_DATA*)0x7FFE0000)->NtMajorVersion */

    iMajorVer = LoadFromAddress(view_as<Address>(view_as<int>(pUserSharedData) + 0x26C), NumberType_Int32);

    /* ((KUSER_SHARED_DATA*)0x7FFE0000)->NtMinorVersion */

    iMinorVer = LoadFromAddress(view_as<Address>(view_as<int>(pUserSharedData) + 0x270), NumberType_Int32);

}



// ...



public void OnPluginStart()

{

    int iMajor, iMinor;

    GetWindowsVersion(iMajor, iMinor);

    PrintToServer("Windows Version : Major=%d, Minor=%d", iMajor, iMinor);

}


Как видите - ничего сложного, за исключением не самой лучшей читабельности кода, но это можно исправить с помощью "#define".

Получение PEB

Process Environment Block, пожалуй, одна из самых полезных и нужных для нас вещей. С его помощью можно получить доступ к списку загруженных модулей, а также адрес самого процесса srcds. Есть только одно "но" - его невозможно получить с помощью LoadFromAddress(), так как его адрес меняется от запуска к запуску сервера. Более того, получить его можно только прибегнув к x86 ассемблеру. Вот как это выглядит на языке C++:

void* GetPEB()

{

    __asm

    {

        mov eax, dword ptr fs:[30];

    }

}


Выглядит просто, не так ли? Увы, но выполнить подобный трюк на SourcePawn невозможно. Ну а если сильно захотеть? Давайте попробуем.

Прежде всего необходимо создать файл в папке gamedata, назовем его "any.tutor.memory":

"Games"

{

    "#default"

    {

        "Addresses"

        {

            "server"

            {

                "windows"

                {

                    "signature" "Find_Server"

                }   

            }       

        }

       

        "Signatures"

        {

            "Find_Server"

            {

                "library"    "server"

                "windows"    "\x4D\x5A"

            }           

        }

    }

}

Увы, но решение без использования данного файла я не нашел. Зачем это необходимо я разъясню позднее.

Далее реализуем необходимый функционал. Для начала напишем вот такой код:

#define Pointer Address

#define nullptr Address_Null



#define int(%1) view_as<int>(%1)

#define ptr(%1) view_as<Pointer>(%1)


Данные дефайны можно опустить, но дабы увеличить читабельность кода я буду их использовать.

Затем напишем функции чтения данных по адресу:

stock int ReadByte(Pointer pAddr)

{

    if(pAddr == nullptr)

    {

        return -1;

    }

   

    return LoadFromAddress(pAddr, NumberType_Int8);

}



stock int ReadWord(Pointer pAddr)

{

    if(pAddr == nullptr)

    {

        return -1;

    }

   

    return LoadFromAddress(pAddr, NumberType_Int16);

}



stock int ReadInt(Pointer pAddr)

{

    if(pAddr == nullptr)

    {

        return -1;

    }

   

    return LoadFromAddress(pAddr, NumberType_Int32);

} 


Мы написали обертки для LoadFromAddress(), которые позволят читать типы данных, которые нам необходимы. Без этих функций наш код стал бы достаточно большим и неудобным в чтении.

После этих функций создадим еще две:

stock Pointer Transpose(Pointer pAddr, int iOffset)

{

    return ptr(int(pAddr) + iOffset);

}



stock int Dereference(Pointer pAddr, int iOffset = 0)

{

    if(pAddr == nullptr)

    {

        return -1;

    }

   

    return ReadInt(Transpose(pAddr, iOffset));

} 


Функция Transpose() нужна для изменения значения указателя, не прибегая к приведению типов. Функция Dereference() аналогична Transpose(), но после изменения адреса она еще и произведет разыменования указателя. Опять же - всё для повышенной читабельности кода.

А теперь реализуем действительно необходимый функционал:

stock Pointer WriteData(Pointer pAddr, int[] data, int iSize)

{

    if(pAddr == nullptr)

    {

        return nullptr;

    }

   

    for(int i = 0; i < iSize; i++)

    {

        StoreToAddress(pAddr, data[i], NumberType_Int8);

       

        pAddr++;

    }

   

    pAddr++;

   

    return pAddr;

}



stock Pointer FindPattern(Pointer pStart, int iSize, int[] pattern, int iPatternSize, int iOffset)

{

    for(int i = 0; i < iSize; i++)

    {

        if(ReadByte(pStart) == pattern[0])

        {

            bool bFound = true;

           

            for(int j = 1; j < iPatternSize; j++)

            {

                int iDestByte = ReadByte(ptr(int(pStart) + j));

                int iSrcByte = pattern[j];

   

                if(iSrcByte != 0xFF)

                {

                    if(iDestByte != iSrcByte)

                    {

                        bFound = false;

                        break;

                    }

                }

            }

           

            if(bFound)

            {

                return ptr(int(pStart) + iOffset);

            }

        }

       

        pStart++;

    }

   

    return nullptr;

}



stock int GetModuleSize(Pointer pAddr)

{

    if(pAddr == nullptr)

    {

        return 0;

    }

   

    if(ReadWord(pAddr) == 0x5A4D) // MZ

    {

        int iOffset = Dereference(pAddr, 0x3C); // NT headers offset

        int iRes = Dereference(pAddr, iOffset + 0x50); // nt->OptionalHeader.SizeOfImage

        return iRes;

    }

    else

    {

        return -1;

    }

}



stock int GetModuleSize(Pointer pAddr)

{

    if(pAddr == nullptr)

    {

        return 0;

    }

   

    if(ReadWord(pAddr) == 0x5A4D) // MZ

    {

        int iOffset = Dereference(pAddr, 0x3C); // NT headers offset

        int iRes = Dereference(pAddr, iOffset + 0x50); // nt->OptionalHeader.SizeOfImage

        return iRes;

    }

    else

    {

        return -1;

    }

}


WriteData() нужна для записи массива байт по указанному адресу. Не обращайте внимание на то, что передается "int[]", функция работает с этим аргументом, как с "byte[]". GetModuleSize() функция получает виртуальный размер модуля, загруженного в адресное пространство. В аргумент pAddr нужно передавать результат GetModuleHandle()/LoadLibrary(), но об этом позднее. FindPattern() нужна для поиска паттерна в памяти без использования gamedata.

Теперь напишем достаточно специфичную функцию:

bool CreateMemoryForSDKCall(Pointer pAddr)

{

    if(pAddr == nullptr)

    {

        return false;

    }

   

    static Pointer pZeroMem = nullptr;

   

    if(pZeroMem != nullptr)

    {

        return true;

    }

   

    pAddr = ptr(int(pAddr) + GetModuleSize(pAddr) - 1);

   

    /* I could use "true", but compiler blames this line with "redundant test" */

    while(pAddr)

    {

        int b = ReadByte(pAddr);

       

        if(b != 0x00)

        {

            break;

        }

       

        pAddr--;

    }

   

    /* Align for safe code injection */

    pZeroMem = ptr(int(pAddr) + 0x10 & 0xFFFFFFF0);

   

    /* Add unique signature for PrepSDKCall_SetSignature() call */

    Addr = pZeroMem;

    for(int i = 0; i < 4; i++)

    {

        StoreToAddress(pAddr, 0xDEADBEEF, NumberType_Int32);

        pAddr = Transpose(pAddr, 4);

    }

   

    return true;

}


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

А теперь напишем кое-что действительно впечатляющее:

Pointer g_pPEB = nullptr;



int g_GetPEBAsmCode[] =

{

    0x64, 0xA1, 0x30, 0x00, 0x00, 0x00, // mov eax, dword ptr fs:[30]

    0xC3                                // ret

};



int g_DeadBeef[] =

{

    0xEF, 0xBE, 0xAD, 0xDE,

    0xEF, 0xBE, 0xAD, 0xDE,

    0xEF, 0xBE, 0xAD, 0xDE,

    0xEF, 0xBE, 0xAD, 0xDE

};



/* Get Process Environment Block pointer */

stock Pointer NtCurrentPeb()

{   

    /* Don't do these crazy stuffs again */

    if(g_pPEB != nullptr)

    {

        return g_pPEB;

    }

   

    Handle h = LoadGameConfigFile("any.tutor.memory");

    if(h == null)

    {

        return nullptr;

    }

   

    Pointer pServerBase = GameConfGetAddress(h, "server");

   

    delete h;   

   

    if(pServerBase == nullptr)

    {

        return nullptr;

    }

   

    if(CreateMemoryForSDKCall(pServerBase) == false)

    {

        return nullptr;

    }

   

    /* Init handle variable for call */

    Handle hGetPEB = INVALID_HANDLE;

   

    /* SDKCall_Static means don't use "this" argument */

    StartPrepSDKCall(SDKCall_Static);

   

    /* Create SDK call by finding our memory pointer from CreateMemoryForSDKCall() */

    PrepSDKCall_SetSignature(SDKLibrary_Server, "\xEF\xBE\xAD\xDE\xEF\xBE\xAD\xDE\xEF\xBE\xAD\xDE\xEF\xBE\xAD\xDE", 16);

    PrepSDKCall_SetReturnInfo(SDKType_PlainOldData, SDKPass_Plain);

    hGetPEB = EndPrepSDKCall();

   

    /* Failed to create SDKCall handle. Probably special signature wasn't found */

    if(hGetPEB == INVALID_HANDLE)

    {

        return nullptr;

    }

   

    int iServerSize = GetModuleSize(pServerBase);

    Pointer p = FindPattern(pServerBase, iServerSize, g_DeadBeef, 16, 0);

   

    if(p == nullptr)

    {

        return nullptr;

    }

   

    /* Create little function that will help us to get process' PEB */

    WriteData(p, g_GetPEBAsmCode, 7);

   

    g_pPEB = SDKCall(hGetPEB);

   

    /* We don't need this SDK call anymore */

    delete hGetPEB;

   

    return g_pPEB;

} 

Впечатляет, не правда ли? Возможно, кто-то даже назовет всё это извращением и безумием, но тем не менее давайте разберем всё по порядку.

Сначала функция проверяет, был ли уже найдена PEB, и возвращает его адрес, если была. Затем происходит получение адреса модуля server. Мы используем здесь небольшой трюк, основываясь на архитектуре PE файлов, а именно правилу, что первые два байта exe или dll равны константе "MZ". При старте поиска ядро SourceMod сразу же натыкается на эти два байта и возвращает адрес начала модуля. После всего этого вызывается функция CreateMemoryForSDKCall(). Ее цель - создание уникального паттерна в неиспользуемой области адресного пространства модуля server, который будет найден функцией PrepSDKCall_SetSignature() и FindPattern(). Затем мы строим SDK вызов, который на самом деле является не SDK, а нашей будущей функцией, написанной на x86 ассемблере. PrepSDKCall_SetSignature() просто находит наш уникальный паттерн, в который мы в будущем сможем записать любой код. Затем мы с помощью FindPattern() находим это же самое место и изменяем его, внедряя туда наш ассемблерный код. После всего этого мы производим вызов SDKCall() функции и получаем наш PEB адрес. Хитро, не правда ли?

Теперь, имея на руках PEB, можно делать очень много интересных вещей. Давайте попробуем получить адрес srcds, например.

stock Pointer GetSRCDSPtr()

{

    /* Impossible to get module handle without PEB */

    if(NtCurrentPeb() == nullptr)

    {

        return nullptr;

    }

   

    return ptr(Dereference(NtCurrentPeb(), 8)); // g_pPEB->ImageBaseAddress

}



// ...



public void OnPluginStart()

{

    PrintToServer("srcds.exe : Addr=0x%X", GetSRCDSPtr());

} 


Переменная ImageBaseAddress, которая входит в состав структуры PEB и располагается по смещению 8, содержит адрес процесса, который загрузил библиотеку. Функция просто получает это число, ничего сложного. Точно так же работает функция GetModuleHandle(), если в ее аргументе указать 0.

Заключение

Туториал получился достаточно большой, как по мне, поэтому я считаю, что пока что достаточно. Мы рассмотрели реализацию получения версии Windows и возможность выполнения собственного x86 ассемблерного кода. Как видите, это возможно. Если пользователям понравится подобная тематика, то я продолжу написание туториалов, в которых рассмотрю еще более интересные вещи. Благодарю за чтение.

3 комментария:

  1. Серьёзно. Даже более чем.
    У меня есть кое-какие вопросы, если еще не отошел от этого языка я бы хотел связаться с тобой.

    ОтветитьУдалить
  2. Seminole Hard Rock Hotel Casino - MapyRO
    Find Seminole Hard Rock Hotel Casino, Fort 상주 출장샵 Lauderdale in picturesque Florida. 청주 출장안마 Seminole Hard Rock Hotel & 포천 출장마사지 Casino, Fort Lauderdale, is 남원 출장샵 a hotel on the 충청북도 출장마사지 lake

    ОтветитьУдалить