Доступ к памяти процесса

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

Подключение к процессу

Как вы помните, перед началом работы с памятью процесса к нему нужно подключить отладчик. После этого он получает полный доступ к адресному пространству процесса. Мы выполняли это действие через диалог интерфейса пользователя. То же самое должен уметь внутриигровой бот. Рассмотрим, какими WinAPI-функциями он может для этого воспользоваться.

Практически все объекты и ресурсы Windows доступны через их дескрипторы. WinAPI-функция OpenProcess позволяет получить дескриптор работающего процесса. Возникает вопрос: как сообщить ОС, что нас интересует именно процесс игрового приложения? Для этой цели служит идентификатор процесса (process identifier или PID). PID – это уникальный номер, который ОС присваивает каждому процессу при старте. Его мы должны передать в OpenProcess входным параметром. Далее получив дескриптор процесса, мы можем обращаться к его памяти с помощью других WinAPI-функций.

Windows отвечает за распределение своих ресурсов между запущенными процессами. Один из этих ресурсов – память. Если любой процесс всегда будет иметь доступ к памяти других процессов, это может привести к сбоям в их работе. Система в целом будет ненадёжна. Поэтому ОС имеет специальный механизм защиты доступа к своим объектам. Рассмотрим его подробнее.

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

Представим, что мы разрабатываем пользовательское приложение, например внутриигрового бота. Каким образом оно может взаимодействовать с каким-нибудь Windows объектом? Каждый объект представляет собой структуру, состоящую из заголовка (header) и тела (body). Тело содержит данные, специфичные для каждого типа объектов. Заголовок же включает метаинформацию, которая используется менеджером объектов (Object Manager). Именно он предоставляет доступ к ресурсам ОС через соответствующие им объекты.

Модель безопасности Windows ограничивает процессам доступ к системным объектам и различным действиям, требующих прав администратора. Можно сказать, что менеджер объектов реализует модель безопасности Windows. Согласно ей, процесс должен иметь специальные привилегии, чтобы получить доступ к памяти другого через вызов OpenProcess. Управлять привилегиями процесса можно с помощью специального объекта Windows под названием маркер доступа (access token).

Учитывая модель безопасности Windows, полный алгоритм подключения к процессу через WinAPI-функцию OpenProcess выглядит следующим образом:

  1. Получить дескриптор текущего процесса.

  2. По дескриптору получить маркер доступа текущего процесса.

  3. Предоставить привилегию SE_DEBUG_NAME для маркера доступа. Эта привилегия даёт право отлаживать другие процессы.

  4. Получить дескриптор целевого процесса через вызов OpenProcess.

Приложение, реализующее этот алгоритм, должно быть запущено с правами администратора. Без них невозможно выполнить третий шаг и предоставить текущему процессу привилегию SE_DEBUG_NAME через WinAPI-функцию AdjustTokenPrivileges.

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

Листинг 3-1 демонстрирует код приложения, которое подключается к процессу с заданным PID.

Приложение из листинга 3-1 подключается к процессу с PID равным 1804. Вам нужно заменить его на PID работающего в данный момент процесса. Узнать идентификаторы всех запущенных процессов можно с помощью приложения Task Manager (диспетчер задач). Укажите PID целевого процесса в следующей строке файла OpenProcess.cpp:

    DWORD pid = 1804;

Каждый шаг алгоритма подключения к процессу выполняется отдельной функцией. Все они вызываются из функции main, которая получает управление сразу при старте приложения. Рассмотрим её код подробнее.

Сначала мы с помощью WinAPI-функции GetCurrentProcess получаем дескриптор текущего процесса и сохраняем его в переменной hProc.

Далее вызывается WinAPI-функция OpenProcessToken, которая возвращает маркер доступа. В неё мы передаём дескриптор hProc и маску доступа TOKEN_ADJUST_PRIVILEGES. Благодаря этой маске мы получаем право менять возвращаемый функцией маркер доступа. Его мы сохраняем в переменной hToken.

