找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5983

积分

0

好友

769

主题
发表于 6 天前 | 查看: 14| 回复: 0

智能门铃设备长期以来都是硬件安全问题的重点关注对象和重灾区。许多这类设备为了把成本和功耗压到极致,要么跑裸机,要么上 RTOS,再配上各种冷门、找不到文档和 SDK 的国产 SOC,分析起来相当麻烦。裸机编程中的手写原语和未经充分测试的边界条件,更是让安全问题雪上加霜。

本文对一台闲置的智能门铃进行了硬件分析、bootloader 固件提取和启动流程分析。通过对 bootloader 固件进行 反汇编,梳理了运行过程中的内存布局、烧录信息的储存与读取方式,以及主镜像的装载和解压方式。最后借助逻辑分析仪监听片外 Flash 的 SPI 总线,成功获取并解压出主镜像,供下一步分析使用。

设备与平台

  • 所用硬件设备包括:FT232 串口转 USB 模块、DSlogic U3Pro16 逻辑分析仪、安捷伦 E3631A 电源、XTW-3 编程器(如果需要改固件的话,本文不包括)。
  • IDA 9.0 用于固件分析。

串口控制台探测与第一次固件 Dump

首先确定主电源的供电电压。未查到 DCDC1 控制器型号,但考虑到标准 POE 电源应该是 48V,为了保险这里给 12V,设备顺利启动,运行功耗约 2.5W。

将串口引脚焊接引出,通过 USB 转串口模块连到 PC。试了几种常见波特率,最终确定是 115200。

几乎可以确定这个设备跑的不是 Linux,而是某种 RTOS 或者裸机。spiboot 是它的 bootloader,很可能来自厂商 SDK。0x30000000 是主镜像的地址,也就是 RESET 向量所在的位置。

向串口发送一个空行,发现控制台没有锁,直接给出一张命令表。其中竟然有 flash 这种操作,可以直接读 Flash。经过测试发现,它实际上就是直接读片内总线上的任意地址,设备完全没有隔离手段。

试读一个地址,直接就是一条跳转指令。最初的想法是编写一个 Python 脚本,通过串口自动发送 flash r 00000000 一直读到 Flash 末尾,然后用正则把数据抠出来。实际测试后发现,一次只能读 32 位,速度极其慢,大量时间浪费在无用字符、低波特率和设备控制台命令解析上,整片 dump 下来需要 50 多个小时。

因此打算先 dump 个 300KB 左右看看:

RAM 内存 Dump,显示 ANYK S3C 魔数

看到一个魔数 ANYK S3C——厂商代号和 S3C。这让人联想到三星系 SOC 的经典 S3C 启动,接下来还有一些不明含义的参数。

继续往下翻,在 0x200 处,看起来很可能就是一个固件的入口点。头部是一条 B 指令,跳到 +0x44 位置。下面这一堆数据中,还包含 SPIF 这个 ASCII 字符串,附近应该是元数据。注意到这里并不是 AArch32 的向量表,看来这个固件是由其他代码跳过去的。

Flash 偏移 0x200 处的 SIP@ 元数据

在字符常量池里发现了 SPIBOOT_VERBurn_Info 等字符串,几乎可以确定 0x200 入口处就是那个 bootloader。

SPIBoot 错误日志字符串

再看一下 0x200 处 B 指令跳过去的地方——0x244。这附近是一个 startup 例程:在 0x30770000-0x3079000 附近架栈,清 BSS,在 0x3077FA00 处,然后跳到准备 C 环境的主函数之类的地方。红色是因为那些数是从常量区拿到的绝对寻址,现在还没设置基地址,所以没法识别引用。

Bootloader 启动入口汇编代码

这就引出一个问题:bootloader 说主镜像从 0x30000000 处开始跑,那 bootloader 自己跑在哪?SDRAM 还是 SRAM?谁加载并跳转了它?我们刚才说的 0x244 是 Flash 中的地址空间,然而 bootloader 的栈在 0x30770000-0x3079000 附近,它自己肯定也在附近,那它的基地址应该是多少?

ARM 启动流程与基地址确定

回顾一下 S3C 的踏脚石启动流程:

