NJUOS-12-进程的地址空间

本文最后更新于:1 年前

延续虚拟化部分的内容,继续讲讲地址空间昂!!!

复习&状态机

image-20221223160129041

注意是第一个init程序昂!后面就OS就变成中断处理程序啦!!!

C -> 汇编状态机视角

  • C的视角的状态机

image-20221223160514812

  • 汇编语言的状态机:image-20221223160552149

什么什么代码段啊,堆栈啊,都没有。在汇编语言的眼里,本质上内存里就是一块儿平坦的地址空间。

  • 函数与系统调用:

image-20221223164021536

syscall开销很大诶,能不能不这么大开销。既能陷入内核,又不那么大开销。 -> 不用syscall陷入内核可以吗?

进程的地址空间

神奇指针

char *p 可以和 intptr_t 互相转换

  • 可以指向 “任何地方”
    • 合法的地址 (可读或可写)
      • 代码 (main, %rip 会从此处取出待执行的指令),只读
      • 数据 (static int x),读写
      • 堆栈 (int y),读写
      • 运行时分配的内存 (???),读写
      • 动态链接库 (???)
    • 非法的地址
      • NULL,导致 segmentation fault

它们停留在概念中,但实际呢?

查看进程的地址空间

pmap (1) - report memory of a process

  • Claim: pmap 是通过访问 procfs (/proc/) 实现的
  • 如何验证这一点?

pmap 进程号:可以查看所有的地址空间。尝试用上面这个pamp,来调试下面编译生成a.out并且跑起来的进程。

image-20221223161242330


查看进程的地址空间

  • minimal.S (静态链接)
  • 最小的 Hello World (静态/动态链接)
    • 进程的地址空间:若干连续的 “段”
    • “段” 的内存可以访问
    • 不在段内/违反权限的内存访问 触发 SIGSEGV
      • gdb 可以 “越权访问”,但不能访问 “不存在” 的地址

静态链接简单,动态链接麻烦一点!!!

操作系统提供查看进程地址空间的机制

RTFM: /proc/[pid]/maps (man 5 proc)

image-20221223161715524

  • 进程地址空间中的每一段
    • 地址 (范围) 和权限 (rwxsp)
    • 对应的文件: offset, dev, inode, pathname
      • TFM 里有更详细的解释
  • 和 readelf (-1) 里的信息互相验证
    • 课后习题:定义一些代码/数据,观察变化
1
2
3
4
5
6
address           perms offset   dev   inode      pathname
00400000-00401000 r--p 00000000 fd:00 525733 a.out
00401000-00495000 r-xp 00001000 fd:00 525733 a.out
00495000-004bc000 r--p 00095000 fd:00 525733 a.out
004bd000-004c3000 rw-p 000bc000 fd:00 525733 a.out
004c3000-004c4000 rw-p 00000000 00:00 0 [heap]

更完整的地址空间映象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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] (这叕是什么?)
  • 是不是 bss? 给我们的代码加一个大数组试试!

在c语言里面加了大数组之后,可以看看,跑起来之后,分配的内存的类型,以及后面的标识昂。发现是anno(anonymous)

RTFM (5 proc): 我们发现的宝藏

vdso (7): Virtual system calls: 只读的系统调用也许可以不陷入内核执行。

img

无需陷入内核的系统调用

  • 例子: time (2)
    • 直接调试 vdso.c
    • 时间:内核维护秒级的时间 (所有进程映射同一个页面)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <sys/time.h>
#include <unistd.h>
#include <stdio.h>
#include <time.h>

double gettime() {
struct timeval t;
gettimeofday(&t, NULL); // trapless system call
return t.tv_sec + t.tv_usec / 1000000.0;
}

int main() {
printf("Time stamp: %ld\n", time(NULL)); // trapless system call
double st = gettime();
sleep(1);
double ed = gettime();
printf("Time: %.6lfs\n", ed - st);
}

  • 例子: gettimeofday (2)
    • RTFSC (非常聪明的实现)
  • 更多的例子:RTFM
    • 计算机系统里没有魔法!我们理解了进程地址空间的全部!

对应上面的C -> 汇编状态机视角,诶嘿,time这个系统函数的调用,不需要陷入内核,好像不用syscall???

time和gettimeofday都是,不进入操作系统内核的情况下,完成操作系统的调用!!!

  • 原理:

image-20221223164907792

