开源 simpleimg-loadaddr
从 Linux PowerPC simpleImage 中恢复真实加载地址

当路由器从错误的地址启动内核时,它通常不会输出什么有价值的错误信息——系统只是静静地卡死在那里。

今年早些时候,我们在验证 Enterasys WS-AP3715i 的 OpenWRT 24.10 镜像时,就遇到了这样的问题。镜像看起来没有异常,Bootloader 也成功接受了镜像,但设备始终无法启动。

最终我们发现,问题根源只是 arch/powerpc/boot/wrapper 中的一处加载地址配置不一致。而在排查这个问题的过程中,我们顺手开发了一个小工具,并决定将其开源:

simpleimg-loadaddr

这篇文章不仅是项目发布公告,更重要的是解释:

一句话说明这个 Bug

OpenWRT 的一次提交修改了 WS-AP3715i 镜像中 uImage Header 所声明的加载地址,却忘记同步修改 simpleImage 链接脚本中使用的加载地址。

结果就是:

  • 内核被链接到地址 A;
  • Bootloader 被告知加载到地址 B;
  • 最终生成出来的固件从设计上就不可能正常启动。

相关修复和讨论可以在以下链接找到:

openwrt/openwrt#23121

我们的贡献并不是修复这个 Bug。

我们解决的是另一个问题:

如果手上只有一个 simpleImage 二进制文件,如何判断它真正期望被加载到哪个地址?

为什么不能直接查看 ELF

simpleImage 本质上是一个被剥离后的裸二进制文件。

PowerPC 平台上的构建流程大致如下:

  1. 编译并链接启动代码,生成 ELF 可执行文件。
  2. 使用 objcopy -O binary 将其转换成纯二进制文件。
  3. 将结果封装成 uImage,或者直接写入最终固件。

问题出在第二步。

一旦 ELF 被转换成裸二进制:

  • Program Header 消失;
  • Section Table 消失;
  • Symbol 信息消失;
  • 链接器记录的元数据全部消失。

虽然第三步生成的 uImage Header 中也包含一个加载地址字段,但那个字段本质上只是给 Bootloader 的提示信息。

而前面的 WS-AP3715i Bug 已经证明:

uImage Header 中的加载地址并不一定可信。

当固件真正发布出来时:

  • ELF 文件不存在;
  • 编译日志不存在;
  • wrapper 调用参数不存在;

你手上剩下的只有一个 simpleImage 文件。

对于很多架构来说,这并不是问题,因为:

  • ELF 保留加载地址信息;
  • FIT Image 保留元数据;
  • 或者系统支持位置无关执行。

但 PowerPC simpleImage 两者都不具备。

它既没有元数据,也没有重定位信息。

如果不知道链接器原本使用的加载地址,就无法正确运行它。

那么什么信息还保留下来了?

把 ELF 转换成裸二进制确实会丢失大量信息,但并不是所有内容都会消失。

代码本身必须保留下来,而 PowerPC 的 crt0.S 中恰好包含了一段依赖加载地址的关键逻辑:

p_start:    .long   _start
p_base:                 /* only used here */
        mflr    r30
        ...

其中 _start 定义在 zImage.lds.S 中,它的值正是链接时使用的加载地址。

由于 p_start 是一个位于固定位置的 4 字节常量,因此真实加载地址实际上被直接编码进了最终生成的裸二进制文件中。

换句话说:

加载地址并没有消失。

问题只是:

我们不知道它在文件中的什么位置。

幸运的是,=p_base= 本身也具有明显的特征。

它开头的两条指令几乎不可能被重构掉:

bcl     20, 31, 0          ; 42 9f 00 05
mflr    r30                ; 7d 48 02 a6

PowerPC 通常使用这种方式获取当前程序计数器的位置。

对应的字节序列为:

42 9f 00 05 7d 48 02 a6

在绝大多数 mpc85xx 平台上,这段字节序列都可以作为可靠的定位锚点。

一旦找到它的位置,=p_start= 与 p_base 之间的偏移就是固定的:

p_start_offset = p_base_offset - 0x18

对于 32 位 PowerPC 来说,这个关系始终成立;64 位平台则需要额外处理对齐问题。

读取该位置的 4 字节大端整数后,我们便得到了真正的加载地址。

也就是说:

虽然格式已经被剥离,但真正需要的信息其实一直都隐藏在文件里。

工具实现

simpleimg-loadaddr 将上述观察封装成了一个独立的 C 程序。

除了标准 C 库之外,它没有任何外部依赖。

对于一个 simpleImage 文件,它会执行以下步骤:

  1. 将整个文件读入内存;
  2. 搜索 bcl 20,31,.+0 ; mflr r30 对应的字节特征;
  3. 计算 p_start 所在位置;
  4. 读取对应的 32 位大端整数;
  5. 判断镜像是否为位置无关镜像。