S3C6410 启动流程框图

BootROM 是一块生产时写入的片内存储器,不可编程。上电后复位到此处,内部存着的 BL0 对外设和时钟系统进行最低初始化,根据引脚状态决定从哪里读取镜像,将 BL1 固件复制到内部的一块 ITCM。因为不需要复杂的时序、训练、刷新和各种型号适配,所以 BL0 就能完成这些。BL1 是一个小固件,进一步初始化外设、初始化 SDRAM 或 DRAM。因为它是可以开发的,所以能灵活地做初始化或某些操作。然后搬运 BL2——这就是我们所说的 bootloader,类似 Uboot。BL2 基本是一个完整的裸机程序,从各种地方拿到镜像和文件系统,然后引导。

幸运的是,我们手上这块 SOC 的启动流程要简单得多。

首先这个 SPIBoot 程序,也就是 bootloader,几乎可以肯定是 BL2。SDRAM 我们已经知道从 0x30000000 开始蔓延 8MB 或 16MB。刚才也知道 bootloader 在 0x30770000-0x3079000 附近,所以 bootloader 在 SDRAM 里。然而 Flash 一开头看见的就是 BL2,似乎并没有 BL1 存在——但肯定要有代码初始化 SDRAM、搬运 BL2 并跳过去。

因此笔者猜测,AK3760 这颗芯片并没有传统意义上随用户镜像编译的 BL1,而是把 BL0 和 BL1 全部固化在 BootROM 中。做出这一猜测的理由是:它不像大多数 S3C 芯片一样外挂 SDRAM,而是直接合封确定型号的 RAM,因此根本无需适配各种 SDRAM 的刷新和时序,可以在 BootROM 中直接硬编码 SDRAM 的初始化。这一点要等后续找到 BootROM 并反汇编时才能验证。

可以猜想启动过程:BootROM 最小初始化外设和 SDRAM,读 Flash 中 ANYKS3C 这个魔数并解析后面那一堆参数(可能是时钟、Flash 页大小之类),搬运 bootloader,然后跳过去。bootloader 再搬运和启动用户镜像。

对于基地址的确定,可以依赖 bootloader 中一些编译期硬编码的地址。比如刚刚看的 startup 函数中,可以确定 BSS 从地址 0x3077FA00 开始。我们看看 Flash 中固件的 rodata(常量字符串区)在哪结束。

Bootloader rodata 段内存 Dump

main 函数入口汇编代码

巧了,0xFA00 正好是 rodata 结束、BSS 开始的地方。再看 startup 最后的跳转,跳到 0x2FD4 处。那里刚好有一个函数序言,而且就是 main!(名字是后标的,因为这不是 ELF,没有符号表。)因此可以几乎确定:整个 Flash 从 0 地址到 bootloader 结束,全部被搬到 0x30770000 处。

为什么是这里?这里正好是 SDRAM 区域接近 8MB 的末端。因为 0x30000000 SDRAM 开始处一会要装压缩后的镜像并解压,所以 bootloader 被搬到了高地址处。

Bootloader 加载主镜像的流程

前面 bootloader 曾经打印出过一段主镜像信息。以此为切入点,分析 bootloader 的主要行为。发现这些字符串都是下面这个函数打印的,字符串传入的那个函数就是 debug_printf。反编译器面对可变参数有点乱套了——aBurnInfo_ptr 这里打出了那个 Burn Info: 字符串,所以实际上并不是从这个函数获得的,而是作为参数 a1 传入。

