
如果你是一位从 Windows 98/XP 时代走过来的老程序员看到“VC6”和“暗黑2”这两个词是不是瞬间有种 DNA 动了的感觉在那个互联网尚未普及、技术资料匮乏的年代很多人的编程启蒙就是从用 Visual C 6.0VC6写一些“好玩”的小工具开始的比如修改游戏内存。今天这篇文章我们就来一次彻底的“技术考古”和“原理复现”。我们将用 VC6 写一个修改《暗黑破坏神2》金钱的小程序。这不仅仅是为了怀旧更是为了深入理解几个至今仍至关重要的技术概念进程内存操作、指针寻址、以及 Windows API 在底层交互中的应用。你会发现二十年前的技术思路在今天的安全研究、逆向工程、甚至某些自动化测试场景中依然闪烁着智慧的光芒。很多人可能会问都202X年了为什么还要用古董级的 VC6直接用 Cheat Engine 不是更简单吗问得好。这正是本文要讲清楚的核心价值知其然更要知其所以然Cheat Engine 是强大的工具但它封装了所有细节。通过亲手用 VC6 实现你将透彻理解内存修改背后的每一个 API 调用和原理。极致的教学环境VC6 的 MFC 框架简单直接没有现代 IDE 的复杂抽象非常适合初学者理解 Windows 桌面程序exe的完整生命周期和消息机制。经典技术的永恒性ReadProcessMemory和WriteProcessMemory这两个核心 API从 Windows 95 到 Windows 11 依然有效。学会它们你就掌握了与任何进程交互的一把钥匙。所以本文不仅是一篇怀旧教程更是一堂扎实的 Windows 系统编程实践课。你将得到一个可以实际编译运行的、能够修改《暗黑破坏神2》游戏金钱的完整 MFC 程序并彻底明白它为何能工作。1. 核心原理我们如何修改另一个程序的内存在开始写代码之前必须把核心原理吃透。这决定了你是否能举一反三而不是只会照抄。现代操作系统如 Windows为每个运行的程序进程分配了独立的虚拟内存空间。这就像给每个进程一个独立的、封闭的沙箱进程 A 无法直接访问进程 B 沙箱里的东西。这是操作系统最重要的安全机制之一。那么我们的小程序称为“外部进程”如何才能修改《暗黑破坏神2》称为“目标进程”沙箱里的金钱数值呢Windows 为有特殊权限的程序留了“后门”主要通过以下几个关键步骤获取目标进程的“操作权限”我们需要先找到暗黑2的进程并获得一个具有足够权限的“句柄”。这就像拿到目标沙箱的管理员钥匙。定位金钱在内存中的“地址”光有钥匙不行还得知道钱具体放在沙箱的哪个位置。这个地址是动态的每次启动游戏都可能变化。通过合法 API 进行读写使用 Windows 提供的ReadProcessMemory和WriteProcessMemory这两个函数。它们就像两个特制的工具允许你拿着钥匙句柄在指定的地址内存位置读取或修改数据。整个过程可以类比为你外部程序通过物业操作系统的批准拿到了某户目标进程的房门钥匙进程句柄和房间内保险箱的具体位置图内存地址然后用物业提供的专用工具Read/WriteProcessMemory来查看或调整保险箱里的现金金钱数据。一个至关重要的概念基址与偏移游戏中的金钱值其存储地址并非固定不变。它通常存储在一个“动态地址”中。这个地址可以通过一个“基址”加上一系列“偏移”计算出来。基址通常指向游戏主模块如Game.exe加载到内存中的起始地址偏移则指向该模块内部具体的变量位置。我们的小程序需要先获取基址然后像指针链一样一层层加上偏移最终找到存储金钱的那个确切地址。2. 环境准备搭建“复古”开发环境要原汁原味地体验我们需要准备以下环境。请注意由于 VC6 和《暗黑破坏神2》都是较老的软件在现代系统如 Windows 10/11上运行可能需要一些兼容性设置。2.1 软件准备开发工具Microsoft Visual C 6.0 (VC6)。你可以在网上找到其安装包。建议将其安装在非系统盘如 D:\Program Files\Microsoft Visual Studio并以 Windows XP SP3 兼容模式运行。目标游戏《暗黑破坏神2毁灭之王》Diablo II: Lord of Destruction版本建议为 1.14d因为这个版本稳定且资料丰富。确保游戏可以正常运行。辅助工具用于找地址Cheat Engine (CE)。这是我们用来侦查内存地址的“雷达”。我们将用它来找到金钱的动态地址和偏移但最终修改功能由我们自己的 VC6 程序实现。2.2 VC6 兼容性设置Windows 10/11找到 VC6 的安装目录右键点击MSDEV.EXE主程序选择“属性”。在“兼容性”选项卡中勾选“以兼容模式运行这个程序”选择“Windows XP (Service Pack 3)”。勾选“以管理员身份运行此程序”。可选在高 DPI 设置中选择“应用程序”。同样地对你之后编译生成的.exe文件如果运行时有问题也可以尝试类似的兼容性设置。2.3 工程创建准备启动 VC6我们将创建一个标准的 MFC 对话框程序这样能快速构建出带有按钮、输入框的图形界面。3. 第一步创建 MFC 对话框项目打开 VC6点击File-New。选择Projects标签页选中MFC AppWizard (exe)。在Project name中输入项目名例如D2MoneyEditor选择好保存位置点击OK。在接下来的向导中Step 1: 选择Dialog based基于对话框。点击Next。Step 2: 可以保持默认设置取消About box可选。点击Next。Step 3: 保持默认使用 MFC Standard。点击Next。Step 4: 确认类名通常为CD2MoneyEditorDlg。点击Finish然后OK。现在你将看到一个对话框设计界面。这就是我们程序的主窗口。4. 第二步设计程序界面我们需要一个简单直观的界面包含以下控件一个按钮用于“获取”或“刷新”游戏中的金钱数值。一个编辑框用于显示当前游戏中的金钱。另一个编辑框用于输入我们想修改成的金钱数值。一个按钮用于执行“修改”操作。一个静态文本用于显示状态信息如是否找到游戏进程。设计步骤在右侧的控件工具箱中拖放控件到对话框上拖放两个Static Text静态文本分别修改其Caption属性为“当前金钱”和“目标金钱”。拖放两个Edit Box编辑框放在对应静态文本旁边。分别选中它们按Enter键在属性对话框中设置其ID。例如显示当前金钱的编辑框 ID 设为IDC_EDIT_CURRENT输入目标金钱的编辑框 ID 设为IDC_EDIT_TARGET。将IDC_EDIT_CURRENT的Read-Only属性勾选上因为它只用于显示。拖放两个Button按钮。一个修改Caption为“读取金钱”ID 设为IDC_BUTTON_READ另一个修改Caption为“修改金钱”ID 设为IDC_BUTTON_WRITE。拖放一个较大的Static Text用于显示状态ID 设为IDC_STATIC_STATUS可以拉宽一些。调整布局使其美观。你的对话框应该大致如下所示[当前金钱] [编辑框 (IDC_EDIT_CURRENT)] [读取金钱按钮] [目标金钱] [编辑框 (IDC_EDIT_TARGET)] [修改金钱按钮] [状态信息显示区 (IDC_STATIC_STATUS)]按CtrlW或点击菜单View-ClassWizard确保所有控件 ID 都已被映射到对话框类CD2MoneyEditorDlg的成员变量。我们稍后会为编辑框添加变量。5. 第三步使用 Cheat Engine 定位金钱地址与偏移这是最关键的一步我们需要找到金钱在内存中的“寻址路径”。我们假设你已经打开了《暗黑破坏神2》并进入了有角色的游戏。打开 Cheat Engine (CE)并附加到游戏进程。运行 CE点击左上角的电脑图标“选择进程”。在进程列表中找到Game.exe如果是窗口模式可能是Diablo II选中并点击Open。首次扫描金钱数值。确保游戏角色身上有金钱例如 5000 金币。在 CE 的Value输入框中输入你当前的金钱数如 5000Scan Type选择Exact ValueValue Type选择4 Bytes因为金钱通常是 4 字节整数。点击First Scan。左侧会列出大量地址这很正常。改变金钱数值进行过滤。回到游戏通过买卖物品等方式改变你的金钱数例如变成 4500。回到 CE在Value输入框中输入新的金钱数4500点击Next Scan。重复这个过程几次直到左侧的地址列表减少到几个甚至一个。找到基址与偏移。在剩下的地址中选择一个最有可能的通常只有一个双击它将其添加到下方的地址列表中。在下方列表中右键点击这个地址选择Find out what writes to this address。回到游戏再次改变金钱比如捡钱或花钱。CE 会捕获到修改该地址的指令。在捕获到的指令上右键选择Show disassembler。在反汇编窗口中你可以看到类似mov [eaxxxxx], ecx的指令。这里的xxxx就是一个偏移量例如0xFC。更重要的是你需要找到eax或其他寄存器的值是从哪里来的。往上翻看代码可能会看到mov eax, [Game.exexxxxxx]这样的指令。这里的Game.exexxxxxx就是基址例如Game.exe0x123456。记录下这两个关键信息基址 (Base Address)Game.exe0x123456这里的0x123456是十六进制偏移。偏移 (Offset)0xFC同样是十六进制。请注意以上地址0x123456和0xFC仅为示例实际数值因游戏版本和补丁而异。你需要用自己的 CE 找到真实值。一个经典的 1.14d 版本寻址链可能是[[Game.exe 0x11C3FC] 0xFC]。这意味着首先读取Game.exe模块基址 0x11C3FC这个地址处的值这个值是一个指针我们称为一级指针。然后将这个指针的值加上0xFC得到的就是最终存储金钱的地址。6. 第四步编写核心代码——进程内存读写现在回到 VC6开始编写我们程序的核心逻辑。我们需要用到几个关键的 Windows API。6.1 添加必要的头文件和库在D2MoneyEditorDlg.cpp文件的开头#endif // _AFX_NO_AFXCMN_SUPPORT这行之后添加以下包含语句// D2MoneyEditorDlg.cpp : implementation file // #include stdafx.h #include D2MoneyEditor.h #include D2MoneyEditorDlg.h #include tlhelp32.h // 用于进程快照 #include psapi.h // 用于获取模块基址 (GetModuleBaseName等)如果使用EnumProcessModules则需要链接Psapi.lib #pragma comment(lib, Psapi.lib) // 静态链接Psapi.lib库6.2 定义全局变量与常量在CD2MoneyEditorDlg类的头文件D2MoneyEditorDlg.h中在类定义里添加一些成员变量// D2MoneyEditorDlg.h : header file // class CD2MoneyEditorDlg : public CDialog { // ... 其他代码 ... private: DWORD m_dwProcessId; // 存储找到的游戏进程ID HANDLE m_hProcess; // 存储打开的游戏进程句柄 DWORD_PTR m_dwBaseAddr; // 存储计算出的Game.exe模块基址 // 根据你用CE找到的地址修改下面的常量 const DWORD_PTR BASE_OFFSET 0x11C3FC; // 示例一级偏移 const DWORD_PTR MONEY_OFFSET 0xFC; // 示例二级偏移金钱偏移 // ... 其他代码 ... };6.3 实现“查找游戏进程”功能我们需要一个函数来找到Game.exe的进程并获取其句柄和模块基址。在CD2MoneyEditorDlg类中添加一个成员函数。在D2MoneyEditorDlg.h的类声明中添加函数原型BOOL FindDiabloProcess();在D2MoneyEditorDlg.cpp中实现这个函数BOOL CD2MoneyEditorDlg::FindDiabloProcess() { m_dwProcessId 0; m_hProcess NULL; m_dwBaseAddr 0; HANDLE hSnapshot CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hSnapshot INVALID_HANDLE_VALUE) { SetStatusText(_T(创建进程快照失败)); return FALSE; } PROCESSENTRY32 pe32; pe32.dwSize sizeof(PROCESSENTRY32); if (Process32First(hSnapshot, pe32)) { do { CString strProcessName pe32.szExeFile; strProcessName.MakeLower(); // 转为小写比较 if (strProcessName.Find(_T(game.exe)) ! -1) { m_dwProcessId pe32.th32ProcessID; break; } } while (Process32Next(hSnapshot, pe32)); } CloseHandle(hSnapshot); if (m_dwProcessId 0) { SetStatusText(_T(未找到 Game.exe 进程请确保游戏已运行。)); return FALSE; } // 打开进程获取句柄 m_hProcess OpenProcess(PROCESS_ALL_ACCESS, FALSE, m_dwProcessId); if (m_hProcess NULL) { SetStatusText(_T(打开进程失败请尝试以管理员身份运行本程序。)); return FALSE; } // 获取 Game.exe 模块的基址 HMODULE hMods[1024]; DWORD cbNeeded; if (EnumProcessModules(m_hProcess, hMods, sizeof(hMods), cbNeeded)) { // 通常第一个模块就是主模块 Game.exe if (cbNeeded 0) { m_dwBaseAddr (DWORD_PTR)hMods[0]; TCHAR szModName[MAX_PATH]; if (GetModuleBaseName(m_hProcess, hMods[0], szModName, sizeof(szModName)/sizeof(TCHAR))) { CString strMsg; strMsg.Format(_T(成功附加到进程: %s (PID: %d, 基址: 0x%08X)), szModName, m_dwProcessId, m_dwBaseAddr); SetStatusText(strMsg); return TRUE; } } } // 如果EnumProcessModules失败尝试另一种方法例如通过PEB遍历这里简化处理 SetStatusText(_T(获取模块信息失败。)); CloseHandle(m_hProcess); m_hProcess NULL; return FALSE; }同时添加一个辅助函数来更新状态文本在头文件中声明在cpp中实现// 在 D2MoneyEditorDlg.h 中声明 private: void SetStatusText(LPCTSTR lpszText); // 在 D2MoneyEditorDlg.cpp 中实现 void CD2MoneyEditorDlg::SetStatusText(LPCTSTR lpszText) { CStatic* pStatus (CStatic*)GetDlgItem(IDC_STATIC_STATUS); if (pStatus ::IsWindow(pStatus-m_hWnd)) { pStatus-SetWindowText(lpszText); } }6.4 实现“读取金钱”功能首先使用 ClassWizard 为IDC_EDIT_CURRENT编辑框添加一个CString类型的成员变量例如m_strCurrentMoney。然后为“读取金钱”按钮 (IDC_BUTTON_READ) 添加一个BN_CLICKED消息处理函数。在消息处理函数中编写读取逻辑void CD2MoneyEditorDlg::OnButtonRead() { UpdateData(TRUE); // 将控件数据更新到变量虽然这里不需要但好习惯 if (!FindDiabloProcess()) { return; // 查找进程失败状态已更新 } // 1. 计算一级指针地址 DWORD_PTR dwPointerAddr m_dwBaseAddr BASE_OFFSET; DWORD_PTR dwFirstPointer 0; SIZE_T bytesRead 0; // 2. 读取一级指针的值 if (!ReadProcessMemory(m_hProcess, (LPCVOID)dwPointerAddr, dwFirstPointer, sizeof(dwFirstPointer), bytesRead)) { SetStatusText(_T(读取一级指针失败)); CloseHandle(m_hProcess); m_hProcess NULL; return; } if (dwFirstPointer 0) { SetStatusText(_T(一级指针为空可能角色未在游戏中或地址已失效。)); CloseHandle(m_hProcess); m_hProcess NULL; return; } // 3. 计算最终金钱地址 DWORD_PTR dwMoneyAddr dwFirstPointer MONEY_OFFSET; DWORD dwMoneyValue 0; // 4. 读取金钱数值 if (!ReadProcessMemory(m_hProcess, (LPCVOID)dwMoneyAddr, dwMoneyValue, sizeof(dwMoneyValue), bytesRead)) { SetStatusText(_T(读取金钱数值失败)); CloseHandle(m_hProcess); m_hProcess NULL; return; } // 5. 显示到界面 m_strCurrentMoney.Format(_T(%u), dwMoneyValue); UpdateData(FALSE); // 将变量数据更新到控件 CString strMsg; strMsg.Format(_T(读取成功当前金钱: %u), dwMoneyValue); SetStatusText(strMsg); // 6. 关闭进程句柄为了安全每次操作后关闭。也可以保持打开直到程序关闭 CloseHandle(m_hProcess); m_hProcess NULL; }6.5 实现“修改金钱”功能首先使用 ClassWizard 为IDC_EDIT_TARGET编辑框添加一个CString类型的成员变量例如m_strTargetMoney。然后为“修改金钱”按钮 (IDC_BUTTON_WRITE) 添加BN_CLICKED消息处理函数。在消息处理函数中编写修改逻辑void CD2MoneyEditorDlg::OnButtonWrite() { UpdateData(TRUE); // 获取输入框中的目标金钱字符串 // 将字符串转换为数值 DWORD dwTargetMoney _ttol(m_strTargetMoney); if (dwTargetMoney 0) { SetStatusText(_T(请输入有效的金钱数值大于0。)); return; } if (!FindDiabloProcess()) { return; } // 1. 计算一级指针地址 DWORD_PTR dwPointerAddr m_dwBaseAddr BASE_OFFSET; DWORD_PTR dwFirstPointer 0; SIZE_T bytesRead 0; // 2. 读取一级指针的值 if (!ReadProcessMemory(m_hProcess, (LPCVOID)dwPointerAddr, dwFirstPointer, sizeof(dwFirstPointer), bytesRead)) { SetStatusText(_T(读取一级指针失败)); CloseHandle(m_hProcess); m_hProcess NULL; return; } if (dwFirstPointer 0) { SetStatusText(_T(一级指针为空无法修改。)); CloseHandle(m_hProcess); m_hProcess NULL; return; } // 3. 计算最终金钱地址 DWORD_PTR dwMoneyAddr dwFirstPointer MONEY_OFFSET; SIZE_T bytesWritten 0; // 4. 写入新的金钱数值 if (!WriteProcessMemory(m_hProcess, (LPVOID)dwMoneyAddr, dwTargetMoney, sizeof(dwTargetMoney), bytesWritten)) { SetStatusText(_T(写入金钱数值失败)); CloseHandle(m_hProcess); m_hProcess NULL; return; } // 5. 再次读取以验证 DWORD dwVerifyMoney 0; ReadProcessMemory(m_hProcess, (LPCVOID)dwMoneyAddr, dwVerifyMoney, sizeof(dwVerifyMoney), bytesRead); if (dwVerifyMoney dwTargetMoney) { CString strMsg; strMsg.Format(_T(修改成功新金钱: %u), dwVerifyMoney); SetStatusText(strMsg); // 同时更新“当前金钱”显示 m_strCurrentMoney.Format(_T(%u), dwVerifyMoney); UpdateData(FALSE); } else { SetStatusText(_T(修改后验证失败可能地址有误或游戏有保护。)); } CloseHandle(m_hProcess); m_hProcess NULL; }7. 第五步编译、运行与测试编译项目按F7或点击Build-Build D2MoneyEditor.exe。确保没有编译错误。运行游戏启动《暗黑破坏神2》进入一个有角色的游戏。以管理员身份运行你的程序由于需要PROCESS_ALL_ACCESS权限最好右键点击编译生成的D2MoneyEditor.exe选择“以管理员身份运行”。测试功能点击“读取金钱”按钮。状态栏应显示成功附加并且“当前金钱”编辑框会显示游戏中的金钱数。在“目标金钱”编辑框中输入一个新数值例如 999999。点击“修改金钱”按钮。状态栏应显示修改成功并且“当前金钱”编辑框会更新为新数值。切换回游戏查看角色金钱是否已改变。8. 常见问题与排查思路问题现象可能原因排查方式解决方案点击“读取”无反应状态栏报错“未找到进程”1. 游戏未运行。2. 游戏进程名不是Game.exe例如是Diablo II.exe。3. VC6 程序权限不足。1. 确认游戏已进入有角色的游戏画面。2. 打开任务管理器查看游戏进程的准确名称。3. 检查 VC6 程序是否以管理员身份运行。1. 运行游戏。2. 修改FindDiabloProcess函数中的进程名判断逻辑。3. 右键以管理员身份运行程序。“读取”成功但显示的金钱为0或错误1. 基址 (BASE_OFFSET) 或偏移 (MONEY_OFFSET) 不正确。2. 游戏版本与偏移不匹配。3. 指针链不止两级。1. 使用 Cheat Engine 重新精确查找地址和偏移。2. 确认游戏版本如 1.14d。3. 在 CE 中手动添加指针观察指针链层级。1. 用 CE 的指针扫描功能找到准确的基址和偏移更新代码中的常量。2. 寻找对应游戏版本的可靠偏移数据。3. 修改代码实现多级指针读取例如[[[Base偏移1]偏移2]偏移3]。“修改”按钮点击后游戏内金钱未变化1. 写入地址错误同读取错误。2. 游戏有内存保护如代码校验。3. 写入的数据类型或大小不对。1. 在 CE 中直接修改该地址看游戏内是否生效。2. 尝试修改其他简单数值如生命值测试。3. 检查WriteProcessMemory的返回值。1. 修正地址。2. 单机游戏通常无强保护。如果无效可能是地址失效或需要先“锁定”数值在 CE 中可测试。3. 确保写入的是 4 字节的DWORD。程序编译时提示Psapi.lib链接错误VC6 默认未链接Psapi.lib库。查看编译输出窗口的错误信息。确保已在代码开头添加#pragma comment(lib, Psapi.lib)。或者去Project-Settings-Link选项卡中添加该库。程序运行时崩溃1. 空指针访问。2. 进程句柄无效时进行操作。3. 地址访问违规。1. 在关键函数调用后检查返回值。2. 使用调试器F5运行查看崩溃点。1. 在调用ReadProcessMemory/WriteProcessMemory前确保m_hProcess有效且不为NULL。2. 添加更多错误判断和日志输出。9. 最佳实践与深入探索恭喜你一个功能完整的游戏内存修改器已经诞生了但这仅仅是开始。基于这个项目你可以进行更多有价值的探索模块化与健壮性将进程查找、内存读写等函数封装成独立的类如CProcessMemory提高代码复用性。添加更完善的错误处理记录日志文件便于排查复杂问题。使用VirtualQueryEx检查内存页属性避免访问不可读写的区域导致程序崩溃。支持多级指针与偏移数组现实中很多游戏数据的地址链可能很长如[[[Base0x10]0x20]0x30]。你可以设计一个函数传入一个偏移数组{0x10, 0x20, 0x30}和基址自动逐级计算最终地址。特征码扫描AOB Scan基址偏移法在游戏更新后容易失效。更高级的方法是扫描内存中的一段特定字节序列特征码来定位动态地址。这需要学习汇编和模式匹配算法。扩展到修改其他属性用同样的方法你可以修改角色的生命、魔法、经验、技能点、物品属性等。只需要用 CE 找到对应的地址和偏移即可。理解现代反作弊Anti-Cheat如今的在线游戏如 Battle.net 上的暗黑2重制版拥有强大的反作弊系统如 Warden直接读写内存会被立刻检测并封号。学习本项目的意义在于理解原理切勿将其用于任何破坏在线游戏公平性或违反用户协议的行为。单机游戏是学习和测试的合法沙盒。应用于合法领域这些技术同样可用于软件自动化测试模拟用户操作、数据分析读取其他进程的日志信息、或者开发调试辅助工具。关键在于用途的合法性与授权。通过这个从 VC6 和《暗黑破坏神2》开始的旅程你亲手实践了进程间通信、内存管理、指针运算等底层编程概念。这些知识是理解操作系统、安全软件、逆向工程乃至游戏引擎的宝贵基石。希望这份“怀旧”的代码能成为你探索更广阔技术世界的一块坚实垫脚石。建议收藏本文当你未来遇到更复杂的内存分析需求时回头看看这个简单的起点或许能带来新的启发。