2.进程的地址空间
操作系统、应用程序都是状态机。计算机从得到 reset 信号开始,从硬件初始化到最后启动了 init 进程。这在概念上可以接受,但是实际理解还不是特别清楚的一件事情。
Linux 从一个初始的程序 (状态机) 开始,构建出了整个应用程序世界——通过 fork, execve, exit,我们可以在操作系统中创建出很多并发/并行执行的程序。
在状态机模型中,进程的状态由 两部分组成;其中 R(register) 是由体系结构决定的,看手册就行,gdb 中 info registers 即可查看相对比较简单。比较有趣的是 M(memory) 还有一些模糊的东西。进程 “平坦” 的地址空间 4GB 里到底有什么,以及我们是否可以 “入侵” 另一个进程的地址空间?
这部分的内容
进程的地址空间
mmap 系统调用
三类游戏外挂的实现原理
金山游侠:内存修改
按键精灵:GUI 事件发送
变速齿轮:代码注入
1. linux 进程的地址空间
两个很基本 (但也很困难) 的问题
1 以下程序的 (可能) 输出是什么?
含义是输出 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 这样的系统调用只能让进程自己改变自己的地址空间。进程 状态机在“执行指令的机器”上运行,状态机是个封闭世界,但如果允许一个进程对其他进程的地址空间有访问权,意味着可以任意改变另一个程序的行为。
我们想让一个程序入侵另一个程序的地址空间,这样就可以任意控制另一个程序的行为。两个我们用过的这样的工具
调试器 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,
最后更新于