2.进程的地址空间

操作系统、应用程序都是状态机。计算机从得到 reset 信号开始,从硬件初始化到最后启动了 init 进程。这在概念上可以接受,但是实际理解还不是特别清楚的一件事情。

Linux 从一个初始的程序 (状态机) 开始,构建出了整个应用程序世界——通过 fork, execve, exit,我们可以在操作系统中创建出很多并发/并行执行的程序。

在状态机模型中,进程的状态由 (M,R)(M, R) 两部分组成;其中 R(register) 是由体系结构决定的,看手册就行,gdb 中 info registers 即可查看相对比较简单。比较有趣的是 M(memory) 还有一些模糊的东西。进程 “平坦” 的地址空间 4GB 里到底有什么,以及我们是否可以 “入侵” 另一个进程的地址空间?

这部分的内容

  • 进程的地址空间

  • mmap 系统调用

  • 三类游戏外挂的实现原理

    • 金山游侠:内存修改

    • 按键精灵:GUI 事件发送

    • 变速齿轮:代码注入

1. linux 进程的地址空间

两个很基本 (但也很困难) 的问题

  • 1 以下程序的 (可能) 输出是什么?

printf("%p\n", main);

含义是输出 main 的地址。

  • 2 任何一个数字都可以强制转换为指针,然后可以对指针操作,解引用、复制。何种指针访问不会引发 segmentation fault?

char *p = random();
*p = 0; // 什么时候访问合法? 

这种小问题问 chatgpt 就好了。gpt 会给我们很多知识,尤其对于概念解释方面的,甚至超越了百度知道里的许多随意回答的人。

gpt 会有一些关键词如地址空间,我们可能会想到,有没有一种工具能看到地址空间,继续追问,会得到一些答案,其中有 pmap,我们继续追问也好,去查 man 也好。

我们用 minimal 看看其地址空间,可以在 gdb 里调试,停下来获得 pid,然后 pmap [pid]

这个工具至少能让我们看到,内存块是一段一段连续的,而且是带各种权限的。所以地址空间就是带权限的连续的内存段。这也就解释了前面的问题,main 在可读段,段错误就是做出了权限允许之外的事情。

真实的进程地址空间

除了这个指令,我们还可以 cat /proc/[pid]/maps ,我们能看到更多的信息,其中有一些和 pmap 是一致的。

直截了当的一个信息:pmap 这个命令是通过读取 maps 这个文件实现的。

  • Claim: pmap 是通过访问 procfs (/proc/) 实现的

  • 如何验证这一点?

一些信念:计算机系统没有什么东西是搞不定的,总能搞的,先搞一个简单的,原理上一样,复杂的就是在简单的上再加点东西。

pstree 实现也是这么做的,如果自己实现 pmap 也不是不可能做到。

如何证明?strace 一下就好了。要记得这些验证我们想法的工具。

所有的东西都是通过系统调用实现的,proc 是对象,/proc/[pid]/maps 也是对象,操作系统为进程提供了 api,对这些对象进行操作,这就是对操作系统的理解。

如果对自己的要求高一点,可以去看 procfs 的手册,可以看到每个 field 的描述,我们更有信心去自己实现 pmap,依照文档里的东西。当我们看到一个程序的 pmap,会根据权限有个自己的判断,如何证明我们的判断呢?可以在程序里打印地址,也可以定义比较大的数组查看段大小变化。

#define MB * 1048576
char mem[64 MB];

这是个很好玩的宏定义。程序跑起来确实也可以看到段大小的变化。

前面都是静态链接程序的地址空间。

如果是动态链接,整个地址空间会变得复杂一些。

一个问题,动态链接执行的第一条指令在哪里呢?当我们谈状态机的时候,静态链接,状态机 reset 程序就被加载进来,地址已经固定了。

而动态链接,运行的瞬间,甚至不知道系统里的 libc 是哪个,甚至地址空间里都没有 libc。也即进程里没有 printf 这个东西,

静态链接的程序 elf 头标记了入口,动态链接的程序有个 interpreter,借助这个东西才能启动另一个东西,所以这是个程序加载器,loader。这个加载器会解析这个程序依赖的文件,然后加载动态库,后面才会把 libc 加载进去,这时候地址空间会发生很大的变化。当然了,这时候加载器还在,后面还可以加载其他东西。

