【OS】Linux Process Memory的探究

前言

刷B站刷到了南大OS的课程,不得不说酒吧舞的教育水平真滴高,狠狠的看了一些关于进程地址相关的课程。

进程地址空间

1.导言

先导入两个问题:

  • 如下的程序会输出什么?

    #include<stdio.h>
    
    int main() {
        printf("%p\n", main);
    }
    
  • 如果生成一个随机指针,什么情况会segmentation fault?

    chat *ptr = random();
    *p;
    

要解决上述两个问题,我们可以向进程空间中尝试了解。

那么,在Linux中,一个进程的地址空间是如何映射的?可以使用什么样的手段去查看某个进程的地址分配呢?

2.手动解决第一个问题

实践是检验真理的唯一标准,我们可以通过编译的方式来查看第一个问题的答案。

  • 动态编译

    gcc -o libc_ main.c
    
  • 静态编译

    gcc --static -o static_ main.c
    
  • 写成一个Makefile

    all: clean static libc
    clean:
    	rm -rf static_ libc_
    static:
    	gcc --static -o static_ main.c
    libc:
    	gcc -o libc_ main.c
    

运行两个程序,静态编译的结果每次都是相同的0x401745,而动态编译出来的结果是0x55ff1b6ff149,并且每次除了后3位不变其他都是会变的。

(之所以动态编译的main函数地址会变,是因为gcc编译的选项默认开启了PIE的保护手段,会将代码段的函数地址给随机化)

3.尝试分析结果

上述的实验结果已经看到了,那么为什么会这样呢?同时0x40开头的地址和0x55开头的地址分别是什么?

这些问题的答案我们不妨使用gdb来调试查看

针对静态编译的文件,进行starti的操作,来到起点函数,随后执行vmmap进行查看

我这里可以执行vmmap是因为安装了gdb的插件pwndbg

image-20230424161815295

可以非常鲜明的看到当前的内存状态。vmmap函数的底层是通过读取/proc/[pid]/maps这个文件来进行内存的查看的,我们直接cat这个文件来看看

image-20230424162150342

通过ps来读取static_这个文件的pid,随后进行maps的查看

我们的main函数地址就在0x401000~0x498000这一段代码段中,具有可执行权限。

main函数的位置打个断点,然后continue到main函数的开始部分,查看maps

image-20230424162449563

我们发现0x4c5000这个具有可读可写的段存放的其实是got表,同时0x4c8000开始的一段heap内存在经历_start函数之后被扩容了

随后就是0x7f开头的地址了,那么这里的vvar、vdso分别是什么呢?

(stack我们知道就是执行函数调用栈的一个内存数据结构)

看玩静态编译的程序后,我们再来看看动态编译的程序!

image-20230424163852167

这是在_start函数之前,maps的状态

可以看到比起静态编译,多了4段,都是与ld-linux-x86-64.so.2有关的。

ld是用来连接libc文件的,所以会在_start函数中将libc连接到这个进程中

我们continuemain函数的位置,再来看看maps的状态:

image-20230424164107693

惊奇的发现,libc.so.6被注入了进程,这就意味着我们可以调用库函数了。

但是这里的anon又是什么东西?gpt-3.5给出的解释如下:

在Linux中,anon是一种匿名内存映射,它是一个没有关联文件名的内存段。它通常用于动态分配内存、堆栈和共享内存等用途。

当进程需要使用一些内存时,它可以通过系统调用(如malloc())向内核请求一段内存。如果这个内存区域不需要关联任何文件,那么内核会创建一个anon内存段来为进程提供所需的内存。

anon内存段的创建时机包括:

  1. 进程调用malloc()等动态内存分配函数来分配内存;
  2. 进程调用mmap()函数映射一块匿名内存区域;
  3. 进程使用堆栈时,堆栈也是使用anon内存段分配的。

anon内存段在进程终止或释放内存时会被销毁。

看到上述的回答我自己都有点疑惑,malloc申请出来的内存不是会在[heap]中吗?

别急,gpt-3.5给出的解释如下:

你提到的heap是一个进程的堆空间,它用于存储动态分配的内存。当进程调用malloc()等函数分配内存时,它们通常会在堆空间中分配内存。

anon内存段与堆空间是不同的概念。anon内存段通常是使用mmap()函数映射的一块匿名内存区域,或者是操作系统通过其他方式动态分配的一块内存。anon内存段通常不会被标识为heap,因为它们与堆空间的分配方式不同。

当进程使用malloc()等函数分配内存时,通常会在堆空间中分配内存,但是在某些情况下(例如大块内存分配),malloc()函数可能会选择使用mmap()函数来分配内存。此时,分配的内存将被标识为heap。但是,即使是使用mmap()函数分配的内存也可能不会被标识为heap,而是被标识为anon或其他类型的内存段。