int __fastcall load_and_boot_image(_DWORD *a1)
{
  _DWORD *v2; // r7
  int (__fastcall *v3)(_DWORD, _DWORD); // r4
  int result; // r0
  int *v5; // r2
  int v6; // r3
  int v7; // r3
  unsigned int v8; // r6
  int v9; // r1
  _DWORD *v10; // r4
  int v11; // r1
  int (__fastcall *v12)(_DWORD); // r4
  unsigned int v13; // r2
  char *v14; // r0
  int v15; // r3
  int v16; // r0

  v2 = a1 + 4;
  v3 = debug_printf_ptr;
  debug_printf_ptr(aSLoaing_ptr[0], a1 + 4);
  ((void (__fastcall *)(char *, _DWORD, _DWORD, _DWORD))v3)(aBurnInfo_ptr, a1[2], *a1, a1[1]);
  if ( *a1 > *(_DWORD *)off_30772C08 )
    return ((int (__fastcall *)(char *))v3)(off_30772C0C);
  if ( *(_DWORD *)off_30772C10[0] == 0x800000 )
    v5 = (int *)off_30772C14[0];
  else
    v5 = (int *)off_30772C18;
  v6 = *v5;
  if ( *(_DWORD *)off_30772C10[0] == 0x800000 )
    v7 = v6 - *a1;
  else
    v7 = v6 + 0x800000;
  v8 = (v7 + 3) & 0xFFFFFFFC;
  if ( !off_30772C1C(a1, v8, *a1) )
    return debug_printf_ptr(aReadBiosFail_ptr, v9);
  v10 = (_DWORD *)off_30772C20;
  result = off_30772C24(v8, *a1, a1[1], *(_DWORD *)off_30772C20);
  if ( *v10 == result )
    return debug_printf_ptr(aErrIsnTDecompr_ptr[0], v11);
  if ( result )
  {
    v12 = (int (__fastcall *)(_DWORD))a1[1];
    v13 = 0;
    v14 = (char *)v12 + 4;
    while ( v13 <= 0xFF )
    {
      v15 = *((_DWORD *)v12 + v13++);
      if ( v15 == dword_30772C28 )
      {
        off_30772C30(v14, off_30772C2C, 60);
        break;
      }
      v14 += 4;
    }
    v16 = debug_printf_ptr(off_30772C34[0], v2);
    return v12(v16);
  }
  return result;
}

a1[2]*a1a1[1],就是打印出来的页号、长度和加载地址。注意 v12 = (int (__fastcall *)(_DWORD))a1[1]v12 实际上是指向 0x30000000 的指针,return v12(v16)——这就是 bootloader 跳转到主镜像 RESET 向量的地方!(v16 那个参数是反编译器混乱了。)

根据错误处理路径的字符串推断,具体流程分析如下:

函数 load_and_boot_image(_DWORD *a1)

打印 "<name> Loading..."
打印 Burn Info (page/len/run_addr)
  ├─ len > 上限 ────────────► 报错返回
  ├─ 按 DRAM 大小算临时缓冲区地址(4字节对齐)
  ├─ 从 Flash page 读镜像到缓冲区
  │    └─ 失败 ────────────► "Read BIOS Fail!" 返回
  ├─ 解压 缓冲区 → run_addr
  │    └─ 失败 ────────────► "Err: Isn't decompressed" 返回
  ├─ 头 1KB 扫魔数 → 命中则注入 60 字节板级参数
  └─ 打印 "Decompress BIOS Ok! Jump To 0x30000000" → 跳转执行

off_30772C1C 是加载镜像一类的函数,off_30772C24 负责解压,off_30772C30 实际上是 memcpy

Bootloader 主函数分析

回过头去看 main 函数,它调用了 load_and_boot_image。其中部分操作函数已经根据参数猜测并检查过功能,最后做了命名。命名只是猜测,不一定准确。

看看 spi_boot_main 的几个初始化函数:

spi_boot_main 24:51 这里就是函数局部变量声明结束后的开头

  memcpy_ptr(v19, off_307732B8, 5);
  *((_DWORD *)off_307732C0 + 1) = 0;
  ram_size_alias = read_ram_size_alias();
  v1 = ram_size_alias;
  *(_DWORD *)off_307732C8[0] = ram_size_alias;
  if ( ram_size_alias == 0x800000 )
  {
    v4 = (_DWORD *)off_307732D4[0];
    *(_DWORD *)off_307732D4[0] = *(_DWORD *)off_307732CC[0] + 8372224;
    v16 = *off_30773338;
    *(_DWORD *)off_307732D0[0] = (*off_30773338 - 2621437) & 0xFFFFFFFC;
    v17 = (_DWORD *)off_307732DC[0];
    *(_DWORD *)off_307732D8[0] = (v16 - 3) & 0xFFFFFFFC;
    *v17 = 0x200000;
    *(_DWORD *)off_307732E0 = 3670016;
  }
  else
  {
    v2 = *(_DWORD *)off_307732CC[0] + ram_size_alias;
    v3 = (unsigned int *)off_307732D0[0];
    v4 = (_DWORD *)off_307732D4[0];
    *(_DWORD *)off_307732D4[0] = *(_DWORD *)off_307732CC[0] + 8372224;
    *v3 = (v2 - 2621437) & 0xFFFFFFFC;
    v5 = (_DWORD *)off_307732DC[0];
    *(_DWORD *)off_307732D8[0] = (v2 - 3) & 0xFFFFFFFC;
    *v5 = v1 - 11010048;
    *(_DWORD *)off_307732E0 = 7798784;
  }

首先根据 SDRAM 容量准备布局表。从 off_307732CC 开始的一个结构体,其中的几个魔数:0x30770000 刚好是 bootloader 装载地址,0x307FC000 是 bootloader 结束地址。发现 0x307FC000 刚好是 8MB-16KB,又被传入 mmu_init(可以看后面的初始化部分分析),这刚好是一级页表的大小!所以大概可以猜到 0x307FC000 是 TTB 地址。

随后再算出后面装载镜像和解压的可用区上界(off_307732D8):

  • 8MB:上方 0x30770000 起就是 spiboot 自己,不能碰 → 上界 = spiboot 起点。
  • 16MB:spiboot 在 8MB 处,其上还有整整 8MB 空闲,物理末尾才是上界。

off_307732D0 = 上界 − 2.5MB,应该是一个缓冲区顶。

off_307732CC → g_ram_base_ptr        // 基地址 *→ 0x30000000
off_307732C8 → g_ram_size_ptr        // RAM大小,根据read_ram_size返回8M或16M *→ 0x800000 / 0x1000000
off_30773338 → g_spiboot_load_addr   // 与我们前面分析一致,bootloader装载地址 → 0x30770000
off_307732D4 → g_mmu_ttb_ptr         // *→ 0x307FC000
off_307732D8 → g_usable_top_ptr      // *→ 0x3076FFFC / 0x30FFFFFC
off_307732D0 → g_topbuf_base_ptr     // *→ 0x304F0000 / 0x30D80000
off_307732DC → g_pool_size_ptr       // *→ 0x200000 / 0x580000
off_307732E0 → g_max_image_size      // → 0x380000 / 0x770000

现在拿着布局表再回看 load_and_boot_image 函数,关注 off_30772C1C 函数——这是从 Flash 中搬运压缩后的镜像。8MB RAM 情况下,它搬到 spiboot 下面;16MB RAM 情况下,它搬到 8MB 处。

8MB:
v6 = *g_usable_top      = 0x3076FFFC
v7 = v6 - len           = 0x3076FFFC - 0x2B72AC = 0x304B8D50
v8 = align_up4(v7)      = 0x304B8D50   (已对齐,无变化)
占用区间 [0x304B8D50, 0x3076FFFC),上端紧贴 spiboot 代码底部

16MB:
v6 = *g_ram_base        = 0x30000000
v7 = v6 + 0x800000      = 0x30800000   (硬编码跳过 spiboot 所在的第一个 8MB)
v8 = 0x30800000
占用区间 [0x30800000, 0x30AB72AC)

// 读 Flash
off_30772C1C( a1,          // 整个 entry 指针
              v8,          // 目的地址(RAM 暂存区)
              *a1 );       // 字节数 = entry->len

而开头的 a1 > off_30772C08 检查镜像长度,与 off_30772C08(即 g_pool_size_ptr)比较,如果池区装不下就拒绝复制。而 off_307732E0 → g_max_image_size 则是允许解压出的最大大小,如果超过了就会踩踏池区或 spiboot。那个 2.5MB 的保留区标记还是不知道用来装什么的。

内存布局如下:

16MB:
0x30000000 ┬──────────────────────────────┐
           │ 解压输出区   上限 0x770000    │ ← off_307732E0,等于到 RAM起点到spiboot 的距离
