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: 无条件跳转指令,同jba是相对地址无条件跳转,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仓库中的描述:

  1. Ghidra主界面点击window,选择Script Manager
  2. 单击右上角列表选项,选择➕,并在内容中选择vxhunter对应脚本所在的目录vxhunter/firmware_tools/ghidra

  1. 点击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()对用户初始化的部分。

这里传入的参数0xc00000r4寄存器,反编译的时候错误传入,是软件本身的锅

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: 将r1r1或运算保存到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)

参考