Весь код, предоставляющий привилегию SE_DEBUG_NAME маркеру доступа hToken, мы реализовали в отдельной функции SetPrivilege. Она выполняет два действия:

  1. Читает локальный уникальный идентификатор (locally unique identifier или LUID) константы, соответствующей привилегии SE_DEBUG_NAME с помощью WinApI функции LookupPrivilegeValue.

  2. Предоставляет маркеру доступа, переданному входным параметром, привилегию SE_DEBUG_NAME (указанную по LUID) через WinAPI-функцию AdjustTokenPrivileges.

Функция SetPrivilege более детально разбирается в статье.

Последнее действие в функции main – подключение к целевому процессу, дескриптор которого сохраняется в переменной hTargetProc. Для этого мы используем WinAPI-функцию OpenProcess. В неё передаются права доступа PROCESS_ALL_ACCESS и PID процесса для подключения. После этого вся его память становится доступна по дескриптору hTargetProc.

Операции чтения и записи

Мы знаем как получить дескриптор целевого процесса. Теперь рассмотрим способы обращения к его памяти.

WinAPI-функция ReadProcessMemory читает данные из указанной области памяти целевого процесса и сохраняет их в память вызывающего процесса. Аналогичная ей функция WriteProcessMemory записывает указанные данные в память целевого процесса. Рассмотрим пример использования этих функций.

Тестовое приложение, приведённое в листинге 3-2, записывает шестнадцатеричное значение DEADBEEF по некоторому абсолютному адресу памяти целевого процесса. Затем по этому же адресу происходит чтение. Если запись была успешной, мы прочитаем то же самое значение DEADBEEF.

Абсолютный адрес 001E0000 для записи значения DEADBEEF выбран произвольно. Эту область памяти занимает какой-то сегмент. Операция записи данных в него может привести к аварийному завершению целевого процесса. Поэтому в качестве него не используйте важные системные службы Windows. Лучше всего для нашего теста подойдёт приложение Notepad.

Для запуска приложения ReadWriteProcessMemory.cpp выполните следующие действия:

  1. Запустите Notepad.

  2. С помощью Task Manager прочитайте PID процесса Notepad.

  3. Присвойте этот PID соответствующей переменной в исходном коде приложения ReadWriteProcessMemory.cpp:

    DWORD pid = 5356;
  4. С помощью отладчика WinDbg прочитайте базовый адрес любого сегмента динамической памяти процесса Notepad. Для этого воспользуйтесь уже знакомой нам командой !address.

  5. Отключите WinDbg от процесса Notepad с помощью команды .detach.

  6. Присвойте базовый адрес сегмента динамической памяти переменной address в функции main:

    DWORD_PTR address = 0x001E0000;

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

  7. Скомпилируйте приложение ReadWriteProcessMemory.cpp. Разрядность (x86 или x64) полученного EXE-файл должна соответствовать разрядности Notepad. В противном случае наше приложение не сможет к нему подключиться.

  8. Запустите тестовое приложение с правами администратора из командной строки Windows.

После успешного выполнения нашего примера, вы увидите в консоли строку:

Result of reading dword at 0x1e0000 address = 0xdeadbeef

В этом выводе указан абсолютный адрес для записи и прочитанное по нему же значение.

Обратите внимание на функции-обёртки WriteDword и ReadDword в листинге 3-2. Они скрывают несущественные детали и предоставляют простой интерфейс к WinAPI-функциям WriteProcessMemory и ReadProcessMemory. Их параметры представлены в таблице 3-4.

{caption: "Таблица 3-4. Параметры функций WriteProcessMemory и ReadProcessMemory", width: "100%", column-widths: "10%,20%,*"}

Номер параметра

Параметр

Описание

1

hProc

Дескриптор целевого процесса, к памяти которого идёт обращение.

2

address

Абсолютный адрес области памяти для доступа.

3

result

Указатель на область памяти текущего процесса, в которую будет сохранён результат вызова ReadProcessMemory.

3

value

Указатель на буфер данных, которые будут записаны функцией WriteProcessMemory в память целевого процесса.

4

sizeof(...)

Число байт для чтения или записи.

5

NULL

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

Доступ к сегментам TEB и PEB