0x30770000 ├──────────────────────────────┤ ← *off_30773338 (spiboot 链接地址)
           │ spiboot 代码/数据/bss/栈      │   0x8C000
0x307FC000 ├──────────────────────────────┤ ← off_307732D4,16KB 对齐
           │ 16KB 页表 (推断 MMU TTB)      │
0x30800000 ├──────────────────────────────┤ ← 压缩数据暂存区起点(硬编码 BASE+8MB)
           │ 暂存区      上限 0x580000     │ ← off_307732DC
0x30D80000 ├──────────────────────────────┤ ← off_307732D0
           │ 顶部保留区   0x280000 (2.5M)  │
0x31000000 ┴──────────────────────────────┘   off_307732D8 = 0x30FFFFFC

8MB:
0x30000000 ┬──────────────────────────────┐
           │ 解压输出区   上限 0x380000    │ ← off_307732E0
0x30380000 ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
           │ 空隙 0x170000                │   (保守余量?)
0x304F0000 ├──────────────────────────────┤ ← off_307732D0
           │ 顶部保留区 2.5M               │
           │   启动期借用为压缩数据暂存区   │   压缩镜像长度上限 0x200000
0x30770000 ├──────────────────────────────┤   off_307732D8 = 0x3076FFFC
           │ spiboot(与 16MB 同一链接地址)│
0x307FC000 ├──────────────────────────────┤ ← off_307732D4(两档恒定)
           │ 16KB 页表                    │
0x30800000 ┴──────────────────────────────┘
spi_boot_main 52:62

  v6 = mmu_init(*v4);
  MEMORY[0x8000004] = MEMORY[0x8000004] & 0xFC012E00 | 0xD00B;
  while ( (MEMORY[0x8000004] & 0x1000) == 1 )
    ;
  v7 = off_307732E8(v6);
  off_307732EC(v7);
  off_307732F0(0);
  sdram_set_timing(140000000);
  v8 = off_307732F4();
  uart_init_alias(*((_DWORD *)off_307732C0 + 1), 115200, v8);
  init_something();

read_ram_size 这个函数实际上在读 0x2002D004 附近的一个值,sdram_set_timing 也是在附近写。猜测这附近是 SDRAM 控制器的相关寄存器。0x08000004 写入后等待置位,很可能是 PLL 等待锁定。uart_init 也是写 0x08000000 附近,几乎可以确定 0x08000000 附近是外设控制器的寄存器。

在无法获得官方 datasheet 的情况下,也许可以据此逆向出此 SOC 的寄存器映射,为此 SOC 的程序魔改或同厂芯片(如 AK3918)提供逆向思路。

最后是其核心,位于 63 到 85 行:

spi_boot_main 63:85

  memset_alias(boot_info, 0, 32);
  v9 = (int (__fastcall *)(char *, _BYTE *))debug_printf_alias;
  debug_printf_alias(off_3077330C, off_30773308[0]);
  spi_flash_init_alias();
  if ( (unsigned __int8)read_image_burn_info(boot_info, v20) )
    load_and_boot_image(boot_info);
  v10 = v9(off_30773314, v20);
  v11 = sub_30772D9C(v10);
  v12 = off_30773318(v11);
  v13 = off_3077331C(v12);
  v14 = off_30773320(v13);
  for ( i = off_30773324(v14); ; i = off_30773334() )
  {
    v16 = off_30773328(i);
    off_3077332C(v16);
    if ( (unsigned __int8)off_30773330() == 1 )
    {
      if ( (unsigned __int8)read_image_burn_info(boot_info, v20) )
        load_and_boot_image(boot_info);
      else
        debug_printf_alias(off_30773314, v20);
    }
  }

核心启动链其实是:初始化布局表 → 初始化 MMU → 提高主频 → 初始化外设和用于 Flash 的 SPI → 读 burninfo → 装载镜像 → 解压镜像 → 跳转。

还有一条 recovery 路径:第一次读不到 burninfo,会从其他地方搞来一个镜像,然后再读。sub_30772D9Coff_30773320 这几个函数控制这个过程。根据函数内容大致猜测是一套轻量的 TCP 协议栈,获取镜像并解析。因为这条路径在笔者的设备上并未用到,这里不做详细分析。

