【问题标题】:Why do Compilers put data inside .text(code) section of the PE and ELF files and how does the CPU distinguish between data and code?为什么编译器将数据放在 PE 和 ELF 文件的 .text(code) 部分,CPU 如何区分数据和代码?
【发布时间】:2019-08-31 14:06:13
【问题描述】:

所以我参考了这篇论文:

二进制搅拌:自随机指令地址 旧版 x86 二进制代码

https://www.utdallas.edu/~hamlen/wartell12ccs.pdf

代码与数据交错:现代编译器积极交错 PE 和 ELF 二进制文件中的代码段中的静态数据 性能原因。在编译的二进制文件中通常没有 区分数据字节和代码的方法。不经意间 将数据与代码一起随机化会破坏二进制文件, 给指令级随机化器带来困难。可行的 解决方案必须以某种方式保留数据,同时随机化所有 可访问的代码。

但我有一些问题:

  1. 这如何加速程序?!我只能想象这只会使cpu执行更复杂?

  2. CPU 是如何区分代码和数据的呢?因为据我所知,除非有跳转类型的指令,否则cpu会以线性方式依次执行每条指令,那么cpu如何知道代码中的哪些指令是代码,哪些是数据?

  3. 考虑到代码部分是可执行的并且 CPU 可能会错误地将恶意数据作为代码执行,这对安全性是否非常不利? (也许攻击者将程序重定向到该指令?)

【问题讨论】:

  • 有趣的论文,顺便说一句。其余部分是有道理的,尽管它们的术语可能会有些混乱。例如他们谈论计算跳转,但我认为它们是指所有间接跳转,就像任何函数指针一样。不像有多个固定大小的代码块的计算 goto,因此您 计算 跳转目标地址,而不是从表中查找它。无论如何,他们提出的二进制随机化器确实需要处理手写二进制文件的特殊情况,这些二进制文件确实将代码和数据混合在一起。

标签: x86 cpu reverse-engineering compiler-optimization elf


【解决方案1】:

是的,他们提出的二进制随机化器需要处理这种情况,因为可能存在混淆的二进制文件,或者由于作者不了解或出于某些奇怪的原因,手写代码可能会做任意事情。

但是不,普通编译器不会为 x86 执行此操作。此答案按书面形式解决了 SO 问题,而不是包含这些声明的论文:

出于性能原因,现代编译器在 PE 和 ELF 二进制文件的代码段中积极交错静态数据

需要引用! 根据我使用 GCC 和 clang 等编译器的经验,以及查看 MSVC 和 ICC 的 asm 输出的一些经验,这对于 x86 来说是完全错误的。

普通编译器将静态只读数据放入section .rodata(ELF 平台)或section .rdata(Windows)。 .rodata 部分(和.text 部分)作为文本的一部分链接,但所有只读数据因为整个可执行文件或库被组合在一起,所有的代码被单独组合在一起。 What's the difference of section and segment in ELF file format(或者最近,即使在单独的 ELF 段中,所以 .rodata 可以映射为 noexec。)


Intel's optimization guide 表示不要混合代码/数据,尤其是读写数据

汇编/编译器编码规则 50。(M 影响,L 一般性) 如果(希望是只读的)数据必须 与代码出现在同一页面上,避免在间接跳转后立即放置。例如, 按照最有可能的目标进行间接跳转,并将数据放在无条件分支之后。

汇编/编译器编码规则 51。(H 影响,L 通用性) 始终将代码和数据放在 单独的页面。尽可能避免自我修改代码。如果要修改代码,请尝试在 一次并确保执行修改的代码和被修改的代码都在 单独的 4 KB 页面或单独对齐的 1 KB 子页面。

(有趣的事实:Skylake 实际上具有用于自修改代码管道核的缓存行粒度;在最近的高端 uarch 上,将读/写数据放入 64 字节的代码中是安全的。)


在同一页中混合代码和数据在 x86 上几乎为零的优势,并且浪费了代码字节上的数据 TLB 覆盖,并浪费了数据字节上的指令 TLB 覆盖。同样在 64 字节缓存行中浪费 L1i / L1d 中的空间。唯一的优势是统一缓存(L2 和 L3)的代码+数据局部性,但通常不会这样做。 (例如,在 code-fetch 将一行带入 L2 之后,从同一行获取数据可能会在 L2 中命中,而必须去 RAM 获取来自另一个缓存行的数据。)

但是由于 L1iTLB 和 L1dTLB 分离,以及 L2 TLB 作为统一的受害者缓存 (maybe I think?),x86 CPU 没有为此优化。 iTLB 未命中而在现代 Intel CPU 上从同一缓存行读取字节时,获取“冷”函数并不能防止 dTLB 未命中。

x86 上的代码大小优势为零。 x86-64 的 PC 相对寻址模式是 [RIP + rel32],因此它可以寻址当前位置 +-2GiB 内的任何内容。 32 位 x86 甚至没有 PC 相对寻址模式。

