Skip to content

Latest commit

 

History

History
66 lines (38 loc) · 6.83 KB

File metadata and controls

66 lines (38 loc) · 6.83 KB

任务五:代码与数据的分离

到目前为止,你的链接器可以处理多种重定位类型,也能正确决议符号冲突。但如果仔细观察我们生成的可执行文件,会发现一个问题:所有内容——代码、数据、常量——都被放在同一个内存段中,并且都具有读、写、执行的全部权限。

这在功能上没问题,程序能正常运行。但从系统安全的角度看,这是一个隐患。

为什么需要分离

在CSAPP的第三章中,你应该见过缓冲区溢出攻击。攻击者通过溢出覆盖栈上的返回地址,让程序跳转到攻击者准备的恶意代码。这类攻击之所以可能,部分原因就是数据区域具有可执行权限——攻击者可以把恶意指令写入缓冲区,然后诱导程序去执行它。

现代系统的一个重要防御机制是数据执行保护(DEP, Data Execution Prevention)。核心思想很简单:数据就是数据,不应该被当作代码执行;代码就是代码,不应该在运行时被修改。如果我们能把代码和数据放在不同的内存段,并给它们设置不同的权限,就能大大提高安全性。

要实现权限隔离,首先需要将不同性质的内容在内存中分开放置。但这里有个问题。回想一下目标文件的结构,编译器已经把不同类型的内容放在了不同的节中:

  • .text节包含程序代码
  • .data节包含已初始化的全局变量
  • .rodata节包含只读常量(比如字符串字面量)
  • .bss节为未初始化的全局变量预留空间

这些节在逻辑上已经完成了分类。但在任务二中,我们把所有节的内容拼接在了一起,形成一个大的内存块。这就是这个任务的目标:引入多段布局,为后续的权限设置做准备。

节与段的关系

在开始实现之前,我们需要理清一个概念:节(Section)和段(Segment)是什么关系。

你已经熟悉了节的概念。目标文件包含多个节,每个节有自己的名字(如.text.data)、类型、内容。编译器可能还会生成一些节的变种,比如.text.startup.text.hot等,它们在逻辑上都属于代码,但被编译器分开存放。

段是另一个层次的概念,它是操作系统的视角。当操作系统加载程序时,它不关心有多少个节,只关心有哪些内存区域需要加载,每个区域的地址、大小、权限是什么。程序头表(Program Header Table)就是描述这些段的。

在标准的ELF格式中,节和段是多对一的关系。一个ELF可执行文件可能有二十多个节,但程序头表中只有少数几个段。比如,所有以.text开头的节(.text.text.startup.text.hot等)会被组织在一个代码段中,所有以.rodata开头的节会被组织在一个只读数据段中。链接器负责决定哪些节应该放入同一个段。

Note

链接视图与加载视图

ELF文件同时包含节头表(Section Header Table)和程序头表(Program Header Table)。节头表提供链接视图,它是链接器工作时使用的细粒度组织方式,每个节有独立的名字、属性。程序头表提供加载视图,它是操作系统加载程序时使用的粗粒度组织方式,描述少数几个内存段。同一份数据在两种视图中以不同的方式呈现。

为了降低实验的复杂度,FLE格式做了一个简化:每个节恰好对应一个段,形成一对一映射。这意味着输出文件中有多少个节,就有多少个段。因此,当你决定如何组织输出节时,实际上也就决定了如何组织段。

这给了你两种实现策略。第一种是合并变种。将同一前缀的节合并成一个输出节:所有.text开头的节(.text.text.startup.text.hot等)合并成输出节.text,所有.rodata开头的节合并成输出节.rodata,所有.data开头的节合并成.data,所有.bss开头的节合并成.bss。这样最终得到几个大的输出节,每个对应一个段。这种方式是大部分链接器的默认行为。

第二种是保持独立。保留每个输入节的原始名字,不进行合并。.text.text.startup.rodata.data等在输出文件中仍然是独立的节,各自对应一个独立的段。这种方式实现起来更直接,但会导致输出文件中有更多的节和段。

在这个实验中,我们允许你选择任意一种策略,只要满足基本要求即可。但无论选择哪种策略,你都需要处理一个基本问题:来自不同输入文件的同名节应该如何组织。标准的做法是将它们合并——a.o.textb.o.text合并成输出文件中的一个.text节。这样做的原因是这些节在逻辑上是相同类型的内容,应该共享同一个地址空间。如果不合并,比如分别命名为.text.a.text.b,虽然在技术上可行,但会增加后续处理的复杂度,也不是链接器的常见行为。

Tip

真实的链接器通过链接脚本(linker script)精确控制节的组织方式。链接脚本可以指定哪些输入节应该合并,它们的顺序如何,甚至可以将同名节放到不同的输出位置。GNU ld的默认链接脚本会合并同名节,但你可以编写自定义脚本来改变这种行为。在这个实验中,我们建议你采用合并同名节的标准做法,这样生成的程序结构更清晰,也更容易理解。

生成程序头

每个输出节都需要对应一个程序头。程序头描述了这个段在内存中的位置和属性。你需要为每个段设置虚拟地址(vaddr)、文件偏移(offset)、大小(size)等字段。

段的虚拟地址应该连续分配。第一个段可以从0x400000开始(这是Linux上用户程序的典型起始地址),后续段紧接着前一个段的结束位置。计算方式是:当前段的起始地址等于前一个段的起始地址加上前一个段的大小。

在这个任务中,暂时不需要设置不同的权限。所有段都可以设为rwx(可读、可写、可执行)。权限的设置将在任务六中完成。对齐也暂时不需要考虑,段可以紧密排列而不留空隙。

一些建议

在实现这个任务时,一个常见的问题是符号地址计算错误。当你引入多个段后,每个符号的地址不再是简单的"基地址 + 偏移",而是"所在段的基地址 + 段内偏移"。在处理重定位时,如果发现计算出的地址明显不对,检查一下是否正确地使用了符号所在段的基地址。

你可以在生成可执行文件后,用我们提供的工具检查程序头的内容。确认每个段的大小和地址是否符合预期。如果程序运行出错,也可以通过检查程序头来判断是否是内存布局的问题。

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

make test_5