镜像信息读取函数与镜像分区表结构

进入 read_image_burn_info 函数看一下:

int __fastcall read_image_burn_info(int a1, int a2)
{
  int result; // r0
  int v5; // r4
  unsigned int v6; // r3
  int v7; // r7
  int v8; // r4
  unsigned int v9; // r5
  bool v10; // cf
  int v11; // r2
  unsigned int v12[9]; // [sp+0h] [bp-24h] BYREF

  if ( !a2 )
    return 0;
  v5 = *(_DWORD *)off_30772A70;
  result = (unsigned __int8)read_page_from_flash_alias(257, *(_DWORD *)off_30772A70, 1);
  if ( result )
  {
    memcpy_alias1(v12, v5, 4);
    v6 = v12[0];
    if ( v12[0] <= 3 )
    {
      v7 = 0;
      v8 = v5 + 4;
      v9 = 0;
      while ( 1 )
      {
        v10 = v9 >= v6;
        v11 = 16;
        ++v9;
        if ( v10 )
          break;
        v7 = 0;
        if ( !strncmp_alias(v8 + 16, a2, 16) )
        {
          memcpy_alias1(a1, v8, 32);
          v7 = 1;
          break;
        }
        v6 = v12[0];
        v8 += 32;
      }
      debug_printf_alias1(off_30772A84, v7, v11);
      return v7;
    }
    else
    {
      return 0;
    }
  }
  return result;
}

可以看到一个关键的魔数 257,它传入的函数调用了 SPI 操作来读 Flash。这里将其命名为 read_page_from_flash_alias:第一个参数是起始页,第三个参数是要读的页数。burn_info 在 257 页的位置,是编译时硬编码进去的。

这里应该有一个 burn_info 表,储存了一个(或多个)表项信息,由表的第一个字给出,存于 v12,每个表项长 32 字节。被 strncmp 比对的,是表项结构第 16 字节位置,等于 a2,也就是等于 "BIOS",以此来选中某一特定类型的表项(镜像信息)。同时看到 memcpy 把从表项起始的 32 字节拷贝出去,这就是镜像元数据。

直接去 dump 下来的 Flash 内容中找 257 页,地址 0x10100

Flash 第 257 页 Burn Info 分区表

再对照 load_and_boot 所用的数据就能看出各个表项的含义:

Flash第257页,256字节
                    ┌───────────────────────────────┐
0x10100  +0x00      │  count = 2                    │  u32,条目数(校验 ≤ 3)
                    ├───────────────────────────────┤
0x10104  +0x04      │                               │
                    │        entry[0]  "BIOS"       │  32字节
                    │                               │
0x10124  +0x24      ├───────────────────────────────┤
                    │                               │
                    │      entry[1]  "MACADDR"      │  32字节
                    │                               │
0x10144  +0x44      ├───────────────────────────────┤
                    │                               │
                    │   (未用,页内剩余 188 字节)     │  最多还能再放 1 条
                    │                               │
0x10200             └───────────────────────────────┘

每个表项:

 偏移   大小   字段
 ────────────────────────────────────────────────
 +0x00  4       len        数据字节数
 +0x04  4       run_addr   解压目标地址 = 入口(数据分区为 0)
 +0x08  4       page       Flash 起始页号(× 0x100 = 字节偏移)
 +0x0C  4       reserved   两条均为 0xFFFFFFFF
 +0x10  16      name[16]   ASCII,NUL 结尾,strncmp 比较键
 ────────────────────────────────────────────────

entry[0]  "BIOS"
0x30780104   AC 72 2B 00   len      = 0x002B72AC  2,847,404 B  (2.72 MB)
0x30780108   00 00 00 30   run_addr = 0x30000000
0x3078010C   02 01 00 00   page     = 0x00000102  258 → Flash 0x010200
0x30780110   FF FF FF FF   reserved
0x30780114   42 49 4F 53   name     "BIOS"