因此,anon内存段不一定是通过malloc()函数分配的,也不一定与堆空间有关。它们通常是使用mmap()函数映射的一块匿名内存区域,或者是操作系统通过其他方式动态分配的一块内存。

也就是说,anon是一种不需要关联文件的内存段。

4.拓展vvar与vdso

咱们看到啥,不懂啥,就去学啥。这里对于vvarvdso的解释是来自gpt3.5的

在 Linux 中,vvar 和 vdso 都是进程虚拟地址空间中的特殊区域。

vvar(variable address)是一段特殊的内核虚拟地址空间,它存储了一些全局变量,这些变量与进程有关,例如进程 ID、线程 ID 等。它通常位于进程虚拟地址空间的高地址区域。

vdso(virtual dynamic shared object)是一种特殊的共享库,它包含了一些用户态应用程序可以使用的系统调用的实现。vdso 不同于一般的共享库,它是直接映射到用户态应用程序的地址空间中,而不需要通过动态链接器进行加载。这种技术可以避免进入内核态的开销,从而提高系统调用的性能。

总的来说,vvar 和 vdso 都是 Linux 内核提供的一种机制,用于提高进程与内核之间的通信效率和性能。

这里对vdso进行一下拓展的介绍。

咱们在C中或多或少调用过这样一个函数time(),获取当前的时间。

但是有没有想过,如果每次调用time这种函数陷入内核态,再回到用户态,开销是否太大了?

所以可以使用vdso的技术,将时间戳等信息共享在vdso中,让用户调用time()的时候,无需陷入内核态就可以获取时间

5.总结

上述使用gdb的操作,让我们了解到了进程中的内存空间是怎样的。

其实除了vmmap这种拓展的gdb指令,使用linux自带的pmap指令,或者直接去cat /proc/[pid]/maps查看进程内存map也是可以的

我们在实践后,可以得到如下的结论:

  • 进程的地址空间是若干个连续的内存段
  • 每个有着不同的权限(rwx)
  • 如果触发了违反段权限的操作,就会sigsegv

管理进程地址空间

1.导言

在上述见识到了一个process的maps中都有什么之后,我们应该有这样的思考?

  • 一个进程的地址空间如何进行管理?
  • Linux中有什么系统调用与进程空间相关?

2.mmap

这里直接给出一种修改进程地址空间的系统调用mmap

要使用mmap,首先得#include<sys/mman.h>,因为在这个头文件中有mmap的定义

void *mmap (void *__addr, size_t __len, int __prot, int __flags, int __fd, __off_t __offset);
int munmap (void *__addr, size_t __len);
int mprotect (void *__addr, size_t __len, int __prot);
  • mmap是获取内存映射
  • munmap是释放内存映射
  • mprotect则是修改内存映射空间的权限

来做一个例子:

#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, 8 GiB, PROT_READ | PROT_WRITE, 0x20 | 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 + 7 GiB) = 3;
    printf("Read get: %d\n", *(p + 4 GiB));
    printf("Read get: %d\n", *(p + 6 GiB));
    printf("Read get: %d\n", *(p + 7 GiB));
}

如上代码映射了一个8G的空间,可读可写,同时是一个匿名私人空间,不使用fd来记录,使用gdb调试发现开辟的空间在libc的上方,且偏移是固定的

image-20230424223910019

之后对内存空间进行写入,随后读取其中的数据。

看似对8G空间的内存进行读写是非常耗时的,但是由于我们使用的是mmap的系统调用,并且使用的是匿名空间,所以执行的速度非常快

image-20230424224317203

我们这里尝试使用mmap去映射文件到内存中

import mmap, hexdump

with open("/mnt/e/virtualMachine/win7-en.iso", "rb") as f:	# 映射一个iso镜像文件到内存中
    m = mmap.mmap(f.fileno(), prot=mmap.PROT_READ, length=5 << 30)
    hexdump.hexdump(m[-512:])

发现读取的速度非常快,这是因为我们没有读取这个文件中的所有,而仅仅是访问了一段映射的内存。

所以我们可以使用mmap来进行大文件的操作。不管是读取还是拷贝,都是不错的选择。

3./proc/[pid]/mem

linux已经对一个process的内存地址进行了抽象,将其抽象成了一个file(everything is a file)

所以如果我们使用fopen的方式将这个mem文件给打开,随后使用fseek的方式将指针偏移到某个地址,最后fwrite的方式将内存修改,就完成了一次对进程空间的修改。这种写文件的方式不同于ptrace的调用。

这种思想方式甚至出过某个Misc题目,印象还挺深刻,春秋杯的一个Python文件通过open("/proc/self/mem")写入内存,由于python的二进制文件没开PIE,所以可以通过修改got表的方式将某个函数hook成system的地址,然后执行execve的调用。

后话

学了Linux这么些时日,发现原来挺多细小的知识点还是没细究...