每个进程有一段地址空间,操作系统会给每个进程都映射同一块儿的vvar,里面存的是系统当前时间。操作系统会(比如没过一秒),把这个vvar加个1。(所有进程都映射到同一个vvar昂!)

  • 不用陷入内核的系统调用:

image-20221224094145577

man vdso -> 上面这图就来自于vdso的手册昂!!!

小总结

  • 所以这里讲了这么多,其实给我们提供了很多的方法,让我们真真正正能够看到。一个运行的进程,在我们的内存中,是如何给它进行内存的分配和管理之类的。
  • 能看到,就是全部的全部昂!!!

(小知识) 系统调用的实现

“执行系统调用时,进程陷入内核态执行”——不,不是的。

系统调用就是一组接口的约定,谁说一定要 int 指令?-> 很慢!!!

  • 光一条指令就要保存 ss, rsp, cs, rip, rflags (40 字节) 到内存

SYSCALL — Fast System Call -> 做的操作比上面提到的int少得多,不需要保存所有的寄存器,系统调用可以像函数调用一样执行,只保存尽可能少的一些信息。

1
2
3
4
5
6
7
RCX    <- RIP; (* 下条指令执行的地址 *)
RIP <- IA32_LSTAR;
R11 <- RFLAGS;
RFLAGS <- RFLAGS & ~(IA32_FMASK);
CPL <- 0; (* 进入 Ring 0 执行 *)
CS.Selector <- IA32_STAR[47:32] & 0xFFFC
SS.Selector <- IA32_STAR[47:32] + 8

(小知识) 系统调用的实现 (cont’d)

我们看到上的VDSO和SYSCALL了,有没有可能在不进入操作系统内核的时候,实现几乎所有的系统调用呢?有!还真有

能不能让其他系统调用也 trap 进入内核?

把vdso做的更加激进一点!!!系统调用无非就是通信嘛!!!syscall并不是唯一的同步方式!!!


img使用共享内存和内核通信!

  • 内核线程在 spinning 等待系统调用的到来
  • 收到系统调用请求后立即开始执行
  • 进程 spin 等待系统调用完成
  • 如果系统调用很多,可以打包处理

进程的地址空间管理

Execve 之后……

进程只有少量内存映射

  • 静态链接:代码、数据、堆栈、堆区
  • 动态链接:代码、数据、堆栈、堆区、INTERP (ld.so)

地址空间里剩下的部分是怎么创建的?

  • libc.so 都没有啊…… -> 操作系统帮助我们提供系统调用,把libc.so读取进来。
  • 创建了以后,我们还能修改它吗?
    • 肯定是能的:动态链接库可以动态加载 (M4)
    • 当然是通过系统调用了

进程的地址空间 (cont’d)

进程的地址空间 = 内存里若干连续的 “段”

  • 每一段是可访问 (读/写/执行) 的内存
    • 可能映射到某个文件和/或在进程间共享

管理进程地址空间的系统调用

1
2
3
4
5
6
// 映射
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);
  • RTFM

    • 说人话:状态上增加/删除/修改一段可访问的内存

    对于内存这个状态机,做了一些改变呢!!!

image-20221224095942261

man 2 mmap,可以查看mmap的手册昂!

1
2
3
4
PROT_NONE   Pages may not be accessed.
PROT_READ Pages may be read.
PROT_WRITE Pages may be written.
PROT_EXEC Pages may be executed.

手册里面有很多东西很全昂,比如这里对于上面的 int prot,这个参数的讲解,protection保护权限,总共就下面这几种昂!!!

image-20221224100916040

甚至可以把文件的一部分,直接映射到内存里面来。把文件搬进来,而且可以做到,我想访问文件哪里,我就去搬文件的哪里进内存。 -> 非常好用!!!

我们的加载器,其实用的就是这个昂!!!!!! -> 进程在创建的时候,加载器分配内存用的都是mmap昂!!!

例子

  1. 使用strace查看动态编译后的文件

image-20221224103130379

  1. readelf -l a.out

告诉我们:当加载到这个程序的时候,要把什么东西,加载到哪里。文件是什么,权限是什么,偏移量是什么。

image-20221224103324247

mmap很简单,就是把上面的elf文件中的内容,读取并进行处理,作为参数传递给系统调用mmap,就可以完成正确的文件 -> 内存的映射。

把文件映射到进程地址空间?

它们的确好像没有什么区别

  • 文件 = 字节序列
  • 内存 = 字节序列
  • 操作系统允许映射好像挺合理的……
    • 带来了很大的方便
    • ELF loader 用 mmap 非常容易实现
      • 解析出要加载哪部分到内存,直接 mmap 就完了

