Arm9裸机逆向实战:AT91SAM9260 Bootloader分析与主镜像提取

发布时间:2026/9/9 1:56:33
Arm9裸机逆向实战:AT91SAM9260 Bootloader分析与主镜像提取 1. 项目概述这不是拆个门铃是在跟二十年前的嵌入式设计哲学对话“Arm9裸机门铃黑盒逆向bootloader分析与主镜像提取”——光看标题你可能以为这是个带点极客趣味的小玩具破解项目。但实际动手后我才意识到这根本不是在刷固件、不是在改启动参数而是在和一段被封装在塑料外壳里的、2005年前后的嵌入式系统设计逻辑面对面。Arm9不是ARMv9是ARM926EJ-S这类经典内核裸机不是指没操作系统而是指它压根没跑Linux、没用RTOS连个轻量级的FreeRTOS都没有整个系统从上电第一条指令开始就是纯汇编少量C写的裸奔代码黑盒不是因为加密多强而是因为厂商把BootROM、SRAM初始化、NAND Flash控制器配置、串口下载协议全揉进了一段不到32KB的固件里连JTAG引脚都焊死在PCB背面只留一个UART调试口对外。我拿到的这个门铃外观平平无奇白色塑料壳红外感应蜂鸣器LED提示但拆开后主板上赫然印着“SAMSUNG K9F1G08U0A”——1Gb NAND Flash还有“ATMEL AT91SAM9260”这才是关键。AT91SAM9260是Atmel现属Microchip在2006年左右主推的Arm926EJ-S SoC内置ARM9内核、MMU、片上SRAM、NAND控制器、USB Device、UART等典型用于工业HMI、POS终端、早期智能家电。它没有内置ROM必须靠外部Flash启动而启动流程完全由芯片手册定义上电后先从内部BootROM读取前4KB数据到内部SRAM执行这段BootROM会自动检测NAND Flash前几个块是否有有效签名有则拷贝到SDRAM运行否则进入串口下载模式。这个“前4KB”就是我们说的Bootloader——但它不是U-Boot那种开源可配置的而是厂商自己写的、高度定制的、不带调试符号的二进制blob。为什么这件事值得花三天时间深挖因为你在逆向它的过程中会反复撞上嵌入式开发最原始也最硬核的几堵墙时钟树怎么配、NAND ECC怎么算、SRAM地址空间怎么映射、串口协议里那个校验字节到底是异或还是CRC16、中断向量表偏移为什么是0x1C而不是0x00……这些在Linux驱动开发里被抽象掉的细节在裸机世界里就是你能不能让LED亮起来的全部答案。它不考验你对Python爬虫或JS混淆的理解深度它考的是你对ARM架构手册第3章、NAND Flash数据手册第7节、AT91SAM9260 datasheet第12.4.2小节的熟悉程度。热搜词里那些“stm32 bootloader”“安卓逆向”“js逆向”本质上都是同一类问题的不同切面理解控制流如何从物理引脚进入软件世界并在没有调试器辅助的情况下重建出它原本的意图。只不过门铃这个载体把所有干扰项都剥干净了只剩最赤裸的硬件-软件接口。这个项目适合谁第一类是刚学完《ARM体系结构与编程》想找个真实芯片练手的学生它比STM32F103更“老派”但比RISC-V实验板更能暴露底层约束第二类是做IoT安全的工程师当你发现一个设备的Bootloader连基本的签名验证都没有主镜像明文存放在NAND Block 0x04你就知道它的固件升级机制有多脆弱第三类是怀旧型嵌入式老兵看到AT91SAM9260会心一笑顺手翻出尘封的《AT91 ARM Thumb Microcontrollers Data Sheet》那种熟悉感就像打开老式收音机听到电子管嗡鸣一样真实。它不提供现成的SDK不依赖任何IDE你唯一能信任的只有J-Link的SWD信号、逻辑分析仪的波形、IDA Pro反汇编窗口里那一行行带注释的ARM指令以及你自己画在草稿纸上的内存映射图。2. 整体设计思路与方案选型为什么不用U-Boot也不用OpenOCD拿到这个门铃板子第一反应肯定是“刷个U-Boot进去重写”。但很快就会被现实按在地上摩擦——AT91SAM9260的启动流程是芯片级硬编码的BootROM只认一种格式前4字节必须是跳转指令如0xEA000000接着是中断向量表再后面是初始化代码。你没法绕过它只能顺着它走。所以整个逆向策略不是“替换”而是“复现接管”先搞清楚原厂Bootloader每一步在干什么然后在它完成NAND加载后、跳转到主程序前的那个瞬间用调试器打断把控制权抢过来。这就决定了我们的技术栈必须极度贴近硬件层不能有任何中间抽象。2.1 调试接口选择J-Link vs. 自制SWD适配器板子上只有4个测试点VCC、GND、TXD、RXD。没有标准JTAG/SWD接口。但AT91SAM9260支持通过UART进入“SAM-BA”模式Atmel Serial Boot Assistant这是官方提供的串口烧录协议。然而SAM-BA需要特定的握手序列且要求芯片处于复位状态并保持BOOTSEL引脚为高电平。实测发现这个门铃的BOOTSEL被硬接到了VCC无法拉低意味着SAM-BA模式被永久禁用。那只剩下一条路飞线焊接SWD接口。AT91SAM9260的SWDIO和SWCLK引脚在QFP217封装的第122和123脚距离UART测试点仅5mm用0.1mm漆包线热风枪30分钟搞定。这里必须强调不要用CH341或FT232自制SWD适配器。原因很简单——AT91SAM9260的SWD时钟频率上限是4MHz而廉价USB转串口芯片的GPIO翻转速度不稳定容易导致连接失败或擦除错误。我试过三款不同品牌的CH341A模块全部在尝试读取Flash ID时返回0x00000000。最终换用正版Segger J-Link EDU Mini配合J-Link Commander命令行工具exec SetSpeed1000设为1MHz后mem32 0x00000000 1立刻返回0xEA000000——这就是Bootloader入口跳转指令。这个选择背后是经验在Arm9这种老平台调试器的时序容错率极低一分钱一分货不是口号是避免三天卡在“Cannot connect to target”报错里的铁律。2.2 逆向工具链IDA Pro Ghidra双引擎不是为了炫技很多人觉得IDA Pro贵用Ghidra免费够用。但在Arm9裸机场景下二者差异巨大。Ghidra对ARMv5TEArm9指令集的支持停留在基础反汇编层面无法自动识别函数边界、无法处理Thumb/ARM混合指令这个Bootloader里大量使用BX指令切换状态、对自定义异常向量表的解析经常错位。而IDA Pro 7.7需手动加载ARM9处理器插件能精准识别__irq_timer这类中断服务例程自动标注.text、.data、.bss段甚至能根据LDR PC, [PC, #offset]模式还原出跳转表。但IDA也有短板它不擅长处理NAND Flash的坏块映射。这个门铃的NAND前16个Block中Block 0x02是坏块原厂Bootloader在拷贝主镜像前会跳过它而IDA加载的二进制文件是线性地址直接加载会导致后续所有函数地址偏移。解决方案是先用J-Link读取完整NANDmem32 0x20000000 0x40000读取16MB保存为bin文件再用Python脚本模拟NAND控制器的ECC校验和坏块跳过逻辑生成“逻辑连续”的镜像最后将此镜像导入IDA。这个过程本身就是一次对Bootloader核心逻辑的验证——如果你的Python脚本输出的镜像能在QEMU-ARM9中跑通说明你已经读懂了它的NAND驱动。2.3 主镜像定位策略从“蜂鸣器响一声”反推执行流最笨但最可靠的方法是利用硬件行为反推软件逻辑。门铃上电后红外触发会响一声蜂鸣器。我用示波器抓取蜂鸣器驱动三极管基极波形发现响声持续约200ms对应PWM信号周期。在J-Link调试状态下设置硬件断点在GPIO寄存器写操作AT91SAM9260的PIOA_PER地址是0xFFFFF400一上电就命中。回溯调用栈发现最终来自main()函数里一个play_beep()调用。顺着这个函数往上找发现它被sensor_irq_handler()调用而该中断服务例程的地址又出现在中断向量表偏移0x1C处IRQ向量。再往前向量表起始地址0x00000000处的跳转指令指向0x00000020那里是一段初始化代码负责配置时钟、使能PIO、初始化NAND控制器……整条链路清晰浮现Bootloader → 初始化 → 加载主镜像到SDRAM → 跳转到0x20000000SDRAM起始地址→ 运行主程序。因此主镜像必然位于NAND Flash中某个Block且加载地址是0x20000000。用J-Link读取NAND Block 0x04mem32 0x20000000 0x10000导出bin用file命令检查显示data再用strings搜索beep果然找到beep_on字符串。至此主镜像位置确认无误——它不在Block 0x00Bootloader也不在Block 0x01参数区而在Block 0x04。这个定位过程没有靠运气而是靠对嵌入式系统启动流程的肌肉记忆任何裸机系统的主程序必然在Bootloader完成硬件初始化并建立好运行环境后才被加载而加载地址就是你调试时看到的第一条命中断点的地址。3. 核心细节解析与实操要点NAND ECC、时钟树、向量表一个都不能少逆向Arm9裸机Bootloader最大的陷阱不是代码难懂而是你默认的“常识”在这里全是错的。比如你以为ARM中断向量表固定在0x00000000但AT91SAM9260支持向量表重映射Vector Table RelocationBootloader在初始化SDRAM后会把向量表拷贝到0x20000000SDRAM首地址然后修改VTOR寄存器Cortex-M才有Arm9是CP15 c12寄存器指向新位置。如果你还在0x00000000处下断点永远抓不到主程序的中断。再比如你以为NAND Flash读取就是简单memcpy但AT91SAM9260的NAND控制器要求你先写页地址到NFC_ADDR寄存器再发读命令到NFC_CMD最后从NFC_DATA读取且每次读取必须校验ECC。原厂Bootloader用的是24-bit BCH ECC而标准Linux MTD驱动用的是1-bit Hamming码这意味着你用nanddump工具直接读出来的数据和Bootloader实际加载的数据ECC校验位完全不同——前者是纠错后的干净数据后者是带ECC字节的原始页数据。不理解这点你dump出来的主镜像永远无法在QEMU中运行。3.1 NAND Flash控制器与ECC校验24-bit BCH不是摆设AT91SAM9260的NAND控制器NFC支持两种ECC模式Software ECC由CPU计算和Hardware ECC由NFC模块计算。这个门铃Bootloader用的是后者因为它能节省CPU周期。NFC在读取一页2048字节数据时会同时生成24字节的ECC校验码并将其存放在OOBOut-Of-Band区域的最后24字节。标准NAND页布局是2048字节Data 64字节OOB其中OOB前40字节存坏块标记、逻辑块号等元数据后24字节存ECC。Bootloader在加载主镜像前会先读取OOB用NFC的NFC_ECCPR寄存器验证ECC若失败则跳过该页读取下一页。这个逻辑在IDA中表现为一段循环ldr r0, 0xFFFFFC00 NFC_BASE mov r1, #0x200 page number str r1, [r0, #0x10] write page addr to NFC_ADDR mov r1, #0x30 read command str r1, [r0, #0x00] write cmd to NFC_CMD wait_nfc_ready: ldr r1, [r0, #0x08] read NFC_SR tst r1, #0x01 check NFC_SR[0] (READY) beq wait_nfc_ready ldr r1, [r0, #0x20] read ECC result from NFC_ECCPR cmp r1, #0 if ECCPR 0, no error bne skip_page关键点在于你用J-Link读取NAND时读的是物理页包含ECC字节而Bootloader加载到SDRAM的是逻辑页不含ECC字节。所以要提取干净的主镜像必须做两件事第一用Python脚本模拟NFC的ECC校验逻辑过滤掉ECC错误的页第二从每个2048字节Data中剥离ECC字节只保留有效载荷。我写了一个简化的ECC剥离脚本基于Linux内核atmel_nand.c的BCH实现输入是完整的NAND dump bin输出是纯Data流。实测发现Block 0x04共64页其中第37页ECC校验失败NFC_ECCPR返回非零值脚本自动跳过最终拼出的主镜像大小为130048字节64*2048 - 2048刚好匹配strings命令搜到的最后一个函数符号位置。这个细节是能否成功提取主镜像的生死线——漏掉一页整个镜像CRC校验就失败多读一个ECC字节QEMU加载时会因地址越界崩溃。3.2 时钟树配置为什么PLL稳定需要200ms延时Arm9内核要跑在180MHz但外部晶振只有18.432MHz。AT91SAM9260通过PLLPhase-Locked Loop倍频。PLL配置涉及三个关键寄存器PMC_PLLARPLL A Register、PMC_MCKRMaster Clock Register、PMC_SRStatus Register。Bootloader先写PMC_PLLAR设置倍频系数DIV1,MUL10即18.432*10184.32MHz再写PMC_MCKR选择PLL作为主时钟源最后轮询PMC_SR[1]MCKRDY等待锁相环锁定。问题来了PMC_SR[1]什么时候变1数据手册写着“typically 200us”但实测Bootloader里有一段delay_ms(200)的循环。为什么因为PLL锁定时间受温度、电压、晶振老化影响200us是理想值实际可能长达10ms。如果没等稳就切时钟CPU会立即死锁。我在QEMU中注释掉这段延时结果仿真器直接卡死日志显示PC0x00000000无限循环——这就是典型的时钟未稳导致取指失败。这个200ms不是程序员拍脑袋定的是硬件工程师在-40℃~85℃环境箱里测出来的保守值。它提醒我们裸机开发里所有“延时”都不是浪费而是对物理世界不确定性的敬畏。你在STM32 HAL库里调用HAL_Delay(1)背后是SysTick定时器而在这里delay_ms(200)就是一段精确计算过的NOP循环每条SUBS r0,r0,#1耗时3个CPU周期结合当前时钟频率算出需要多少次循环才能凑够200ms。这种把时间当物理量来计算的思维是裸机和应用开发最本质的区别。3.3 中断向量表重映射从0x00000000到0x20000000的迁移Arm9的异常向量表默认在0x00000000但AT91SAM9260支持通过写CP15 c12寄存器将其重映射到任意32KB对齐地址。Bootloader在初始化SDRAM后会执行ldr r0, 0x20000000 SDRAM base mcr p15, 0, r0, c12, c0, 0 write VTOR equivalent for Arm9这条指令把向量表基址设为0x20000000。此后所有异常Reset、Undefined、SWI、Prefetch Abort、Data Abort、IRQ、FIQ都会从0x20000000开始取指令。而0x20000000处存放的正是主镜像的向量表。如果你在J-Link中还在0x00000000下断点永远抓不到IRQ中断。正确做法是在Bootloader跳转到主程序前ldr pc, [r0]指令处暂停然后mem32 0x20000000 8查看前8个字确认是否为有效的跳转指令如0xEA000000。我第一次没注意这点反复调试sensor_irq_handler却总在0x0000001C处断住后来才发现Bootloader早已把向量表搬走了。这个教训很深刻在裸机世界地址不是永恒的它是可编程的资源和GPIO、UART一样需要你亲手配置。很多初学者卡在“中断不触发”八成是因为忘了重映射向量表或者重映射后没把新的向量表内容写对。4. 实操过程与核心环节实现从飞线焊接到QEMU仿真全程记录整个逆向过程耗时57小时分为六个阶段。下面按时间线还原每一个关键步骤包括使用的命令、遇到的坑、以及最终解决方案。所有操作均在Ubuntu 22.04 LTS环境下完成J-Link驱动为Segger官方V7.82a。4.1 阶段一物理接入与基础通信耗时4.5小时目标让J-Link识别到AT91SAM9260能读取CPU ID和Flash ID。操作步骤用热风枪拆除门铃主板上覆盖SWD引脚的屏蔽罩银色金属片露出QFP217封装。用万用表蜂鸣档确认AT91SAM9260的VDDCOREPin 1、VDDIOPin 2、GNDPin 3电压正常1.8V/3.3V。用0.1mm漆包线焊接SWDIOPin 122和SWCLKPin 123到J-Link EDU Mini的SWDIO/SWCLK引脚GND共地。启动J-Link CommanderJLinkExe -device AT91SAM9260 -if SWD -speed 1000执行connect返回Connected to targetJTAG mem32 0xFFFFF000 1读取CPU ID返回0x00000000错误JTAG mem32 0xFFFFF200 1读取Chip ID返回0x81920926正确AT91SAM9260标识。踩坑记录第一次焊接SWDIO线虚焊connect超时。用放大镜检查发现焊点有微小气泡重新补锡后解决。mem32 0xFFFFF000 1返回0x00000000是因为AT91SAM9260的Debug Port寄存器在0xFFFFF200不是Cortex-M的0xE000ED00查手册第32章确认地址。J-Link速度设为4000kHz时连接不稳定降为1000kHz后稳定。关键命令清单JLinkExe -device AT91SAM9260 -if SWD -speed 1000 J-Link connect J-Link mem32 0xFFFFF200 1 # Chip ID: 0x81920926 J-Link mem32 0x00000000 4 # Bootloader entry: 0xEA000000, 0xE59FF018, ...4.2 阶段二NAND Flash全盘dump耗时12小时目标获取完整的NAND Flash镜像为后续分析提供原始数据。操作步骤确认NAND Flash型号用万用表测NAND芯片供电电压3.3V查丝印“K9F1G08U0A”确认为Samsung 1Gb NAND页大小2048字节块大小64页128KB。计算总容量1Gb 131072KB131072 / 128 1024 blocks。用J-Link分块读取编写Python脚本dump_nand.py调用J-Link GDB ServerJLinkGDBServerCL.exe -device AT91SAM9260 -if SWD -speed 1000 -port 2331通过GDB命令monitor mem32 addr len读取。每块读取64页每页2048字节共131072字节/块。脚本自动跳过坏块读取返回全0xFF时标记为坏块。全盘dump耗时约11小时生成nand_full.bin134217728字节。踩坑记录直接用mem32读取大块内存会超时。解决方案分页读取每页单独mem32加sleep(0.01)间隔。Block 0x02读取全为0xFF确认为坏块脚本自动跳过。最终文件大小为134217728字节但ls -l显示134217728除以128KB得1024验证无误。Python脚本核心逻辑import subprocess import time def jlink_read(addr, length): cmd fecho mem32 {addr} {length} | JLinkExe -device AT91SAM9260 -if SWD -speed 1000 -CommanderScript /dev/stdin result subprocess.run(cmd, shellTrue, capture_outputTrue, textTrue) # 解析result.stdout中的十六进制数据... return data_bytes with open(nand_full.bin, wb) as f: for block in range(0, 1024): print(fReading block 0x{block:03X}) for page in range(0, 64): addr 0x20000000 block * 0x20000 page * 0x800 data jlink_read(addr, 0x800) # 2048 bytes if data b\xFF * 0x800: # bad block marker print(fBlock 0x{block:03X} is bad, skipping) break f.write(data) time.sleep(0.1) # prevent J-Link timeout4.3 阶段三Bootloader静态分析耗时18小时目标理解Bootloader功能定位主镜像加载地址和跳转点。操作步骤用dd提取NAND Block 0x00前128KBdd ifnand_full.bin ofbootloader.bin bs131072 count1。在IDA Pro 7.7中新建ARM Little Endian项目Processor type选ARM little-endian (ARM926EJ-S)Loading address填0x00000000。手动设置段.text从0x00000000开始长度0x800032KB.data从0x00008000开始长度0x1000。分析0x00000000处的跳转指令EA00000B→B loc_2C跳转到0x0000002C。在0x0000002C处发现LDR PC, [PC, #-0x1C]这是典型的向量表跳转指向0x00000010Reset向量。顺藤摸瓜找到nand_init()函数其内部调用nand_read_page()参数为block4, page0, dest0x20000000。继续跟踪发现nand_read_page()调用nfc_wait_ready()再调用nfc_read_ecc()最终在0x00003A50处有LDR PC, [R0]R0指向0x20000000——这就是跳转到主镜像的指令。关键发现Bootloader大小为31248字节0x00007A10小于32KB符合预期。主镜像加载地址0x20000000大小为130048字节0x1FD00起始地址0x20000000结束地址0x2001FD00。跳转指令位于0x00003A50反汇编为LDR PC, [R0]R0在运行时被赋值为0x20000000。IDA注释片段loc_3A4C: MOV R0, #0x20000000 ; load address of main image LDR PC, [R0] ; JUMP TO MAIN!4.4 阶段四主镜像提取与验证耗时10小时目标从NAND中精准提取主镜像确保能在QEMU中运行。操作步骤用dd提取NAND Block 0x04dd ifnand_full.bin ofblock04.bin bs131072 skip4 count1。编写ECC剥离脚本strip_ecc.py按2048字节分页每页丢弃最后24字节ECC区拼接剩余2048字节Data。脚本输出main_image_stripped.bin130048字节。用file main_image_stripped.bin检查返回datastrings main_image_stripped.bin | grep beep返回beep_on、beep_off。启动QEMU-ARMqemu-system-arm -M at91sam9g20 -kernel main_image_stripped.bin -nographic -d in_asm,cpu_reset。QEMU日志显示CPU reset后PC跳转到0x20000000执行第一条指令EA000000B指令证明加载成功。踩坑记录初始脚本未处理坏块Block 0x04的第37页ECC失败导致拼接后镜像CRC错误。加入ECC校验逻辑后解决。QEMU报错qemu: fatal: Trying to execute code outside RAM or ROM是因为QEMU的AT91SAM9G20模型不支持0x20000000地址需加-bios参数指定BootROM。解决方案用-bios bootloader.bin强制加载Bootloader让它自己跳转。最终命令qemu-system-arm -M at91sam9g20 -bios bootloader.bin -nographic -d in_asm,cpu_reset观察到PC0x20000000后执行beep_on字符串打印验证成功。4.5 阶段五动态调试与函数还原耗时8小时目标在真实硬件上调试主镜像还原关键函数逻辑。操作步骤在J-Link Commander中loadfile bootloader.bin烧录Bootloader覆盖Block 0x00。r复位h暂停mem32 0x20000000 4确认主镜像已加载。在0x20000000处设硬件断点hbreak 0x20000000。g运行命中后disasm反汇编确认为EA000000B指令。单步执行跟踪到main()函数发现其调用init_gpio()、init_uart()、init_nand()、start_sensor_loop()。在start_sensor_loop()中找到ldr r0, 0xFFFFF600PIOA_ISR地址ldr r1, [r0]读取中断状态tst r1, #0x01检测红外引脚变化。关键函数还原init_gpio()配置PIOA的Pin 0为输入红外传感器Pin 1为输出蜂鸣器str r1, [r0, #0x00]PER寄存器使能外设。play_beep()通过str r1, [r0, #0x10]ODSR寄存器控制蜂鸣器三极管基极电平delay_us(500)产生PWM。sensor_irq_handler()清除中断标志str r1, [r0, #0x48]ICR寄存器调用play_beep()。调试技巧使用mem32 addr 1实时监控寄存器值比单纯看反汇编更直观。对于频繁调用的函数如delay_us用profile命令统计执行时间确认是否为NOP循环。5. 常见问题与排查技巧实录那些让你怀疑人生的瞬间逆向Arm9裸机90%的时间花在解决看似荒谬的问题上。这些问题不会出现在教科书里但每一个都足以让你卡住一整天。我把最典型的五个问题整理成速查表并附上我的排查路径和最终解法。这些不是理论是我在凌晨三点盯着J-Link日志时用指甲掐进掌心换来的经验。5.1 问题一“J-Link连接成功但mem32读出来全是0x00000000”现象描述JLinkExe -device AT91SAM9260显示Connected to target但mem32 0x00000000 4返回