菜鸡的单机游戏找自信之旅。

准备工作

工具

本文基于MacOS 10.14.6开发,亲测在10.15也可以正常运行。

  • **Cheat Engine for Mac**:强大的CE,用于定位内存中的关键数据,在MacOS下需要关闭SIP使用。
  • Xcode:用于写代码的IDE。
  • Slay the Spire(杀戮尖塔)好评如潮(97%)的卡牌+rougelike游戏,steam售价史低¥50。
  • gdb:Unix系统下动态调试利器,可通过brea install gdb一键安装,但还需要额外的签名步骤。我还装了插件peda
  • vmmap:获取进程的内存信息,由于MacOS没有proc目录,就只能用这么一个工具。

逆向之旅

要实现的功能是无限,就是下图中箭头标识的数字,然后就可以任性出牌了。

定位关键地址

要对游戏中的数据进行修改,当然首先要知道数据在哪,包括数据数据,以及代码数据,所以有两个主要步骤:

  1. 定位数据在内存中的位置;
  2. 通过数据查找到代码中对费数据进行操作的代码,进行修改实现无限费。

使用CE获取游戏的内存:

通过变化游戏中费的数值,对费的内存进行追踪,最终找到该数据在内存中的地址(关于该步骤的具体操作可以参考一些CE的基础教程):

对该内存赋予一个自定义数值,观察游戏中的变化,可以最终确定这个内存就是我们所需要的:


右键该地址并选择Find out what writes to this address可以追踪修改了该数据的代码。然后在游戏中(多次)出牌造成费的改变,从而捕获到一个代码对该数据进行了多次修改:

已知代码在内存中的地位=代码段基址+代码偏移值,其中代码偏移值是固定的,至少游戏不更新的话是不会改变,而代码段基址在每次程序运行的时候都不一样。例如上图中目标代码的地址是0x112CF8B48,使用vmmap可以定位到该代码所在段的信息。其中55505是程序进程的pid,通过ps命令获取:

1
2
3
4
# 获取游戏进程的pid
$ ps aux | grep java
# 获取目标代码所在段信息,猜测该段的地址应该包含“112”
$ vmmap 55505 | grep -i 112


最终得到在本次运行中,该代码段基址为0x112CD5000,做个减法得到代码偏移值是0x23B48。另外记住这个段的名称是VM_ALLOCATE,游戏是基于JAVA开发并运行在JVM上的,该段的长度是2496K,这是可以用于后面自动化定位代码使用的信息。

修改代码

在修改代码之前得先查看一下具体代码的实现,这里就用到了gdb。打开gdb并附载游戏进程,这时候游戏进程被挂起,被暂停了:

1
2
$ gdb
gdb> attach 55505

已知关键代码的地址是0x112CF8B48,可以扩大范围查看一下上下文(20条)的指令:

1
gdb> x/20i 0x112CF8B00


可以看到修改费数据的代码的简单逻辑如下(从地址b41开始):

  1. 从栈中取32bit数据放到eax;
  2. 减小栈(结合上一指令,相当于pop了一个数据);
  3. 把eax的数据放到[rcx+rbx*1],也就是当前费数据所在位置。

为了避免破坏栈平衡,第二条指令最好不要变化,所以可以修改的方式就有两种:

  1. 把自定的立即数放到eax中;
  2. 把自定的立即数放到[rcx+rbx*1]中。

由于x64指令集的指令不等长,而使用立即数的话会增加指令长度,因此此处选择方案1,即对地址b41处指令修改,将立即数赋予eax,拟修改后的指令为:

1
2
3
4
b41: mov al, 0xa
b43: nop
b44: add rsp, 0x8
b48: mov [rcx+rbx*1], eax

由于指令mov al, 0xa长度为2字节,而原有指令mov eax, [rsp]为3字节,因此需要使用一个nop指令进行填充。预期的效果就是费恒为0xa即10。

编写修改器

关键API说明

获取目标进程上下文

1
2
3
kern_return_t task_for_pid(mach_port_name_t target_tport, 
int pid,
mach_port_name_t *t)

通过目标进程的pid,获取目标进程的上下文(task),从而后面可以根据task对目标进程内存进行操控。

  • target_tport:一般传入mach_task_self()即修改器自身的task;
  • pid:传入目标进程的pid;
  • *t:用于接收返回的task;
  • 返回值:函数调用的结果。

从目标进程内存读取数据

1
2
3
4
5
kern_return_t mach_vm_read(vm_map_t target_task, 
mach_vm_address_t address,
mach_vm_size_t size,
vm_offset_t *data,
mach_msg_type_number_t *dataCnt)