Мы научились работать с памятью целевого процесса. Но есть одна проблема: доступ на чтение или запись конкретной переменной происходит по её абсолютному адресу. Вопрос в том, как его найти? Мы уже знаем, что его можно вычислить по базовому адресу сегмента, в котором находится эта переменная, и её смещению. Предположим, что мы знаем, какой сегмент следует искать. Как узнать его базовый адрес? К счастью, метаинформацию об адресном пространстве процесса можно найти в его памяти. Например, в специальных сегментах TEB и PEB.

В памяти процесса для каждого потока есть соответствующий ему TEB сегмент. Кроме прочей информации он содержит базовый адрес сегмента стека, выделенного этому потоку. В стеке же хранится большая часть переменных, используемых в потоке. Остальные переменные находятся в сегменте динамической памяти процесса, выделяемом по умолчанию. Его базовый адрес хранится в PEB сегменте. Следовательно, чтобы найти сегменты стека потока и динамической памяти процесса, нам надо найти PEB и соответствующий потоку TEB. Эта задача упрощается тем, что все TEB сегменты содержат базовый адрес PEB. Таким образом, задача сводится к поиску TEB сегмента.

Доступ к TEB текущего процесса

Главный поток 32-битного процесса

Рассмотрим методы доступа к TEB сегменту. Начнём с самого простого варианта этой задачи. Предположим, что у нас есть однопоточное приложение. Как ему получить доступ к TEB своего главного потока? Существует несколько способов.

Самый простой и прямолинейный метод – воспользоваться регистром FS процессора на x86 архитектуре или регистром GS на архитектуре x64. Вообще, процессор предоставляет ОС решать, как использовать эти регистры. Windows хранит в них указатель на TEB сегмент потока, который исполняется в данный момент. Листинг 3-3 демонстрирует чтение регистра FS.

В функции GetTeb используются ассемблерные вставки. Эта возможность C++ позволяет добавлять в программу код на языке ассемблера, каждая команда которого соответствует одной инструкции процессора. Другими словами мы спускаемся на самый нижний уровень и оперируем элементарными действиями процессора.

Рассмотрим код GetTeb подробнее. Функция начинается с выделения памяти на стеке для локальной переменной pTeb типа PTEB. Согласно WinAPI документации, тип PTEB – это указатель на структуру, содержащую все данные сегмента TEB. Далее идёт блок с двумя командами на языке ассемблера:

  1. Запись в регистр EAX некоторого значения. Оно находится по абсолютному адресу памяти, который рассчитывается по формуле:

    линейный адрес = базовый адрес из регистра FS + 0x18
  2. Запись значение регистра EAX в переменную pTeb.

В результате этих команд базовый адрес регистра TEB оказывается записан в переменную pTeb. Её мы и возвращаем из функции.

Почему GetTeb не может просто вернуть значение регистра FS? Ведь он, по идее, должен указывать на TEB сегмент. Чтобы ответить на этот вопрос, рассмотрим как в Windows происходит доступ к сегментам процесса.

Большинство современных ОС использует защищённый режим процессора (protected processor mode). В этом режиме адресация сегментов происходит через глобальную таблицу дескрипторов (Global Descriptor Table или GDT). В регистрах FS и GS хранится селектор, который является индексом записи в таблице дескрипторов. В этой записи находится базовый адрес сегмента TEB. Запрос к GDT по селектору выполняется аппаратным блоком сегментации (segmentation unit) процессора. Результат этого запроса временно хранится в процессоре и недоступен для приложений или ОС. Таким образом, у Windows нет эффективного способа узнать базовый адрес сегмента TEB. Его можно прочитать из таблицы дескрипторов через WinAPI-функции GetThreadSelectorEntry и Wow64GetThreadSelectorEntry, но этот способ неэффективен из-за накладных расходов. Именно поэтому в TEB сегменте хранится его собственный базовый адрес.

Если вы интересуетесь подробностями, пример использования функции GetThreadSelectorEntry приведён в следующем обсуждении на форуме.

