Skip to content

Latest commit

 

History

History
88 lines (56 loc) · 6.55 KB

File metadata and controls

88 lines (56 loc) · 6.55 KB

任务六:完善内存保护机制

现在你的链接器已经能够生成包含多个段的可执行文件了,不同类型的内容分别位于不同的段中。但如果你检查生成的程序头,会发现所有段仍然具有相同的权限标志:可读、可写、可执行。是时候让这些段真正发挥它们应有的保护作用了。

权限的意义

还记得我们在Attack Homework中学到的内容吗?攻击者经常利用的一个关键点是:如果一块内存既可写又可执行,就可以把恶意代码写进去然后执行。如果我们能确保代码所在的内存不可写,数据所在的内存不可执行,这类攻击就会变得困难得多。

让我们重新审视段应该具有的权限:

代码段应该是只读可执行(r-x)。程序启动后,代码不应该再被修改。如果有人试图往代码段写入内容——无论是程序的bug还是攻击者的尝试——操作系统应该阻止这个操作并终止程序。但代码当然需要可执行权限,否则程序无法运行。

只读数据段应该是只读(r--)。字符串常量、const声明的全局变量,这些内容在程序的生命周期中应该保持不变。给它们写权限没有意义,反而会带来风险。同时,它们也不需要执行权限——这些是数据,不是指令。

可读写数据段应该是可读可写(rw-)。全局变量的值会在程序运行中改变,所以需要写权限。但它们不应该被当作代码执行,所以不需要执行权限。

在FLE格式中,权限标志定义在PHF枚举中:PHF::R表示可读,PHF::W表示可写,PHF::X表示可执行。你可以用按位或操作组合它们,比如PHF::R | PHF::X表示只读可执行。

为什么需要对齐

如果你实现了权限设置后就直接运行测试,可能会遇到一个意外的问题:程序无法加载,或者在某些系统上行为异常。这是因为我们还遗漏了一个重要的细节——内存对齐。

操作系统管理内存的基本单位是页(page)。在x86-64系统上,一个页通常是4KB(4096字节)。当操作系统设置内存权限时,它是以页为单位进行的——整个页要么可写,要么不可写,不能把一个页的前半部分设为可写、后半部分设为只读。

现在设想一下,如果我们的代码段结束于地址0x400ABC,紧接着只读数据段从0x400ABC开始。这两个段位于同一个物理页内(因为它们之间没有跨越4KB的边界)。操作系统该如何设置这个页的权限?代码段需要可执行但不可写,只读数据段需要可读但不可执行,这是矛盾的。

解决方案是确保每个段的起始地址都对齐到页边界。具体来说,如果代码段结束于0x400ABC,下一个段应该从0x401000开始(向上舍入到最近的4KB边界),而不是紧接着0x400ABC。这样,每个段都完整地占据一个或多个页,操作系统可以为每个页设置独立的权限。

在实现时,最简单的做法是让每个段的起始地址都对齐到页边界。虽然理论上只有在权限切换时(比如从可执行段到只读段)才必须对齐,但对所有段统一对齐可以简化实现,也为将来可能的权限调整留出余地。一个简单的对齐算法是:如果当前地址是addr,页大小是page_size(4096),那么对齐后的地址是:

$$ \text{aligned} = \lceil \text{addr} / \text{page\_size} \rceil \times \text{page\_size} $$

或者用代码表达:(addr + page_size - 1) / page_size * page_size(整数除法)。

Note

对齐会在段之间引入一些"空隙"。这些空间在文件中不需要存储内容,但在虚拟地址空间中它们是存在的。这是为了权限隔离必须付出的代价,在大多数情况下,浪费的空间相对于程序的总大小是可以接受的。

block-beta
columns 3
  block:File
    columns 1
    F1[".text (500B)"]
    F2[".data (200B)"]
  end

  space

  block:Memory
    columns 1
    M1[".text (r-x)<br/>0x400000"]
    Hole1["<Padding/Hole><br/>(3596B)"]
    M2[".data (rw-)<br/>0x401000"]
  end

  F1 --> M1
  F2 --> M2
  style Hole1 fill:#eee,stroke-dasharray: 5 5
Loading

.bss节的特殊性

在前面的任务中,我们一直把.bss节当作普通的数据节处理——读取它的内容,拼接到可读写数据段中。但如果你仔细观察目标文件,会发现.bss节的内容似乎总是空的,即使节头显示它有一定的大小。

.bss这个名字来自"Block Started by Symbol"(实际上这是一个历史误读,但名字已经约定俗成了)。它用于存储未初始化的全局变量。在C语言中,未初始化的全局变量会被自动初始化为零。比如:

int uninitialized_array[1000];  // 自动初始化为全零

这个数组需要4000字节的空间,并且每个元素都是0。如果我们在目标文件和可执行文件中真的存储4000个零字节,会造成巨大的空间浪费。想象一个程序有几个大的未初始化数组,文件大小可能膨胀到几兆甚至几十兆,而它们存储的仅仅是零。

所以编译器和链接器采用了一个巧妙的优化:.bss节在文件中不占用实际空间,只在节头中记录它的大小。当程序加载时,操作系统看到.bss段的描述,会分配相应大小的内存,并将其初始化为零。这样,在磁盘上我们只需要几个字节来记录大小,而不需要存储成千上万个零。

在实现时,你需要特殊处理.bss节。当你遍历节并拼接内容时,检查节的类型(在节头的type字段中,.bss的类型是8,即SHT_NOBITS)。对于.bss节,不要复制它的内容(因为没有内容可复制),但仍然需要在段的大小计算中包含它——毕竟程序运行时这块内存是存在的。

在符号解析时,.bss节中定义的符号仍然有有效的地址。比如,如果.bss节从可读写数据段的偏移1000开始,大小是4000,那么在这个节中偏移0处定义的符号地址就是段基址加1000。重定位时可以正常引用这些符号,就像它们在任何其他节中定义一样。

Tip

在生成程序头时,.bss节的大小会体现在程序头的size字段中。加载器看到这个大小,知道需要为这个段分配多少内存,即使段在文件中可能不占用(或只占用很少)空间。这是段的内存大小(size字段)和段在文件中占用的大小可能不同的一个典型例子。

完成后,运行测试来验证:

make test_6