メモリを監視する
もくてき
ゲームのHPとかを監視するプログラムを作る。
今回はSONIC MANIAを対象に、ゲーム内のデータを取得してみよう。
ぜんてい
動的か静的かの話
わかりやすくする為に、ゲーム内キャラのHPを考えてみよう(ソニックの場合は残りのリング数だが)。
HPの値は当然メモリのどこかに配置されるわけだが、この時「毎回メモリの同じ場所に配置される」か「ゲームを起動したりプレイを開始する度に異なる場所に配置される」かはゲームによる。
ゲームに限らずプログラムのメモリ管理には、このような決まったアドレスに確保される静的メモリと、毎回必要な時に必要な場所を取ってくる動的メモリに分けられる。
で、ゲームのチートやメモリの監視をするなら、当然静的メモリのアドレス(以下静的アドレス)を掴んだほうが都合がいい。
ゲームを起動する度に、HPのアドレスは何処だとCheat Engineで探してきて、アドレス入力するんじゃめんどくさくてしょうがない。
もっとも、UNDERTALEのようなHPが動的アドレスに置かれている場合でも、必ずそのアドレスを指し示す静的メモリが存在する。
よってCheat EngineでHPを意味する静的メモリ、もしくはHPを意味する動的メモリのアドレスを保持した静的メモリを探してくれば、何度ゲームを再起動してもアプリからHPを参照しにいけるわけだ。
ASLRって何や
ところが、メモリ管理の都合上、静的メモリでありながらメモリアドレスが固定されないケースがある。
そもそも静的であるとは、exeファイルの配置位置から固定の位置に配置されているという意味だ。
どういう事かというと、exeを実行した際、その中身の実行すべきプログラム部分がメモリ上にどかっとコピーされる。
その後必要なDLLなんかもどかーっとメモリ上にロードしていった後、晴れてexeだったプログラム部分をメモリから読み出して実行するわけだ。
この時のメモリ上のexeファイルの置き場をベースアドレスとか言うんだが、ベースアドレスは一般に0x400000(32bitアプリ)または0x140000000(64bit)で固定になっている。
よって前述の「静的である」をより使える表現に変えると、「ベースアドレス+固定値で表される」という風になるわけだ。
以後はこのベースアドレスから幾らの値をオフセットと呼ぶ。
ところが、「いやーやっぱメモリアドレスが毎回固定だと怖いよね―、セキュリティ的に危なさそうだし…」という理由で、ASLRというものが導入された。
デフォルトでオンになっているこの機能は、ベースアドレス自体を毎回変えてしまうという厄介なファックだ。
そのためプログラムから特定の静的アドレスを参照しに行く場合、ベースアドレスを取得→+オフセット分のアドレスを参照、としないといけない。
ちなみにCheat Engineではこのような「緑字+HEX値」で表されるぞ。
具体的な手順
Cheat Engineでオフセット値を特定するところまでは省略する。
概要
Windowsにおいて、EXEとDLL(ようは実行可能なコードを含むやつ)をまとめて「モジュール」と呼ぶ。
実行中のプロセスと、そのプロセスが掴んでいるモジュールはどれもWinAPIから取得できる。
手順としては、
- CreateToolhelp32Snapshotで実行中のプロセス一覧(スナップショットと言う)のハンドル取得
- Process32First/Process32Nextでスナップショットを舐めていく
- 目的のプロセス(名前がsonicmania.exe)を見つけたら、そのプロセスIDに対しOpenProcessでプロセスハンドル取得
- プロセスハンドルからEnumProcessModulesでモジュールハンドルの一覧取得
- exeモジュールを見つけたらそのモジュールハンドルがベースアドレスだ!!!
といった感じ。
この時、WinAPIではおなじみの「ハンドル」が用いられるが、プロセスのハンドルがこれと言って他に使いみちがないのに対し、モジュールハンドルはそのモジュールがロードされているアドレスそのものを指す。
つまり、モジュールハンドルを得てしまえば、そいつをベースに固定オフセット分ずらしたアドレスを読み込めばいいわけだ。
実際のコード
以上をふまえたコードがこう。
#include <iostream>
#include <Windows.h>
#include <conio.h>
#include <charconv>
#include <TlHelp32.h>
#include <random>
#include <string>
#include <Psapi.h>
#pragma comment(lib,"psapi.lib")
// 「マルチバイト文字セットを利用する」じゃないとコケると思う
using playtime = UINT32;
HANDLE getHandleFromName(LPCSTR app_name);
void getProps(HANDLE hProcess, size_t baseAddr, int* ring, int* life, playtime* time);
size_t getBaseAddress(HANDLE hProcess, LPCSTR procName);
int main()
{
auto processName = "SonicMania.exe";
// ゲームのプロセス掴みに行く
HANDLE hProcess = getHandleFromName(processName);
std::cout << "process handle:" << hProcess << std::endl;
// error check
if (hProcess == NULL) {
std::cout << "プロセスハンドル握れんかったわ" << std::endl;
exit(0);
}
// プロセスハンドルからモジュールハンドル引いてくる
auto baseAddr = getBaseAddress(hProcess, processName);
std::cout << "module handle:" << baseAddr << std::endl;
// 監視開始
int rings = 0;
int life = 0;
playtime time = 0;
while (!_kbhit()) {
// 終了確認
DWORD status;
GetExitCodeProcess(hProcess, &status);
if (status != STILL_ACTIVE) break;
// 取ってくる
getProps(hProcess, baseAddr, &rings, &life, &time);
std::cout << rings << " : " << life << " : " << time << "\r";
Sleep(200);
}
// close
// ほんとは何らかの方法で終了時にフックしたい
if (hProcess != NULL) {
CloseHandle(hProcess);
}
}
union number {
float f;
int i;
uint32_t bytes;
};
// 1バイトずつのブロックを4バイト分組み立てる
number build4Bytes(BYTE* buf) {
number ret;
ret.bytes = 0;
for (int i = 0; i < 4; i++) {
auto bits = buf[i] << (8 * i);
ret.bytes |= (uint32_t)bits;
}
return ret;
}
// リング、残機、タイムを引っこ抜く
void getProps(HANDLE hProcess, size_t baseAddr, int* ring, int* life, playtime* time) {
// リング (exe + 469ad4:4)
// 残機 (exe + 469ae0:4)
// 時間 (exe + A8F074:4)
BYTE buf[4];
size_t pRing = baseAddr + 0x469ad4;
size_t pLife = baseAddr + 0x469ae0;
size_t pTime = baseAddr + 0xA8F074;
// dump rings
if (ReadProcessMemory(hProcess, (LPCVOID)pRing, (LPVOID)buf, 4, NULL)) {
*ring = build4Bytes(buf).i;
}
// dump lifes
if (ReadProcessMemory(hProcess, (LPCVOID)pLife, (LPVOID)buf, 4, NULL)) {
*life = build4Bytes(buf).i;
}
// dump times
if (ReadProcessMemory(hProcess, (LPCVOID)pTime, (LPVOID)buf, 4, NULL)) {
*time = build4Bytes(buf).bytes;
}
}
// ウィンドウ名→プロセスハンドル
// この辺嫁 https://www.keicode.com/windows/win07.php
HANDLE getHandleFromName(LPCSTR app_name) {
HANDLE hSnap;
PROCESSENTRY32 pe;
DWORD dwProcessId = 0;
BOOL bResult;
if ((hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)) == INVALID_HANDLE_VALUE) {
return NULL;
}
pe.dwSize = sizeof(pe);
// プロセス列挙→比較のループ
bResult = Process32First(hSnap, &pe);
while (bResult) {
if (!lstrcmpi(pe.szExeFile, app_name)) {
dwProcessId = pe.th32ProcessID;
CloseHandle(hSnap);
break;
}
bResult = Process32Next(hSnap, &pe);
}
// 取れてたらOpenする
if (dwProcessId != 0) {
return OpenProcess(PROCESS_ALL_ACCESS, TRUE, dwProcessId);
}
else {
return NULL;
}
}
// プロセスハンドル→モジュールハンドル
// ほぼパクリ↓
// http://eternalwindows.jp/windevelop/dll/dll12.html
size_t getBaseAddress(HANDLE hProcess, LPCSTR procName) {
DWORD size;
HMODULE lphModules[128];
EnumProcessModules(hProcess, lphModules, sizeof(lphModules), &size);
for (int i = 0; i < size / (sizeof(HMODULE)); i++) {
CHAR name[1024];
MODULEINFO info;
GetModuleBaseName(hProcess, lphModules[i], name, sizeof(name));
GetModuleInformation(hProcess, lphModules[i], &info, sizeof(info));
// とりあえず表示
// std::cout << "NAME:" << name << "\nInfo:baseAddr --- " << info.lpBaseOfDll
// << "\nInfo:entry --- " << info.lpBaseOfDll << "\n\n";
// プロセス名と同じモジュールがいたらそいつを返す
// 尚モジュールハンドルとベースアドレスは一致する
if (!lstrcmpi(name, procName)) {
return (size_t)lphModules[i];
}
}
return NULL;
}
上記コードでは細かいところで余計なことをしているが、メインはgetHandleFromNameとgetBaseAddressの2つだと思ってくれ。
実際にメモリアドレスを指定してぶっこ抜くのはReadProcessMemory関数を使うわけだが、これも調べりゃいくらでも出るので省略。
ともかくこんな感じでメモリが抜ける。
総括
ASLRはクソ