返回资讯中心
外部精选
软件工程
#开发者实时流

GCC 12+ 将字符串常量拆散,Linux 发行版路径审计失效

来源标题:Paths in Pieces

Linux 发行版和包管理器用 grep 审计二进制中的绝对路径可能失效:GCC 12 起编译 x86-64 程序时,字符串常量不再原样出现在二进制中。示例中 /home/linuxbrew/.linuxbrew 在可执行文件里出现 0 次,但程序运行仍能正常打印该路径。

Hacker News 最新acossta2026/11/12 · 今天23 分钟阅读

以下正文由 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →