工控二进制逆向框架ICSREF学习笔记

引言

2019年的安全届四大顶会之一NDSS录用论文前段时间发布了,其中有一篇关于工控逆向的论文,ICSREF: A Framework for Automated Reverse Engineering of Industrial Control Systems Binaries,论文最主要的工作就是开发了一个针对工控二进制文件的逆向框架——ICSREF

这里把论文的一些学习笔记和ICSREF框架的源码审计笔记做一个简单的记录。

文章针对的主要是codesys套件编译出来的PRG文件进行的逆向分析。codesys是一个独立于硬件的编程平台,是用于对PLC控制器的最强大的编程工具之一,支持IEC 61131-3的五种编程语言的标准。应用非常广泛,市场上超过250家公司都使用codesys,超过20%的PLC都可以采用codesys编程。codesys本质是一套工具,包括编程软件和设备runtime等等。codesys编译的时候会生成一个PRG文件,这就是文章研究的基础。

环境:用codesys+树莓派搭建模拟PLC环境

安装

首先是codesys,翻了下实验室的一堆PLC,都没有直接用codesys的,不过有个施耐德的PLC用的组态软件somachine应该是基于codesys的,因为打开somachine的时候,状态栏出现了一个codesys gateway的图标,和利时的autothink也是。不过还是想找使用原生codesys的。

麻神说codesys套件有树莓派的RTS,可以在树莓派上搞,然后就去扒拉了一下,这里记录下。
首先是上codesys官网下CODESYS Development System V3CODESYS Control for Raspberry Pi MC SL
找个win机器,把两个东西装好
然后点Update Raspberry Pi

然后就会弹出左边的框,这个时候,树莓派的用户名密码ip输一下,然后install就OK了。

可以在树莓派上看一下

确实是跑起来了。

编程

可以新建一个标准工程一个试试

选上树莓派。

然后自然是硬件组态步骤,这里由于树莓派就比较简单了
devide->通讯设置然后输入ip搜一下就ok了。

然后就可以去写一个简单的梯形图

就可以编译下发,之后调试->登录到即可在线操作、监控调试等等。

当然codesys还可以编写HMI,不过这里不多赘述。

PRG文件格式

不得不说作者的逆向功底确实是深厚啊。。。根据PRG文件介绍一下其主要的结构

PRG二进制文件的前0x50个字节构成包含各种信息的文件头。
这里列出一些重要的信息:

  • offset=0x04 : 此位置的值+0x08得到函数符号表的结尾
  • offset=0x20 : 此位置的值+0x18得到程序的入口点
  • offset=0x2c : 此位置的值+0x18得到程序的结尾
  • offset=0x44 : 此位置的值+0x18得到函数符号表的结尾

符号表结构

符号表结构的开头没有在header里面显示出来,而ICSREF在寻找的时候是通过可显字符匹配大于4字节的连续可显字符就作为符号表。如下图所示:

结构基本一样,函数名+\x00+cnt+\x00,实际运行时会根据后两个数据字节来计算调用相应函数所需的跳转偏移量。

I/O映射关系

PRG文件在PLC里面运行的时候,对于PLC具体的物理I/O会映射到存储器的某地址,比如在WAGO 750-881的PLC中,接受输入时会从0x28CFEC00 - 0x28CFF7F8读取,刷新输出时更新0x28CFD800 - 0x28CFE3F8的值。
而这个值我们可以通过解密CODESYS安装目录下的一个TRG文件可以获得。具体的解密方式是将TRG文件和一个固定的2048位块进行异或操作即可。

功能块结构(Block结构)

根据61131-3的标准,PLC的程序的组织单元,我们称作POU,对应一个block,所以存在很多函数block(功能块)。以西门子s7-300为例,就存在组织块(OB)、系统功能块(SFB)、系统功能(SFC)、系统数据块(SDB)、功能(FC)、功能块(FB)等。编程的时候是对块进行编程。

例如codesys默认的主块就是PLC_PRG,当然根据需要可以添加更多的功能块(POU)。

打个比方的话,就是写一个Python工程,有很多类,每个类是一个文件。这里就是每个功能是一个块。而一个块里面可能包含一个或者多个子程序。

这里要区分下子程序和函数,可自行维基百科关键字subroutine

那么在PRG二进制文件中,每一个子程序都有入口和出口程序,入口对应的二进制序列为\x0d\xc0\xa0\xe1\x00\x58\x2d\xe9\x0c\xb0\xa0\xe1,出口对应的二进制序列为\x00\xa8\x1b\xe9
入口程序翻译成汇编如下:

MOV R12, SP 
STMFD SP!, {R11, R12, LR} 
MOV R11, R12

出口程序翻译成汇编如下:

LDMDB R11, {R11, SP, PC}

通过这样就可以寻找出所有的子程序。然后因为是arm架构,所以可以直接进行反汇编。

全局变量初始化

第一个子程序紧跟在header后面,从0x50开始。这个功能块是个特殊的函数,可以视作全局的INIT函数,用于初始化VAR_GLOBAL类型的变量和函数。
在用61131-3系列语言编程的时候,和普通的编程语言不大一样,所有用到的变量需要在一个地方声明,当然你可以在每个POU声明局部变量。

而每个变量都有一个类型,其中全局变量就是VAR_GLOBAL类型,你可以在任意的POU里面使用。

第一个功能块后面有三个很短的子程序,再后面跟着一个子程序目的是调用SYSDEBUG子程序,用于对动态调试的支持。

静态链接库和用户功能块的导入

SYSDEBUG子程序后面紧跟的子程序用于导入静态链接功能块库,和用户编写的功能块库。
静态链接库功能块和用户自定义功能块由两个结构上相邻的子程序构成:第一个用于执行主要功能,第二个用于初始化内存。

所有的子程序的倒数第二个就是我们俗称的main程序,也就是之前提到的在codesys编程的时候默认为PLC_PRG的POU。这个程序也就是扫描周期的起点了。

函数调用

由于PLC对于实时性的要求,基本上PLC程序编译器编译后都不会存在运行时解析,即像是延迟加载绑定这种是不存在的,所以也就意味都是静态调用关系,所以很容提取出函数之间的调用关系,重构出CFG图。
PRG文件中从一个子程序到另一个子程序或动态链接函数的调用间接跳转的指令如下:

