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?

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

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

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

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

真实的进程地址空间

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

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

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

  • 如何验证这一点?

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

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

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

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

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

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

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

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

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

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

vdso

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

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

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

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

2. mmap 管理进程地址空间

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

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

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

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

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

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 例子:申请大量的内存空间

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

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

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

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

理解 mmap 例子:everything is a file

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

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 ,这里面有整个进程的关系。

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

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

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

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

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

一些思路

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

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

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

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

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

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

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

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

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

关于外挂/代码注入

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

代码注入,hooking,

最后更新于

这有帮助吗?