使用 Memory Mapping

Example 1:

  • 用 mmap 申请大量内存空间(mmap-alloc.c)
    • 瞬间完成
    • 不妨 strace/gdb 看一下
    • libc 的 malloc/free 在初始空间用完后使用 sbrk/mmap 申请空间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#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, 3 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);
}
*(int *)(p + 1 GiB) = 114;
*(int *)(p + 2 GiB) = 514;
printf("Read get: %d\n", *(int *)(p + 1 GiB));
printf("Read get: %d\n", *(int *)(p + 2 GiB));
}

分配3GB的内存,瞬间完成,非常快!!!

  • 查看strace:

image-20221224103838179

只花费了非常短的时间昂!!!自己先考虑一下!why?

mmap只需要标记这块内存被分配给我了就ok,取用的时候,再实际分配给我就行昂!(lazy load)


Example 2:

1
2
3
4
5
6
7
8
#!/usr/bin/env python3

import mmap, hexdump

with open('/dev/sda', 'rb') as fp:
mm = mmap.mmap(fp.fileno(), prot=mmap.PROT_READ, length=128 << 30)
hexdump.hexdump(mm[:512])

mmap可以映射内存,也可以映射磁盘!!!

/dev/sda是一个虚拟设备,虚拟磁盘昂!映射128G的内存,把前面的512个字节打印出来。

  • mmap是非常简单的系统调用,但是非常强大!!!

Memory-Mapped File: 一致性

但我们好像带来了一些问题……

  • 如果把页面映射到文件
    • 修改什么时候生效?
      • 立即生效:那会造成巨大量的磁盘 I/O
      • unmap (进程终止) 时生效:好像又太迟了……
    • 若干个映射到同一个文件的进程?
      • 共享一份内存?
      • 各自有本地的副本?

请查阅手册,看看操作系统是如何规定这些操作的行为的

  • 例如阅读 msync (2)man 2 msync,修改过的内存,重新推回到文件里面昂!
  • 这才是操作系统真正的复杂性

地址空间的隔离

地址空间:实现进程隔离

每个 *ptr 都只能访问本进程 (状态机) 的内存

  • 除非 mmap 显示指定、映射共享文件或共享内存多线程
  • 实现了操作系统最重要的功能:进程之间的隔离

任何一个程序都不能因为 bug 或恶意行为侵犯其他程序执行

  • “连方法都没有”
  • 吗……?

很有意思的内容(外挂!!!)

电子游戏的上一个黄金时代

电子竞技的先行者:“即时战略游戏” (Real-Time Strategy)

  • Command and Conquer(Westwood), Starcraft (Microsoft)…
    • 如果我们想 “侵犯” 游戏的执行……呢?

img

前互联网时代的神器 (1): 金山游侠

在进程的内存中找到代表 “金钱”、“生命” 的重要属性并且改掉

img


只要有访问其他进程内存和在程序上 “悬浮显示” 的 API 即可

  • 想象成是另一个进程内存的 “调试器”
  • 在 Linux 中可以轻松拥有:dosbox-hack.c
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdint.h>
#include <sys/mman.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdbool.h>

#define LENGTH(arr) (sizeof(arr) / sizeof(arr[0]))

int n, fd, pid;
uint64_t found[4096];
bool reset;

// scan方法很重要昂!
void scan(uint16_t val) {
uintptr_t start, kb;
char perm[16];
// 读取这个进程内存文件
FILE *fp = popen("pmap -x $(pidof dosbox) | tail -n +3", "r"); assert(fp);

// 解析每一行,找到其中可以写的内存!!!
if (reset) n = 0;
while (fscanf(fp, "%lx", &start) == 1 && (intptr_t)start > 0) {
assert(fscanf(fp, "%ld%*ld%*ld%s%*[^\n]s", &kb, perm) >= 1);
if (perm[1] != 'w') continue;

uintptr_t size = kb * 1024;
char *mem = malloc(size); assert(mem);
assert(lseek(fd, start, SEEK_SET) != (off_t)-1);
assert(read(fd, mem, size) == size);
for (int i = 0; i < size; i += 2) {
uint16_t v = *(uint16_t *)(&mem[i]);
if (reset) {
if (val == v && n < LENGTH(found)) found[n++] = start + i;
} else {
for (int j = 0; j < n; j++) {
if (found[j] == start + i && v != val) found[j] = 0;
}
}
}
free(mem);
}
pclose(fp);

int s = 0;
for (int i = 0; i < n; i++) {
if (found[i] != 0) s++;
}
reset = false;
printf("There are %d match(es).\n", s);
}