这解释了为什么 strace 看 a.out 的 system call ,静态链接和动态链接差距很大。动态链接有些看不懂的操作,这些操作把我们需要的库函数搬到地址空间里,这就是加载的过程,动态链接库后面会讲,用一个更好玩的方式。

0000555555554000 r--p     a.out
0000555555555000 r-xp     a.out
0000555555556000 r--p     a.out
0000555555557000 r--p     a.out
0000555555558000 rw-p     a.out
00007ffff7dc1000 r--p     libc-2.31.so
00007ffff7de3000 r-xp     libc-2.31.so
00007ffff7f5b000 r--p     libc-2.31.so
00007ffff7fa9000 r--p     libc-2.31.so
00007ffff7fad000 rw-p     libc-2.31.so
00007ffff7faf000 rw-p     (这是什么?)
00007ffff7fcb000 r--p     [vvar] (这又是什么?)
00007ffff7fce000 r-xp     [vdso] (这叒是什么?)
00007ffff7fcf000 r--p     (省略相似的 ld-2.31.so)
00007ffffffde000 rw-p     [stack]
ffffffffff600000 --xp     [vsyscall] (这叕是什么?)

vdso

在一个进程的地址空间里,绝大多数是我们可以猜测的东西,此外有两个比较奇怪的东西,[vvar] [vdso]

vvar,有权限的内存段,和地址空间的其他东西一样。帮助我们实现不进入内核的系统调用,这个一个小的发现。

现在学习东西的方式要发生变化,发现一些问题,我们就可以抛出问题,在 AI 时代,这应该成为本能。

问出问题,会收到意向不到的回答。

vdso ,无需陷入内核的系统调用。举例子,用进程感知时间。gettimeofday。显然这是个系统调用,进程的内存里没有时间这个概念。更细节的东西可以gpt。

这个机制带来的想法,是不是真的需要系统调用呢?事实上需要的是进程和系统通信的一种方式。

2. mmap 管理进程地址空间

程序启动的一瞬间,初始状态由 execve 设置的,状态为寄存器和内存段,gdb 可以看到。接下来的问题,初始好的内存段是否可以修改?

我们大概了解了进程的地址空间,即带访问权限的内存段。由此引出的下一个问题,进程的状态里都是由一个地址空间里的段和 register 构成,操作系统应该提供一个 api,可以让我们修改进程的内存映射。

在程序中 malloc 一个比较大的内存然后编译运行,用 pmap 工具查看,是可以看到一段比较大的 wr 段的,说明是可以修改的。但是程序本身只能更改寄存器的值,那么预期是有一个系统调用做了这件事的。这个系统调用就是 mmap()

操作系统提供的修改进程地址空间的系统调用

// 映射
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);
int munmap(void *addr, size_t length);

// 修改映射权限
int mprotect(void *addr, size_t length, int prot);

这几个系统调用的本质:在状态机状态上增加/删除/修改一段可访问的内存,具体的行为,看手册。

mmap 加一段,munmap 取消一段,mprotect 改权限,默认是不可执行的。从名字很容易看出 api 功能。

mmap 的参数

  • addr 地址,默认下不是严格的,是一个建议

  • length 长度

  • prot 权限

  • flags 其他标志

有意思的是 fd 和 offset,文件描述符,这里正式的遇到了这个概念。

everything is a file,所以 file descriptor 即 everything is descriptor。

文件描述符是一个指向操作系统对象的指针,操作系统里有很多对象,进程是对象、窗口是对象、文件、管道、套接字,得到的全是 fd,mmap 最有趣的地方是可以把文件“搬到”进程地址空间中。

当然也不是任何 fd 都能映射,套接字报错。如果申请内存,这个参数可以是

搬到内存后,文件内容和内存会同步。

把文件映射到进程地址空间?好像也合理

  • 文件 = 字节序列 (操作系统中的对象)

  • 内存 = 字节序列

  • 操作系统允许映射好像挺合理的……

这带来了很大的方便,一个好玩的应用,ELF loader 用 mmap 非常容易实现,解析出要加载哪部分到内存,直接 mmap 就完了我们的 loader 的确是这么做的 (strace)

要理解 mmap 的使用,看两个例子就明白了。

理解 mmap 例子:申请大量的内存空间

#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>

#define GiB * (1024LL * 1024 * 1024)

