В этом посте мы расскажем о процессе создания aimbot — программы, автоматически прицеливающейся во врагов в игре жанра «шутер от первого лица» (FPS). Создавать будем aimbot для игры Half-Life 2, работающей на движке Source. Aimbot будет работать внутри процесса игры и использовать для своей работы внутренние функции игры, подвергнутые реверс-инжинирингу (в отличие от других систем, работающих снаружи игры и сканирующих экран).
Для начала изучим Source SDK и используем его как руководство для реверс-инжиниринга исполняемого файла Half-Life 2. Затем мы применим полученные данные, для создания бота. К концу статьи у нас будет написан aimbot, привязывающий прицел игрока к ближайшему врагу.
▍ Реверс-инжиниринг и Source SDK
Давайте рассмотрим, как выполнить реверс-инжиниринг исполняемого файла, чтобы найти нужную нам информацию. Для создания кода, который будет автоматически прицеливаться в мишень, нужно знать следующее:
- Позицию глаз игрока
- Позицию глаз ближайшего врага
- Вектор от глаза игрока к глазу врага (получаемый по двум предыдущим пунктам)
- Способ изменения положения камеры игрока так, чтобы глаз игрока смотрел по вектору к глазу врага
Для всего, кроме третьего пункта, требуется получать информацию от состояния запущенной игры. Эту информацию получают реверс-инжинирингом игры для поиска классов, содержащих соответствующие поля. Обычно это оказывается чрезвычайно трудоёмкой задачей со множеством проб и ошибок; однако в данном случае у нас есть доступ к Source SDK, который мы используем в качестве руководства.
Начнём поиск с нахождения ссылок на позицию глаз в репозитории. Изучив несколько страниц с результатами поиска, мы выйдем на огромный класс CBaseEntity. Внутри этого класса есть две функции:
virtual Vector EyePosition(void);
virtual const QAngle &EyeAngles(void);
Так как CBaseEntity является базовым классом, от которого происходят все сущности (entity) игры, и он содержит члены для позиции глаза и углов камеры, то похоже, именно с ним нам и нужно работать. Дальше нам нужно посмотреть, откуда ссылаются на эти функции. Снова немного поискав на GitHub Source SDK, мы находим интерфейс IServerTools, у которого есть несколько весьма многообещающих функций:
virtual IServerEntity *GetIServerEntity(IClientEntity *pClientEntity) = 0;
virtual bool SnapPlayerToPosition(const Vector &org, const QAngle &ang, IClientEntity *pClientPlayer = NULL) = 0;
virtual bool GetPlayerPosition(Vector &org, QAngle &ang, IClientEntity *pClientPlayer = NULL) = 0;
// ...
virtual CBaseEntity *FirstEntity(void) = 0;
virtual CBaseEntity *NextEntity(CBaseEntity *pEntity) = 0;
В этом интерфейсе очень удобно то, что он предоставляет доступ к позиции локального игрока, что позволяет привязать игрока к другой позиции и углу обзора, а также обеспечивает возможность итеративно обходить сущности. К тому же его экземпляр создаётся глобально и привязан к жёстко прописанной в коде строке.
#define VSERVERTOOLS_INTERFACE_VERSION_1 "VSERVERTOOLS001"
#define VSERVERTOOLS_INTERFACE_VERSION_2 "VSERVERTOOLS002"
#define VSERVERTOOLS_INTERFACE_VERSION "VSERVERTOOLS003"
#define VSERVERTOOLS_INTERFACE_VERSION_INT 3
// ...
EXPOSE_SINGLE_INTERFACE_GLOBALVAR(CServerTools, IServerTools001, VSERVERTOOLS_INTERFACE_VERSION_1, g_ServerTools);
EXPOSE_SINGLE_INTERFACE_GLOBALVAR(CServerTools, IServerTools, VSERVERTOOLS_INTERFACE_VERSION, g_ServerTools);
Можем начать разработку aimbot с поиска этого класса в памяти. Запустив Half-Life 2 и подключив к нему отладчик, поищем строковые ссылки на VSERVERTOOLS.
Мы видим, откуда на них ссылаются:
7BCAB090 | 68 88FA1F7C | push server.7C1FFA88 | 7C1FFA88:"VSERVERTOOLS001"
7BCAB095 | 68 00C4087C | push server.7C08C400 |
7BCAB09A | B9 B02A337C | mov ecx,server.7C332AB0 |
7BCAB09F | E8 8CCA3F00 | call server.7C0A7B30 |
7BCAB0A4 | C3 | ret |
7BCAB0A5 | CC | int3 |
7BCAB0A6 | CC | int3 |
7BCAB0A7 | CC | int3 |
7BCAB0A8 | CC | int3 |
7BCAB0A9 | CC | int3 |
7BCAB0AA | CC | int3 |
7BCAB0AB | CC | int3 |
7BCAB0AC | CC | int3 |
7BCAB0AD | CC | int3 |
7BCAB0AE | CC | int3 |
7BCAB0AF | CC | int3 |
7BCAB0B0 | 68 98FA1F7C | push server.7C1FFA98 | 7C1FFA98:"VSERVERTOOLS002"
7BCAB0B5 | 68 00C4087C | push server.7C08C400 |
7BCAB0BA | B9 BC2A337C | mov ecx,server.7C332ABC |
7BCAB0BF | E8 6CCA3F00 | call server.7C0A7B30 |
7BCAB0C4 | C3 | ret |
В ассемблерном листинге видно, что функция-член server.7C0A7B30 вызывается в server.7C332AB0 и server.7C332ABC. Эта функция получает два аргумента, один из которых — это имя-строка интерфейса. После изучения отладчика становится понятно, что второй параметр — это статический экземпляр чего-то.
Посмотрев на то, что делает в коде макрос EXPOSE_SINGLE_INTERFACE_GLOBALVAR, становится понятнее, что это синглтон CServerTools, предоставленный как глобальный интерфейс. Зная это, мы легко сможем получить указатель на этот синглтон в среде исполнения: мы просто берём адрес этой псевдофункции, перемещающей указатель в EAX, и вызываем её напрямую. Чтобы сделать это, можно написать следующий дженерик-код, который мы продолжим применять для нового использования других функций:
template
T GetFunctionPointer(const std::string moduleName, const DWORD_PTR offset) {
auto moduleBaseAddress{ GetModuleHandleA(moduleName.c_str()) };
if (moduleBaseAddress == nullptr) {
std::cerr << "Could not get base address of " << moduleName
<< std::endl;
std::abort();
}
return reinterpret_cast(
reinterpret_cast(moduleBaseAddress) + offset);
}
IServerTools* GetServerTools() {
constexpr auto globalServerToolsOffset{ 0x3FC400 };
static GetServerToolsFnc getServerToolsFnc{ GetFunctionPointer(
"server.dll", globalServerToolsOffset) };
return getServerToolsFnc();
}
Здесь мы берём базовый адрес, в который загружена server.dll, добавляем смещение, чтобы попасть туда, откуда можно получить доступ к синглтону CServerTools, и возвращаем его как указатель вызывающей функции. Благодаря этому мы сможем вызывать нужные нам функции в интерфейсе и игра будет реагировать соответствующим образом. Нас интересуют две функции: GetPlayerPosition и SnapPlayerToPosition.
Внутри GetPlayerPosition при помощи вызова UTIL_GetLocalPlayer получается класс локального игрока, а также вызываются EyePosition и EyeAngles; внутри SnapPlayerToPosition при помощи SnapEyeAngles корректируются углы обзора игрока. Всё вместе это даёт нам то, что необходимо для получения позиций и углов обзора сущностей, благодаря чему можно выполнить соответствующие вычисления нового вектора и угла обзора, привязывающихся к глазам врагов.
Давайте разбираться по порядку, начнём с GetPlayerPosition. Так как мы можем получить указатель на IServerTools и имеем определение интерфейса, можно выполнить явный вызов GetPlayerPosition и пошагово пройтись по вызову при помощи отладчика. При этом мы попадём сюда:
7C08BEF0 | 55 | push ebp |
7C08BEF1 | 8BEC | mov ebp,esp |
7C08BEF3 | 8B01 | mov eax,dword ptr ds:[ecx] |
7C08BEF5 | 83EC 0C | sub esp,C |
7C08BEF8 | 56 | push esi |
7C08BEF9 | FF75 10 | push dword ptr ss:[ebp+10] |
7C08BEFC | FF50 04 | call dword ptr ds:[eax+4] |
7C08BEFF | 8BF0 | mov esi,eax |
7C08BF01 | 85F6 | test esi,esi |
7C08BF03 | 75 14 | jne server.7C08BF19 |
7C08BF05 | E8 E616E7FF | call server.7BEFD5F0 |
7C08BF0A | 8BF0 | mov esi,eax |
7C08BF0C | 85F6 | test esi,esi |
7C08BF0E | 75 09 | jne server.7C08BF19 |
7C08BF10 | 32C0 | xor al,al |
7C08BF12 | 5E | pop esi |
7C08BF13 | 8BE5 | mov esp,ebp |
7C08BF15 | 5D | pop ebp |
7C08BF16 | C2 0C00 | ret C |
7C08BF19 | 8B06 | mov eax,dword ptr ds:[esi] |
7C08BF1B | 8D4D F4 | lea ecx,dword ptr ss:[ebp-C] |
7C08BF1E | 51 | push ecx |
7C08BF1F | 8BCE | mov ecx,esi |
7C08BF21 | FF90 08020000 | call dword ptr ds:[eax+208] |
7C08BF27 | 8B4D 08 | mov ecx,dword ptr ss:[ebp+8] |
7C08BF2A | D900 | fld st(0),dword ptr ds:[eax] |
7C08BF2C | D919 | fstp dword ptr ds:[ecx],st(0) |
7C08BF2E | D940 04 | fld st(0),dword ptr ds:[eax+4] |
7C08BF31 | D959 04 | fstp dword ptr ds:[ecx+4],st(0) |
7C08BF34 | D940 08 | fld st(0),dword ptr ds:[eax+8] |
7C08BF37 | 8B06 | mov eax,dword ptr ds:[esi] |
7C08BF39 | D959 08 | fstp dword ptr ds:[ecx+8],st(0) |
7C08BF3C | 8BCE | mov ecx,esi |
7C08BF3E | FF90 0C020000 | call dword ptr ds:[eax+20C] |
7C08BF44 | 8B4D 0C | mov ecx,dword ptr ss:[ebp+C] |
7C08BF47 | 5E | pop esi |
7C08BF48 | D900 | fld st(0),dword ptr ds:[eax] |
7C08BF4A | D919 | fstp dword ptr ds:[ecx],st(0) |
7C08BF4C | D940 04 | fld st(0),dword ptr ds:[eax+4] |
7C08BF4F | D959 04 | fstp dword ptr ds:[ecx+4],st(0) |
7C08BF52 | D940 08 | fld st(0),dword ptr ds:[eax+8] |
7C08BF55 | B0 01 | mov al,1 |
7C08BF57 | D959 08 | fstp dword ptr ds:[ecx+8],st(0) |
7C08BF5A | 8BE5 | mov esp,ebp |
7C08BF5C | 5D | pop ebp |
7C08BF5D | C2 0C00 | ret C |
Разбираться в этом довольно долго, однако в виде графа потока управления всё выглядит довольно просто:
Если построчно сопоставить дизассемблированную программу с кодом, то мы достаточно быстро найдём необходимое. Код вызывает функцию UTIL_GetLocalPlayer только тогда, когда переданный параметр pClientEntity равен
null
. Эта логика проверяется в блоке первой функции графа. Если существует действительная клиентская сущность, код переходит к получению позиции и углов глаза для неё, а в противном случае получает сущность локального игрока. Этот вызов происходит при исполнении команды call server.7BEFD5F0 по адресу server.7C08BF05. Как и ранее, мы можем создать указатель функции на UTIL_GetLocalPlayer и вызывать её напрямую.
CBasePlayer* GetLocalPlayer() {
constexpr auto globalGetLocalPlayerOffset{ 0x26D5F0 };
static GetLocalPlayerFnc getLocalPlayerFnc{ GetFunctionPointer(
"server.dll", globalGetLocalPlayerOffset) };
return getLocalPlayerFnc();
}
Следующими в дизассемблированном коде идут вызовы функций EyePosition и EyeAngles. Нас интересует только получение позиций глаза, поэтому важен только первый вызов. Для получения адреса функции мы можем пошагово пройти по вызову, пока не вызовем адрес, находящийся в [EAX+0x208]. После исполнения этой команды мы перейдём на server.dll+0x119D00, таким образом узнав, где находится функция.
Vector GetEyePosition(CBaseEntity* entity) {
constexpr auto globalGetEyePositionOffset{ 0x119D00 };
static GetEyePositionFnc getEyePositionFnc{ GetFunctionPointer(
"server.dll", globalGetEyePositionOffset) };
return getEyePositionFnc(entity);
}
И это всё, что нам нужно от GetPlayerPosition; теперь у нас есть возможность получения указателя локальной сущности игрока и получения позиции глаза сущности. Последнее, что нам потребуется — возможность задания угла обзора игрока. Как говорилось выше, это можно сделать, вызвав функцию SnapPlayerToPosition и посмотрев, где находится функция SnapEyeAngles. Дизассемблированный код SnapEyeAngles выглядит следующим образом:
7C08C360 | 55 | push ebp |
7C08C361 | 8BEC | mov ebp,esp |
7C08C363 | 8B01 | mov eax,dword ptr ds:[ecx] |
7C08C365 | 83EC 0C | sub esp,C |
7C08C368 | 56 | push esi |
7C08C369 | FF75 10 | push dword ptr ss:[ebp+10] |
7C08C36C | FF50 04 | call dword ptr ds:[eax+4] |
7C08C36F | 8BF0 | mov esi,eax |
7C08C371 | 85F6 | test esi,esi |
7C08C373 | 75 14 | jne server.7C08C389 |
7C08C375 | E8 7612E7FF | call server.7BEFD5F0 |
7C08C37A | 8BF0 | mov esi,eax |
7C08C37C | 85F6 | test esi,esi |
7C08C37E | 75 09 | jne server.7C08C389 |
7C08C380 | 32C0 | xor al,al |
7C08C382 | 5E | pop esi |
7C08C383 | 8BE5 | mov esp,ebp |
7C08C385 | 5D | pop ebp |
7C08C386 | C2 0C00 | ret C |
7C08C389 | 8B06 | mov eax,dword ptr ds:[esi] |
7C08C38B | 8BCE | mov ecx,esi |
7C08C38D | FF90 24020000 | call dword ptr ds:[eax+224] |
7C08C393 | 8B4D 08 | mov ecx,dword ptr ss:[ebp+8] |
7C08C396 | F3:0F1001 | movss xmm0,dword ptr ds:[ecx] |
7C08C39A | F3:0F5C00 | subss xmm0,dword ptr ds:[eax] |
7C08C39E | F3:0F1145 F4 | movss dword ptr ss:[ebp-C],xmm0 |
7C08C3A3 | F3:0F1041 04 | movss xmm0,dword ptr ds:[ecx+4] |
7C08C3A8 | F3:0F5C40 04 | subss xmm0,dword ptr ds:[eax+4] |
7C08C3AD | F3:0F1145 F8 | movss dword ptr ss:[ebp-8],xmm0 |
7C08C3B2 | F3:0F1041 08 | movss xmm0,dword ptr ds:[ecx+8] |
7C08C3B7 | 8BCE | mov ecx,esi |
7C08C3B9 | F3:0F5C40 08 | subss xmm0,dword ptr ds:[eax+8] |
7C08C3BE | 8D45 F4 | lea eax,dword ptr ss:[ebp-C] |
7C08C3C1 | 50 | push eax |
7C08C3C2 | F3:0F1145 FC | movss dword ptr ss:[ebp-4],xmm0 |
7C08C3C7 | E8 14CFD0FF | call server.7BD992E0 |
7C08C3CC | FF75 0C | push dword ptr ss:[ebp+C] |
7C08C3CF | 8BCE | mov ecx,esi |
7C08C3D1 | E8 4A0FE0FF | call server.7BE8D320 |
7C08C3D6 | 8B06 | mov eax,dword ptr ds:[esi] |
7C08C3D8 | 8BCE | mov ecx,esi |
7C08C3DA | 6A FF | push FFFFFFFF |
7C08C3DC | 6A 00 | push 0 |
7C08C3DE | FF90 88000000 | call dword ptr ds:[eax+88] |
7C08C3E4 | B0 01 | mov al,1 |
7C08C3E6 | 5E | pop esi |
7C08C3E7 | 8BE5 | mov esp,ebp |
7C08C3E9 | 5D | pop ebp |
7C08C3EA | C2 0C00 | ret C |
Повторив уже описанный выше процесс, мы выясним, что команда call server.7BE8D320 является вызовом SnapEyeAngles. Мы можем определить следующую функцию:
void SnapEyeAngles(CBasePlayer* player, const QAngle& angles)
{
constexpr auto globalSnapEyeAnglesOffset{ 0x1FD320 };
static SnapEyeAnglesFnc snapEyeAnglesFnc{ GetFunctionPointer(
"server.dll", globalSnapEyeAnglesOffset) };
return snapEyeAnglesFnc(player, angles);
}
Итак, теперь у нас есть всё необходимое.
▍ Создание aimbot
Для создания aimbot нам нужно следующее:
- Итеративно обойти сущности
- Если сущность является врагом, то найти расстояние между сущностью и игроком
- Отслеживать ближайшую сущность
- Вычислить вектор «глаз-глаз» между игроком и ближайшим врагом
- Корректировать углы глаза игрока так, чтобы он следовал за этим вектором
Ранее мы узнали, что для получения позиции игрока можно вызвать GetPlayerPosition. Для циклического обхода списка сущностей можно вызвать FirstEntity и NextEntity, которые возвращают указатель на экземпляр CBaseEntity. Чтобы понять, является ли сущность врагом, можно сравнить имя сущности со множеством имён враждебных NPC-сущностей. Если мы получили сущность врага, то вычисляем расстояние между игроком и сущностью и сохраняем позицию сущности, если она ближайшая из всех, пока найденных нами.
После итеративного обхода всего списка сущностей мы получаем ближайшего врага, вычисляем вектор «глаз-глаз» и корректируем углы глаза игрока при помощи функции VectorAngles.
В виде кода получим следующее:
auto* serverEntity{ reinterpret_cast(
GetServerTools()->FirstEntity()) };
if (serverEntity != nullptr) {
do {
if (serverEntity == GetServerTools()->FirstEntity()) {
SetPlayerEyeAnglesToPosition(closestEnemyVector);
closestEnemyDistance = std::numeric_limits::max();
closestEnemyVector = GetFurthestVector();
}
auto* modelName{ serverEntity->GetModelName().ToCStr() };
if (modelName != nullptr) {
auto entityName{ std::string{GetEntityName(serverEntity)} };
if (IsEntityEnemy(entityName)) {
Vector eyePosition{};
QAngle eyeAngles{};
GetServerTools()->GetPlayerPosition(eyePosition, eyeAngles);
auto enemyEyePosition{ GetEyePosition(serverEntity) };
auto distance{ VectorDistance(enemyEyePosition, eyePosition) };
if (distance <= closestEnemyDistance) {
closestEnemyDistance = distance;
closestEnemyVector = enemyEyePosition;
}
}
}
serverEntity = reinterpret_cast(
GetServerTools()->NextEntity(serverEntity));
} while (serverEntity != nullptr);
}
В коде есть несколько вспомогательных функций, которые мы ранее не рассматривали: функция GetFurthestVector возвращает вектор с максимальными значениями float в полях x, y и z; GetEntityName возвращает имя сущности в виде строки, получая член m_iName экземпляра CBaseEntity; а IsEntityEnemy просто сверяет имя сущности со множеством враждебных NPC.
Векторные вычисления и расчёт нового угла обзора происходят в показанной ниже SetPlayerEyeAnglesToPosition:
void SetPlayerEyeAnglesToPosition(const Vector& enemyEyePosition) {
Vector eyePosition{};
QAngle eyeAngles{};
GetServerTools()->GetPlayerPosition(eyePosition, eyeAngles);
Vector forwardVector{ enemyEyePosition.x - eyePosition.x,
enemyEyePosition.y - eyePosition.y,
enemyEyePosition.z - eyePosition.z
};
VectorNormalize(forwardVector);
QAngle newEyeAngles{};
VectorAngles(forwardVector, newEyeAngles);
SnapEyeAngles(GetLocalPlayer(), newEyeAngles);
}
Эта функция вычисляет вектор «глаз-глаз», вычитая из вектора позиции глаза врага вектор позиции глаза игрока. Затем этот новый вектор нормализуется и передаётся функции VectorAngles для вычисления новых углов обзора. Затем углы глаза игрока корректируются под эти новые углы, что должно создавать эффект слежения за сущностью.
Как это выглядит в действии?
Вы видите почти прозрачный прицел, следующий за головой идущего по комнате NPC. Когда NPC отходит достаточно далеко, код выполняет привязку к более близкому NPC. Всё работает!
▍ Заключение
Описанные в статье методики в общем случае применимы к любой игре жанра FPS. Способ получения позиций и углов может различаться в разных игровых движках, однако векторные вычисления для создания угла обзора из вектора расстояния применимы в любом случае.
Реверс-инжиниринг Half-Life 2 был сильно упрощён открытостью Source SDK. Возможность сопоставления кода и структур данных с ассемблерным кодом существенно упростила отладку, однако обычно такого везения не бывает! Надеюсь, эта статья помогла вам понять, как работают aimbot и показала, что создавать их не очень сложно.
Полный исходный код aimbot выложен на GitHub, можете свободно с ним экспериментировать.