【OwVA】永恒之蓝漏洞分析

OwVA 是一个系列,主要用来记录对一些漏洞的分析。

介绍

永恒之蓝是 2017 年爆发的勒索病毒 WannaCry 所使用的漏洞,该漏洞位于负责 Windows 的 SMBv1 消息收发的组建中(srv.sys),属于一个溢出型漏洞,攻击者可以使用该漏洞获得远程代码执行(RCE)的能力。

本次分析使用 Windows 7 SP1 x64 中文版作为漏洞环境,提取系统中的 srv.sys 进行分析。

背景

什么是 SMB 服务

SMB 服务全称 Server Message Block,属于 Windows 文件共享服务,位于 Windows 的 445 端口中,可以在多台计算机之间共享文件以及完成其他任务,如网络打印机的打印。简而言之,可以对通过 SMB 服务对 Windows 系统共享的文件进行操作。漏洞就发生在处理 SMBv1 消息的过程中。

SMB 消息结构

SMB 消息通过一系列的命令与服务器进行交互。SMB 消息的结构可以分成三个部分:定长的消息头部,变长的参数区块和变长的数据区块。消息头部用来指示当前发送的消息为 SMB 消息,并指定当前需要执行的命令以及上下文。SMB 消息头部中的 UID 用来区分不同的 SMB 会话,一个 SMB 消息头部如下图所示,这是一个向服务器发送 NT Transaction 命令的 SMB 消息:

参数区块和数据区块是两个变长的数据区域。其中参数区块的第一个字节代表后面跟着的参数区块长度(字长度),即后面的「2 * 长度」个字节都属于参数区块,如下图所示,这里的 20 表示后面的 40 个字节属于参数区块:

数据区块的前两个字节代表数据区块的长度(字节长度),后面的「长度」个字节属于数据区块,如下图所示,这里的 27 表示后面 27 个字节属于数据区块:

此外,SMB 消息可以以 AndX 的形式进行构造,以这种方式构造的消息,可以发送一个 SMB 消息头,在消息头后面可以跟着多个参数与数据区块,这种消息类似于一个链表,跟在参数区块长度字段后面的 4 个字节用于指示下一个参数区块和数据区块的命令和偏移。如下图表示后面没有更多的参数和数据:

具体的 SMB 中所支持的命令可以参考:https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-cifs/089b6f3e-b91d-4659-83a7-3e50a1a5faf7

SMB 的每一条消息的大小都不能超过 MaxBufferSize 指定的值,如果需要发送超过该大小的值,则可以分为多个 SMB 消息进行发送,只要设置相应 SMB 消息参数数据区域中的 TotalDataCount 大于 DataCount,就表示当前的 SMB 消息并不是一个完整的 SMB 消息,服务器会根据 SMB 消息的 TID、UID、MID 来识别同一个 SMB 消息,并将后续的数据与之前收到的数据进行拼接,只有收到了完整的 SMB 消息(即 TotalDataCount 等于所有包的 DataCount 之和)之后才会进行实际的处理。

漏洞

永恒之蓝的攻击完成实际上涉及到了三个漏洞。第一个漏洞属于一个缓冲区溢出漏洞,位于处理 SMB 的相应命令的逻辑中。当 SMB 消息的命令为 TRANSACTION2 的时候,可以通过 Setup 字段指定需要执行的子命令,这个时候 SMB 消息的参数区块和数据区块的格式如下:

 SMB_Parameters
   {
   UCHAR  WordCount;
   Words
     {
     USHORT TotalParameterCount;
     USHORT TotalDataCount;
     USHORT MaxParameterCount;
     USHORT MaxDataCount;
     UCHAR  MaxSetupCount;
     UCHAR  Reserved1;
     USHORT Flags;
     ULONG  Timeout;
     USHORT Reserved2;
     USHORT ParameterCount;
     USHORT ParameterOffset;
     USHORT DataCount;
     USHORT DataOffset;
     UCHAR  SetupCount;
     UCHAR  Reserved3;
     USHORT Setup[SetupCount];  // 通过这个指定子命令
     }
   }
 SMB_Data
   {
   USHORT ByteCount;
   Bytes
     {
     UCHAR Name;
     UCHAR Pad1[];
     UCHAR Trans2_Parameters[ParameterCount];
     UCHAR Pad2[];
     UCHAR Trans2_Data[DataCount];
     }
   }