Структура TEB определена в заголовочном файле winternal.h, который распространяется с Windows SDK. Она отличается для разных версий Windows. Поэтому важно, чтобы ваши версии ОС и Windows SDK совпадали. Перед началом работы с TEB структурой всегда уточняйте её поля в заголовочном файле.

Определение структуры TEB из Windows SDK версии 8.1 выглядит следующим образом:

typedef struct _TEB {
    PVOID Reserved1[12];
    PPEB ProcessEnvironmentBlock;
    PVOID Reserved2[399];
    BYTE Reserved3[1952];
    PVOID TlsSlots[64];
    BYTE Reserved4[8];
    PVOID Reserved5[26];
    PVOID ReservedForOle;  // Windows 2000 only
    PVOID Reserved6[4];
    PVOID TlsExpansionSlots;
} TEB, *PTEB;

В ней среди прочих есть поле ProcessEnvironmentBlock, которое указывает на структуру PEB. Через него мы можем получить доступ к PEB сегменту.

Главный поток 64-битного процесса

Мы не можем просто заменить регистр FS на GS и использовать функцию GetTeb из листинга 3-3 на 64-разрядной системе. Проблема в том, что компилятор Visual Studio C++ не поддерживает ассемблерные вставки при компиляции 64-разрядных приложений. Вместо них следует использовать встроенные функции компилятора (compiler intrinsics).

Листинг 3-4 демонстрирует функцию GetTeb, переписанную для поддержки обеих архитектур: x86 и x64.

В новом варианте GetTeb используется директива условной компиляции препроцессора. С её помощью перед компиляцией выбирается подходящая реализация функции. Если макрос _M_X64 определён, значит целевая архитектура приложения 64-разрядная. В этом случае вызывается встроенная функция компилятора __readgsqword, которая читает 64-битное значение со смещением 0x30 от базового адреса сегмента TEB (на него указывает регистр GS через селектор). Для 32-разрядной архитектуры вызывается встроенная функция __readfsdword, которая читает 32-битное значение со смещением 0x18 от базового адреса сегмента TEB (на него указывает регистр FS).

Новая реализация функции GetTeb может вызвать вопрос: почему поле структуры TEB с базовым адресом сегмента имеет разные смещения для x86 и x64 архитектур? Чтобы ответить на него, рассмотрим определение структуры NT_TIB, которая используется для представления части TEB, независимой от версии Windows:

typedef struct _NT_TIB {
    struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList;
    PVOID StackBase;
    PVOID StackLimit;
    PVOID SubSystemTib;
     union
     {
          PVOID FiberData;
          ULONG Version;
     };
    PVOID ArbitraryUserPointer;
    struct _NT_TIB *Self;
} NT_TIB;

Поле с базовым адресом сегмента TEB называется Self. До него идут шесть полей, каждое из которых имеет тип PVOID. PVOID – это указатель на область памяти. Его размер зависит от разрядности процессора: 32 бита (или 4 байта) для архитектуры x86 и 64 бита (или 8 байт) для x64. Таким образом, в первом случае поле Self окажется смещено на 24 байта (6 4), а во втором на 48 байт (6 8). Переведём эти числа в шестнадцатеричную систему счисления и получим 0x18 и 0x30 соответственно.

Вместо того чтобы указывать смещения явно, мы можем использовать информацию о них из структуры NT_TIB. Листинг 3-5 демонстрирует это решение.

Эта реализация функции GetTeb заимствована из статьи. В ней используются уже знакомые нам встроенные функции компилятора __readgsqword и __readfsdword. Мы применяем определение структуры NT_TIB, чтобы прочитать смещение её поля Self, содержащее базовый адрес сегмента TEB. Для этого мы последовательно приводим типы. Общий алгоритм расчёта смещения выглядит следующим образом:

  1. Указатель на нулевой абсолютный адрес, который обозначается литералом nullptr, приводим к типу PNT_TIB с помощью оператора static_cast. Таким образом мы получаем указатель на структуру типа NT_TIB, расположенную по адресу 0.

  2. С помощью оператора доступа к полю -> читаем поле Self структуры NT_TIB.

  3. С помощью операции взятия адреса & читаем абсолютный адрес поля Self. В данном случае абсолютный адрес совпадёт с относительным, поскольку он считается от нуля.

  4. Приведём полученный относительный адрес поля Self к типу DWORD или QWORD (в зависимости от целевой архитектуры) с помощью оператора reinterpret_cast. Это приведение необходимо, так как встроенные функции компилятора ожидают конкретный тип входного параметра.