典型使用方式如下:

$ ./simpleimg-loadaddr simpleImage.tl-wdr4900-v1

输出结果:

SimpleImage Analysis Results:
==============================

Position Independent: NO
The image must be loaded at a specific address.
Load address: 0x1500000

对于 TP-Link TL-WDR4900 v1,工具得到的结果为:

0x1500000

这与已知加载地址完全一致。

当使用 -v 参数时,程序还会输出:

  • 特征码所在偏移;
  • 原始指令字节;
  • 推导出的 p_start 位置;

这些信息在验证不同 Linux 版本或者厂商修改过的启动代码时非常有帮助。

整个程序大约只有 370 行代码。

虽然最初的需求允许使用 libbfd 和 =libopcodes=,但最终我们没有采用这些库。

原因很简单:

它们依赖的元数据早已在 simpleImage 中被丢弃。

对于这种格式而言,一个简单直接的字节扫描器反而更可靠、更容易理解,也更容易分析其失效模式。

AI 给我们的真正启示

这个项目还有一个意外收获。

最初我们向 AI 描述需求时,已经给出了相当详细的信息:

  • 工具目标
  • 输入格式
  • 示例文件
  • crt0.SzImage.lds.S
  • 实际应用场景
  • 甚至包括使用 binutils 库的建议

然而 AI 最初提交的几版程序虽然在技术上完全正确,却都解决了错误的问题。

例如:

  • 有一版使用 libbfd 分析输入文件
  • 有一版直接读取 uImage Header 中的加载地址
  • 还有一版要求输入 ELF 文件,然后读取 Program Header

这些方案本身并没有错。

问题在于:

它们都默认用户能够获得 ELF 或可信的元数据。

而真实场景恰恰相反。

我们面对的是已经发布出去的固件镜像。

此时:

  • ELF 不存在
  • 构建日志不存在
  • wrapper 参数不存在
  • 符号信息不存在

后来我们在需求中补充了两句话:

实际场景中无法获得 ELF 文件。

程序必须直接分析裸二进制文件,且不能依赖任何符号信息。

增加这两条约束之后,AI 在一次尝试中就生成了最终采用的方案。

这个项目带来的经验并不是:

AI 帮我们写出了这个工具。

而是:

AI 输出质量与提示词的精确程度,往往比提示词长度更相关。

工具什么时候会失效

我们也希望明确说明目前已知的局限性。

crt0.S 发生变化

工具依赖于:

bcl 20,31,.+0
mflr r30

作为定位锚点。

只要 PowerPC 启动代码继续采用当前的 PIC 初始化方式,这个特征就会保持稳定。

但如果未来:

  • 启动流程重构;
  • 编译器生成不同指令序列;
  • PowerPC 引入新的位置无关启动机制;

那么工具就需要学习新的识别方式。

p_base 与 p_start 的偏移变化

目前使用:

p_start = p_base - 0x18

这一规则。

该规则来自对上游 Linux PowerPC 源码的分析与验证。

我们已经在 simpleImage.tl-wdr4900-v1 上完成验证。

如果你的平台包含大量 Out-of-Tree 修改,请先使用已知正确的镜像进行验证。

真正的位置无关镜像

如果读取到:

Load Address = 0x0

工具会认为该镜像支持位置无关执行。

这与当前 PowerPC 启动代码中 p_start 的含义一致。

获取项目

项目仓库:

github.com/hardenedvault/simpleimg-loadaddr

编译方法:

cc simpleimg-loadaddr.c -o simpleimg-loadaddr

测试数据方面:

  • OpenWRT 25.12.4 mpc85xx Image Builder 中可以提取多个 simpleImage;
  • simpleImage.ws-ap3715i-initramfs 目前仍然保留错误加载地址;
  • Linux 6.12.87 源码中的 arch/powerpc/boot/crt0.SzImage.lds.S 可用于验证算法实现。

如果你维护:

  • 下游 Linux 内核;
  • 固件构建系统;
  • OpenWRT 衍生发行版;

我们非常希望收到以下反馈:

  • 工具计算出错误加载地址的设备;
  • 工具结果正确但 uImage Header 不一致的设备;
  • 更多 64 位 mpc85xx simpleImage 样本。

Pull Request、Issue 以及复现案例都非常欢迎。

对于 OpenWRT 生态而言,让构建系统与诊断工具对同一个二进制文件得出一致结论,总归是一件好事。

至少当下一次有人面对一个毫无输出、毫无日志、毫无报错的卡死设备时,他们能够更快地回答那个关键问题:

这个内核究竟希望被加载到哪里?