VxWorks 固件分析初体验
Preface
最近在研究工控设备的固件,尝试对其进行一些基础的分析工作,手头大多数的固件采用的是VxWorks
操作系统,而非一般嵌入式设备使用的Linux
,很多东西属于刚入门一窍不通。另外,关于VxWroks
的工作和学习资料非常杂乱且稀缺,即便找到的也对新手十分不友好,本人在摸爬滚打中也踩了不少坑,这里做一个总的整理,希望能对同样受此困扰的盆友提供一点启发。
⚠️ 本博客随时根据笔者对VxWorks
的认知理解进行更新调整
From the beginning
一切的开始源于seebug
上的一篇针对VxWorks
固件分析的文章 基于 VxWorks 的嵌入式设备固件分析方法介绍,作者写的时间比较久远,笔者照葫芦画瓢遇到了许多坑,没办法像原作者一样十分顺利地完成分析工作,不过核心的分析流程还是以该博客为主。
采用的是施耐德昆腾系列PLC的NOE-711以太网模块固件[1]
Extract firmware
要对固件进行分析,需要先提取固件映像中的内容,最常见的就是利用binwalk
,首先对解压的固件映像文件分析,查看内容:
发现是单个压缩文件,直接用binwalk
解压发现得到一个385
的文件,与压缩包的偏移对应,对385
再次用binwalk
查看基本信息:
可以看到VxWorks
的内核信息,这个就是具体被加载的映像了,后面就需要用到反编译的工具了,目前常用的有:
- IDA Pro
- Ghidra
Analysis firmware loading address
通过binwalk -A 385
指令可以发现该固件采用的是PowerPC big-endian
架构,将其拖到IDA
中,更改相应架构:
加载地址先选择默认:
赶紧去的时候发现一个函数都没有,按c
将光标所在地址处的内容解析成代码,可以参考这篇文章[2]包含对IDA
常用快捷键的介绍。发现只有部分函数被解析了出来,这是因为初始内存地址没有选对:
按照原博客提供的思路,寻找起始地址的方式有很多,有些过于麻烦,并非是分析的重点,这里先用最简便的方法。
Analysis firmware header code
就像linux
等常用操作系统需要BIOS
将操作系统加载到指定的内存地址上一样,VxWroks
在初始化的时候也需要将操作系统的代码加载到指定内存,因此我们可以通过分析初始代码的一些行为来确定初始化地址。在汇编代码的起始处,有一段针对r1,r3
寄存器的操作,原博客对这段代码做了解释,这里同样粘一下:
原文中应该是笔误,跳转地址是
0x1CD94
有一些基础知识需要说明一下:
r1
:栈指针寄存器,相当于esp
r3
:第一个参数,相当于x64
里第一个存储参数的寄存器lis
[3]:寄存器高位赋值,加载立即数并左移16位,通常接addi
命令,实现对寄存器高低位的赋值addi
: 立即数增加,与add
相似b
: 无条件跳转指令,同j
,ba
是相对地址无条件跳转,bl
是跳转前将下一条指令的地址记录到lr
一开始开辟的栈地址被称作初始化栈,为userInit
函数(VxWorks
引导后运行的第一个函数)开的,这里可以猜想跳转的就是userInit
,而初始化栈的高地址为0x10
,这里直接用一下原博客的图:
可以知道映像加载地址和初始栈的地址是一致的,为0x10
知道加载地址后,我们重新用IDA
加载固件,并设置起始地址如下:
Repair function name
直接解析得到的函数名都是Sub_xxx
偏移的形式,在一开始对385
分析的时候可以发现固件中存在函数符号表,其地址为0x301E74
,用010 editor
编辑查看385
文件,找到对应地址,得到数据如下:
此处有一点没弄明白,用binwalk查看得到的符号表地址是
0x301E74
,但原作者选择的却是0x301E60
,这点没有理解
关于vxworks
的符号表,这里穿插一下,可以看看这篇文章再谈VxWorks符号表修复 - JaubertLong,测试固件采用的是VxWorks5.x
版本的(和6.x
版本有较大不同),符号表表项以16个字节一个单位,因此在图中可以分辨出一个表项的数据如下:
按照原作者给出的修复脚本,我尝试对原固件的函数名称进行符号修复,但发现会出现很多未知的警告和问题,最终修复效果很差,后来用了另一个博客[6]的修复脚本成功进行了修复,修复代码如下:
from idaapi import *
loadAddress = 0x10000
eaStart = 0x301E64 + loadAddress # 起始地址和结束地址的选取是根据最后4个字节类型是否为符号表类型来的
eaEnd = 0x3293A4 + loadAddress
ea = eaStart
while ea < eaEnd:
offset = 0
MakeStr(Dword(ea - offset), BADADDR)
sName = GetString(Dword(ea - offset), -1, ASCSTR_C)
print sName
if sName:
eaFunc = Dword(ea - offset + 4)
MakeName(eaFunc, sName)
MakeCode(eaFunc)
MakeFunction(eaFunc, BADADDR)
ea += 0x10
如果用
IDA Pro7.0+
的版本,还会遇到MakeStr
函数报错的问题,解决方法点这里
IDA Pro
本身并不支持PowerPC
指令架构的反编译,为了方便对源代码分析,后门采用另一款反编译软件Ghidra
,有两种好处
- 一是在
Ghidra
中使用VxHunter
,作者给出了较为详细的教程,我们可以利用vxhunter
实现自动化的函数名修复 - 二是
Ghidra
对于VxWorks
(ppc架构)的反编译做的比IDA
好,可以直接得到反编译的代码
Repair symbol with Ghidra and vxhunter
关于Ghidra
的使用,其官网[4]给出了非常详细的教程,这里不再赘述,主要说明一下如何在Ghidra
中添加vxhunter
,根据Github
仓库中的描述:
- 在
Ghidra
主界面点击window
,选择Script Manager
- 单击右上角列表选项,选择➕,并在内容中选择
vxhunter
对应脚本所在的目录vxhunter/firmware_tools/ghidra
- 点击
activating
(绿色的激活按钮),关闭后就能在VxWorks
分类中看到新加的脚本了
当脚本激活后,我们运行脚本vxhunter_firmware_init.py
,它会自动找到符号表所在位置,并将对应的函数名称修复,下面是运行后的结果,右侧还有反编译的代码。
选择userInit
函数,还能看到对应的汇编代码,发现和之前的猜想是一致的:
Firmware analysis
此时一切就绪,可以对固件的内容进行分析了,现阶段笔者对于VxWorks
的理解较浅,这里只是做简单的分析,以loginUserAdd
函数为例,查询官方文档[5],容易发现该函数用于添加一个登录用户,因此,可以猜测存在一些后门用户被默认添加进来。
在Ghidra
中查询LoginUserAdd
函数,可以发现它被另外几个函数调用:
查看其中具体的添加用户过程如下:
更详细的分析留待以后补充。
The mechanism of VxWroks
本部分主要是借助测试固件的反编译代码来了解VxWorks
的工作原理,直接顺着源码撸一遍,参考的资料主要是[6]和安全客大牛的[7]
_sysInit
最开始是_sysInit(void)
函数,用于系统初始化,反编译结果如下:
/* WARNING: This function may have set the stack pointer */
void _sysInit(void)
{
instructionSynchronize(); // 指令同步,对应isync指令
TLBInvalidateAll(); // 快表操作,对应tlbia指令
usrInit(0,0xc000000);
return;
}
前面两个函数可以暂时理解为一些基础的初始化操作,重点关注userInit()
对用户初始化的部分。
这里传入的参数
0xc00000
是r4
寄存器,反编译的时候错误传入,是软件本身的锅
userInit
先看下汇编代码和反编译的结果:
这里需要说明一点,在powerpc中,寄存器
r0, r3-r12
和特殊寄存器lr,ctr,xer,fpscr
是易失性的,即它们的值会在函数调用过程中发生变化,因此在函数调用过程中不能采用它们的值,这也解释了经常性会看到一些指令将数据从某些易失的寄存器保存到非易失的寄存器中。
stwu r1, local_18(r1)
:stwu
保存rs
寄存器内容到内存,上面红框中对应的是将r1
的内容送到r1+local_18
的地址(r1
是栈顶指针)。 相当于开辟了栈空间,此时栈大小为0x18
mfspr r0, lr
: 将lr
(函数返回地址寄存器)的值给r0
,相当于保存一遍函数返回地址,在查询指令的时候也可以查mflr
stw r31, local_4(r1)
: 将r31
的内容送到r1+local4
地址中stw r0, local_res4(r1)
: 将r0
的内容送到r1+local_res4
中or r31 r1,r1
: 将r1
与r1
或运算保存到r31
中,或本身不影响r1
的值,这里相当于是做一个mov r31 r1
,用或运算可能效率更高stw r3, local_10(r31)
: 将r3
的内容送到r31+local_10
的地址中lwz r3, local_10(r31)
:lwz
按字取值,相当于r3=mem(r31+local_10)
上面一堆数据移来移去,可能容易绕晕,但是看下面是进入正式的函数调用,可以猜想这些操作对应x86
中函数调用后对栈的布局操作,将参数放到栈基址特定偏移的位置。注意之前说过r3
是第一个参数寄存器,相当于其保存了传入的参数0,我们看一下这几个local
变量:
现在我们可以绘制一下此时的栈中布局:
| |
--------------
| r0 | 0x4, r0此时保存了函数返回地址lr
--------------
| | 初始r1,栈顶,记作0x0,当开辟栈空间之后此处就是栈基址
--------------
| r31的值 | -0x4
--------------
| | -0x8
--------------
| | -0xC
--------------
| r3的值 | -0x10,传入的函数参数
--------------
| r31 | -0x14 ,在or r31 r1,r1指令后,r31保存的是r1的值
--------------
| r1的值 | -0x18 ,此时保存的是栈基址地址
--------------
下面依次看每个函数的工作:
sysStart(0)
void sysStart(undefined4 param_1)
{
bzero(&_func_smObjObjShow,0x157914); //将起始地址到结束地址部分的内容初始化为0
sysStartType = param_1; // 选择系统的启动方式,有bootram(压缩式)和rom(非压缩式)两种
intVecBaseSet(0); // 设置中断向量表起始地址为0
return;
}
cacheLibInit(1,1)
: 对函数库的初始化,具体的暂时不关心excVecInit()
: 初始化中断向量表,在前面intVecBaseSet
设置中断向量表起始地址为0之后
undefined8 excVecInit(void)
{
int *piVar1;
undefined4 *puVar2;
puVar2 = &DAT_0030a488;
if (PTR_excExcHandle_0030a490 != (undefined *)0x0) {
do {
(*(code *)puVar2[1])(*puVar2,puVar2[2]); // Var2[1]指向的是函数excConnect,这里相当于执行函数excConnect(常数, excExcHandle),是对中断表做初始化的过程
piVar1 = puVar2 + 5; // 注意Var2的位置,这里的+5是地址+5(理解为数组下标偏移),从0030a488的位置向下偏移5个位置,对应的是`excExcHandle`
puVar2 = puVar2 + 3; // 同上,此时更新Var2的位置,到了一个新的常数位置,对应地址是0030a494,
} while (*piVar1 != 0); // 验证handle是否为空
}
return 0;
}
里面比较关键的是变量puVar2
,它被赋予了一个地址0030a488
,直接跳转到对应位置查看:
该过程应该明了了,通过两个指针不断地迭代,调用函数excConnect(data, excExcHandle)
来完成中断的初始化,直到excExcHandle
为0结束,这时候终端表初始化完毕。
sysHwInit()
:初始化外设为disable
的状态usrCacheEnable()
: 使能状态,让固件可以使用wvLibInit()
: 在这个里面只有一个evtObjLogFuncBind();
函数,应该时做了一个事件日志函数的绑定操作,可能是用于对发生事件进行日志输出的usrKernelInit()
: 到了最重要的内核初始化了
void usrKernelInit(void)
{
undefined8 uVar1;
classLibInit(); // 类函数库初始化
taskLibInit(); // 任务函数库初始化
// 下面的qInit和workQInit()都是对队列的初始化,因为VxWorks的工作方式是任务型的,创建任务添加到队列中执行
qInit(&readyQHead,qPriBMapClassId,&readyQBMap,0x100);
qInit(&activeQHead,qFifoClassId);
qInit(&tickQHead,qPriListClassId);
workQInit();
uVar1 = sysMemTop();
// kernelInit最重要的功能就是创建了一个usrRoot任务,配置一大堆任务必要信息
kernelInit(usrRoot,20000,0x490d2c,uVar1,5000,0);
return;
}
在kernelInit
中将usrRoot
作为参数传入,并在调用taskInit
的时候用作参数,实现对usrRoot
任务的初始化,具体的初始化过程可以参考施耐德140NOE77101固件逆向分析 - B3ale (qianfei11.github.io)
参考
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!