在最近的ctf比赛中,开始出现以stm32系列固件分析为代表的物联网安全类题目,由于这类问题涉及到嵌入式硬件、嵌入式系统等多方面的知识,未来出题、研究空间都非常广阔,这次就让我们借助题目一起来学习一下。由于考研失踪了很久,很多联系我的同学我也没能回复,希望大家原谅,之后一段时间我就正式回来啦。
啥是stm32?
STM32是一种功能强大的32位的单片机,它基于低功耗的ARM内核,由于它采取的Cortex的内核,性能比起传统的51单片机强了不知道多少倍,而且还提供了强大的外围设备,比如usb,这都是以往的单片机难以想象的。
除了性能、功能外,由于它模块化的理念,丰富的库,我们可以更多在软件层面开展工作,相对于51单片机庞大的电子电路知识来说,对我们这些计算机人友好不少。
当前,不管是工厂、自控设备、物联网终端等等,stm32所占的份额都在不断扩大,在安全领域,stm32也是越来越多出现在我们视野里,可以说,stm32成了物联网安全不得不碰的东西。
SCTF PassWordLock PLUS
先来看个实际的题目,它给了一个stm32f103c8t6的hex固件,描述是一个stm32连接了四个按钮,按照一定的顺序点击按钮就会输出flag。通过前一个SCTF PassWordLock可以得到按键的顺序,剩下的就需要我们自己分析了。
我会先用我们实际做题的思路来给大家分析这道题目,然后提出几个疑问,并在后续为大家解答。
我们可以用Frida或者IDA pro进行静态分析,因为Frida初始化工作较为简单,所以我们这里以IDA pro为例。首先我们需要设置处理器为ARM
接着还需要点击processor option设置ARM的相关参数,这里要根据对应的stm32的架构进行选择,没得商量,这里是stm32f103c8t6查阅资料后确定这里是armv7-m、thumb-2(后面我会提到stm32的命名方法)。
接下来ida会问你固件ram、rom、Loading address,前两项分别代表着ram、rom的起始地址,第三项则是装载地址,一般是和rom地址相同(在stm32中,程序、bootloader会映射到ROM位置)。这里开发者可以自行设置,但默认值如下所示。
图中为stm32开发工具mdk,当我们对一个项目进行编译、烧写时,可以自行设定其rom、ram的相关值,不过一般都是使用默认值,特别的是其中的ram size,他和我们使用的stm32设备的内存有关,比如常见的64k内存(后面我们也会说如何判断内存大小),即0x10000。
到此为止ida已经成功载入了文件,但是没有识别出来任何代码,只有一片一片的DCB,这个命令的意思是分配一段内存单元,并对该内存单元初始化,这里我们就不免联想到stm32开发时,会有个startup的过程,其中有一部分就是开辟一片空间并将函数地址放入,构建向量表,我们打开对比一下
这里是DCD,ida里是DCB,这两条指令的作用是一样的,只是数据长度不同罢了(stm32为32位机,自然应该是d的double word了),我们可以按d对数据长度进行转换,就可以得到DCD的代码,后面的即是各个函数的地址了,我们只需要一一对应即可。
对各个函数手工用c(ida pro的c键即为强制转换为代码)进行强制转换,即可得到对应的汇编代码,还可以利用hexrays 插件将代码转换为伪c代码,更方便我们分析。
做到这一步,实际上后面就可以完全使用静态分析的方式来做了
此段代码较为简单,就是将字符串写入ram,然后调用后续函数
其中sub_800388是调用usart进行数据发送的,开始发送了“SCTF{”几个字符串。
按键顺序正确后发送剩余的flag,剩余的flag就是简单对刚刚输入到ram的字符串进行了处理
f = "t_h_1_s_1_s_n_0_t_r_1_g_h_t_f_l_a_g_"
s = list(f)
s[6] = "t"
s[0x10] = "_"
s[0x4] = "a"
s[0xe] = s[0x1]
s[0xc] = "_"
print(s)
最后要注意的是usart发送数据长度是有讲究的,这里为8位,我们在后面会提到为什么。
上述做法有什么问题吗?答,没有,做出来了,而且也不慢。但是过程总让人觉得不够优雅,而且部分地方磕磕碰碰,还有几个悬而未决的问题:
- 程序怎么跑起来的?
- 为什么一堆看上去复杂的结构体代码?
- 几个按钮的工作机制是什么?
- 这里的usart凭什么是8位的?(不能因为答案正确就放弃探究了)
- 我能不能重写这道题目的代码?
再后来我把丢掉的stm32课本、手册捡了起来,重新复习了相关的知识、开发流程,最终复现了这道题目,并且通过使用proteus动态调试的方式得到了flag
图为内存中字符串存储情况
图为使用proteus搭建的仿真电路
接下来的文章我一一回答上面的几个问题。
最简单的方式让程序先跑起来
我们就从最最最开始的地方开始看,我们使用非常方便的stm32cude新建一个stm32的工程(可以通过直接点点点完成stm32的初始化),我这里选择的是和stm32f103c8t6。
stm系列的命名就对应了他的结构:
- 32代表他是32位的处理器
- f则代表了他是普通功耗的处理器,此外还有L,即low,代表低功耗处理器
- f后面的第一位数字越大代表性能越强。主要有0、1、2、3、4、7,第二位、第三为特定功能,比如29指带有DSP、FPU
- 0代表最低配,使用的是 Cortex-M0内核
- 1、2代表 Cortex-M3 内核,主频为 72MHZ。
- 3、4代表 Cortex-M4 内核,主频为 180MHZ。
- 7代表 Cortex-M4 内核
- c代表引脚数,大致如下图,引脚会影响我们选择的封装方式
- 8,代表了闪存容量,这里就是64KB闪存
- t则是说明封装方式,这里是QFP大类的封装方式,封装的知识大家有兴趣可以自己查一下,这是我的知识盲区
- 最后的6则是板子能用的温度范围
LQFP48代表封装方式,48说明有48个引脚。不过不要怕,在我们没有操作之前,他的引脚都非常简单,就三类:
- power,比如vssa、vbat等,他们都有自己的含义,但归根结底都是和电相关的,我们暂且不学。
- reset,重置用的。
- 普通引脚,pa、pb、pc、pd打头的,他们后期“变身”成各种独特的引脚,但没变之前其实就是……引脚,你想让他干啥都行。
我们可以直接点击图中的引脚即可选择功能,这是简单的点了点进行了一些配置,比如使用上了rcc_osc是用外部晶振,把pa0特化为了gpio_input(pa0口可以接收外界数据了!),pa2为usart(就是上面做题时遇到的万恶的usart),pa3设置为了外部中断(题目的按钮功能就是这么实现的,按下去即触发中断)。我这里是为了演示功能,大家要复现题目的话需要把题目涉及到的每一个引脚都老老实实设置好,否则后面编程是会因为没有初始化引脚而报错的。
之后一路点点点,最后选择生成工程文件的项目格式,即生成后你想用什么工具进行开发,个人比较喜欢mdk,所以就选择mdk了,这样stm32cube就会为我们生成工程文件,到此,一个stm32项目就完成了创建过程,我们已经可以在mdk编译烧录到板子或者进行proteus仿真运行了。
在项目的MDK/ARM目录下我们可以发现startup_stm32f103x6.s文件,该文件完成初始化的代码,可以看到就是我们ida最开始那一段。
同样目录下有xxx.uvprojx文件,点击即可使用mdk打开项目
可以看到stm32cube为我们生成了一堆代码,为我们封装了一堆非常好用的函数,并且根据我们上面的设置为我们初始化好了各个引脚。我们找到main函数,就可以开始编程了。
库函数的使用非常方便,stm32f1xx_hal_gpio.h里就包含了gpio相应的函数,例如:
HAL_GPIO_WritePin(GPIOB,GPIO_PIN_5,GPIO_PIN_RESET); //可以将PB5引脚设置为reset状态
通过这类提供的库函数,简单写一下代码就可以轻松复现这道题目了。
为啥那么多结构体代码
但是事情显然没有这么简单,我们在做题过程中可没有那么多库函数可供识别,甚至于有的题目压根就不是用stm32cube这种“投机取巧”的方式出的,我们在ida中看到的大量代码都是对结构体的操作,这又是为什么呢?
实际上,stm32通过寄存器来控制一切,电源控制需要寄存器、时钟控制需要寄存器、GPIO也需要寄存器,而大部分时候我们是不会手写这些寄存器的初始化工作的,而mdk、stm32cube等开发工具也为我们提供好了对应功能的函数,我们也不再需要直接操作寄存器实现功能,但永远不要忘记,我们实际上用的函数实际上一直都是在用寄存器最终实现,而寄存器又是怎么和ida识别出来的大量结构体产生联系的呢?
答案就是存储器映射,stm32内存空间采取存储器映射的方式,也就是说,有一部分存储空间直接就是外设寄存器的映射,通过设置这些存储空间的值,就可以直接改变外设的状态。
而stm32手册中详细说明了外设的寄存器哪一位是干什么的,我们只需要设置相应的结构体,将结构体的指针指向外设寄存器对应的内存地址,就可以通过操作结构体的方式进而直接操作外设。
这里我们就通过一个GPIO的初始化来看看,我们新建一个.c文件,并开始编写如下代码
#define PERIPHY_BASE ((uint32_t)0x40000000)
#define ABP1PERIPHY PERIPHY_BASE
#define ABP2PERIPHY (PERIPHY_BASE + 0x10000)
#define GPIOA_BASE (ABP2PERIPHY + 0x800)
#define GPIOB_BASE (ABP2PERIPHY + 0xC00)
#define GPIOC_BASE (ABP2PERIPHY + 0x1000)
其中APB是一种性能较低的主线,主要用于低带宽的周边外设之间的连接,APB2负责AD,I/O,高级TIM,串口1,APB1负责DA,USB,SPI,I2C,CAN,串口2345,普通TIM。也就是说我们的GPIO就是挂在这根主线上的。
我们首先根据内存地址设置PERIPHY_BASE,也就一切寄存器的基址,我们通过对基址的简单加减就可以得到其他寄存器的地址,接着我们根据手册找到ABP2总线,再找到各个GPIO的偏移,加起来即可。
#define __IO volatile
typedef unsigned int uint32_t;
为了方便使用我们把volatile和 unsigned int改改名,注意volatile是防止编译器优化代码的,毕竟我们的结构体最终要实打实的控制外设,任何优化可能出现意想不到的情况。
接下来就是要定义GPIO相关寄存器的结构体,因为太多了就简单写两个
typedef struct
{
__IO CRL_Bit CRL;
__IO CRH_Bit CRH;
__IO IDR_Bit IDR;
__IO ODR_Bit ODR;
__IO BSRR_Bit BSRR;
__IO BRR_Bit BRR;
__IO LCKR_Bit LCKR;
}GPIO_Type;
typedef struct
{
uint32_t MODE8 : 2;
uint32_t CNF8 : 2;
uint32_t MODE9 : 2;
uint32_t CNF9 : 2;
uint32_t MODE10 : 2;
uint32_t CNF10 : 2;
uint32_t MODE11 : 2;
uint32_t CNF11 : 2;
uint32_t MODE12 : 2;
uint32_t CNF12 : 2;
uint32_t MODE13 : 2;
uint32_t CNF13 : 2;
uint32_t MODE14 : 2;
uint32_t CNF14 : 2;
uint32_t MODE15 : 2;
uint32_t CNF15: 2;
}CRH_Bit;
每个 GPIO 端口有两个 32 位配置寄存器(GPIOx_CRL,GPIOx_CRH),两个 32 位数据寄存器(GPIOx_IDR,GPIOx_ODR),一个 32 位置位/复位寄存器 (GPIOx_BSRR),一个 16 位复位寄存器(GPIOx_BRR)和一个 32 位锁定寄存器 (GPIOx_LCKR)。
其中CRL和CRH是配置用的,可以将引脚配置为不同的模式,L、H是指low、high,即引脚号小的为CRL,引脚号大的为CRH,他主要有两个关键:
- CNFx,x代表第几个引脚,如GPIOA_CRL1表示的就是PA1引脚,代表不同的配置模式
- MODEx,x代表第几个引脚,代表启用引脚为输入还是输出
IDR、ODR是数据寄存器,I代表输入,O代表输出,主要是:
- ODRx,x代表第几个引脚,一般用来读取引脚输出的值
- IDRx,x代表第几个引脚,一般用来读取引脚输入的值
我们可以通过修改ODR、IDR来直接操作引脚的数据,但是我们一般不这样干,我们会选择BSRR和BRR来操作,BSRR是bit set reset的意思,即设置位、复位,而BRR是bit reset的意思,即复位,主要是下面两个:
- BRx,将第x引脚置为0
- BSy,将第x引脚置为1
有了这些知识,我们就可以初始化我们的引脚了,我写了一个非常简单的初始化程序,标好了注释供大家参考
void setGPIOConfig(void){
//使能IOA
//使能IOB
//使能IOC
RCC->APB2ENR.IOPAEN = 1;
RCC->APB2ENR.IOPBEN = 1;
RCC->APB2ENR.IOPCEN = 1;
/*
配置IOA为输出模式且为最大速率
推挽输出模式
PA0 -> LED1
PA1 -> LED K3
PA2 -> LED K1
PA9 -> LED G
PA15 -> LED K2
*/
GPIOA->CRL.MODE0 = 3;
GPIOA->CRL.MODE1 = 3;
GPIOA->CRL.MODE2 = 3;
GPIOA->CRH.MODE9 = 3;
GPIOA->CRH.MODE15 = 3;
/*
配置IOA为输入
上拉/下拉输入模式
PA10-> L KEY0
PA11-> R KEY1
PA12-> R KEY2
*/
GPIOA->CRH.MODE10 = 0;
GPIOA->CRH.MODE11 = 0;
GPIOA->CRH.MODE12 = 0;
GPIOA->CRH.CNF10 = 2;
GPIOA->CRH.CNF11 = 2;
GPIOA->CRH.CNF12 = 2;
/*
配置IOB为输出模式且为最大速率
推挽输出模式
PB3 -> LED C
PB4 -> LED K4
PB7 -> LED E
PB8 -> LED0
PB9 -> LED P
PB12 -> LED B
PB13 -> LED A
PB14 -> LED F
*/
GPIOB->CRL.MODE3 = 3;
GPIOB->CRL.MODE4 = 3;
GPIOB->CRL.MODE7 = 3;
GPIOB->CRH.MODE8 = 3;
GPIOB->CRH.MODE9 = 3;
GPIOB->CRH.MODE12 = 3;
GPIOB->CRH.MODE13 = 3;
GPIOB->CRH.MODE14 = 3;
/*
配置IOC为输出模式且为最大速率
推挽输出模式
PC13 -> LED D
*/
GPIOC->CRH.MODE13 = 3;
/*
设置LED0和LED1为高电平
PB8 -> LED0
PA0 -> LED1
*/
GPIOB->BSRR.BR8 = 1;
GPIOA->BSRR.BR0 = 1;
/*
其余为低电平
*/
GPIOA->BRR.BR1 = 1;
GPIOA->BRR.BR2 = 1;
GPIOA->BRR.BR9 = 1;
GPIOA->BRR.BR15 = 1;
GPIOB->BRR.BR3 =1 ;
GPIOB->BRR.BR4 =1 ;
GPIOB->BRR.BR7 =1 ;
GPIOB->BRR.BR8 =1 ;
GPIOB->BRR.BR9 =1 ;
GPIOB->BRR.BR12 =1 ;
GPIOB->BRR.BR13 =1 ;
GPIOB->BRR.BR14 =1 ;
GPIOC->BRR.BR13 = 1;
}
到此我们就已经聊完了GPIO方面的设置,我们可以用setGPIOConfig函数来代替之前stm32cube为我们自动生成的MX_GPIO_Init函数了。并且,我们也可以直接通过结构体赋值的形式来实现很多功能,比如下面的灯泡交替闪烁
void Delay()
{
uint32_t i,j;
for(i=0;i<1024;i++)
for(j=0;j<2048;j++)
;
}
int test()
{
while(1){
GPIOA->BSRR.BS8 = 1;
Delay();
GPIOA->BRR.BR8 = 1;
}
return 0;
}
中断让按钮生效
题目是要依次按下某些按钮才会触发usart传输数据,对于中断来讲,我们同样可以采取stm32cube生成或者自己编写寄存器结构体的形式,由于寄存器结构体的形式篇幅过长,这里就不展开了,有机会再和大家聊,这里只说cube自动生成的形式
还是在引脚界面,选好引脚,选择GPIO_EXTI1,注意还需要在右侧的nvic界面启动中断,之后就会生成响应的代码了
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
switch(GPIO_Pin)
{
case GPIO_PIN_4:
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_9);
break;
case GPIO_PIN_7:
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_15);
break;
default:
break;
}
}
采用回调函数,里面是一个switch结构,我们只需在对应的case下面编写我们的代码即可,我上面是实现了按一个按钮,对应的led就改变一次状态,稍微改一下就变成题目的形式啦。
小总结
篇幅限制我们还没有解决usart的问题,另外我们距离完全替代cube生成代码还有很大距离,在之后的文章中将会继续给大家分享相关知识。
关于自己编写初始化代码的问题,单单是跑起来让一个引脚的led灯亮所写的代码我就写了700多行,是一项庞大的工程,但是收获也很多,相信我,自己写一遍后,在ida pro里去看真的会亲切很多!如果大家对这部分感兴趣,可以给我留言或与我联系,我也会在后面陆续把我写的初始化相关的代码放出来。