书接上回

依旧懒得贴图下面是完整版

Nobody(大多数)游戏修改学习笔记

(感觉继续写在上一帖的话太长了,索性就重新开一贴)

我们先处理上一帖留下的问题,衣物整洁度我目前无法实现编辑的功能,只能hook实现无限数值了,代码会在后面按照顺序讲解

我个人喜欢以调试的视角去解析代码,所以我这里会按照执行顺序一步一步解读

首先是主函数

int main()
{
	//读取目标游戏进程ID
	DWORD game_PID = GetProcessIDByName(L"Nobody.exe");
	if (game_PID == 0)
	{
		printf("未找到目标游戏进程\n");
		return 1;
	}

	HANDLE game_handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, game_PID);
	
    ////游戏模块基址                                 
	LPVOID base_address = GetModuleBaseAddress(game_PID, L"GameAssembly.dll");

	ChoiceMenu(game_handle, game_PID, base_address);
}

就是获取游戏的模块基址,怎么获取的呢

DWORD GetProcessIDByName(const wchar_t* process_gamename)
{
	//创建系统快照
	HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
	if(snapshot == INVALID_HANDLE_VALUE)
	{
		return 0;
	}
	//初始化进程结构体
	PROCESSENTRY32 process_entry = {0};
	process_entry.dwSize = sizeof(PROCESSENTRY32);
	//获取第一个进程,这里意味着告诉系统“从这个快照的开头开始遍历”
	if(!Process32First(snapshot, &process_entry))
		{
		CloseHandle(snapshot);
		return 0;
	}
	//遍历所有进程
	do
	{
		if(wcscmp(process_entry.szExeFile, process_gamename) == 0)
		{
			CloseHandle(snapshot);
			return process_entry.th32ProcessID;
		}
	} while (Process32Next(snapshot, &process_entry));

	CloseHandle(snapshot);
	return 0;

}

这是根据给定的进程名查找并返回该进程的 ID(PID),这算是一种模板了,还有其它也可以实现,感兴趣的可以自行去了解

游戏进程我们拿到了,先前也说过了,游戏的运算逻辑绝大部分是在DLL里实现的,因此我们需要获取dll的模块基址,并非Nobody.exe的基址

LPVOID GetModuleBaseAddress(DWORD processID, const wchar_t* moduleName)
{
	LPVOID lpBaseAddress = nullptr;
	//打开目标进程
	HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processID);
	if(hProcess != NULL)
	{
		HMODULE hmod[MAX_PATH];
		DWORD cbNeeded;
		//枚举进程内的模块
		if (EnumProcessModules(hProcess, hmod, sizeof(hmod), &cbNeeded))
		{
			//计算模块数量
			DWORD moduleCount = cbNeeded / sizeof(HMODULE);
			for (DWORD i = 0; i < moduleCount; i++)
			{
				wchar_t szModuleName[MAX_PATH];
				//遍历并匹配名称
				if (GetModuleBaseName(hProcess, hmod[i], szModuleName, sizeof(szModuleName) / sizeof(wchar_t)))
				{
					if (wcscmp(szModuleName, moduleName) == 0)
					{
						lpBaseAddress = hmod[i];
						break;
					}
				}
			}
		}
		CloseHandle(hProcess);
	}
	
	return lpBaseAddress;
}

这是获取模块基址的方法,也算是一种模板了,与上面的函数搭配起来就是查询基址的双子星,嘿嘿,当然也有其它方法,可以自行了解

然后进入我们的选择菜单