Версия функции GetTeb из листинга 3-5 позволяет исключить явное указание смещений в коде. Благодаря этому она будет корректно работать для всех версий Windows даже в тех, где эти смещения изменятся.

WinAPI-функции доступа к TEB

Получить доступ к TEB сегменту можно и через WinAPI. Функция NtCurrentTeb реализует тот же алгоритм, что и GetTeb из листинга 3-5. С её помощью можно получить указатель на структуру типа TEB текущего потока. Листинг 3-6 демонстрирует использование NtCurrentTeb.

Теперь все манипуляции над регистрами FS и GS происходят на уровне системной библиотеки ОС. Мы можем рассчитывать на её корректную работу для всех архитектур, поддерживаемых Windows (x86, x64, ARM).

До сих пор мы рассматривали случай однопоточного приложения. Если например нам нужно получить TEB вспомогательного потока из функции main (то есть главного потока), то все рассмотренные выше способы не подходят.

WinAPI-функция NtQueryInformationThread предоставляет доступ к TEB любого потока. Она работает только в контексте вызывающего процесса, т.е. с её помощью вы не сможете прочитать TEB игрового приложения из бота. Но в некоторых случаях NtQueryInformationThread может быть полезна. Листинг 3-7 демонстрирует реализацию GetTeb, которая использует NtQueryInformationThread.

Параметры функции NtQueryInformationThread приведены в таблице 3-5.

{caption: "Таблица 3-5. Параметры функции NtQueryInformationThread", width: "100%", {column-widths: "20%,*"}}

Параметр

Описание

GetCurrentThread()

Дескриптор целевого потока, TEB которого требуется прочитать. В примере используется дескриптор текущего потока.

---

---

ThreadBasicInformation

Константа типа перечисление (enum) THREADINFOCLASS. Она определяет тип структуры, возвращаемой функцией.

---

---

threadInfo

Указатель на структуру, в которую функция запишет свой результат.

---

---

sizeof(...)

Размер структуры с результатом работы функции. В нашем случае – это размер threadInfo.

---

---

NULL

Указатель на переменную. В неё запишется итоговый размер структуры с результатом (threadInfo).

Чтобы прочитать структуру типа THREAD_BASIC_INFORMATION для заданного потока, мы должны передать в функцию NtQueryInformationThread константу ThreadBasicInformation из перечисления THREADINFOCLASS. К сожалению, эта константа недокументированна. Кроме того, она не определена в заголовочном файле winternl.h. В нём есть только константа ThreadIsIoPending.

Чтобы использовать недокументированную константу, её надо определить самостоятельно. Для этого добавим новое перечисление типа THREADINFOCLASS2, которое содержит нужную нам ThreadBasicInformation. Подробнее об этой константе, вы можете узнать в неофициальной документации.

В нашем новом перечислении THREADINFOCLASS2 не должно быть константы с именем ThreadIsIoPending, иначе она будет конфликтовать с определением из заголовочного файла winternl.h. Поэтому в листинге 3-7 мы переименовали её на _ThreadIsIoPending.

Функция NtQueryInformationThread возвращает структуру данных, тип который зависит от переданного вторым параметром константы. Если мы передаём недокументированную константу ThreadBasicInformation, то тип возвращаемой структуры будет также недокументирован. Поэтому мы должны самостоятельно определить её тип THREAD_BASIC_INFORMATION. Вы можете найти его в уже упомянутой неофициальной документации или скопировать из листинга 3-7.

Обратите внимание на определение структуры THREAD_BASIC_INFORMATION. Базовый адрес сегмента TEB хранится в её поле TebBaseAddress. Она отличается от структуры TEB, с которой мы сталкивались ранее.

