本文系原创,转载请说明出处
Please Subscribe Wechat Official Account:信安科研人,获取更多的原创安全资讯
源码:
原文:
作者及团队:
纽约大学的momalab团队,自2019年起,这个团队一共产出四个工作,每个工作都发表在体系结构和网络安全的顶会上:
[1] A. Keliris and M. Maniatakos. “ICSREF: A Framework for Automated Reverse Engineering of Industrial Control Systems Binaries”. In: Network and Distributed System Security Symposium (NDSS). 2019
Github:
[2] D. Tychalas and M. Maniatakos. “IFFSET: in-field fuzzing of industrial control systems using system emulation”. In: IEEE Design, Automation & Test in Europe Conference & Exhibition (DATE). 2020, pp. 662–665
Video:
Github:
[3] D. Tychalas, H. Benkraouda, and M. Maniatakos. “ICSFuzz: Manipulating I/Os and Repurposing Binary Code to Enable Instrumented Fuzzing in ICS Control Applications”. In: USENIX Security. 2021
Video:
Github:
[4] Rajput, P. H. N., Doumanidis, C., & Maniatakos, M. “ICSPatch: Automated Vulnerability Localization and Non-Intrusive Hotpatching in Industrial Control Systems using Data Dependence Graphs.”. In: USENIX Security. 2023
Github:
什么是FUZZING?
模糊测试是一种自动化
研究问题
•考虑到PLC控制应用程序的二进制文件是使用专有编译器从高级PLC编程语言编译而来的,是否会引入安全漏洞?
•PLC二控制应用程序的进制文件由专有的runtime加载,作为PLC操作系统的一个进程执行。考虑到这些runtime是从常规的C/ c++源代码编译的,runtime有多易受攻击?
•考虑到PLC控制应用程序的二进制文件的执行受实时约束和大量使用GPIO,Fuzzing可以用来发现潜在的漏洞吗?
知识背景1 CODESYS RUNTIME
注:下文说的runtime都是指codesys runtime
runtime是什么:是一个自包含elf的二进制文件,驻留在linux系统的/usr/bin中,通过包装器进程部署,作为OS初始化的一部分,包含在etc/init.d的引导脚本中。
包装器进程(Wrapper Process)是一种运行在操作系统上的进程,它的主要作用是将外部请求传递给内部进程或服务,并将其响应返回给请求方。包装器进程通常用于隔离和保护内部进程或服务,从而提高系统的安全性和稳定性。
PLC的Runtime模块通常包括以下几个功能:
-
扫描循环(Scan Cycle):PLC的扫描循环是指PLC控制器在一个循环中完成对所有输入、输出和程序的扫描所需的时间。在每个循环中,PLC会对输入信号进行采样、判断和处理,然后根据程序的要求输出相应的控制信号。
-
任务调度(Task Scheduling):任务调度是指PLC控制器在扫描循环中对程序的执行顺序和优先级进行管理和控制。PLC的任务调度机制通常采用优先级、时间片和中断等方式,以实现程序的同步和异步执行。
-
数据通信(Data Communication):数据通信是指PLC控制器与其他设备之间进行数据交换和通信的过程。PLC的数据通信机制通常包括数据采集、数据存储、数据处理和数据输出等过程,以实现与其他设备的数据交换和通信。
-
错误处理(Error Handling):错误处理是指PLC控制器在执行过程中遇到异常情况时,如输入信号异常、程序执行错误等,能够及时进行处理和报警。PLC的错误处理机制通常包括错误诊断、错误恢复和错误报警等功能。
控制应用程序如何加载与运行:
-
控制应用程序加载过程开始于runtime的初始化期间,通过一个file-open的系统调用到应用程序的二进制文件所在的硬编码文件夹位置。
-
然后runtime开始在内存中复制控制应用程序代码和数据。
-
在控制应用程序文件完成内存加载之后,开始执行,由一组基于pthread API的自定义函数处理。
-
控制应用程序二进制代码被推到一个新实例化的线程的堆栈中,强制在所有主进程线程的堆栈段上启用执行特权(这个操作可以是任意代码执行的主要推动因素)。
-
除了控制应用程序的加载/执行之外,runtime还会生成另外两个与控制应用程序本身相关的线程,一个是KBUS_CYCLE,它充当控制应用程序和KBUS之间的中介,另一个是VISU,它根据嵌入到源代码中的信息提供控制过程的可视化。
下表列出了在runtime进程运行的时候,与之交互关系最活跃的几个函数:
比较重要的一个函数
KBUS: 是一个轻量级的进程间通信系统,用于在runtime线程之间传递数据,更重要的是,它处理从GPIO端口到控制应用程序本身的控制应用程序数据。
知识背景2 控制应用程序
控制应用程序的格式:控制应用程序二进制文件与传统的计算机程序二进制文件相似,由头文件、主程序、数据段和静态和动态链接的库组成。
ICSREF里面给出了这个二进制文件的具体格式
控制二进制文件包含:控制二进制文件还包含从库和用户定义的F/FB调用函数或函数块(F/FB)。这两者都是静态链接的,并以两个连续子例程的格式包含在二进制文件中。第一个包含表示F/FB功能的指令,第二个初始化它的本地内存。接下来,将PLC (PLC_PRG)的主要功能封装到下一个子程序中。这个子例程是最有趣的组件,因为它包含了控制逻辑。控制二进制文件中的动态链接函数通过位于最后一个代码子例程之后的符号表解析。符号表包含两个字节的数据,runtime使用它们计算调用相应函数所需的跳转偏移量。
知识背景3 控制应用程序的编程语言分析
梯形图(LD):(图形化)这种语言类似于电路,取代了硬接线继电器控制系统。
功能框图(FBD):(图形)FBD也是基于连接功能块(FB)的接线图。
结构化文本(ST):(基于文本)该语言是最接近高级计算机编程语言的语言,基于Pascal。
下图可视化了三种语言的差距:
各编程语言差异分析
(1)作者使用差异工具(例如vbindiff)进行的初始自动分析表明,不同的语言会产生不同的二进制文件。
(2)进一步的研究发现,差异的主要来源是编译器为不同的PLC语言插入了数量可变的无操作指令(NOPs)。
NOP有很多种的变体,其中大多数使用的为典型指令(例如mov r0, r0)。
NOP指令在嵌入式系统中的主要作用是用于调整程序执行的时间,或者在一些情况下起到占位符的作用。下面是一些常见的用途:
调整程序执行时间:在某些情况下,需要调整程序的执行时间,以便使不同的硬件部件能够协调工作。NOP指令可以用来延迟程序执行的时间,以便确保硬件能够在正确的时间进行操作。
占位符:在一些情况下,程序需要保留一些空白的指令,以便在将来填充其他指令。在这种情况下,NOP指令可以充当占位符,占据一个指令位置,使程序保持完整性。
调试:NOP指令还可以用于调试嵌入式系统中的程序。在调试时,可以在程序中插入NOP指令,以便在特定的时间点停止程序执行,并允许开发人员检查程序状态和变量值。
总之,NOP指令在嵌入式系统中的作用是非常灵活的,它可以在程序的执行中起到不同的作用,具体取决于系统的需求和开发人员的意图。
应用程序可能的漏洞函数
作者通过将C/C++和PLC编程环境中的脆弱函数相比较,以确定PLC控制应用程序编程语言是否具有内存安全问题。
(1)作者发现PLC程序也允许通过指针操作内存。
表2显示分析的函数列表中的第二列中列出的标准编程语言(如C++)中的字符串操作都存在潜在的安全漏洞。
而Codesys标准库包括一个函数数组,包括SysStrCpy (SysLibStr库),Concat(标准库),以及SysMemCpy, SysMemMove, SysMemSet,和SysMemCmp,都是Codesys SysLibMem库的一部分。
(2)其中可能存在漏洞的函数如:SysMemCpy和SysMemMove不比较源缓冲区和目标缓冲区的大小,因此容易导致潜在的崩溃导致缓冲区溢出。
FUZZING框架
整体难点
1 测试用例执行导致的系统崩溃无法反馈
2 输入无法通过常规的方式(如stdio)传递到PLC控制应用程序中
3 输入交付难同步
4 难插桩以获取覆盖率信息
FUZZING 控制应用程序
难点
控制应用程序的二进制文件不遵循传统的二进制执行文件,有其自己的执行机制,这种特点导致:
1、不能调用execve这种直接的系统调用来执行二进制文件
2、测试用例执行错误将无反馈
3、输入(如一些信号值)不能通过传统的方式传到控制应用程序中
4、由于控制二进制文件执行须遵循PLC的Scan Cycle,fuzzing的输入传递不容易同步
5、无法将插桩应用于二进制文件
fuzzing可以大致分为两个部分:
输入生成和执行控制。执行控制部分与PLC系统通信,并执行输入的测试用例,同时接收PLC系统发来的正常和异常的信号;输入生成产生大量的变异的输入至运行起的二进制文件,以让系统产生崩溃状态。
执行控制
(1)异步和同步控制应用程序
控制应用程序按照执行过程可以分为异步和同步两类:
首先,什么是同步什么是异步?
同步操作是指程序执行任务时,会等待当前任务完成之后再进行下一个任务,这种方式需要一直等待任务完成,才能继续执行下一个任务,因此在等待任务完成的过程中,程序的执行会阻塞。
异步操作是指程序执行任务时,不需要等待当前任务完成,可以在任务执行的同时,执行其他任务。程序会在执行任务时立即返回,继续执行其他任务,当任务完成后,会通过回调函数或者其他机制通知程序任务已经完成,程序会再次执行相关代码。
举例来说,同步操作就像你需要等待红绿灯变绿才能通过路口一样,而异步操作就像你可以在等待红绿灯的过程中,做其他的事情一样。
下图的左边是同步,右边是异步
对于同步控制应用程序:
同步类型的控制应用程序二进制遵循扫描周期模型,其周期性地检查预定的存储器映射地址来更新输入,根据接收的值执行操作并写入到相关的输出地址。
可以从下图中观察到在一个processing cycle内,也就是上问题提到的扫描周期,PLC的控制应用程序从I/O Memory中读取输入值,经过符合控制应用程序的计算,最后将输出值写入I/O Memory中,更新输出。
对于异步控制应用程序:
典型的有罗克韦尔的ControlLogix L5控制器,与传统的同步扫描架构的PLC不同,此类PLC采用与大多数现代计算机操作系统类似的方法如在每个任务之间切割处理时间来处理多个任务
具体可以看这篇:
总之,在管理员使用异步的方式执行控制应用程序,当控制应用程序接收外部的信号,将会更新输入或者结束执行。
(2)可利用点
同步控制应用程序需要等待控制应用程序执行完毕才可以执行下一次,这对fuzzing技术来说是个效率灾难,因为fuzzing需要将大量的测试用例输入到PLC中尽可能的多次执行,以提高fuzzing的效率(这或许是传统计算机软件fuzzing和PLCfuzzing的不同点之一)。而异步的控制应用程序不需要等待,等执行的异步控制应用程序执行完了会返回一个结束信号给系统,告诉系统我这里执行完毕了,那么这就意味着对fuzzing来说,可以用异步的方式同时测试多个控制应用程序,巨幅提高测试效率。
(3)作者的方法
控制fuzzer(模糊测试工具)将测试用例发送到PLC应用程序的输入过程,以满足指定时间尺度内的PLC任务执行周期。
为什么不用异步的方法?作者提到:异步程序是PLC程序的特殊情况,占可用二进制文件的一小部分,限制了这种方法的适用性。
(4)如何检测到程序执行异常产生的终止
传统的OS中,一个异步的程序执行发生错误会调用SIGSEGV信号来结束执行,并调用一个异常处理函数。
然而,控制应用程序执行线程对整个PLC操作系统而言是静默的,因为上文提到这些控制应用程序执行线程由runtime进程控制,具体到runtime中的scheduler模块,这个模块决定控制应用程序过程的终止。
对整体操作系统而言,唯一可见的信息就是futex系统调用。
futex(Fast Userspace Mutex)是一种系统调用,用于实现用户空间进程之间的互斥锁。futex主要用于进程之间的同步和通信,可以用于实现多线程的互斥锁和条件变量等功能。
在Linux操作系统中,futex系统调用包括两个主要的函数:
futex():用于等待或唤醒一个futex。
futex_wait():等待futex的值发生变化。
在使用futex系统调用实现互斥锁时,一般需要借助其他的系统调用,如mmap()和munmap()等,来实现共享内存的操作,从而实现多个线程之间的数据共享和同步。
需要注意的是,futex系统调用在Linux操作系统中是一个比较底层的系统调用,使用起来需要一定的技术水平和经验。在实际的应用中,一般使用高级的并发编程库,如pthread等,来实现多线程的互斥和同步操作。
但是!控制应用程序的终止可以从父进程或者直接的祖宗进程监测到!也就是说可以用整出多个父进程或者祖宗进程来执行一个控制应用程序就行了!
上文提到,codesys runtime是由一个wrapper脚本调用并启动,那么就可以fork多个wrapper进程来执行多个测试对象并接收对应的输入,fork出一个wrapper进程后,用wait()方法挂起这个进程,只要这个进程的子进程收到一个PID终止信号,那就终止并重新出实话runtime进程。
输入控制
要知道,PLC控制应用程序的输入一般是真实的物理信号、模拟信号、或者数字信号,由一个专门的外围I/O设备or模块管理。本质上,这个I/O设备or模块连接到传感器获取信号,通过标记为“GPIO”的LINUX设备将信号值传递给PLC,然后通过KBUS子系统与runtime进行通信。然后每个扫描周期,KBUS将输入的数据传递到控制应用程序。
整体流程见上图介绍如下:
-
I/O模块接收来自传感器的信号,通过GPIO转发给PLC。
-
GPIO接收并存储输入数据在它们的内存映射空间。
-
KBUS打开GPIO设备文件并执行读取系统调用,将输入数据移动到runtime进程中自己的内存空间中。
-
KBUS_CYCLE_TASK是与控制应用过程一起生成的线程,它通过write系统调用将输入数据交付给控制过程的内存空间。基于控制应用程序的扫描周期长度,此事件可重复发生。
作者通过逆向工程和调试实现近似GPIO的功能,以在输入数据处理方面模仿GPIO。然而,它与操作系统的交互与典型的GPIO不同:使用定制的系统调用来中继数据,而不是典型的读/写。
此外,根据I/O模块,数据可以逐位地传递到设备,而I/O模块必须手动重构,这是一个非常不可靠的过程。 对于输入交互来说,KBus是一个更有吸引力的选择,因为输入已经被接收并存储为数字值。
KBus的地址空间可以通过在支持Linux的设备中/proc/maps上获得的内存映射信息来提取。 KBus还被实例化为Linux文件系统中的一个设备,可以在/dev文件夹中访问,作为整个系统的通信通道。
插桩
插桩是模糊测试一种反馈信息的方法,插桩指在程序中插入几个桩点,每当程序运行到一个桩点时,累加计数,除以总桩点就是覆盖率。
上文提到控制应用程序有很多NOP指令,作者实验发现这些NOP指令并不会影响程序的操作,而是跟控制应用程序的大小有关,程序越大那么NOP的指令数量越多。
于是作者通过将NOP指令(下图中的mov r0,r0)替换为右图的STR指令,用PC寄存器计数,来获取覆盖率信息。
FUZZING RUNTIME
上文说到,runtime本质上是个elf文件,它通过将控制应用程序二进制文件生成为它的一个线程来加载和执行它。通常,当一个线程通过设置了CLONE_VM标志的clone()系统调用生成时,任何使用mmap()执行的内存映射都会影响进程和线程。
说人话就是这两个二进制文件(runtime和控制应用程序二进制文件)之间的相互依赖意味着它们的安全性在本质上是强相关的。
因此,分析runtime二进制文件和控制应用程序本身所采用的安全措施很重要。很简单,使用checksec工具check一下发现如下,基本上传统软件的保护措施都没开,除了codesys 3.x版本开了个NX
Runtime是一个复杂的应用程序,包括大量的实用函数,用于与环境交互、执行维护、处理控制应用程序并与之通信。
Runtime应用程序不能被认为是一个独立的软件来fuzzing,因为它只能存在于runtime上下文中,共享相同的内存空间。许多函数和实用程序与控件应用程序有直接联系,并影响其执行状态。
方法
作者对任何与runtime相关的东西进行fuzzing的唯一可能性是通过动态链接的库、Linux中的共享对象(.so)及其包含的函数。因为库本身不是有效的执行目标,所以它必须托管在一个外部程序中,即一个测试工具,它会动态地加载库并声明一些包含的函数。代码必须短小精悍,以最大化性能并简化分析崩溃实例时的调试工作,如下图对KBUS发送消息的函数的fuzzing:
这个输入参数是已知的,而对于那些未知输入参数的函数库,就无法继续fuzzing。
总结
•ICSFuzz利用KBUS子系统向控制应用程序提供输入,因此需要使用物理PLC,使其不可扩展。
•如果测试进程与控制应用程序的扫描周期的同步丢失,ICSFuzz周期性地下降模糊输入,因此很慢。
•ICSFuzz缺乏观察控制程序状态的自动化,涉及人工的崩溃监控。
•ICSFuzz还对WAGO PLC上的Codesys运行时的共享库函数进行了有限的无状态模糊,发现了一些崩溃。然而,由于fuzzing的无状态和脱离上下文的性质,它错过了需要运行时执行上下文的漏洞。