void ChoiceMenu(HANDLE game_handle, DWORD game_PID, LPVOID base_address)
{
	DWORD choice;

	printf("*********************************\n");
	printf("*\t欢迎游玩大多数游戏\t*\n");

	printf("*********************************\n");

	printf("* 模块基址: 0x%" PRIxPTR "\t*\n", base_address);
	
	printf("* 请选择要修改的数值类型:\t*\n");
	printf("* 0.退出修改\t\t\t*\n");
	printf("* 1.金钱\t\t\t*\n");
	printf("* 2.饱腹度\t\t\t*\n");
	printf("* 3.情绪值\t\t\t*\n");
	printf("* 4.心态\t\t\t*\n");
	printf("* 5.衣物整洁度\t\t\t*\n");
	printf("* 6.技能点\t\t\t*\n");
    
    //可后续添加新的属性修改

	printf("*********************************\n");

	do
	{
		printf("请选择要修改的数值:\n");
		scanf_s("%u", &choice);

		switch (choice)
		{
			case 1:
				{
					FLOAT num;
					printf("请输入金钱数值: ");
					scanf_s("%f", &num);
					SetGameValue(game_handle, base_address, Money, num);
					printf("金钱数值已修改为: %.2f\n", num);
					break;
				}
			case 2:
				{
					FLOAT num;
					printf("请输入饱腹度数值: ");
					scanf_s("%f", &num);
					SetGameValue(game_handle, base_address, Food, num);
					printf("饱腹度数值已修改为: %f\n", num);
					break;
				}
			case 3:
				{
					FLOAT num;
					printf("请输入情绪值数值: ");
					scanf_s("%f", &num);
					SetGameValue(game_handle, base_address, emotion, num);
					printf("情绪值数值已修改为: %f\n", num);
					break;
				}
			case 4:
				{
					FLOAT num;
					printf("请输入心态数值: ");
					scanf_s("%f", &num);
					SetGameValue(game_handle, base_address, Mindset, num);
					printf("心态数值已修改为: %f\n", num);
					break;
				}
			case 5:
				{
					CHAR tmp;
					static LPVOID hook_addr;
					printf("请选择 a.无限整洁度 or b.取消无限整洁度\n");
					scanf_s(" %c", &tmp, 1);
					if (tmp == 'a')
					{
						hook_addr = HookGetClothClean(game_handle, base_address);
						if (hook_addr != NULL)
						{
							printf("已成功安装衣物整洁度无限的Hook\n");
						}
						else
						{
							printf("安装衣物整洁度无限的Hook失败\n");
						}
						
					}
					else if (tmp == 'b')
					{
						if (UnHook(game_handle, hook_addr, Clothingclean))
						{
							printf("已成功取消衣物整洁度无限的Hook\n");
						}
						else
						{
							printf("取消衣物整洁度无限的Hook失败\n");
						}
						
					}
					else
					{
						printf("无效的选择\n");
					}
					
					break;
				}
			case 6:
				{
					UINT num;
					CHAR tmp;
					static LPVOID hook_addr;
					printf("请选择 a.编辑技能点 or b.无限技能点 or c.取消无限技能点\n");
					scanf_s(" %c", &tmp,1);
					if (tmp == 'a')
					{
						printf("请输入技能点数值: ");
						scanf_s("%u", &num);
						SetGameValue(game_handle, base_address, SkillPoint, num);
						printf("技能点数值已修改为: %u\n", num);
						break;
					}
					else if (tmp == 'b')
					{
						hook_addr = HookGetSkillPiont(game_handle, base_address);
						if (hook_addr != NULL)
						{
							printf("已成功安装技能点无限的Hook\n");
						}
						else
						{
							printf("安装技能点无限的Hook失败\n");
						}
						break;
					}
					else if (tmp == 'c')
					{
						if (UnHook(game_handle, hook_addr, SkillPoint))
						{
							printf("已成功取消技能点无限的Hook\n");
						}
						else
						{
							printf("取消技能点无限的Hook失败\n");
						}
						break;
					}
					else
					{
						printf("无效的选择\n");
						break;
					}
				}
          //可后续添加新的属性修改
		}
	} while (choice);
	
}

这里感觉没什么说的,主要是选择菜单的搭建以及修改操作的逻辑判断与执行

这里先介绍一下常量和一些定义

typedef enum Type{
	Money, Food, emotion, Mindset, Clothingclean, SkillPoint, TypeCount
}ValueType;

typedef struct {
	DWORD* offset;
	size_t offsetsize;
	size_t valuesize;
	const char* name;
} ValueInfo;

