NSA武器库之Eternalblue SMB漏洞浅析

来源:互联网 发布:金纺柔顺剂危害知乎 编辑:程序博客网 时间:2024/06/06 10:48

本文参考自
狄仁杰探案之永恒之蓝
DoublePulsar Initial SMB Backdoor Ring 0 Shellcode Analysis
The legacy code behind WannaCry – the skeleton in the closet

漏洞成因

下面的大部分源代码是基于IDA反编译srv.sys(v6.1.7601.17608,Windows 7 32位)得到的。这个例子说明,无论漏洞多么严重,其核心总是存在一个简单的bug,常常是在遗留代码中实现的那些大多数开发人员不再使用的功能。漏洞的核心位于负责转换SMBv1消息中的FEA(SMBv1标准中定义的Full Extended Attribute)列表块的函数,更具体地说,是转换OS/2和SMBv1的NTV变体的部分。用例本身(在OS/2和NT格式之间进行转换)意味着这是在20世纪90年代写的代码-代码可能会做相当危险的操作,因为这样的转换需要实现消息的复制与重写。观察以下代码片段(从SRV.SYS反编译得到),将指向SMB_FEA_LIST块的开头的指针作为参数a1。

int __stdcall SrvOs2FeaListSizeToNt(_DWORD *a1){  _WORD *v1; // eax@1  unsigned int v2; // edi@1  unsigned int v3; // esi@1  int v4; // ebx@3  int v6; // [sp+Ch] [bp-4h]@1  v1 = a1;  v6 = 0;  v2 = (unsigned int)a1 + *a1;  v3 = (unsigned int)(a1 + 1);  if ( (unsigned int)(a1 + 1) < v2 )  {    while ( v3 + 4 < v2 )    {      v4 = *(_WORD *)(v3 + 2) + *(_BYTE *)(v3 + 1);      if ( v4 + v3 + 4 + 1 > v2 )        break;      if ( RtlSizeTAdd(v6, (v4 + 12) & 0xFFFFFFFC, &v6) < 0 )        return 0;      v3 += v4 + 5;      if ( v3 >= v2 )        return v6;      v1 = a1;    }    *v1 = (_WORD)(v3 - v1);  }  return v6;}

关键是*v1 = (_WORD)(v3 - v1);这行代码。一旦结束遍历SMBv1消息中的整个FEA元素列表,它就会被执行。该行将用计算所得的长度覆写a1函数参数指向的存储区域中SMB_FEA_LIST块的长度,写得清楚一点就是下面这样。

(WORD)SMB_FEA_LIST-> SizeOfListInBytes =(WORD)((DWORD)pointer_to_end_of_list - (DWORD)pointer_to_start_of_list);

实际上看汇编代码更简单,这种情况很少见。
这里写图片描述
指针相减应该得到列表的大小-这也意味着我们不盲目地信任消息中的长度值(这样做是正确的!)。但是,这里存在一个关键的问题:SizeOfListInBytes字段本身是32位DWORD(根据规范为ULONG),但是我们正在存储一个16位的值。因此,如果SizeOfListInBytes的原始值包含大于65535(2^16 -1)的值,则该值的高16位将在操作期间保留。例如,如果我们有一个SMB_FEA_LIST块,其中SizeOfListInBytes的值为65536(2^16,即0x10000),但实际长度为65535字节(0xFFFF),则从此函数返回后SizeOfListInBytes的值将会为131071(0x1FFFF)。除了覆盖大小值之外,该函数还可以以NT格式计算FEA列表中数据的总长度;SMB_FEA结构的解析在所有步骤(包括用于存储尾随零的额外字节)中得到适当的验证,并且由于使用安全函数RtlSizeTAdd,计算不会溢出。除了返回从输入解析的FEA数据的总长度(通过重写头部中的SizeOfListInBytes),该函数还以NT格式返回重新计算的相同数据的大小。在这一点上的主要问题是如果输入消息中SizeOfListInBytes的值足够大,这两个值可能会不同步-它最终会比SMB1消息的实际大小更大。像如上所示的类型不匹配的编程错误可能会出现在代码中的任何位置-而且由于后果仅在正常使用期间不太可能发生的特定情况下触发,因此可能难以追踪。在这种情况下,更糟糕的是敏感的内存操作的值也依赖于这个错误计算的结果。上面显示的有漏洞的函数是在SrvOs2FeaListToNt中调用的。因为这个函数很长,我们只会在这里展示相关的部分。首先,我们可以看到NT格式的FEA列表块重新计算后的大小存储在变量v5中,而OS/2格式的SMB_FEA_LIST的大小(已损坏)被写入SMB_FEA_LIST块的前四个字节。然后我们分配足够的空间来保存NT格式的FEA列表块。

  v5 = SrvOs2FeaListSizeToNt(a1);  // ...  v7 = (_DWORD *)SrvAllocateNonPagedPool(v5, 21);

不久之后,我们通过损坏的长度值定义复制操作的边界。

  v8 =  & a1 [ *(_DWORD *)a1 -  5 ];

它可能看起来有点吓人,但可以重写成一个更简单的形式。

 v8 = SMB_FEA_LIST + SMB_FEA_LIST - > SizeOfListInBytes -  5

我们正在使用SizeOfListInBytes(此时可能已经包含了大于列表的实际大小的值)的值减去5并将其添加到指针中,因此我们得到的结束指针可能已经超出了分配的内存区域。然后,代码遍历SMB_FEA_LIST块(我们对代码进行了一些细微的更改,使得某些变量的作用更清晰)。

while(!(* source_position & 0x7F))      {        v12 = dest_position;        v11 =(signed  __int16)source_position;        dest_position =(_DWORD *)SrvOs2FeaToNt(dest_position,source_position);        source_position + =(unsigned  __int8)source_position [ 1 ] +  *((_WORD *)source_position +  1)+  5 ;        ifsource_position > v8)        {          dest_position = v12;          goto LABEL_13;        }      }