也许作者在考虑 ARM,其中附近的静态数据允许 PC 相对加载(具有小的偏移量)将 32 位常量放入寄存器?(这称为“文字池” " 在 ARM 上,您会在函数之间找到它们。)

我认为它们不是指 立即 数据,例如 mov eax, 12345,其中 32 位 12345 是指令编码的一部分。那不是要使用加载指令加载的静态数据;即时数据是另一回事。

显然它只适用于只读数据;在指令指针附近写入将触发管道清除以处理自修改代码的可能性。而且您通常需要 W^X(写入或执行,而不是两者)作为内存页面。

CPU如何区分代码和数据?

递增。 CPU 在 RIP 中获取字节,并将它们解码为指令。从程序入口点开始后,沿着被取的分支继续执行,然后通过未取的分支等等。

在架构上,它不关心除了当前正在执行的字节之外的字节,或者正在被指令加载/存储为数据的字节。最近执行的字节将保留在 L1-I 缓存中,以防再次需要它们,对于 L1-D 缓存中的数据也是如此。

在无条件分支或ret 之后有数据而不是其他代码并不重要。 函数之间的填充可以是任何东西。如果数据具有某种模式(例如,现代 CPU 在 16 或 32 字节的宽块中获取/解码),可能存在罕见的极端情况,即数据可能会停止预解码或解码阶段,但 CPU 的任何后期阶段都是只查看来自正确路径的实际解码指令。 (或者是对一个分支的错误推测......)

因此,如果执行到达一个字节,则该字节是指令的(部分)。这对 CPU 来说完全没问题,但对于想要查看可执行文件并将每个字节分类为非此即彼的程序却无济于事。

code-fetch 总是检查 TLB 中的权限,所以如果 RIP 指向一个不可执行的页面,它就会出错。 (页表项中的 NX 位)。

但实际上就 CPU 而言,并没有真正的区别。 x86 是冯诺依曼架构。如果需要,指令可以加载自己的代码字节。

例如movzx eax, byte ptr [rip - 1] 设置 EAX 为 0x000000FF,加载 rel32 = -1 = 0xffffffff 位移的最后一个字节。


考虑到代码部分是可执行的并且 CPU 可能会错误地将恶意数据作为代码执行,这对安全性是否非常不利? (也许攻击者将程序重定向到该指令?)

可执行页面中的只读数据可用作 Spectre 小工具,或用于面向返回的编程 (ROP) 攻击的小工具。但通常实际代码中已经有足够多的此类小工具,我认为这没什么大不了的。

但是,是的,与您的其他观点不同,这实际上是有效的一个小反对意见。

最近(2019 年或 2018 年末),GNU Binutils ld 已开始将 .rodata 部分与 .text 部分放在一个单独的页面中,以便它可以只读没有 exec允许。这使得静态只读数据在 x86-64 等 ISA 上无法执行,其中 exec 权限与读取权限是分开的。即在一个单独的 ELF 段中。

你可以使不可执行的东西越多越好,混合代码+常量将要求它们是可执行的。

【讨论】:

  • 作者的目标似乎是在操作系统/编译器不支持 PIC 的情况下提出一种随机化可执行二进制文件的指令地址的技术。然而,他们的技术甚至可以随机化 PIC 二进制文件。图 9 和 10 显示了对 Windows 和 Linux 性能的影响(有时性能会显着提高,有时会降低),但目标始终不是提高性能。
  • @HadiBrais:我阅读了链接的文章回答了所写的 SO 问题之后。 SO问题是前提/声称普通编译器出于性能原因首先交错代码和静态数据。对于 x86,这种说法是错误的。找到它的那篇文章的观点在这里基本上是无关紧要的。
【解决方案2】:
  1. 交错代码和数据将使数据更接近使用它的代码。这将使数据可以通过更简单、更快捷的指令访问。
  2. CPU 不会,由程序员/编译器确保将数据放在实际程序流程之外的位置。如果程序流意外进入数据块,CPU 会将数据解释为指令。通常数据放在函数之间,但有时编译器可以添加额外的分支指令来为函数内的数据块腾出位置。
  3. 通常这不是问题,因为程序员或编译器会确保程序流未输入数据部分,但您部分正确,因为如果攻击者设法欺骗 CPU 执行数据,这将不会被内存保护机制捕获。

【讨论】:

  • 我希望大多数编译器只会将“只读”数据(常量、跳转表等)放在代码段中。
  • 1.不,对于 x86,这不是正确的。代码大小优势为零。看我的回答。 x86 的编译器这样做。 (但是,是的,如果他们这样做了,这不会是一个问题,并且对于第 1 点是正确的 ARM 来说,这是一种常见的做法,具有有限的位移 PC 相对负载。)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-02-06
  • 1970-01-01
  • 2017-06-21
  • 2012-12-19
  • 2021-07-09
  • 1970-01-01
相关资源
最近更新 更多