GCC 12+ 将字符串常量拆散,Linux 发行版路径审计失效
来源标题:Paths in Pieces
Linux 发行版和包管理器用 grep 审计二进制中的绝对路径可能失效:GCC 12 起编译 x86-64 程序时,字符串常量不再原样出现在二进制中。示例中 /home/linuxbrew/.linuxbrew 在可执行文件里出现 0 次,但程序运行仍能正常打印该路径。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
许多[1][2][3][4][5][6][7][8][9] Linux 发行版和包管理器会审计刚构建出的二进制文件,检查是否存在不可重定位的绝对路径。如果二进制文件中包含 /path/from/build/machine 这样的路径,它可能无法在最终用户的机器上运行。
那么,对二进制文件做个基本的 grep,或者用 GNU binutils 的 strings 稍微复杂一点:
$ strings bin/app | grep /path/from/build/machine
就能检测到这些路径,对吧?
答案是否定的,至少在使用 GCC 并以 x86-64 为目标时如此:你不应假定字符串常量会原样出现在二进制文件中。
在这个 C++ 源码中,我们用字符串常量创建了一个 std::string:
#include <iostream>
#include <string>
#define PREFIX "/home/linuxbrew/.linuxbrew"
int main() {
std::string prefix{PREFIX};
std::cout << prefix << '\n';
用 GCC 12 或更高版本编译并运行,会打印出该路径:
$ g++ -O2 main.cpp -o main
$ ./main
/home/linuxbrew/.linuxbrew
但该路径在二进制文件中出现的次数恰好为 0:
$ grep -acF /home/linuxbrew/.linuxbrew main
0
$ strings ./main | grep -cF /home/linuxbrew/.linuxbrew
0
这些字节肯定存在于二进制文件的某个地方吧?我们对它运行 strings,然后只 grep /home 前缀:
$ strings ./main | grep /home
/home/linuxbrew/xbrew/.linuxbrew
等等,GCC 刚才是把
/home/linuxbrew/.linuxbrew
变成了一个完全不同的路径
/home/linuxbrew/xbrew/.linuxbrew
还凭空多了一个子目录 xbrew?怎么会?
答案是 GCC 对我们的 std::string{PREFIX} 做了优化。我们看到的东西看起来像那个前缀,这纯粹是巧合。
GCC 把我们的字符串拆成两段(相互重叠的)16 字节,像这样
/home/linuxbrew/.linuxbrew
|--------------|.......... - 常量 1
..........|--------------| - 常量 2
它们正好能放进 xmm 寄存器,这样 GCC 就能用向量化移动指令把字符串复制到 std::string 的缓冲区。
从 g++ -O2 -S -masm=intel 的输出中筛出相关部分,我们得到
movdqa xmm0, XMMWORD PTR .LC0[rip]
movups XMMWORD PTR [rax], xmm0
movdqa xmm0, XMMWORD PTR .LC1[rip]
movups XMMWORD PTR 10[rax], xmm0
我们的字符串作为常量存储在 .LC0 和 .LC1 处,它们在二进制文件中恰好相邻:
.LC0:
.quad 7596498784141404207
.quad 3420313983963919726
.LC1:
.quad 7795220194874516088
.quad 8603408430414524009
一般来说,当字符串常量被复制到缓冲区时(例如使用 std::string、strcpy 或 memcpy),就会出现这种情况;而只作为指针传递的字符串,比如 fopen(PREFIX "/x", "r") 中的字符串,则保持完整。
一些包管理器(如 Conda 和 Spack)在构建期间将包安装到一个填充过的前缀中,思路是它们可以被“重定位”到用户选择的更短前缀:二进制文件会被就地修补。Conda 在目录名中使用重复的字符串 _placehold 来让前缀足够长。
现在,即使我们按 Conda 风格填充路径,选一个 256 字节的路径(256 是所有 x86-64 寄存器宽度的方便倍数)
#define PREFIX "/home/conda/feedstock_root/build_artifacts/"
"foo_1699999999999/_h_env"
"_placehold_placehold_placehold_placehold_placehold"
"_placehold_placehold_placehold_placehold_placehold"
"_placehold_placehold_placehold_placehold_placehold"
"_placehold_placehold_placehold_placeho/"
并祈祷 GCC 会把路径的各段连续地放进二进制文件,我们仍然无法在二进制文件中看到我们的前缀。我们得到的反而是一个只有 176 字节(即 11 乘以 16)的短得多的字符串:
$ strings main | grep /home
/home/conda/feedstock_root/build_artifacts/foo_1699999999999/_h_env_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placeho/
这要归功于一项进一步的优化:由于字符串 _placehold 每 10 字节重复一次,16 字节的片段每 lcm(10, 16) = 80 字节重复一次,GCC 只需为填充部分存储 5 个不同的 16 字节常量:
/home/conda/feed
stock_root/build
artifacts/foo_1
699999999999/h
env_placehold_pl
acehold_placehol # 注意
d_placehold_plac # 这些
ehold_placehold # 是
placehold_placeh # 循环
old_placehold_pl # 的
acehold_placeho/
这个优化有帮助吗?
对于字符串字面量,我认为没有。Clang 不会把字符串拆成常量,但它生成的指令本质上相同。例如,取一个更长的路径,并用 -march=x86-64-v3 以 AVX2 为目标:
#define PREFIX "/home/linuxbrew/.linuxbrew/Cellar/foo/1.2.3/share/foo"
Clang 只是把字符串放到专门存放字符串的节 .rodata.str1.1 中:
.L.str:
.asciz "/home/linuxbrew/.linuxbrew/Cellar/foo/1.2.3/share/foo"
然后用两次重叠的 32 字节 ymm 加载和存储,在偏移 21 和 0 处复制它的 53 个字节:
vmovups ymm0, ymmword ptr [rip + .L.str+21]
vmovups ymmword ptr [rax + 21], ymm0
vmovups ymm0, ymmword ptr [rip + .L.str]
vmovups ymmword ptr [rax], ymm0
这与 GCC 的版本并没有特别大的不同,后者改为从 .rodata.cst32 中的常量加载同样的两段:
vmovdqa ymm0, YMMWORD PTR .LC0[rip]
vmovdqu YMMWORD PTR [rax], ymm0
vmovdqa ymm0, YMMWORD PTR .LC1[rip]
vmovdqu YMMWORD PTR 21[rax], ymm0
那么 GCC 得到了什么?在性能方面,大概没有任何可测量的收益:两者执行相同的加载和存储。对于重复的片段,GCC 可能略有优势,因为重复的片段只需加载一次,却可以存储多次(在前面的例子中,16 次存储只用了 11 次加载)。它还使用对齐读取,但这只对古老的 CPU 有影响。
在体积方面,GCC 的片段可能比字符串本身占用更多空间:重叠的字节被存储了两次,而且片段按 16、32 或 64 字节对齐。上面那个 53 字节的路径作为两段要占 64 字节,而作为字符串只占 54 字节。只有当它能带来去重时才有收益,例如许多字符串共享至少 16 字节(SSE2)、32 字节(AVX2)、64 字节(AVX-512)的前缀时。想必在实际中,去重并不能节省多少,尤其是现在一些发行版已将 AVX2 作为基线。
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。