//各属性偏移
static DWORD money_offset[] = { 0xB8, 0, 0xB0, 0x28, 0x18 };
static DWORD food_offset[] = { 0xB8, 0, 0xB0, 0x28, 0x20 };
static DWORD emotion_offset[] = { 0xB8, 0, 0xB0, 0x30, 0x10 };
static DWORD Mindset_offset[] = { 0xB8, 0, 0xB0, 0x28, 0x40 };
static DWORD Clothingclean_offset[] = { 0xB8, 0, 0xB0, 0x28, 0x4C };
static DWORD skillpoint_offset[] = { 0xB8, 0, 0xB0, 0x28, 0xB0};

//技能点数值地址偏移和Hook代码
static UINT64 get_skillpoint_offset = 0x377CD0;
static DWORD Original_get_skillpointCode[6] = { 0x40, 0x53, 0x48, 0x83, 0xEC, 0x40 };
static DWORD Hook_get_skillpointCode[6] = { 0xB8, 0, 0, 0, 0, 0xC3 };


static ValueInfo value_infos[TypeCount] = {
	{ money_offset, sizeof(money_offset) / sizeof(DWORD), sizeof(int), "Money" },
	{ food_offset, sizeof(food_offset) / sizeof(DWORD), sizeof(int), "Food" },
	{ emotion_offset, sizeof(emotion_offset) / sizeof(DWORD), sizeof(int), "Emotion" },
	{ Mindset_offset, sizeof(Mindset_offset) / sizeof(DWORD), sizeof(int), "Mindset" },
	{ Clothingclean_offset, sizeof(Clothingclean_offset) / sizeof(DWORD), sizeof(int), "Clothingclean" },
	{ skillpoint_offset, sizeof(skillpoint_offset) / sizeof(DWORD), sizeof(int), "SkillPoint" }
};

这里的通用属性,我的想法是写个结构体来存储,方便后续的通用函数的传参,不用特定的去为不特别属性重新定义

这里感觉通过名称很容易辨识出来,就不多说

然后就是SetGameValue函数,顾名思义–设置游戏数值

template<typename T>
void SetGameValue(HANDLE handle, LPVOID baseaddr, UINT Type, T EditValue)
{
	LPVOID value_address = (LPVOID)((uintptr_t)baseaddr + 0x29b9d78); //示例偏移地址
	switch (Type)
	{
	case Money:
		ReadProcessMemory(handle, value_address, &value_address , sizeof(value_address), NULL);
		value_address = (LPVOID)GetValueAddress(handle, value_address, Money);
		printf("金钱数值地址: 0x%" PRIxPTR "\n", value_address);
		WriteProcessMemory(handle, value_address, &EditValue, sizeof(EditValue), NULL);
		break;
	case Food:
		ReadProcessMemory(handle, value_address, &value_address, sizeof(value_address), NULL);
		value_address = (LPVOID)GetValueAddress(handle, value_address, Food);
		printf("饱腹度数值地址: 0x%" PRIxPTR "\n", value_address);
		WriteProcessMemory(handle, value_address, &EditValue, sizeof(EditValue), NULL);
		break;
	case emotion:
		ReadProcessMemory(handle, value_address, &value_address, sizeof(value_address), NULL);
		value_address = (LPVOID)GetValueAddress(handle, value_address, emotion);
		printf("情绪值数值地址: 0x%" PRIxPTR "\n", value_address);
		WriteProcessMemory(handle, value_address, &EditValue, sizeof(EditValue), NULL);
		break;
	case Mindset:
		ReadProcessMemory(handle, value_address, &value_address, sizeof(value_address), NULL);
		value_address = (LPVOID)GetValueAddress(handle, value_address, Mindset);
		printf("心态数值地址: 0x%" PRIxPTR "\n", value_address);
		WriteProcessMemory(handle, value_address, &EditValue, sizeof(EditValue), NULL);
		break;
	case SkillPoint:
		ReadProcessMemory(handle, value_address, &value_address, sizeof(value_address), NULL);
		value_address = (LPVOID)GetValueAddress(handle, value_address, SkillPoint);
		printf("技能点数值地址: 0x%" PRIxPTR "\n", value_address);
		EditSkillPoint(handle, value_address, (UINT)EditValue);
		break;
	}
}

