GCC 编译的二进制文件不保留字符串常量中的绝对路径
来源标题:Paths in Pieces
Linux 发行版和包管理器常通过 grep 或 strings 审计二进制文件中的绝对路径,但 GCC 12 及以上版本针对 x86-64 编译时,字符串常量不会以字面形式出现在二进制中。文章示例中,std::string 包含的 /home/linuxbrew/.linuxbrew 路径在二进制里出现 0 次,导致常规检测方法失效。
以下正文由 AI 翻译自 Hacker News(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
许多[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 寄存器,于是它可以用向量化的移动指令把我们的字符串复制到 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 字节的路径(这个长度是所有 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。