Функция NtQueryInformationThread доступна через Native API интерфейс. Она реализована в динамической библиотеке ntdll.dll, которая всегда входит в состав дистрибутива Windows. Эта библиотека активно используется системами ОС. Но, чтобы вызвать её функции из пользовательского приложения, понадобится библиотека импорта ntdll.lib и заголовочный файл winternl.h. Windows SDK предоставляет эти файлы.

Воспользоваться библиотекой импорта можно с помощью директивы pragma:

#pragma comment(lib, "ntdll.lib")

Эта строчка добавляет файл ntdll.lib в список библиотек импорта, которым воспользуется компоновщик.

В архиве примеров к этой книге вы можете найти файл TebPebSelf.cpp, в котором приведены все рассмотренные нами способы доступа к TEB и PEB сегментам.

Доступ к TEB целевого процесса

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

Теперь перейдём к реальной практической задаче и рассмотрим методы доступа к сегментам TEB и PEB целевого процесса. В качестве цели воспользуемся любым стандартным Windows приложением.

Для тестирования дальнейших примеров необходимо выполнить следующие шаги:

  1. Запустить стандартное Windows приложение (например Notepad). Помните, что его разрядность совпадает с разрядностью Windows.

  2. Прочитайте PID процесса приложения с помощью Task Manager.

  3. Присвойте прочитанный PID переменной pid функции main в коде соответствующего примера:

    DWORD pid = 5356;
  4. Скомпилируйте пример.

  5. Запустите его из командной строки с правами администратора.

После выполнения приложение напечатает результат в командную строку.

Повторение базового адреса TEB

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

  1. Прочитать базовый адрес TEB сегмента главного потока текущего процесса.

  2. Прочитать сегмент по этому же базовому адресу в адресном пространстве целевого процесса.

Листинг 3-8 демонстрирует реализацию этого алгоритма.

После запуска приложения TebPebMirror.cpp, в командной строке будут распечатаны базовые адреса трёх сегментов целевого процесса:

  • TEB

  • PEB

  • Сегмент стека главного потока

Мы использовали уже знакомый нам метод предоставления привилегии SE_DEBUG_NAME для маркера доступа текущего процесса с помощью WinAPI-функций OpenProcessToken и SetPrivilege. После этого вызывается функция GetMainThreadTeb, которая принимает входным параметром PID целевого процесса и возвращает указатель на структуру TEB. Алгоритм GetMainThreadTeb следующий:

  1. Прочитать базовый адрес TEB сегмента текущего потока с помощью вызова NtCurrentTeb.

  2. Получить дескриптор целевого процесса с правами доступа PROCESS_VM_READ. Для этого используется WinAPI-функция OpenProcess.

  3. Прочитать структуру TEB целевого процесса с помощью вызова ReadProcessMemory.

В общем случае, при старте нового процесса Windows назначает базовый адрес сегмента TEB произвольно. Для 32-разрядных приложений этот адрес часто оказывается одним и тем же. Но для 64-разрядных приложений, он меняется при каждом запуске. Поэтому рассмотренный нами метод доступа к TEB не рекомендуется применять в реальных ботах. Благодаря своей простоте он хорош только в качестве обучающего примера.

Приложение из листинга 3-8 успешно справляется с однопоточными целевыми процессами. Может ли оно работать с многопоточными? Да, но для этого надо немного изменить его код. Приложение должно создавать столько же вспомогательных потоков, сколько имеет целевой процесс. Для каждого потока надо прочитать базовый адрес соответствующего TEB сегмента. Затем через эти адреса можно пытаться получить доступ к сегментам TEB целевого процесса.

Узнать число потоков в целевом процессе можно с помощью отладчика WinDbg или OllyDbg. Достаточно открыть его карту памяти и посчитать число TEB сегментов в ней.

Для всех примеров этой главы важно помнить, что разрядность целевого процесса и вашего приложения должна быть одинаковой. Чтобы выбрать разрядность компилируемого приложения в Visual Studio, укажите желаемую целевую архитектуру в элементе интерфейса "Solution Platforms" (платформы для решения).

Перебор всех потоков целевого процесса