// 修改对应的值
void overwrite(uint16_t val) {
int s = 0;
for (int i = 0; i < n; i++)
if (found[i] != 0) {
assert(lseek(fd, found[i], SEEK_SET) != (off_t)-1);
write(fd, &val, 2);
s++;
}
printf("%d value(s) written.\n", s);
}

int main() {
char buf[32];
setbuf(stdout, NULL);

// 先得到我现在运行游戏的dosbox的进程
FILE *fp = popen("pidof dosbox", "r");
assert(fscanf(fp, "%d", &pid) == 1);
pclose(fp);

// 把整个进程的内存,都作为一个文件暴露出来了!!!
sprintf(buf, "/proc/%d/mem", pid);
fd = open(buf, O_RDWR); assert(fd > 0);

for (reset = true; !feof(stdin); ) {
int val;
printf("(DOSBox %d) ", pid);
if (scanf("%s", buf) <= 0) { close(fd); exit(0); }
switch (buf[0]) {
case 'q': close(fd); exit(0); break;
case 's': scanf("%d", &val); scan(val); break;
case 'w': scanf("%d", &val); overwrite(val); break;
case 'r': reset = true; printf("Search results reset.\n"); break;
}
}
}

  • 找到了所有可以改的数据,不知道对应关系怎么办?

手动造一个东西(比如消耗钱),看看修改过后,哪些数据是被更改了。 -> 施加对于值的扰动+观察内存的变化,能够帮助我们快速确定,我们要找的内存昂!

  • 把我们找到的对应的内存位置(也就是钱钱对应的位置),改成对应的值,诶嘿,你就是上帝了orz。

前互联网时代的神器 (2): 按键精灵

大量重复固定的任务 (例如 2 秒 17 枪)

img

这个简单,就是给进程发送键盘/鼠标事件

  • 键盘or鼠标,做个驱动。OS把你的驱动认为是正常的设备!很多鼠标支持自定义宏,其实就是这个原理。比如你点了一下,鼠标默认连续触发100次电路这种;或者
  • 利用操作系统/窗口管理器提供的 API
    • xdotool (我们用这玩意测试 vscode 的插件) -> 可以给另外一个进程send键盘 or 鼠标的事件。(OS理所应当,愿意给我们提供的API)
    • evdev (我们用这玩意显示按键;仅课堂展示有效) -> 就是课堂上,老师展示他输入的指令的时候,用的小脚本!!!可以监听按键的变化,并且打印出来。合理的操作系统应该提供的机制昂,不过可能需要一些权限!

前互联网时代的神器 (3): 变速齿轮

调整游戏的逻辑更新速度 -> pokemon,笑死了…难绷…

1
2
3
4
int main(){
step();
sleep(100*time.seconds);
}

怎么办?不是改数据了,改代码!!!把sleep的代码对应的内存给改了,hack一个跳转。hack到我们自己的sleep指令,根据我们的齿轮,我们自己的sleep的时间,可以我们自己控制的。就很方便昂!!!

img


本质是 “欺骗” 进程的时钟

  • 源头:闹钟、睡眠、gettimeofday
  • 拦截它们需要稍稍更复杂的技术

更强大的游戏外挂?

游戏也是程序,也是状态机

  • 通过 API 调用 (和系统调用) 最终取得状态、修改状态
  • 外挂本质上就是,想象成是一个 “为这个游戏专门设计的 gdb”

img

代码注入 (Hooking)

我们可以改内存,也可以改代码

安全补丁!!!也是这么来的!软件运行的时候,可以把数据和代码都改掉昂!!!修复安全漏洞很重要!!!

The Light Side

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
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <stdint.h>
#include <assert.h>
#include <unistd.h>

void foo() { printf("In old function %s\n", __func__); }
void foo_new() { printf("In new function %s\n", __func__); }