此循环将一直进行下去,直到source_position超过v8,此时它将退出循环,并对我们是否到达块的结尾进行最后检查;同样,如果一个SMB_FEA块的第一个字节为0x7F,则这个过程将停止。然而,这些错误情况都和这个漏洞没什么关系。正如我们刚刚所说,在一个SizeOfListInBytes的值损坏的情况下,v8将指向一个超过SMB_FEA_LIST缓冲区结束的内存地址,因此在while循环中,我们将使用超出范围的指针值重复调用SrvOs2FeaToNt。

unsigned int __stdcall SrvOs2FeaToNt(int a1, int a2){  int v2; // esi@1  _BYTE *v3; // ebx@1  unsigned int result; // eax@1  v2 = a1;  *(_BYTE *)(a1 + 4) = *(_BYTE *)a2; // copy ExtendedAttributeFlag  *(_BYTE *)(a1 + 5) = *(_BYTE *)(a2 + 1); // copy AttributeNameLengthInBytes  *(_WORD *)(a1 + 6) = *(_WORD *)(a2 + 2); // copy AttributeValueLengthInBytes  _memmove((void *)(a1 + 8), (const void *)(a2 + 4), *(_BYTE *)(a2 + 1)); // copy AttributeName  v3 = (_BYTE *)(*(_BYTE *)(a1 + 5) + a1 + 8); // calculate current position in target buffer  *v3++ = 0; // add trailing zero  _memmove(v3, (const void *)(*(_BYTE *)(v2 + 5) + a2 + 5), *(_WORD *)(v2 + 6)); // copy AttributeValue  result = (unsigned int)&v3[*(_WORD *)(a1 + 6) + 3] & 0xFFFFFFFC; // calculate final position in destination with alignment  *(_DWORD *)v2 = ((unsigned int)&v3[*(_WORD *)(v2 + 6) + 3] & 0xFFFFFFFC) - v2; // update first 4 bytes in destination buffer with length (result_ptr – start_ptr)  return result;}

通常情况下,这个函数会一个接一个地将两种格式之间的各个SMB_FEA块从a2缓冲区复制到a1缓冲区中,虽然结果有一些微小的差别(比如将块的总大小写入a1的前4个字节)。然而由于SizeOfListInBytes值得损坏从而改变了循环的退出条件,该函数将执行重复的超出内存的操作;特别地,第二次memmove操作可能会导致堆上的缓冲区溢出,从缓冲区结束之后的存储区域读取多达64kB的数据。如果攻击者之前已经设法成功地设置了堆,则溢出可能会破坏堆并覆盖保存SMB数据的后续堆内存块,最终导致代码执行。

调试过程

所有感染的电脑srv!SrvTransaction2DispatchTable的0x0e项均被替换。我们只需顺着这个线索往回顺藤摸瓜。
这里写图片描述
运行fb.py,很快便见windbg断下在如下地址。
这里写图片描述
这里写图片描述
果然是这里将SrvTransaction2DispatchTable+0×38即0xe(0xe*4=0×38)进行了替换。这段代码应该是shellcode,我们尚需弄清控制是如何转移到这shellcode的。用.writemem命令将这段shellcode dump出来,仔细查看,shellcode入口应该是这里。
这里写图片描述
重新启动调试,敲下如下命令。
这里写图片描述
断点命中了。
这里写图片描述
栈顶的91a18290很可能就是转移到shellcode的call指令的返回地址,打开Disassembly窗口。
这里写图片描述
就是这条call dword ptr [eax+4] 指令将控制转移到了shellcode。
这里写图片描述
查看调用堆栈。
这里写图片描述
我们来看看0xffdff1f1地址中存放的shellcode是如何拷贝进去的。
这里写图片描述
memmove拷贝的时候覆盖掉的不仅仅是Context->Connection,同时也覆盖了相邻SRVNET_BUFFER.MDL的内容(偏移0x2c起),从而使得TCP/IP协议栈拷贝到了ffdff1f1内存中。在x86上,ffdf1000到ffffffff都是保留给HAL用的。
这里写图片描述
首先发送SMB的Session Setup AndX(0×73)命令,跟据其响应中的Native OS获取目标操作系统的版本信息。
这里写图片描述
接着利用SMB.SMB_COM_NT_TRANSACT SMB_COM_TRANSACTION2_SECONDARY在内存中精心布局,形成了一些连续的SRVNET_BUFFER内存区域。然后关闭了一个链接,从而释放掉一个SRVNET_BUFFER,而这个释放掉的SRVNET_BUFFER空洞恰恰又会被FeaList分配内存时重用。而SrvOs2FeaListToNt中的Bug又导致了拷贝时越界,直接覆盖掉了其后的SRVNET_BUFFER,修改了MDL。于是后面的发送的数据就被错误的拷贝到了MDL指定的内存中,也就是HAL保留的内存。而这时发送最后一个SMB_COM_TRANSACTION2_SECONDARY分片,从而触发了控制转移。
这里写图片描述

shellcode行为

shellcode基本上执行了下面几个步骤。
第0步:判断x86还是x64。
第1步: 从KPCR中定位IDT,从第一个中断处理反向遍历找到ntoskrnl.exe的基址(DOS MZ头)。
第2步:读取ntoskrnl.exe的导出目录,并使用hash(和应用层shellcode类似)来找到ExAllocatePool/ExFreePool/ZwQuerySystemInformation函数地址。
第3步:使用枚举值SystemQueryModuleInformation调用ZwQuerySystemInformation,得到一个加载驱动的列表。通过它定位到SMB驱动srv.sys。
第4步:将位于SrvTransaction2DispatchTable的SrvTransactionNotImplemented()函数指针指向自己的hook函数。
第5步:使用辅助的DoublePulsar payload(如注入dll),hook函数检查是否正确运行并分配一个可执行的缓冲区来运行原始的shellcode。所有的其它请求直接转发给原始的SrvTransactionNotImplemented()函数。
在受到攻击之后,你能看到SrvTransaction2DispatchTable中缺少的符号。在这里应该有两个处理程序和SrvTransactionNotImplemented符号。下面是DoublePulsar的后门(数组索引14)。
这里写图片描述
在shadow brokers泄漏的文件中你能找到DoublePulsar.exe和EternalBlue.exe。当你使用FuzzBunch中的DoublePulsar,有个选项是将它的shellcode输出到一个文件中。我们发现这只是用来转移注意力的,还发现EternalBlue.exe包含了它自己的payload。
第0步:判断CPU架构
payload非常大,因为它包含x86和x64的shellcode。前面一些字节使用操作码技巧来决定正确的架构(参考我之前的汇编架构检测文章)。
对于x86架构的机器来说头几个字节是这样的。
这里写图片描述
你该注意到inc eax使得je指令不会执行。接着是一个call和pop,他们用来获取当前的指令指针。对于x64架构的机器来说头几个字节是这样的。
这里写图片描述
其中inc eax指令变成了rex的开头部分,因此zf标志寄存器仍然由xor eax,eax操作设置。x64有RIP相对寻址,不需要获取RIP寄存器。X86的payload和x64的基本一样,所以这里只关注x64。我在16进制编辑器中用CC CC(int 3)覆写40 90。int 3是调试器的软件断点。
这里写图片描述
现在执行payload,我们附加的内核调试器将自动断在shellcode开始执行处。
第1步:找到ntoskrnl.exe的基址
一旦shellcode判断在x64上面运行之后,它将开始搜索ntoskrnl.exe的基地址。代码片段如下。
这里写图片描述
相当简单的代码。在用户模式下,x64的GS段包含线程信息块(TIB),其保存了进程环境块(PEB),该结构包含了当前运行进程的各种信息。在内核模式中,这个段寄存器包含内核进程控制区(KPCR),其中偏移0处包含当前进程的PEB。该代码获取KPCR的偏移0x38处的值,它包含一个KIDTENTRY64结构的指针。和x86中类似,我们知道这是中断描述符表。在KIDTENTRY64的偏移4处,你能得到中断处理的函数指针,其代码定义在ntoskrnl.exe中。从那里按页大小(0x1000)反向搜索内存中.exe的DOS MZ头(cmp bx,0x5a4d)。
第2步:定位必要的函数指针
一旦你知道了PE文件的MZ头的位置,你能定位到导出目录并得到你想要的函数的相对虚拟地址。用户层的shellcode这样做通常是为了找到ntdll.dll和kernel32.dll中的一些必要的函数。和大部分用户层的shellcode一样,ring0层的shellcode也使用hash算法代替硬编码字符串以便找到必要的函数。下面是要找的函数。
ZwQuerySystemInformation
ExAllocatePool
ExFreePool
ExAllocatePool能用来创建可执行内存区域,并且ExFreePool能用来清理内存区域。这些很重要,因此shellcode能为它的hook函数和其他函数分配空间。ZwQuerySystemInformation在下一步中是重要的。
第3步:定位SMB驱动srv.sys
ZwQuerySystemInformation的一个特性是一个名为SystemQueryModuleInformation的常量,值为0xb。通过它能得到系统中所有加载的驱动的列表。
这里写图片描述
shellcode在这个列表中搜索两个不同的hash,定位到srv.sys,这是SMB主要运行的驱动之一。
这里写图片描述
这里的过程和用户层得到PEB->Ldr来遍历搜索加载的DLL基本一样,只不过这里要查找的是SMB驱动。
第4步:Patch SMB的Trans2 Dispatch Table
现在DoublePulsar shellcode已经得到了主要的SMB驱动,它遍历.sys的PE节,直到找到.data节。
这里写图片描述
.data节在这里存储着SrvTransaction2DispatchTable,一个处理不同的SMB任务的函数指针数组。shellcode分配一些内存并实现函数hook。
这里写图片描述
接下来shellcode存储名为SrvTransactionNotImplemented()的派遣用的函数指针(以便能在hook函数中调用),然后使用这个hook覆盖SrvTransaction2DispatchTable中的成员。
这里写图片描述
backdoor完成了。现在它返回到它自己的调用栈,并做一些小的清理操作。
第5步:发送Knock和原始的shellcode
当DoublePulsar发送了指定的knock请求(被视为无效的SMB调用),派遣表调用被hook的假的SrvTransactionNotImpletemented()函数。我们能观察到奇怪的行为:正常的SMB响应的MultiplexID必须匹配SMB请求的MultiplexID,但是这里增加了一个常量作为状态码。操作使用了隐写技术,所以在Wireshark中没有合适的解析。
这里写图片描述

状态码0x10 = 成功0x20 = 无效的参数0x30 = 分配失败操作列表0x23 = ping0xc8 = exec0x77 = kill

你能使用下面的算法得到操作码。

t = SMB.Trans2.Timeoutop = (t) + (t >> 8) + (t >> 16) + (t >> 24);

反之,你能使用这个算法制作数据包,其中k是随机生成的。

op = 0x23k = 0xdeadbeeft = 0xff & (op - ((k & 0xffff00) >> 16) - (0xffff & (k & 0xff00) >> 8)) | k & 0xffff00

在一个Trans2 SESSION_SETUP请求中发送一个ping操作将得到一个响应,其中包含需要为exec请求计算的XOR密钥的一部分。XOR密钥的算法如下。

s = SMB.Signature1x = 2 * s ^ (((s & 0xff00 | (s << 16)) << 8) | (((s >> 16) | s & 0xff0000) >> 8))

更多的shellcode能使用Trans2 SESSION_SETUP请求和exec操作发送。使用XOR密钥作为基本流密码,shellcode一次性在数据包4096字节的数据载荷部分中发送。后门将分配一块可执行内存区域,解密复制shellcode并运行。我们能看见hook被安装在SrvTransaction2DispatchTable+0x70(112/8=index 14)处。
这里写图片描述
全部的汇编代码在这里。