Попробуем найти надёжный способ чтения TEB сегментов целевого процесса. Обратимся к WinAPI. Он предоставляет функции прохода по всем потокам, работающим на данный момент в ОС. С их помощью мы можем узнать дескрипторы потоков целевого процесса. Зная эти дескрипторы можно прочитать все TEB сегменты через уже знакомую нам функцию NtQueryInformationThread.

WinAPI-функции прохода по списку активных потоков следующие:

  • CreateToolhelp32Snapshot делает снимок текущего состояния системы со всеми запущенными процессам, их потоками, модулями и сегментами динамической памяти. В функцию можно передать PID целевого процесса, тогда в снимок попадёт только он и его ресурсы.

  • Thread32First начинает перебор потоков в указанном снимке состояния системы. Функция записывает результат своей работы в структуру типа THREADENTRY32, переданную входным параметром по указателю. Эта структура содержит информацию о первом потоке в снимке.

  • Thread32Next продолжает перебор потоков в указанном снимке. Имеет те же входные и выходные параметры, что и функция Thread32First.

Приложение TebPebTraverse.cpp из листинга 3-9 демонстрирует алгоритм перебора потоков.

Это приложение выводит в консоль список потоков целевого процесса. Для каждого из них указывается идентификатор, назначенный ОС (аналог PID для потока), и базовый адрес соответствующего TEB сегмента.

Вся работа приложения происходит в функции ListProcessThreads, в которую передаётся PID целевого процесса. Для создания снимка состояния системы и работы с ним привилегия SE_DEBUG_NAME не требуется. Поэтому при запуске примера будет достаточно предоставить ему только права администратора.

Алгоритм работы функции ListProcessThreads следующий:

  1. Сделать снимок состояния системы через WinAPI вызов CreateToolhelp32Snapshot.

  2. Начать проход по потокам в снимке с помощью функции Thread32First.

  3. Сравнить PID процесса, которому принадлежит последний прочитанный поток, с PID целевого процесса.

  4. Если идентификаторы совпадают, прочитать TEB структуру этого потока с помощью функции GetTeb.

  5. Вывести в консоль полученную информацию о потоке.

  6. Перейти к следующему потоку в снимке состояния системы через вызов Thread32Next. Повторить шаги 3, 4, 5 для каждого потока в снимке.

Метод доступа к TEB из листинга 3-9 надёжен и работает для многопоточных целевых процессов любой разрядности. Применяйте в своих приложениях именно его.

Может быть не совсем понятно, как различать потоки при переборе их функцией Thread32Next. Например, вы ищете TEB главного потока. Структура THREADENTRY32 не содержит идентификатор потока в терминах процесса. Вместо этого в ней есть только глобальный ID, которым пользуется менеджер объектов Windows.

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

Доступ к динамической памяти

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

Следующие WinAPI-функции позволяют получить доступ к сегментам динамической памяти:

  • CreateToolhelp32Snapshot уже знакомая нам функция, которая создаёт снимок текущего состояния системы.

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

  • Heap32ListNext продолжает перебор сегментов в снимке. Имеет те же входные и выходные параметры, что и функция Heap32ListFirst.

WinaAPI также предоставляет две функции для перебора блоков сегментов динамической памяти: Heap32First и Heap32Next. Мы не будем их использовать в примерах этой главы.

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

Листинг 3-10 демонстрирует перебор сегментов динамической памяти целевого процесса.

Это приложение выводит в консоль базовый адрес и флаги каждого сегмента динамической памяти целевого процесса. ID каждого сегмента соответствует его базовому адресу. Флаги важны, поскольку позволяют отличать сегменты друг от друга. Например, сегмент динамической памяти по умолчанию всегда имеет ненулевые флаги.

Функция ListProcessHeaps очень похожа по принципу работы на ListProcessThreads из листинга 3-9. Её алгоритм выглядит следующим образом:

  1. Сделать снимок состояния системы с ресурсами только целевого процесса через вызов CreateToolhelp32Snapshot.

  2. Начать проход по сегментам динамической памяти в снимке с помощью функции Heap32ListFirst.

  3. Вывести в консоль ID и флаги текущего сегмента.

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

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

Выводы

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

Last updated