entry[1] — MACADDR
0x30780124   1E 00 00 00   len      = 0x0000001E  30 B
0x30780128   00 00 00 00   run_addr = 0            ← 数据分区,不可引导
0x3078012C   00 41 00 00   page     = 0x00004100  16640 → Flash 0x410000
0x30780130   FF FF FF FF   reserved
0x30780134   4D 41 43 41 44 44 52  name  "MACADDR"

解压函数与解压算法分析

load_and_boot_image 的关键操作是对镜像进行解压。如果想把镜像从 Flash 提取或写入,首先需要搞清楚它用了什么解压算法。

直接看一下解压函数 off_30772C24。可以看到一个传入压缩镜像起始位置、压缩镜像长度和主镜像入口点地址的函数,可以确定它就是解压器。

decompress_image_warpper 汇编代码

它的子函数中出现了经典的 14 case 分发器和各种错误信息,LLM 识别到这是 zlib 的 inflate 循环。

同时从另一处常量池中发现它是 1.1.3 版本(P.S. 这是一个经典的有漏洞版本,详见 CVE-2002-0059),不过在这种固件中用问题不大。

zlib inflate 错误常量池

off_30772C24 的函数签名也就明确了:

//zlib的解压函数
int uncompress(Bytef *dest, uLongf *destLen, const Bytef *source, uLong sourceLen);

//off_30772C24重排了参数顺序
decompress_image_zlib(src, srclen, dst, &destLen)

destLen 传入了 g_max_image_size——前面布局计算时算出的最大允许解压出的镜像大小,防止解压后产生踩踏。同时返回实际解压出的镜像大小用于解压成功验证。

然后去看一眼 258 页压缩镜像的位置,找那个魔数(其实这里直接找镜像的魔数也行,因为已经知道是压缩文件了,不过正好要分析一下 bootloader 流程所以详细分析了):

压缩镜像开头的 zlib 魔数 78 9C

78 9C:zlib 的常规压缩魔数,zlib 实锤了。

主镜像提取

现在我们知道了主镜像的解压手段,可以直接从 Flash 中提取压缩后的镜像然后解压,得到主镜像。但即使是压缩的镜像也有近 3MB,从串口控制台一个 WORD 一个 WORD 读太慢了,需要一天一夜。

又因为手里只有这一块板子,要是把 Flash 焊下来的时候拆废了板子就废了。因此夹上逻辑分析仪,抓 off_30772C1C 这个函数从 Flash 中读镜像的过程。我们已经看过 bootloader,没有加密镜像。

这片 Flash 型号是 XM25QH64,最高可以跑到 133MHz。因此将采样设为 500MHz 进行总线频率探测,分析发现实际读取的频率仅为 10MHz,最终逻辑分析仪频率设为 50MHz。

逻辑分析仪 SPI 波形截图

观察到是 SPI 方式操作的 Flash,因此解码器设置为 SPI、MODE0,线序是 CS=6、MOSI=1、MISO=2、SCK=0。解码发现镜像的加载使用 03 命令连续慢读。

从大致看起来:

  • 先是一次 500kHz 的读 Flash 最开始的头 ANVKS3C 那附近,低速试着读 → BootROM 行为
  • 然后一次 10MHz 读 Flash 的头 → BootROM 行为
  • 然后是一次从 0 地址读,数十 KB,是我们刚刚看的 BL2 → Bootloader 行为
  • 然后 BL2 读 0x101 页——Burninfo → Bootloader 行为
  • 然后是一次从 102 页开始连续数 MB 读,随后释放 CS → Bootloader 行为

传输大约在 3.5s 结束,后面是主镜像中的逻辑访问 Flash。

因为一个命令发送之后,要发大片的 FF 来读,所以写一个脚本识别每次连续读的起点。如果是 03 命令就直接解析并保存,如果不是就标记一下(3.5s 之后就没用了,因为那已经进入 App 逻辑了)。下面简单给出分帧函数:

def split_transfers(rows):
    """
    扫描所有采样,识别命令帧。
    命令帧 = MOSI 上一段连续的非 0xFF 字节。
    返回传输列表,每个传输 = {
        'cmd_bytes':   命令帧的 MOSI 字节列表(如 [0x03, addr2, addr1, addr0]),
        'cmd_start_i': 命令帧起始行索引,
        'data_start_i':数据开始行索引(命令帧结束后的第一行),
    }
    数据结束由下一个传输的 cmd_start_i 界定(最后一个到文件末尾)。
    """
    n = len(rows)
    transfers = []
    i = 0
    while i < n:
        _, _, mosi = rows[i]
        if mosi != 0xFF:
            # 命令帧开始,收集连续的非 FF 字节
            cmd_bytes = []
            cmd_start_i = i
            while i < n and rows[i][2] != 0xFF:
                cmd_bytes.append(rows[i][2])
                i += 1
            data_start_i = i  # 命令帧结束后,MOSI 回到 FF,这里起是数据
            transfers.append({
                "cmd_bytes": cmd_bytes,
                "cmd_start_i": cmd_start_i,
                "data_start_i": data_start_i,
            })
        else:
            i += 1

    # 用下一段的命令起点,界定上一段的数据终点
    for idx, t in enumerate(transfers):
        if idx + 1 < len(transfers):
            t["data_end_i"] = transfers[idx + 1]["cmd_start_i"]
        else:
            t["data_end_i"] = len(rows)
    return transfers

下面给出了几个 3.5s 以内或附近的 03 慢读,注意只有 3.5s 之前的是在启动过程中:

addr=0x000000        64 字节  起始 1175408300 ns  -> read_0x000000.bin
addr=0x000000       220 字节  起始 1176617680 ns  -> read_0x000000_#1.bin
addr=0x000200     63484 字节  起始 1177061860 ns  -> read_0x000200.bin
addr=0x010100       256 字节  起始 1270143000 ns  -> read_0x010100.bin
addr=0x010100       256 字节  起始 4638866140 ns  -> read_0x010100_#1.bin
addr=0x010200   2847488 字节  起始 1277728440 ns  -> read_0x010200.bin

第 1、2 次是试读和读信息,第 3 次是读 BL2,第 4 次是读 Burninfo 表,最后第 6 次读整个压缩主镜像。这与我们之前对 bootloader 的分析完全一致!顺带一提,4.6s 的那条读取很可能是在读 MACADDR,但与启动无关。

read_0x010200.bin 这个提取出来的就是压缩主镜像!使用标准的 zlib 函数解压:

print("[+] 尝试 1: 标准 zlib 格式解压...")
decompressed = zlib.decompress(data)
print("[v] 成功! 该文件是标准的 zlib 压缩文件(通过内部 Adler-32 校验)。")
return save_output(output_path, decompressed)
  • 当前文件大小: 2847488 字节
  • 前 4 字节魔数 (Hex): 78 9C B4 BD [+] 尝试 1: 标准 zlib 格式解压... [v] 成功! 该文件是标准的 zlib 压缩文件(通过内部 Adler-32 校验)。 [v] 成功恢复数据,原始未压缩大小: 6654908 字节 [v] 解压后的文件已保存至: read_0x010200_extracted.bin
  • read_0x010200_extracted.bin 与使用控制台 reg 命令读出的内存 0x30000000 数据进行比较,确定了这就是主镜像。

    主镜像向量表 Reset_Handler

    很标准的 AArch32 向量表,NOP 刚好在 Reserved 那里。到这里,主镜像的提取完成,可以用于后续分析了。


    这类裸机设备的 固件分析 往往因为缺少文档和 SDK 而变得复杂,但从启动流程、内存布局和总线监听入手,依然能找到可行的提取路径。如果你对嵌入式逆向和硬件安全感兴趣,欢迎到 云栈社区 交流探讨。




    上一篇:CUDA 实现 Flash Decoding:解码阶段并行度重建与性能剖析
    下一篇:Silver Fox(银狐)虚假软件攻击分析:篡改Windows Defender实现持久化入侵
    您需要登录后才可以回帖 登录 | 立即注册

    手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

    GMT+8, 2026-9-12 08:48 , Processed in 0.321438 second(s), 41 queries , Gzip On.

    Powered by Discuz! X3.5

    © 2025-2026 云栈社区.

    快速回复 返回顶部 返回列表