// 48 b8 ff ff ff ff ff ff ff ff movabs $0xffffffffffffffff,%rax
// ff e0 jmpq *%rax
void DSU(void *old, void *new) {
#define ROUNDDOWN(ptr) ((void *)(((uintptr_t)ptr) & ~0xfff))
size_t pg_size = sysconf(_SC_PAGESIZE);
char *pg_boundary = ROUNDDOWN(old);
int flags = PROT_WRITE | PROT_READ | PROT_EXEC;

printf("Dynamically updating... "); fflush(stdout);

// 打开内存保护,然后人为把汇编代码给改了昂!!!补丁把寄存器(原来跳转到了old),改为了跳转到new。这个也叫跳转代码,帮助我们跳转到new昂!
mprotect(pg_boundary, 2 * pg_size, flags);
memcpy(old + 0, "\x48\xb8", 2);
memcpy(old + 2, &new, 8);
memcpy(old + 10, "\xff\xe0", 2);
mprotect(pg_boundary, 2 * pg_size, flags & ~PROT_WRITE);

printf("Done"); fflush(stdout);
}

int main() {
// 同一个foo()方法
foo();
DSU(foo, foo_new);
foo();
}

DSU可以热更新,运行时,把foo()从old变成new,很厉害!!!

这就是个补丁 -> 从一段代码,hack掉。原函数的执行,跳转到另外一个代码段去执行了(热补丁)。

image-20221224114525577

The Dark Side

  • 对于外挂,代码可以静态/动态/vtable/DLL… 注入
    • render(objects)render_hacked(objects)

游戏外挂:攻与防

控制/数据流完整性(System Security)

  • 保护进程的完整性
    • 独立的进程/驱动做完整性验证
  • 保护隐私数据不被其他进程读写
    • 拦截向本进程的 ReadProcessMemoryWriteProcessMemory,发现后立即拒绝执行
  • 例子

其他解决方法

  • AI 监控/社会工程学:如果你强得不正常,当然要盯上你。(统计学习之类的,都可以综合使用!!!)
  • 云/沙盒 (Enclave) 渲染:“计算不再信任操作系统”(CPU有一个运行模式,隔离出一个blackbox(黑地),没有人能够看到所有的运行相关东西,system的问题了。假设计算不再信任操作系统,拉起一个可信的执行环境!!!高可信软件,可信计算!!!)

总结

  • win还是mac还是linux,都给了我们一些,修改访问和修改内存中变量的功能。 -> 调试器需要这种的机制,才能够运行!!!
  • 留了这样的口子,但是一般的进程,是没有权限去做这样的事情的。(当然你是管理者,你乐意怎么搞就怎么搞就好……)

总结

本次课回答的问题

  • Q: 进程的地址空间是如何创建、如何更改的?
    • fork
    • Execve

Take-away messages

  • 进程的地址空间
    • 能文件关联的、带有访问权限的连续内存段
      • a.out, ld.so, libc.so, heap, stack, vdso
    • 地址空间的隔离
  • 进程地址空间的管理 API
    • mmap

References

本节视频包含很多调试,对比,实际操作流程。建议看看,会对我们很多内容的理解,比笔记来的直观的多。

  1. ppt link: http://jyywiki.cn/OS/2022/slides/12.slides#/
  2. 手册很好用!!!
  3. file a.out -> file指令查看文件相关的信息
  4. vi中,:set nowrap,可以设置为没有换行,看起来可能方便昂!
  5. pmap, readelf -l a.out

记得tldr指令昂!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
alex ~  $ tldr readelf
Command readelf does not exist for the host platform. Displaying the page from linux platform

readelf

显示 EFI 文件信息。
更多信息:http://man7.org/linux/man-pages/man1/readelf.1.html.

- 显示 ELF 所有文件信息:
readelf -all path/to/binary

- 显示 ELF 文件的所有头信息:
readelf --headers path/to/binary

- 如果存在符号表项,则显示 ELF 文件内的符号表项:
readelf --symbols path/to/binary

- 显示 ELF 文件头信息:
readelf --file-header path/to/binary
  • gdb中的info inferiors -> 可以在starti之后,查看进程号
  1. Address Space Layout Randomization -> 地址空间随机化,每次程序都被加载到随机的位置上,但是调试器会把这个特性关掉(GDB)。导致我们每次运行程序,看到程序的地址空间都是固定的昂!
  2. Feature: GDB不能访问另外一个进程的vvar昂!
  3. man vdso -> https://zhuanlan.zhihu.com/p/436454953 -> 类似于mmap?
  4. pmap, readelf,很多指令昂!!!
  5. elf文件:https://baike.baidu.com/item/ELF/7120560

NJUOS-12-进程的地址空间
https://alexanderliu-creator.github.io/2022/12/23/njuos-12-jin-cheng-de-di-zhi-kong-jian/
作者
Alexander Liu
发布于
2022年12月23日
许可协议