Newshot
Account

碎片中的路径

Translation(model translation, not the source's own text)

分片存储的路径

2026年11月12日

许多[1][2][3][4][5][6][7][8][9] Linux 发行版和包管理器都会对刚构建出的二进制文件进行审计,查找不可重定位的绝对路径。如果二进制文件中包含 /path/from/build/machine 这样的路径,它可能无法在最终用户的机器上运行。

那么,对二进制文件做个简单的 grep,或者用稍微高级一点的方式


        $ strings bin/app | grep /path/from/build/machine
      

借助 GNU binutils 的 strings 就能检测出这些路径,对吧?

答案是否定的,至少在使用 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  # note
d_placehold_plac  # that
ehold_placehold_  # this
placehold_placeh  # is
old_placehold_pl  # cyclic
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 作为基线。

我能禁用这个优化吗?

除了用 -Os 编译之外,其实不能。从 GCC 10 开始(也许更早的版本也是?),即使没有指定 -march,拆分成片段的优化也会对最长 256 字节的字符串触发。从 GCC 12 开始,它相对于目标的向量宽度,因此更长的字符串也会被拆分:AVX2 最长 512 字节,AVX-512 最长 1024 字节。

我认为你对此唯一的控制是将最大向量宽度设置为 128 位(强制 xmm 操作),通过传递 -mstore-max=128。但这只是恢复了旧的默认值,并且意味着最长 256 字节的字符串仍会以片段形式存储。

也许事后扫描是不好的做法?

这些针对绝对路径的构建后审计很流行,但你可以说它们从一开始就太脆弱了,那么为什么还指望它们能起作用呢?

确实,Debian 比坏路径扫描走得更远。他们的审计断言,一个包可以在不同环境下逐位重建:在不同的机器上,使用不同的用户、时间和日期、主机名、区域设置等。这是一种更可靠的方法,可以将机器特定的细节排除在二进制文件之外。[10]

碎片化路径

我认为,尽管如此,将字符串常量原样保留在二进制文件中仍然是有用的,即使在 Debian 上也是如此,因为这提升了二进制文件的可解释性。运行 strings ./binary 是一种如此简单方便的方式,可以洞察一个不透明的二进制 blob,以至于它超过了拆分和去重带来的边际节省。这让人想起 Fedora 和 Ubuntu 重新默认开启帧指针,尽管有少量开销,只是为了更可靠地使用性能分析器和跟踪工具。

除此之外,还有二进制修补的理由——尽管被认为有些脆弱——但这是 Conda 和 Spack 中的常见做法:在安装到用户指定位置时,绝对路径会通过某种可视为"升级版 sed 调用"的方式被相应地就地修补。这只有在路径以完整形式存储时才有效。

如果您想讨论这个问题,一个合适的地方是我最近发起的 gcc-patches 讨论串,其中包含一个补丁,添加了一种退出机制。

[1] RPM(Fedora、RHEL、openSUSE):check-buildroot 会在每个已安装文件中搜索构建根目录。

[2] Arch Linux:makepkg 会警告对 $srcdir 和 $pkgdir 的引用。

[3] Yocto / OpenEmbedded:buildpaths QA 检查。

[4] FreeBSD ports:stage QA 会搜索暂存目录。

[5] pkgsrc:CHECK_WRKREF,除非包开发者开启,否则关闭。

[6] Homebrew:brew bottle 会扫描前缀以决定 bottle 是否可重定位。

[7] conda-build:包含构建前缀的文件列在 info/has_prefix 中,在安装时重写。

[8] rattler-build:与 conda-build 相同的方案,使用 memmem。

[9] Julia 的 BinaryBuilder:check_absolute_paths 会警告任何文件中的构建前缀。

[10] 甚至 Debian 也保持一个路径固定:自 2024 年初起,两种构建使用相同的构建目录,因为它记录在 .buildinfo 文件中,并在重新构建时重用。因此,嵌入二进制文件中的构建路径在那里也不会被注意到。

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