通过目标进程的task,读取目标进程内存中指定地址的数据。

  • target_task:获取的目标进程task;
  • address:要读取目标进程内存的地址;
  • size:要读取的内存长度,单位是byte;
  • *data:用于接收读取的内存的缓冲区指针;
  • *dataCnt:用于接收读取的长度;
  • 返回值:函数调用的结果。

向目标进程内存写入数据

1
2
3
4
kern_return_t mach_vm_write(vm_map_t target_task,
mach_vm_address_t address,
vm_offset_t data,
mach_msg_type_number_t dataCnt)

通过目标进程的task,向目标进程内存中写入数据。

  • target_task:获取的目标进程task;
  • address:要写入目标进程内存的地址;
  • data:要写入目标进程的数据;
  • dataCnt:要写入的数据长度:
  • 返回值:函数调用结果。

查询内存段信息

1
2
3
4
5
6
7
kern_return_t mach_vm_region(vm_map_t target_task,
mach_vm_address_t *address,
mach_vm_size_t *size,
vm_region_flavor_t flavor,
vm_region_info_t info,
mach_msg_type_number_t *infoCnt,
mach_port_t *object_name)

给定task和内存地址,获取该地址所在内存段的信息,用于遍历进程的内存空间。
参数就懒得解释了,字面意思。

功能实现

要实验修改游戏逻辑总共就两步:

  1. 获取游戏进程pid;
  2. 寻找目标代码所在内存段的基址,并结合偏移计算得到目标地址;
  3. 使用task_for_pid获取目标进程句柄;
  4. 使用mach_vm_write把指令mov al, 0xa写入并覆盖原有的指令mov eax, [rsp]

获取pid

获取游戏pid可以通过ps命令手动查找,也可以通过代码遍历当前运行的进程,从而找到游戏的pid,实现自动化。后者方法可以参考这篇博客

寻找段基址

根据上诉vmmap的分析已经知道目标代码所在段的长度是2496K,所以可以以此为依据在内存中定位到目标段,方法是从内存地址0开始一个段一个段地找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
mach_vm_address_t findBaseAddress(task_t task) {
vm_region_basic_info_data_64_t info;
mach_vm_size_t size;
// 初始从地址0开始查找
mach_vm_address_t targetAddress = 0;
mach_msg_type_name_t count;
mach_port_t objectName;
kern_return_t kr;

while (TRUE) {
kr = mach_vm_region(task, &targetAddress, &size, VM_REGION_BASIC_INFO, (vm_region_info_t)&info, &count, &objectName);
if (kr != KERN_SUCCESS) {
break;
}

if (size == 2555904 && info.offset == 0) {
printf("找到2496K: %llx\n", targetAddress);
break;
}

// 查询下一个段
targetAddress += size;
}

return targetAddress;
}

查找到段基址之后又知道要覆盖的原指令mov eax, [rsp]所在段内偏移地址为0x23B48,所以通过targetAddress + 0x23B48可以得到最终要写入数据的内存地址。

获取task

使用task_for_pid的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 通过pid获取task
task_t getProcessTask(pid_t pid) {
kern_return_t kr;
mach_port_t task;
kr = task_for_pid(mach_task_self(), pid, &task);
if (kr != KERN_SUCCESS) {
printf("获取task错误:%s\n",mach_error_string(kr));
}
printf("获取task成功:%d\n", task);

return task;
}

写入内存,替换指令

万事具备,就剩内存替换了,搬上mach_vm_write

1
2
3
4
5
6
7
8
9
10
11
12
void writeProcess(task_t task, mach_vm_address_t address) {
kern_return_t kr;
// 指令"mov al, 10; nop;"的字节码
char data[] = {0xB0, 0x0A, 0x90};
int size = 3;
kr = mach_vm_write(task, (vm_address_t)address, (vm_offset_t)data, size);
if (kr == KERN_SUCCESS) {
printf("成功写入\n");
} else {
printf("写入错误: %s\n", mach_error_string(kr));
}
}

其他工作

最后把上诉功能封装,再写个while(TRUE)循环打印菜单,就比较有一个修改器的样子了:

在运行完代码,把指令替换完后可以再用gdb查看一下内存中的情况,可以看到本来从栈中读取数据到eax的代码已经被修改为——直接把立即数10放到eax中。这样不管怎么出牌,费都是10,即使没有牌了也是剩下10 费【叉腰】:

后记

在MacOS上开发修改器确实比较蛋疼,比如说用CE的话还要去关闭SIP。
谢谢您嘞蛋疼的MacOS安全机制,好歹写个命令行程序可以使用sudo运行,暂时还没搞定怎么写个App来实现,因为App的权限机制就更复杂了。
其次感谢蛋疼的Apple文档,下面是Apple文档中对上诉一些关键函数的介绍:

溜了溜了。


Site by 喂草。
using hexo blog framework
with theme Noone.
蜀ICP备19016566号.