int main() {
  volatile uint8_t *p = mmap(
    NULL,                       // 建议分配的位置
    32 GiB,                      
    PROT_READ | PROT_WRITE,     // 权限
    MAP_ANONYMOUS | MAP_PRIVATE, 
    -1, 0
    );

  printf("mmap: %lx\n", (uintptr_t)p);

  if ((intptr_t)p == -1) {
    perror("cannot map");
    exit(1);
  }

  *(p + 2 GiB) = 1;
  *(p + 4 GiB) = 2;
  *(p + 31 GiB) = 3;
  printf("Read get: %d\n", *(p + 2 GiB));
  printf("Read get: %d\n", *(p + 4 GiB));
  printf("Read get: %d\n", *(p + 31 GiB));
}

这个代码做的事情,分配一个超过物理内存大小的内存。运行代码的话,会发现是可以分配成功的。

不但能成功,而且是瞬间成功的,程序的输出也符合预期。甚至可以把代码可写的权限去掉,再运行会段错误。分配的过大的话,也会报错,。

一瞬间就完成了,写的时候报异常,缺页,并非真的分配出去了。

mmap/munmap 为 malloc/free 提供了机制。libc 里的大 malloc 会直接调用一次 mmap,可以用 strace/gdb 调试。

理解 mmap 例子:everything is a file

映射大文件,只访问其中的一小部分。

#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>

#define GiB * (1024LL * 1024 * 1024)

int main() {
  int fd;
  unsigned char *mapped_mem;

  fd = open("/dev/sda1", O_RDONLY);
  if (fd == -1) {
      perror("open");
      exit(EXIT_FAILURE);
  }

  mapped_mem = (unsigned char *)mmap(NULL, SIZE_100GB, PROT_READ, MAP_PRIVATE, fd, 0);
  if (mapped_mem == MAP_FAILED) {
      perror("mmap");
      close(fd);
      exit(EXIT_FAILURE);
  }

  for (int i = 0; i < SIZE_512B; i++) {
      printf("%02x ", mapped_mem[i]);
  }

    // 4. 处理完成后,解除映射并关闭文件
    if (munmap(mapped_mem, SIZE_100GB) == -1) {
        perror("munmap");
    }

    close(fd);
    return 0;
}

Memory-Mapped File: 一致性

还有更多的问题

把文件映射到内存,多久同步合适呢?

  • 如果把页面映射到文件

    • 修改什么时候生效?

    • 立即生效:那会造成巨大量的磁盘 I/O

    • unmap (进程终止) 时生效:好像又太迟了……

  • 若干个映射到同一个文件的进程?

    • 共享一份内存?

    • 各自有本地的副本?

查看手册,或者问问 chatgpt,这也是操作系统复杂的地方。

2. 入侵进程地址空间

前面知道进程地址空间里有什么了,mmap 这样的系统调用只能让进程自己改变自己的地址空间。进程 (M,R)(M, R) 状态机在“执行指令的机器”上运行,状态机是个封闭世界,但如果允许一个进程对其他进程的地址空间有访问权,意味着可以任意改变另一个程序的行为。

我们想让一个程序入侵另一个程序的地址空间,这样就可以任意控制另一个程序的行为。两个我们用过的这样的工具

  • 调试器 gdb

    • 任意观测和修改程序的状态

  • Profiler(perf)

    • 时间轴采样

gdb 和被调试的程序是两个不同的程序,两个进程,各自都有进程号。为什么 gdb 可以读取的另外一个进程的地址空间呢。反正总之就是有这样的机制,根据前面的理解,程序就是计算+系统调用,那么 gdb 也是这样实现的,也就是说,操作系统提供了这样的接口。

这些是入侵地址空间合理的应用。更重要的是想开挂。外挂的本质就是入侵地址空间,我们希望另外一个程序按照自己的预期来执行。

入侵进程地址空间 (0): 金手指

第一代游戏外挂,真外挂。直接物理劫持内存,听起来有点离谱,但是在 “卡带机” 时代的确可以做到。

1970 年,没有操作系统,硬件设备就直接是一个状态机。那个时候的游戏机,CPU 和内存在一块板上,是焊丝的,但是卡带是分离的,卡带里面存了要执行的代码。

Game Genie: 一个 Look-up Table (LUT)

  • 当 CPU 读地址 a 时读到 x,则替换为 y

    • NES Game Genie Technical Notes (专利, How did it work?)

    • 今天我们有 Intel Processor Trace

