开启 PIE 编译选项导致的一个诡异问题

上周在工作中遇到一个诡异的问题:代码完全没动过,加了一些编译选项之后,编译正常,二进制也能运行,业务流程却失败了。经过一些排查之后,发现是 PIE 与 ld 链接资源文件的机制产生了冲突,很值得在此做一下记录,顺便复习下链接相关的知识。

问题出现

需求是这样子的:给某个内部的 CGI 应用加上一些安全编译选项,然后重新编译二进制。理清楚都有哪些选项需要开启之后,我就屁颠屁颠地去 Makefile 里修改 CFLAGS 了~ 一顿操作猛如虎,然后 make 一发,完美通过!试着跑了下编译出来的二进制,也正常运行,没有 crash 也没有 coredump。正准备美滋滋地交差时,发现这部分业务出现了问题,流程跑不通——哦吼,完蛋!这玩意可是好几年没人动的陈年老代码,估计部门里都没人熟悉。无奈,只能硬着头皮开始查问题……

排查问题

首先,这个模块没有很详尽的日志,这是最棘手的一点。如果有详尽的日志,可能十几分钟就能发现关键问题点。

由于这次新开了栈溢出检测、NX 等机制,第一个怀疑的就是:以前可能已经存在栈溢出等问题,但是问题被隐藏了,这次只是把旧有问题给暴露了出来。于是,我把 coredump 开了起来,跑了一下业务流程——但是,并没有如期的 coredump 出现,第一个思路以失败告终。

既然没有 coredump,而且编译出来的文件可以正常运行,我又怀疑:会不会这个 CGI 程序以前就是靠着 Bug 跑起来的,但是编译选项破坏了之前赖以正常运行的 Bug,导致业务流程失败。比如类似这种情况:之前会将未初始化的变量置 0,现在不再置 0 了,导致出问题。但是这些陈年老代码逻辑非常复杂,涉及的模块也比较机密,问来问去,没问到很熟悉的人;加上模块本身是 C 写的,加大了调试的麻烦程度。因此,我难以通过白盒的形式排查问题,第二个思路暂时难以进行。

在暂时没思路的情况下,我选择二分法逐一排查新增的编译选项。最后锁定在 PIE 相关的选项上,但即便如此,仍然没有头绪:为什么开启地址无关之后,进程也没 crash,却反倒会影响业务流程呢?

于是我决定祭出黑盒分析大杀器:strace。这下子被我抓到了把柄:业务流程退出前的最后一段逻辑,是在 mmap 一块超大的内存,被系统拒绝了,最后因为「内存不足」而中断业务流程。

至此其实已经足以把两个发现给关联起来了:PIE 导致的链接流程变化进而影响某个长度符号的计算。但我还是功力不足,没有发现两者的关联,只能进一步分析。根据 strace 拿到的关键系统调用路径,结合代码,我来到了 mmap 超大内存的源码处。其逻辑类似于这样子:

extern uint8 _binary_data_start[];
extern uint8 _binary_data_size[];

void handle_input(...) {
    char *buf = malloc(&_binary_data_size);
    memcpy(buf, &_binary_data_start, &_binary_data_size);
    // ...
}

这段代码让我很是疑惑:

  • 为什么代码中找不到 _binary_data_start_binary_data_size 这两个符号的定义?
  • 为什么 malloc 的大小居然是取 _binary_data_size 符号的地址?

代码看不懂,我决定上大杀器:IDA Pro。我分别拿了一份正常运转的二进制跟一份出问题的二进制,来到了这一段关键的 malloc 逻辑,借助神之 F5,我看到了这样子的反汇编结果:

char *buf = malloc(0x55555555);  // 有问题的二进制
char *buf = malloc(0x115);       // 没问题的二进制

至此,距离真相就差一步。由于 _binary_data_size 符号被优化掉了,我给其加了 volatile 后重新编译。然后用 readelf 分别看了两个二进制,这才发现核心原因:在正常的二进制下,_binary_data_size 是一个绝对符号,且其地址刚好就在 0x115 上;而在增加 PIE 选项的二进制下,_binary_data_size 被重定位到了 0x55555555 地址上,这导致代码逻辑里拿到的 _binary_data_size 地址变成了一个超大的值,进一步导致业务流程因为「内存不足」而退出。由于代码中对这种场景有进行处理,因此最终效果是业务流程走不通,但也没有 coredump 发生。

解决方案

其实这两个符号是通过 ld 把资源文件直接链接进来时,由 ld 自动创建的。同时带进来的,还有一个 _binary_data_end 符号,标志着资源文件内容的结束。因此,最简单的做法就是,通过 end - start 来获取 size。另外的可能的方案,或许只能通过自定义 ld 脚本来实现了。但技术方案肯定是越简单越容易维护,最终我选择的也是直接 end - start 来代替 size 这一条简便的解决路径。


参考资料:Why doesn't a linked binary file's _size symbol work correctly?

题图来自 Photo by Alexander Sinn on Unsplash

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据