工控安全入门(五)—— plc逆向初探

 

之前我们学习了包括modbus、S7comm、DNP3等等工控领域的常用协议,从这篇开始,我们一步步开始,学习如何逆向真实的plc固件。

用到的固件为https://github.com/ameng929/NOE77101_Firmware

目前网上几篇对于该固件的分析都是以2018工控安全题目解题为主,并没有相应的知识和说明,这次我们不会做题目方面的说明,而是重点关注如何从零开始逆向固件。

本系列涉及到vxworks操作系统、PowerPC CPU架构的汇编语言、Ghrida的反编译问题,网上提供的资料较少,可能对于很多人来说是全新的知识。由于篇幅所限,也为了不让大家烦躁,我们暂且不对这两个新玩意儿进行长篇大论,我会将知识点穿插讲解。另外,建议大家使用windows系统,mac版的ida无法对PowerPC的固件进行函数识别,使用了好几个版本测试都存在该问题,原因暂时不明。

 

固件提取

这一步其实和路由器的固件逆向非常相似,厂商都会在官网提供固件的升级包,我们通过下载安装来更新设备的软件版本。不过路由器的版本更新相对于plc就频繁得多了,plc升级在大多数厂家的认识中是一件“麻烦”事(假如说一天一更新那工厂还干不干活了……),所以plc的固件是相对稳定的,我们选用的固件虽然看上去很“古老”,但如果考虑到我们现在不是进行漏洞挖掘,而是进行基础学习的话,就很合适了。

这里用到的工具就是大家熟知的binwalk,不过有所不同的是对于路由器来说,我们其实是“知根知底”的,因为架构和操作系统都还在我们的知识范围,而plc的操作系统和cpu就不那么友好了。

如图为binwalk操作某路由器固件,可以看到信息很多,包括文件系统等等一堆

如图为操作plc固件,基本可以说是啥信息都没有

直接binwalk -e NOE77101.bin提取即可,对分离出来的文件再一次binwalk就可以看到很多有用的信息了,包括固件采用的操作系统、内核版本、符号表等等信息

至此我们成功提取除了固件,要注意的是,这里操作系统是vxworks,cpu则是big endian的PowerPC,所以接下来分析工作并不是一帆风顺的,还需要经历诸多磨炼。

 

手动修复与自动修复

手动修复

我们将固件拖入ida准备开始分析

首先要修改处理器选项,因为ida并不能识别出来我们处理器的架构,我们需要将Processor type手动设置为PowerPC的big endian

接着ida还会让我们指定RAM和ROM的起始地址,在我们不知道的情况下,只能直接点击ok,剩下的选项都直接默认即可。其实这里已经有成熟的工具可以用来自动化识别了,但是考虑到我们是第一次实践,还是认认真真的尝试着走一遍。

进入后发现ida的函数少得可怜(mac版的就干脆一个也没有,笔者这里是在windows上保存了idb后在mac上操作的),而且一般的ida不支持对PowerPC的反编译,就很难受。当然,再难受也得硬着头皮看,先找到第一个函数。

看不懂?没事,我们一点点扣。首先这里大量使用到了r1处理器,在PPC(后文中PowerPC都会简称为PPC)中共有32个通用寄存器,虽然叫通用,但有一些寄存器和x86一样被“固化”了,比如r1就是栈寄存器,其余“固化”的还有:

  • r0,函数序言(function prologs)阶段使用,一般不需要我们关心
  • r2, toc指针,名字很高大上,其实就是系统调用时,标识系统调用号。
  • r3,返回值
  • r4-r10,参数,返回值较为特殊(比如乘法导致一个寄存器放不下的时候)时,r4也可以存放返回值
  • r11,用在指针的调用和当作一些语言的环境指针。
  • r12 ,用在异常处理和glink(动态连接器)代码。
  • r13 ,保留作为系统线程ID。
  • r14-r31,本地变量

还有好多好多寄存器,我们贯彻用到哪说哪的原则,这里就不再提了,需要我们再说。

lis r1,1
addi r1,r1,0

这两条指令可就有意思了,lis是加载立即数(load Immediate)的意思,它将16位的立即数传送到r1,然后再左移16位,在本条指令中就是把1放到了寄存器的第17位上,也就是0x10000了。