STR Ri, [SP,#-4]! 
STR LR, [SP,#-4]! 
LDR Ri, =SUB_OFFSET 
LDR Ri, [Ri] 
MOV LR, PC 
MOV PC, Ri 
NOP 
LDR LR, [SP],#4 
LDR Ri, [SP],#4

所以就能提取出某一个子程序中调用的所有函数的偏移。

源码阅读

源码结构(radare2除外):

icsref.py就是主文件,然后核心的代码在PRG_analyze.py里面,

icsref.py中的核心类icsrefPrompt代码使用了cmd2库,扩展自cmd库,基本用法没什么太大变化,用来进行命令行的解析和交互。通过do_*的函数就是实现对应的命令操作。

按顺序来走把,首先是console函数,

def console():
    prompt = icsrefPrompt()
    prompt.prompt = 'reversing@icsref:$ '

    # Load banner
    thisdir = os.path.split(__file__)[0]
    banner_f = os.path.join(thisdir, 'data', 'banner')
    __file__
    with open(banner_f, 'r') as f:
        lines = f.readlines()
    banner = ''
    for line in lines:
        banner += line

    sys.path.append(thisdir)
    for i in os.listdir(os.path.join(thisdir, 'modules')):
        if i.startswith('module_') and i.endswith('.py'):
            # Get name without extension
            mod_name = 'modules.' + os.path.splitext(i)[0]
            # Get module
            mod = importlib.import_module(mod_name)
            # Add the methods of mod (ONLY) to icsrefPrompt class as do_<something>
            name_func_tuples = inspect.getmembers(mod, inspect.isfunction)
            name_func_tuples = [t for t in name_func_tuples if inspect.getmodule(t[1]) == mod]
            for fun in name_func_tuples:
                setattr(icsrefPrompt, 'do_{}'.format(fun[0]), fun[1])

    # Start cmd module
    prompt.cmdloop(banner)
if __name__ == '__main__':
    console()

主要是实例化一个icsrefPrompt类,然后把modules目录下的模块全部注册进来,以do_*加入命令行操作中。
然后是icsrefPrompt类:

class icsrefPrompt(Cmd):
    """
    cmd2 prompt class for the interactive console
    """
    def __init__(self):
        Cmd.__init__(self, use_ipython=True)

    def do_load(self, filename):

    def do_analyze(self, filename):

    def do_save(self, filename):

继承自cmd2库,用于处理命令行交互。实现了三个操作loadanalyzesave,其他操作都在modules目录,由之前的console函数初始化时添加进来。

  • load:主要是加载.dat文件,这个文件是analyze分析的结果保存成的文件,通过dill库将结果反序列化存储的,dill库是一个扩展自pickle的模块,这里不赘述。然后还原关键变量self.prg,本质是个Program类,后面会详细介绍这个类。
  • analyze:核心,实例化一个Program类来解析PRG文件,将结果保存到result目录下,存成.dat格式。
  • save:analyze之后,将结果换个名字保存。

接下来看看PRG_analysis.py文件中的关键类之一program类。

class Program():
    def __init__(self, path):
            """
            init function creates the Program object and does the analyses
            """
            # Program path
            self.path = path

            # Program name
            self.name = os.path.splitext(os.path.basename(self.path))[0]

            # Program hexdump
            self.hexdump = self.__read_file()
            print('DONE: Hexdump generation')   

            # Analyze program header
            # ROM:00000004: End of strings
            # ROM:00000020: Entry point (OUTRO?) + 0x18 (==24)
            self.program_start = struct.unpack('I', self.hexdump[0x20:0x20+4])[0] + 24
            # ROM:0000002C: End of OUTRO? + 0x18 (==24)
            self.program_end = struct.unpack('I', self.hexdump[0x2C:0x2C+4])[0] + 24
            # ROM:00000044: End of dynamic libs (Before SYSDBGHANDLER)
            self.dynlib_end = struct.unpack('I', self.hexdump[0x44:0x44+4])[0]

            print('DONE: Header analysis')

            # Program strings
            self.strings = self.__strings()
            print('DONE: String analysis')

            # I/O analysis from trg file
            self.__find_io()
            print('DONE: I/O analysis')

            # Function Boundaries
            self.FunctionBoundaries = self.__find_blocks()
            print('DONE: Find function boundaries')

            # Program functions
            self.Functions = []

            self.__find_functions()
            print('DONE: Function disassembly')

            # Find all static and dynamic libraries and their offsets
            # Dynamic libraries
            self.dynlibs_dict = self.__find_dynlibs()
            print('DONE: Find dynamic calls')

            # Static libraries
            self.statlibs_dict = self.__find_statlibs()
            print('DONE: Find static calls')

            # All libraries: Add dynamic and static calls
            self.libs_dict = self.dynlibs_dict.copy()
            self.libs_dict.update(self.statlibs_dict)

            # Find library calls for each function
            self.__find_libcalls()
            print('DONE: Call offsets renaming')

            # Save object instance in file
            self.__save_object()
    ....
    ....
    ....
    ....

该类的主要作用就是根据PRG文件的结构对其进行解析,从上面init函数就可以看出来,主要进行提取header、提取io映射关系、寻找子程序、寻找子程序调用关系、寻找库,具体的方法在上一节基本都已经解释了。

此外PRG_analysis.py文件中还有另一个Function类,

class Function():
    def __init__(self, path, start, stop, hexdump, disasm):
        """
        Function initialization
        """
        # Path
        self.path = path
        # Function start offset
        self.start = start
        self.offset = start
        # Function name. Convention: sub_<offset>
        self.name = 'sub_{:x}'.format(self.start)
        # Function stop offset
        self.stop = stop
        # Function length in bytes
        self.length = stop - start
        # Hexdump of particular function
        self.hexdump = hexdump
        # Disassembly listing of particular function
        self.disasm = disasm
        # Create string with opcode sequences for hash matching
        op_str = ''
        for line in self.disasm:
            op = line[43:].split(' ')[0]
            # Discard data
            if len(op) < 8:
                op_str += line[43:].split(' ')[0]
        # Function opcodes SHA256 hash
        self.hash = hashlib.sha256(op_str).hexdigest()
        # Initialize list of calls from function. Gets populated later
        self.calls = {}

就是一个function结构体,每一个从PRG文件提取出的子程序对应一个function类,主要包括偏移信息,反汇编代码,调用的函数等信息。

然后是modules下的各个模块信息:

  • module_analytics.py提供一个analytics命令,用于打印函数调用的统计,每个子程序被调用的次数,以及每个子程序调用了哪些函数。
  • module_cleanup.py提供一个cleanup命令,用于清楚result下面的分析结果文件。
  • module_graphbuilder.py提供一个graphbuilder命令,用于生成程序调用关系图,然后以svg格式保存在result文件夹下面。
  • module_hashmatch.py,提供一个hashmatch命令,作者把很多公开的常用的功能块和子程序做了签名,形成一个库,然后该命令可以匹配库。
  • module_pidargs.py提供一个pidargs命令,仅针对PID功能块,该命令使用angr重构堆栈,然后提取处PID的功能块的函数参数。
    该功能块如下:

其实也就是一个概念验证,毕竟是论文的产物不是工程化的东西,作者用该功能块来验证他能够提取出函数参数,那么对于其他函数也就是本质相同的重复性工作。