if 地址总线的值 == 存放 生命值代码的地址(mov $3,(x) #初始生命值为3),那么数据总线上的 $3 直接做修改。

这个想法很简单,直接替换指令了,这是一个介于主板和卡带之间的东西,这个东西先读卡带,然后做转换。

物理外挂,简单、稳定、有效。

早起,所有的代码都写死,代码在 ROM 里固定的位置。后来就不行了。

后来的游戏都跑在 PC 机上,每次加载的物理地址都不一样了。有了虚拟内存,游戏里的变量到底哪一个才是我们关注的?有没有可能做一个通用的作弊器,找到哪一个是我们关心的,然后改掉。

入侵进程地址空间 (1): CE 修改器

一个游戏,里面一定有很多变量,虽然不知道在哪里,但是凭借着多年写程序的朴素的感觉,游戏是代码实现的,那么一定要定义一个变量来存放一些东西,然后要执行。

状态机的思路,虽然我不知道在哪里,但是暂停游戏,我知道这个程序现在在这个状态上,那就一定要有一个变量等于这个金钱数。比如红色警戒。初始化 5000.

一个想法,花掉一些钱,状态就变了,然后看看是哪个状态变掉了。写一个程序,扫描另一程序的地址空间,感知变量的变化。

打开 [pid]/mem ,这里面有整个进程的关系。

到了 windows 多进程时代,就有了用一个进程入侵另一个进程的想法。在进程的内存中找到代表金钱、生命的重要属性并改掉。如果我们想设计一个软件入侵另一个进程,gdb 是有的,gdb 能入侵并该内存数值,任何一个操作系统只要足够通用,就应该提供 api 实现 gdb,我们就用这些 api 实现游戏修改器。这些需求很合理。

再问问 chatgpt:如何在 windows 里实现一个 调试器来读写另一个进程地址空间。

事实上,游戏修改器就是简易版的游戏调试器,可以感受到,修改器能做的事情,gdb 应该也是可以做到的。定位内存所在位置的一个聪明设计:通过修改筛选内存位置。

那么游戏修改器其实也是一种游戏的 gdb。能实现游戏的修改器,就能实现自己的调试器。就能知道那些是代码,那些是数据,然后反汇编。

入侵进程地址空间 (2): 按键精灵

另外一种类型的修改器,。

未必要入侵地址空间,操作系统对于输入输出也是有接口的,比如 ydotool,发送键盘鼠标事件。

键盘宏、鼠标宏。这类外挂从原理上更简单,给进程发送键盘/鼠标事件。利用操作系统提供的 api。由此带来的一些思路,大模型,给屏幕截图 OCR,转换为文字,大预言模型分析,做一个智能 copilot ,用自然语言,大模型翻译成鼠标和键盘的事件。

一些思路

  • 做个驱动(可编程键盘/鼠标)

  • 用操作系统/窗口管理器提供的 API

入侵进程地址空间 (3): 变速齿轮

最难实现的游戏外挂。程序本身是没有对事件的感知能力的,时间也是外部因素,依赖系统调用。

调整游戏的逻辑更新速度。这个其实实现起来是有困难的。

本质:进程是一个状态机。除了系统调用,所有的指令都是计算,高级程序自己对计算时间是没有感知的,所有和时间相关的的方法只有系统调用。

只要劫持了和时间相关的 syscall,就能改变程序对时间的认识,实现进程的变速。即入侵地址空间,改系统调用的代码。

具体实现的技术,........调用gdb,然后。

代码劫持的本质是 debugger 行为,有事也是程序,外挂就是为游戏专门设计的 gdb,。

关于外挂/代码注入

劫持代码的本质是 debugger 行为,游戏也是程序,也是状态机,外挂就是“为这个游戏专门设计的 gdb”

代码注入,hooking,

技术,无论是计算机系统、编程语言还是人工智能,都是给人类带来福祉的。但越强大的技术就也有越 “负面” 的用途。使用游戏外挂破坏游戏的平衡性、利用漏洞入侵计算机系统,或是用任何技术占他人之先、损害他人的利益,都是一件可耻的事情。同样,如果你希望在人生这场 game (博弈) 中走得更远,我们也希望 “诚朴雄伟” 是每个南大人的气质。

最后更新于