这里就冒出来问题了,我们很显然一般用的都是32位数(不管是编程还是寄存器长度,一般都是32位)啊,PPC的指令长度为4个字节,也就是32位,除去指令码和寄存器外,根本就放不下32位的数,难不成这里的1就是16位的1?

当然不是啦,这里就要看第二条指令了,addi的意思就是让将r1的低十六位+0的值给r1的低十六位,这样就是lis操作高十六位,addi再补上低十六位,成功实现了32位立即数的传送。

下面关于r3的操作同理,接着又是addi操作,将r1的值减了0x10,然后是b指令,b指令就是call了,调用一个函数的意思。还记得r1是干什么的吗?没错,r1是栈寄存器啊,这里开始将栈赋值,然后又对一个栈地址进行了减法操作意味着什么?

没错,一开始进行的实际上是栈的初始化,我们把它叫做initStack,接着的减法实际上就是在栈上开辟了空间(栈是高地址向低地址增长的),这里就应该是整个固件的初始化函数的一部分。

再查看官方手册,我们发现,初始化的栈的地址也就是System Image的地址,所以我们ida最开始选择的RAM、ROM的地址就应该是0x10000。我们重新用ida打开该程序,设置RAM、ROM,就可以发现识别的函数多了很多了。

这一步之后我们需要符号进行还原,毕竟谁也不愿意看着一堆sub_xxx来分析吧?这里我们就要用到binwalk告诉我们的符号表地址了(忘了吗?是0x301e74),我们用010editor打开固件,16进制形式分析,跳转到对应地址。

我们之前已经知道了这是vxworks5的固件,此类固件的符号表比较特殊,16字节为一组,分别表示的是符号字符串地址、符号所在地址、特殊标识(比如0x0500就是函数的意思)、0填充位,所以我们就可以按照这个规则来进行符号的修复。

脚本如下:(大家自行按照自己的固件地址对Start和end进行修改即可)

# coding:utf-8
from idaapi import *
import time

eaStart = 0x31eec4 
eaEnd = 0x348114
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 = ea + 16
print"ok"

修复完后ida的function栏可以说是相当友好了。

自动修复

上面我们对固件进行了繁琐的分析最终才完成了对于函数表的修复,实际上,已经有大神开发了一款插件可以对Vxworks的固件进行自动的修复和分析,这就是平安的银河安全实验室开发的vxhunter,大家可以去github自行下载(感谢大佬们的奉献)。

https://github.com/dark-lbp/vxhunter

之前我们手动分析用的是ida,自动部分我们就用Ghidra来进行(还有一个最大的好处是Ghidra支持对PowerPC的反汇编,所以之后的分析我们都采用Ghidra来进行)。

按照readme安装vxhunter,然后打开windows选项卡,选择scriptManager,运行脚本即可

耐心等待一段时间,即可完成

是不是非常简单?下一步就可以开始进行分析了。

 

小试牛刀

首先我们跳转到之前简单分析的initStack(要注意之前是0x4c,但是我们把基址设置为0x10000后地址应该是0x1004c)

可以看到在initStack操作后,进行了跳转,目标是usrInit,还记得参数的传递规则吗?没错,这里的r3就是为了传递参数的,可以看到,和我们之前说到的一样,还是使用lis和addi的搭配进行32位整数的传递

当然其实再往上看我们还可以发现r4寄存器的身影,因为PowerPC不像x86的stdcall,能通过push来判断哪些是函数的参数,所以我们只能是把r3~r10的身影都锁定,宁可错杀,不可放过。(我们也可以通过查看后续函数的调用来猜测,不过也是猜测,还是多注意为好)

这里可能有些人一看就慌神了,“我靠,这都不认识,怎么看啊?”,不慌不慌,其实这里的调用十分有规律,我们拆开看看

00010018 3c 80 04 00     lis        r4,0x400
0001001c 38 84 00 00     addi       r4,r4,0x0
00010020 7c 90 8b a6     mtspr      IC_CSR,r4
00010024 7c 98 8b a6     mtspr      DC_CST,r4

可以看到,实际上是进行了三组同样的操作,只不过是r4的值不一样罢了,我们选择一组来进行分析。

首先打头的还是lis和addi的组合,将r4的值设置为0x4000000,然后是mtspr指令,指令格式如下:

mtspr      spc_reg,reg

spc_reg是特殊寄存器的意思,指令将reg寄存器的内容传个一个特殊寄存器,所以这条指令的意思就是将0x4000000赋给IC_CSR寄存器。可以看到,这个r4就是个中间商,那么和之后的参数传递应该是没有关系的。

