Misaka Cloud Blog

Back

背景:手头一个 351,232 字节的 .bin 文件,文件名是 OTA 服务器下发的随机 ID。已知信息只有一句话——“一款蓝牙热敏打印机的固件”,其余全靠纯逆向。

这是一个学习/练手项目,不打算烧录设备,但每一步都按”真要烧回设备需要什么”的标准来做。最终在不接触实机的前提下,把这个黑盒还原成了一张完整的软件栈全景图。本文记录这套方法论、一个完整的签名逆向案例,以及四条可迁移的教训。

目录

  1. 起点与原则
  2. 第一步:固件指纹
  3. 案例:8 字节 footer 签名逆向
  4. 安全面扫描
  5. 逆向成果:完整软件栈
  6. 四条教训
  7. 工具选择与项目边界
  8. 总结

1. 起点与原则

起点:

  • 输入0VyHq2zPM5I6hkc2.bin,351,232 B(0x55C00
  • 已知:“一款蓝牙热敏打印机的固件”
  • 没有:芯片型号、厂商资料、文档、原理图、实机

核心原则只有一条:信号 vs 噪声判别。不依赖文件名、不依赖厂商提示,所有结论都要在 bin 本身验证。任何”这看起来像 X”必须给出可证伪的检查。这条原则后面多次救场——固件里充满了诱人的假线索。

目标分两层:

  1. 理解一台便携蓝牙热敏打印机的完整软件栈
  2. 建立”未知 MCU 固件 → 可改造”的标准流程

2. 第一步:固件指纹

不急着反汇编。先跑 5 个 pass 的指纹流程,全部用 Python 完成:

  1. 尺寸 + 整体熵 + 4KB 块熵图 → 分段地图
  2. 向量表 → 架构 + Flash base + Reset + IRQ 数
  3. 字符串过滤 → 厂商/协议/RTOS/模块指纹
  4. 已知 magic 扫描 → 压缩/文件系统块
  5. footer + padding 边界 → 签名候选位置

2.1 整体熵先行

整体熵 7.105 bits/byte——典型的 MCU 固件(代码 + 字库高熵块混合)。如果熵接近 8,说明要么加密要么压缩,纯静态逆向基本判死刑;7.1 说明明文代码占主体,有得玩。

2.2 向量表读出芯片平台

文件开头 256 字节是标准 Cortex-M 向量表:

索引地址含义
00x20013E78Initial SP → RAM ≥ 80KB
10x080041B1Reset_Handler(Thumb)→ Flash base = 0x08000000
11/14/15实装SVCall / PendSV / SysTick 全部存在 → 跑着 RTOS
16+48 个外设 IRQ 全部填实

最关键的一击来自字符串:gd32e50x interrupt controller——芯片平台直接锁定 GigaDevice GD32E50x(Cortex-M33)。IRQ 数量与异常处理布局也对得上 GigaDevice 的标准 HAL 启动模板,交叉印证。

2.3 字符串里的厂商与协议

  • HPRT → 厂商汉印,便携票据打印机大厂
  • COMMENT:Impact Printer;ACTIVE COMMAND:CPCL; → 打印协议是 CPCL(Zebra Comtec Programming Language)
  • 完整 AT 指令集(AT+BAUD=921600AT+CLASS=100680AT+OTA=1……)→ 蓝牙是独立串口模组,MCU 通过 UART 发 AT 指令与其通信,而非内置协议栈
  • 自检页字段 WeChat AppEasy PairingMulticonn → 中国大陆移动票据/微信小票场景
  • 拼写错误 update succesSimplied Chinese → HPRT 固件的长期特征,再次交叉印证厂商判断

2.4 熵图分段 = 内存地图

4KB 块熵图把文件切成清晰的段:

范围大小内容
0x00000–0x00100256 B向量表
0x00100–0x41000~260 KB主程序代码(HAL + RTOS + CPCL 解释器 + AT 解析)
0x44000–0x4900020 KBGB18030 中文字库(80.7% 字节 ≥ 0x80)
0x4B000–0x4F00016 KB位图字模 / Logo
0x55BC4–0x55BF852 B零填充
0x55BF8–0x55C008 B签名 footer

几小时前它还是黑盒;现在有了芯片、厂商、协议、RTOS、内存图,以及下一个目标——末尾那 8 个字节。

3.1 结构

0x55BC4..0x55BF8  00 ... 00     零填充(52B)
0x55BF8..0x55BFC  9D 57 73 5A   sum_le = 0x5A73579D
0x55BFC..0x55C00  85 AC 65 23   xor_le = 0x2365AC85

文件 4 字节对齐,footer 占最末 8 字节。

3.2 先跑便宜假设

看到 8 字节 footer,第一反应不该是 CRC64,而是 (u32 sum, u32 xor) 组合——这是国产便携设备厂商的”标配偷懒”,5 行 Python 就能验证:

import struct

def reseal(fw: bytes) -> bytes:
    """重算 8 字节 footer,返回新 bytes。"""
    assert len(fw) % 4 == 0
    s = x = 0
    for i in range(0, len(fw) - 8, 4):
        w = struct.unpack_from("<I", fw, i)[0]
        s = (s + w) & 0xFFFFFFFF
        x ^= w
    return fw[:-8] + struct.pack("<II", s, x)

一次命中。

诚实交代:实际过程是先跑了完整的 CRC 全家桶(各种多项式、反射、初值排列),全部落空后才回头试这个组合,白白浪费一小时。教训直接沉淀成流程:cheap sweep 必须排在 CRC sweep 前面

3.3 多边界扫描定位签名范围

假设命中后还要确定精确的签名范围。对多个候选边界分别检验 sum/xor:

范围sumxor
[0, size-8)
[0, size-16)✓(尾部 8 字节全零,不改变 sum/xor)
[0, 0x55BC4)✓(零填充同理)
[0, 0x55BC0)
[0x100, size-8)(跳过向量表)

结论:签名范围必须从 offset 0 开始,到 size-8 结束(含零填充)。

3.4 Round-trip 自验是金标准

算法还原的最后一步也是最重要的一步——round-trip 自验:

reseal(original) == original              ✓ byte-perfect
mod[0x10000] ^= 0xAB; reseal(mod)        → footer 变为 36 57 73 5A 2E AC 65 23
对扰动后的 body 重算 sum/xor              → 与新 footer 完全匹配 ✓

正向能复现原签名(证明算法对),扰动能正确重封(证明理解完整)。两条都过,签名算法才算 100% 拿下。

最终结论:无加密、无密钥、无硬件绑定。任意修改固件主体后都能重新封装——这台设备的固件防篡改强度约等于零。

4. 安全面扫描

签名拿下后,对整个安全面做了系统扫描,三个结论都出人意料。

零活跃加密。 固件里确实存在完整的 MD5 常量和一份 256 项全对的 zlib CRC32 反射表——但它们全是 dead code。以 CRC 表为例:全文件搜它的地址作为 32-bit literal,0 引用;搜 movw/movt 加载该地址,0 匹配;搜 ±4KB 内 PC-relative ldr,0 引用。它是从 SDK/libc 链接进来但从未被调用的死代码。存在不等于使用,引用计数才算数。

MCU 不能自写 flash。 全文件找不到 FMC 解锁魔数,app 没有擦写自身 flash 的能力——即使破解了签名,也没法通过 app 自身把改后的固件刷回去。

OTA 与 MCU 无关。 MCU 端的全部 OTA 逻辑就是发一条 AT+OTA=1\r 给蓝牙模组,之后模组独立完成一切。蓝牙 OTA 更新的是模组固件,不是 MCU app。

三条拼起来就是实战约束:改固件容易(重封即可),烧回去难——需要 SWD/JTAG 物理接触,且 bootloader 是否做外层校验未知(本 bin 是 app-only,bootloader 不在其中)。

5. 逆向成果:完整软件栈

后续用 IDA Pro 做了系统性反编译增强(约 250 个函数重命名、120 个变量重命名、200 条注释、76 篇分析笔记),最终把这台打印机还原成了一张完整的全景图:

GD32E50x (Cortex-M33)   Flash 343KB @ 0x08000000   SRAM 80KB
                        PSRAM 2MB @ 0xB0000000 (SQPI)

启动: Reset_Handler → SystemInit → main()
      → GPIO 初始化 → 自研 RTOS 初始化 → 分配 400KB+40KB 打印缓冲
      → BT 模组初始化 → 6 个子系统注册 → LCD 驱动 IC 检测
      → CPCL 任务初始化 → 调度器启动

数据流:
  BT 模组 ──EXTI17──→ USART3 (921600) → 环形缓冲
    → bt_data_comm_task → 协议分流
        ├─ [GS,'J',SOH] 前缀 → 专有帧协议
        └─ 其余字节 → CPCL 解释器(vtable 分派,92 处 memcmp 关键字匹配)
            → TEXT / BARCODE / QR / GRAPHICS 渲染到位图
            → 400KB 缓冲 + 2MB PSRAM
            → SPI1 移位 → TPH 打印头(576 dots)+ TIMER0 加热 PWM
            → TIMER2/3 步进电机走纸 → 切刀 GPIO

外设: USART3=BT  USART1=Debug  SPI1=TPH  SPI0=PSRAM  SPI2=外部Flash
      TIMER0=加热PWM  TIMER2/3=电机  ADC0/1=电压/温度/纸张  EXTI17=BT就绪

几个值得一提的发现:

  • 自研 RTOS:7 级优先级、8 个软件定时器、TCB 链表调度——不是 FreeRTOS 也不是 RT-Thread
  • CPCL 解释器:逐字节状态机 + vtable 分派,35 条命令完整分派表,支持 30+ 代码页(从 PC437 到 GB2312/BIG5,双 14×16 点阵中文字库内嵌)
  • JSON 字段描述符表 @ 0x080409A8:36 字节 stride 的 {char name[32]; void *handler} 结构表,把字段名映射到 handler——而 handler 指针其实是被编译器合并进巨型 dispatcher 函数内部的 case label 地址
  • LCD 驱动 IC 自适应:从 NVRAM 读配置,IST3931 / ST7567A / NV3023 三选一

理解度也分了三档:启动序列、RTOS 结构、BT 数据管线、协议分层、PSRAM、footer 签名等已完全理解(可直接重写);CPCL handler 细节、TPH 确切 GPIO 映射、电机相序、NVRAM 地址映射部分理解;bootloader、蓝牙模组固件、确切 IRQ 编号则需要硬件访问才能继续。

6. 四条教训

这个项目最值钱的可能不是结论,而是踩出来的四条可迁移教训。

教训 1:字符串名字反映业务概念,不反映实现位置

firmware_crc 这个字符串非常诱人。顺着它反汇编对应 handler(0x080226EF),结果发现代码只是把已存储的 CRC 值格式化成字符串——连续 subs #0x30、除以 10 的魔数——并不计算 CRC。真正的算法在别处。字符串说的是”我是谁”(API 字段名),不是”我在哪被算出来”。

教训 2:dead-code 表会骗人

即第 4 节 CRC 表的故事:一份 256 项全对的标准 CRC32 反射表,引用计数为零。存在 ≠ 使用。

教训 3:内循环形状只是必要条件,常数才是充分条件

找到过一个”教科书式”的 table-driven CRC32 内循环:

loop:
    ldrb  r3, [r1], #1
    uxtb  r0, lr
    eors  r0, r3
    ldr.w r0, [ip, r0, lsl #2]
    eor.w lr, r0, lr, lsr #8

形状完全正确。但它加载的表熵只有 2.5 bits/byte——CRC 表应该接近 8。那其实是位图/像素展开 LUT。形状像不等于就是,验证表的常数内容才是判决性证据。

教训 4:弱校验试在前

8 字节 footer 优先试 (sum, xor),5 行代码;CRC 全家桶放后面。顺序错了就是一小时。

以及一条贯穿始终的元教训:round-trip 自验是金标准——正向复现 + 扰动重封,缺一不可。

7. 工具选择与项目边界

前期故意不用 Ghidra/IDA,全程 Python + capstone:

  • 351KB 在脚本可控范围内
  • 流程要固化成可复用的 skill,命令行/Python-only 比 GUI 更容易沉淀
  • 不依赖大工具,强迫每个发现都被精确表达——熵值、引用计数、边界扫描表,而不是”Ghidra 说是这样”

进入大规模反编译阶段后才引入 IDA Pro 做系统性增强。

项目边界也要说清楚:本 bin 是 app-only,bootloader 未拿到(可能存在第二层校验);蓝牙模组固件独立,不在分析范围内;全部结论未经实机验证——这是纯静态分析能做到的极限,也是它诚实的样子。

8. 总结

从一个 351KB 的随机命名 bin 出发:指纹五 pass 锁定 GD32E50x + HPRT + CPCL + 自研 RTOS;cheap-sweep 优先策略 5 行 Python 破解 footer 签名;安全面扫描确认零活跃加密、无 flash 自写能力、OTA 与 MCU 无关;最终还原出从蓝牙字节流到打印头加热 PWM 的完整数据通路。

全套流程(指纹、footer 逆向、安全扫描、外设映射)已沉淀为可复用的方法论。下一个未知固件,起点会高得多。


本文整理自 2026-06 固件逆向项目笔记(AI 辅助静态分析),经人工审校。项目为纯学习目的,未对任何实机做修改。

从 351KB 黑盒到完整软件栈:未知 MCU 固件逆向方法论
https://blog.misakacloud.net/blog/mcu-firmware-reversing-methodology
Author Misaka Cloud
Published at 2026年7月17日