如果指定子命令为 TRANS2_OPEN2(0x00) 时,会在请求数据包传输完成的时候调用 srv!SrvSmbOpen2 函数,该函数会调用 srv!SrvOs2FeaListToNt 函数,在这个函数里面会将请求数据包中的 FEA 信息进行转换,而漏洞就发生在转换的过程中。在转换之前,如下图所示,首先会调用 srv!SrvOS2FeaListSizeToNt 函数对转换的时候所需要的内存大小进行计算:

我们进到 srv!SrvOS2FeaListSizeToNt 这个函数,这个函数根据请求数据包中的 FEA_list 来计算所需的内存大小,这里我们需要了解一下 FEA_list 的数据结构(https://github.com/worawit/MS17-010/blob/eafb47d715fe38045c9ea6dc4cb75ca0ef5487ce/eternalblue_poc.py):

typedef struct _FEA {   /* fea */
    BYTE fEA;        /* flags */
    BYTE cbName;     /* name length not including NULL */
    USHORT cbValue;  /* value length */
} FEA, *PFEA;

typedef struct _FEALIST {    /* feal */
    DWORD cbList;   /* total bytes of structure including full list */
    FEA list[1];    /* variable length FEA structures */
} FEALIST, *PFEALIST;

这里的 FEA 通过长度来指定后面的多少字节属于 Name 和 Value,在 Name 的数据和 Value 的数据之间会间隔 1 个字节,而代码就是通过偏移(cbName + cbValue + 4 + 1)来访问 FEA_list 中的下一个 FEA 结构,如下图中的第 33 行:

而循环的终止也是通过 FEALIST 结构中的 cbList 字段来决定的。但是这里的问题在于,当挪到下一个 FEA 结构的时候,如果该 FEA 结构的末尾超过了当前 FEA_list 的大小,会进入第 27 行,这里会直接更新 FEA_list 的长度字段,用来在后面对 FEA 结构转换的时候判断列表的结尾,并返回之前计算得到的所需要的内存大小。但是在更新长度的时候,仅仅更新了长度字段的低位的两个字节,高位的字节保持不变,因此,如果将原来的长度(即 cbList 字段)设置为大于等于 0x10000,这里的更新就有可能将 cbList 变得更大。

然后,我们回到 srv!SrvOS2FeaListToNt 函数,可以看到使用计算得到的返回值申请了一段内存,然后调用 srv!SrvOs2FeaToNt 函数,将每一个 FEA 结构进行转换,并将转化结果存储在申请的内存中。而由于这里的循环终止条件是根据 FEA_list 中的 cbList 得到的,如果之前将 cbList 改的很大了之后,这里的循环转化写入内存的操作就会导致溢出。

下面可以看一下实际的调试情况,下图是程序在执行 srv!SrvOS2FeaListSizeToNt 之前,rcx 寄存器存放着 FEA_list 的地址,因此可以看到 cbList 为 0x10000:

当执行完这个函数之后,可以看到 cbList 已经被更新,且变大了:

而此时的返回值小于 cbList 的值:

因此,在后续的转换过程中就会导致溢出。

但是,为了使其能够溢出,需要将 FEA_list 的大小设置为大于 0x10000,也就是上面的数据结构中的 TotalDataCount 要大于 0x10000,但是上面结构中的 TotalDataCount 是 USHORT 类型的,占两个字节,因此无法满足大小的条件。这时,就需要利用另一个漏洞来完成。

在背景中提到,如果需要发送的数据过大,则需要拆分成多个 SMB 数据包进行发送,服务器会在收到后续数据包(即 Secondary 结尾的那些命令)时,通过 srv!SrvSmbTransactionSecondary 来合并,如果合并完了,则调用 srv!ExecuteTransaction 对数据包的命令进行执行。

srv!ExecuteTransaction 会根据当前收到的数据包的命令类型,对 SMB 数据包进行处理。也就是说,在使用具体函数解释当前的子命令的时候,服务器是根据最后一个收到的包中的命令来完成,而不是发送的第一个包,如下图所示:

而图中的 SrvTransaction2DispatchTable 是存放着函数指针的一个数组,v9 则是第一个数据包中的 Setup 字段对应的内容。

因此,为了发送 TotalDataCount 大于 0x10000 的数据包,我们可以借助 SMB_COM_NT_TRANSACT 命令,该命令的 TotalDataCount 字段为 ULONG 类型,然后在后续的包发送的时候,我们又使用 SMB_COM_TRANSACTION2_SECONDARY 进行发送,这样在最后一个包发送完成的时候,就能以 SMB_COM_TRANSACTION2 包的逻辑处理命令,从而调用 srv!SrvSmbOpen2 函数,进入第一个漏洞的逻辑。

第三个漏洞是为了在利用过程中对堆的布局进行构造,因此在利用部分进行介绍。

利用

现在有了溢出的漏洞,接下来需要利用这个漏洞。由于溢出的缓冲区属于 Windows 的 Non-Paged Pool,因此使用池溢出对数据结构(这里使用的是 SrvNetBufer)进行覆盖,从而实现漏洞利用。由于我目前对 Windows 内核内存池分配机制不了解,因此只能通过阅读现有的利用代码对其理解。

为了能溢出到目标的数据结构,我们需要在执行 srv!SrvOS2FeaListToNt 函数时申请的内存后面放置我们要溢出的数据结构(即 SrvNetBuffer)。这里需要找到可以在内存中可以控制申请内存大小的方式,因此就需要引入第三个漏洞。当发送 SMB_COM_SESSION_SETUP_ANDX 命令的时候,会调用 srv!BlockingSessionSetupAndX 函数,该函数会根据 SMB 请求包中的 WordCount 的不同而调用不同的方法计算出需要申请内存的大小对内存进行申请。当 WordCount 为 12 的时候,会调用 srv!GetExtendedSecurityParameters 函数,当 SordCount 为 13 的时候,会调用 srv!GetNtSecurityParameters 函数。除此之外,如果 WordCount 为 12,但是如果 FLAGS2_EXTENDED_SECURITY 或者 CAP_EXTENDED_SECURITY 没有被设置,仍然会调用 srv!GetNtSecurityParameters 函数,如下图请求包所示:

由于这部分代码 IDA Pro 的伪代码显示的不是很准确,因此结合动态调试和汇编的方式进行查看。如下汇编代码所示:

其中 rdi 寄存器存放的是 WorkContext 对象,而 rdi + 0xc0 的地址存放的是 SMB 请求包的内容,因此这里的 rax 存放的就是 SMB 请求包的地址:

那么 0x0a 偏移处的值就是请求包中 Flags2 对应的值,如果 Flags2 中的 Extended Security Negotiation 位没有设置,这里就会跳转进入 loc_7A9F9,从而不会将 cl 寄存器设置为 1。然后继续往下看,当 cl 为 0 的时候,会继续跳转:

进入 loc_7AD99,之后再经过几次直接跳转,从而会调用 srv!GetNtSecurityParameters 函数。该函数根据 WordCount 为 13 来进行解析,最终会将我们可以控制的数据部分解析为需要申请的内存大小,然后申请内存。因此,通过这个漏洞,我们可以申请任意大小的内存。

之后,利用的基本思路是首先申请一个较大的内存(A),之后申请几个 SrvNetBuffer 的内存,然后再申请一个与咱们溢出的缓冲区相同的内存(B),该内存用于占位,接着释放内存 A,这一步是为了内核中的其他申请占用了内存 B 后面的空间,然后再申请若干个 SrvNetBuffer 的内存,目的是有一个 SrvNetBuffer 的地址能位于内存 B 的后面,最后释放内存 B,发送所构造的溢出包的最后一个包,执行溢出。

溢出之后对 SrvNetBuffer 内容的修改需要结合 SrvNetBuffer 结构的具体构造,这一部分目前没有详细地去了解,可以参考 https://github.com/worawit/MS17-010/tree/eafb47d715fe38045c9ea6dc4cb75ca0ef5487ce 这里的 Exploit。

总结

这是第一篇每周漏洞分析,但是花的时间远远超过了一周,主要是最近各种项目实在太忙,还有就是第一次分析 Windows 内核的漏洞,很多地方都不了解(比如内核内存堆、系统组件等)。不过这个漏洞分析下来,除了发现 Windows 内核漏洞确实复杂性很大以外,还明确了不能过分依赖 IDA Pro 伪代码功能,最靠谱的还是动态调试加上汇编代码一步一步去理解。

目前还有很多问题没有解决:

  • Windows 内核内存池申请内存的基本步骤
  • 逆向是如何准确逆向出在程序中所使用的数据结构的?
  • Windows 下程序及内核利用的利用方式和缓解机制

参考

Leave a Reply

Your email address will not be published. Required fields are marked *