这里通过枚举,来定向选择想要修改的数值,方法就是通过模块基址,依据提前写好的偏移,一步一步正确的地址,然后通过API去修改该地址的数据,从而达到修改游戏数据的操作

函数的参数我用了模板,因为有不同类型的参数会传进来,后续的属性也不一定是同类型,所以选择用模板更通用

注意:偏移并不是单纯的相加,而是一个指针链(GPT这么说的),通俗点讲就是俄罗斯套娃,一个地址套一个,所以应该是该地址指向的值去加上偏移,然后指向数据的直接地址,还不懂的话,那就看代码

uintptr_t GetValueAddress(HANDLE hprocess, LPVOID baseaddr, ValueType type)
 {
	if (type > TypeCount) return 0;
	ValueInfo* desc = &value_infos[type];
	return DerefPointerChain(hprocess, baseaddr, desc->offset, desc->offsetsize);
}

uintptr_t DerefPointerChain(HANDLE hprocess, LPVOID baseaddr, DWORD offset[], size_t offsetCount)
{
	uintptr_t currentAddress = (uintptr_t)baseaddr;
	for (size_t i = 0; i < offsetCount; i++)
	{
		currentAddress += offset[i];

		if (i == offsetCount - 1)
		{
			return currentAddress;
		}

		if (!ReadProcessMemory(hprocess, (LPCVOID)currentAddress, &currentAddress, sizeof(currentAddress), NULL))
		{
			return 0;
		}
	}
	return 0;
}

GetValueAddress函数算是个过渡函数,整理下一个函数需要的参数,主要是DerefPointerChain函数需要的参数有点多

从代码来看其实也没有多难,无非就是循环一下拼指针链而已

接下来就是编辑技能点了(吐个槽:我就纳闷了,一个单机游戏为啥数值还要加密,真就不明白了)

这个技能点呢我刚开始改的时候是挺迷茫的,无从下手,但是看到风大大的修改器却可以做到,我就纳闷了,我又回到CE,认真研究起来看到玩家属性里有个_talenPoints

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

看到这个类型是MemoryCheck.MemoryInt,感觉这里面藏着加密的算法,然后就去看看,在搜索栏里输入,选择MemoryCheck.MemoryInt,可以看到

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在询问了GPT后,他告诉我GetDecrypted里或许有我想找的算法,

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这两个代码让我警觉起来,原因是在之前的尝试中_talenPoints的值也是高位与低位相异或,所以我询问GPT,他也说这两行代码比较可疑,于是这个rcx的值怎么确定呢

我尝试下断点,结果没有停下来,我突然想起_talenPoints的地址就是rcx吗,不然这个怎么高低位去异或呢,想到着我就去验证,在CE中去改变值,然后通过异或来得到想要的值,发现真的可以,哈哈,总算被我逮到了

接下来就是代码的演示了

void EditSkillPoint(HANDLE hprocess, LPVOID SkillPointAddr, UINT EditNum)
{
	UINT64 OriginalValue = 0;
	DWORD OriHighValue;
	DWORD OriLowValue;


	ReadProcessMemory(hprocess, (LPCVOID)SkillPointAddr, &OriginalValue, sizeof(OriginalValue), NULL);
	if(OriginalValue == 0)
	{
		printf("未找到技能点数值地址\n");
		return;
	}

	OriHighValue = (DWORD)(OriginalValue >> 32);
	OriLowValue = (DWORD)(OriginalValue & 0xFFFFFFFF);
	//构造自己想要的数值
	OriLowValue = OriHighValue ^ EditNum;
	OriginalValue = ((UINT64)OriHighValue << 32) | OriLowValue;
	
	//修改内存保护以允许写入
	DWORD oldProtect;
	VirtualProtectEx(hprocess, (LPVOID)SkillPointAddr, sizeof(OriginalValue), PAGE_EXECUTE_READWRITE, &oldProtect);
	//写入修改的值
	WriteProcessMemory(hprocess, (LPVOID)SkillPointAddr, &OriginalValue, sizeof(OriginalValue), NULL);
	//恢复原来的内存保护
	VirtualProtectEx(hprocess, (LPVOID)SkillPointAddr, sizeof(OriginalValue), oldProtect, &oldProtect);
}

他的偏移可以在那界面看到,是0xB0

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

后面就是构造自己想要的值,然后通过异或来编辑成自己修改的数值

然后是两个hook

一个技能点一个衣物整洁度,我感觉实现的代码都大差不差,主要是hook那个地址,这里我就简单说一下

技能点的hook的是

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个直接修改获取的结果,实现无限的功能

整洁度的首先把第一层的改写地址找到,应该是get_Clean函数,然后进入到汇编界面,一直拉到函数最后,发现有个函数

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在跟踪到这个函数,这个函数就是管理衣物整洁的函数,就可以hook了

注意:这个hook的是当前装备的衣服,不包含放在背包的衣服

这里贴上代码

//hook衣物整洁度
LPVOID HookGetClothClean(HANDLE hprocess, LPVOID base_address)
{
	static UINT64 getclothclean_offset = 0x382250;
	LPVOID hook_addr = (LPVOID)((uintptr_t)base_address + getclothclean_offset);
	BYTE hook_code[] = { 
		0xFF, 0x31,								// push [rcx]
		0xC7, 0x01, 0x00, 0x00, 0xC8, 0x42,		// mov dword ptr [rcx], 0x42C80000 (100.0f的IEEE 754表示) + ret
		0xF3, 0x0F, 0x10, 0x01,					// movss xmm0, [rcx]
		0x8F, 0x01,								// pop [rcx]
		0xC3									// ret
	};
	BYTE original_code[sizeof(hook_code)] = {0};

	if (!ReadProcessMemory(hprocess, hook_addr, original_code, sizeof(original_code), NULL))
	{
		return 0;
	}
	if (!WriteProcessMemory(hprocess, hook_addr, hook_code, sizeof(hook_code), NULL))
	{
		printf("写入Hook代码失败\n");
		return NULL;
	}
	
	return hook_addr;
}


//hook技能点
LPVOID HookGetSkillPiont(HANDLE hprocess, LPVOID base_address)
{
	static UINT64 get_skillpoint_offset = 0x377CD0;
	LPVOID hook_addr = (LPVOID)((uintptr_t)base_address + get_skillpoint_offset);
	BYTE original_code[6] = {0}, hook_code[6] = {0xB8, 0x37, 0x25, 0x00, 0x00, 0xC3};
	if(!ReadProcessMemory(hprocess, hook_addr, original_code, sizeof(original_code), NULL))
	{
		return 0;
	}
	if (!WriteProcessMemory(hprocess, hook_addr, hook_code, sizeof(hook_code), NULL))
	{
		printf("写入Hook代码失败\n");
		return NULL;
	}
	return hook_addr;
}

//取消hook
BOOL UnHook(HANDLE hprocess, LPVOID hook_addr, UINT Type)
{
	if (Type == SkillPoint)
	{
		BYTE original_code[6] = { 0x40, 0x53, 0x48, 0x83, 0xEC, 0x40 };
		if (!WriteProcessMemory(hprocess, hook_addr, original_code, sizeof(original_code), NULL))
		{
			printf("恢复原始代码失败\n");
			return FALSE;
		}
		return TRUE;
	}
	else if(Type == Clothingclean)
	{
		BYTE original_code[] = 
		{
			0x48,0x89,0x5C,0x24,0x08,
			0x48,0x89,0x74,0x24,0x10,
			0x57,0x48,0x83,0xEC,0x40
		};
		if (!WriteProcessMemory(hprocess, hook_addr, original_code, sizeof(original_code), NULL))
		{
			printf("恢复原始代码失败\n");
			return FALSE;
		}
		return TRUE;
	}
	else
	{
		printf("无效的数值类型\n");
		return FALSE;
	}
	
}

下面的就是取消钩子,没什么说的了,硬编码就是在改之前提前存储的

PS:这里的地址我也是按照偏移来弄得,但是我实验了好几次,发现偏移不会变,因此就直接写死偏移了,想要更稳妥一点看可以尝试AOB扫描。

至此,现阶段的修改器就介绍完毕,后续有新添加的属性我也会继续写在这篇帖子之后

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