看到这里有些同学可能就会说了:“你不是说Ghidra有反汇编功能吗?那你还费劲让我们看这个?”,哎,别急别急,我们就来看看Ghidra给我们的反汇编是什么样的

它就分析出了三条,instructionSynchronize()实际上对应的指令是isync,也就是指令同步的意思,并不关键,TLBInvalidateAll()实际上对应的指令是tlbia,也就是快表的相关操作(快表不知道的去翻大二专业课《操作系统》吧,文章内实在没地方讲了),而最后就是函数调用了。

有没有发现?这反编译显然是把我们的mtspr给落下了,直接将r4当成了参数,导致usrInit有了俩参数!其实我们在刚才的探索中很明显看到r4在这绝对没有起到参数传递的功能。这就说明了对于PowerPC的反编译,ida干不了,但Ghrida干得也不是很好,所以在之后的分析中我们要时刻留意,绝对不能只看反编译结果。

接着我们调到usrInit函数来看看,还是先看汇编部分

这部分是大家熟悉的函数序言,我们简单分析一下。

stwu r1,local_18(r1)

意思就是将r1的内容送到r1+local_18的地址中,r1我们说过是栈顶指针。

mfspr r0,LR

这句话在ida上会被翻译为mflr r0,就是讲LR的值给了r0,LR寄存器是记录函数返回地址的寄存器

stw r31,local_4(r1)

和第一句格式相同,是将r31的内容送到r1+local4的地址中

stw r0,local_res4(r1)

这里local_res4是个正数,也就是说将r0送到了一个栈基址往上的地方,在想到r0内容是函数返回地址,是不是就清晰了?实际上就是相当于x86中call指令将函数返回地址保存在栈上的操作。

or r31,r1,r1

or指令的操作是将第二个操作数与第三个操作数or后保存进第一个数中。那就有人要问了,r1和r1进行了or之后不还是r1吗?好问题,实际上这种格式就是PPC的mov指令,写成x86就是mov r31,r1,为什么这样大费周折呢?这里我猜测是为了提高效率。

说到这是不是大家已经脑补出了函数序言的基本行为?实际上和x86并没有什么本质上的区别。

继续来看该函数

又是一堆函数,那我们就先看看第一个吧,它以param1作为参数,而param1就是r3,前面分析了是0

首先是bzero操作,参数是开始地址和结束地址,将中间部分都置为0,之后又这段地址将用来保存数据。

sysStartType是系统的启动类型,包括有bootram启动和rom启动,压缩式和非压缩式等等,这里受篇幅所限,先不细讲了。

intVecBaseSet是非常非常非常重要的一个函数,不知道大家看到intVec有没有想起来在《Windows调试艺术》中我们说过有个东西叫中断向量表(Interrupt Vector Table),其实就是那玩意,这个函数就是用来设置起始地址的,这里的起始地址固定为0。

返回到usrInit我们又看见下面还一个名字中带Vec的啊,那正好再来看看这个伙计

是不是又觉得有点难了?别慌,慢慢来,首先是var2变成了地址,而后又大量使用var2的数组形式,那就让我们瞅瞅地址指向了啥

哦,var2[0]是个数,var2[1]是个地址,指向的是个函数,var2[2]是个地址,指向的是个结构体。

往后走首先是if检验excExhandle(也就是var2[2])是不是为NULL,不是继续,显然这里不是,那就继续。

接着是个非常怪异的循环,涉及到指针、地址、结构体、结构体指针的变换,可能会很难,我尽量说的简单一些。

首先找迭代变量,这里很显然是*var1,而var1是啥?var1是var2+5,var2加5(这里的加5是地址加5,不是真的+5),看看图中地址就知道了,它跑到了handle,也就是说var1在验证handle是否为空。

var2同样在迭代,不过它是每次+3,在看图,也就是到了0x200那个地方,下面又是一个相同的结构。

最终是以*var2、var2[2]为参数调用var[1],也就是excExcHandle(data,excExHandle),在迭代不断地进行该函数,完成Interrupt Vector Table的初始化工作。

 

总结

可以看到,plc固件的逆向涉及到了非常非常多的新知识,由于篇幅所限,这次我们仅仅是完成了最基本的工作,接下来我们会一点点深入,直到彻底吃透该固件。

(完)