OpenStack 远程代码执行(CVE-2021-40085)分析

 

作者:极光无限(莫斯科)安全研究中心漏洞研究员Paul

01 简要概述

这篇文章将描述我在OpenStack中发现的漏洞,基于多种因素的巧妙结合,该漏洞可以实现远程代码执行。产生该漏洞的根本原因很简单,但是对其进行成功的利用需要很大工作量,我将对此进行讲述。

注意,为了确保使用OpenStack各大厂商的安全,我将不会介绍一些很深入的技术细节,也不会提供有效的漏洞利用,仅对原理部分进行阐述。注意到这个漏洞的厂商也请及时跟进修复这个漏洞。

 

02 OpenStack

OpenStack是一个开源的云计算基础设施软件项目,是全球三大最活跃的开源项目之一。它被许多大公司广泛使用,其中一些甚至将其作为服务提供。OpenStack由不同的组件(最新版包含39个组件)组成,他们实现了云基础设施的不同特性。在本文中,我将讨论其中之一——Neutron组件。

 

03 Neutron组件

Neutron是OpenStack的核心组件。它为由其他OpenStack服务管理的接口设备提供”网络连接服务”。几乎每个OpenStack部署都要包含这个组件,所以如果我们能够在Neutron中找到一个漏洞,它将适用于每个部署。

经过一段时间的源码研究之后,我发现了一个有趣的问题。OpenStack使用dnsmasq作为一个DHCP服务。任何拥有创建或者修改云基础架构网络配置权限的人同样也可以指定额外的dhcp选项。基本上,OpenStack使用这些额外的dhcp选项作为dnsmasq的dhcp-optsfile配置文件中的索引。有一个问题是这些额外的dhcp选项允许在其中使用换行符,从而导致可以将任意索引注入dhcp-optsfile配置文件。让我们研究一下使用它可以做什么。

 

04 Attacking Neutron

第一眼看,这似乎不是一个安全问题,因为能够注入任意DHCP选项到你自己的网络并不能带来任何安全影响。但是,通常OpenStack部署会包含一个共享网络来为用户提供外部IP。这些网络通常受到限制,因此客户不能够修改网络配置,但是有一个重要的事情是额外的dhcp选项参数与连接网络的用户控制端口相关。端口是一个抽象的对象,它将您的实例连接到网络的抽象。这意味着即使攻击者没有修改网络选项的权限,但他仍然能够通过修改端口将任意dhcp选项注入到该网络的配置文件中。这将导致对该网络中运行的任何实例进行中间人攻击。

但是,在我看来,这种中间人攻击不够可靠并且我想获得更大的影响。所以我决定攻击dnsmasq本身。许多服务包含有可能导致代码执行的配置指令,所以我的计划是检查在dnsmasq中是否可行。不幸的是这并不可行,原因在于”dhcp-optsfile”配置只能包含一组有限制的指令,并且它们除了指定将由DHCP服务器发送给客户端的不同DHCP选项外,无法做其他任何事情。

由此我决定孤注一掷——启动一个fuzzer。

 

05 Fuzzing

Fuzzing是一种艺术,做到完美的模糊测试覆盖率将会是一个非常艰巨的任务。所以我决定首先仅对配置文件解析功能进行模糊测试。为了确保模糊测试过程中应用程序仅执行必要部分功能,我们对源码进行修补,当然这个工作很简单,并不需要花费太多时间。这样做可以使得模糊测试更为有效。我对通过该方法发现一个漏洞并没有抱有太大希望,所以在AFL工作期间,为了对DHCP协议进行整体的模糊测试,我开始准备dnsmasq。

幸运的是,AFL在较短的一段时间便引发了一系列崩溃,在对第一波崩溃进行跟踪分析之后我发现了一些有趣的事情。这些崩溃与解析十六进制编码的字符串有关,当dnsmasq在配置文件中找到MAC或IPv6地址(它们都是由冒号分割的一组十六进制值)时,它会解码获取的十六进制值并存储其原数据如下:

addrs = digs = 1;
for (cp = comma; *cp; cp++)
// …

}
// …
if (is_hex && digs > 1)
{
new->len = digs;
new->val = safe_malloc(new->len);
parse_hex(comma, new->val, digs, NULL, NULL);
}

我们可以看到,程序中通过冒号的数量来判断十六进制解码字符串的长度。然后它直接分配了该长度的缓冲区并调用”parse_hex”函数对十六进制字符串进行解码并保存到缓冲区中。”parse_hex”函数实现如下:

intparse_hex(char*in,unsignedchar*out,intmaxlen,
unsigned int *wildcard_mask,int*mac_type)
{
intmask=0,i=0;
char*r;
while(maxlen==-1||i<maxlen)
{
for(r=in;*r!=0&&*r!=':'&&*r!='-'&&*r!='';r++)
if(*r!='*'&&!isxdigit((unsignedchar)*r))
return-1;
//...
intj,bytes=(1+(r-in))/2;for(j=0;j<bytes;j++)
{
//...
out[i]=strtol(&in[j*2],NULL,16);i++;
//...
}
//...
in=r+1;
}
//...
}

尽管在”parse_hex”函数中有一个”maxlen”参数,但是它将字符串的开头和结尾的差值除以2作为十六进制编码段的长度。因此可以绕过”maxlen”的限制来溢出分配的缓冲区。这就意味着这里的崩溃是一个安全问题,那么现在我们着手研究它是否可以被利用。

 

06 Pre-Exploitation

熟悉二进制漏洞利用的人都知道,现在的应用程序包含了许多缓解措施,这使得漏洞利用变得更加困难。同样的,在我们发现的堆缓冲区溢出中利用需要我们攻击分配器,让它在任意地址上分配内存块,从而覆盖应用程序内存中的任意值。在此之前我们首先要做的就是找到想要分配块的地址,但坏消息是程序开启了ASLR,它会在程序启动时随机化程序的内存地址。所以如果我们想在libc内存段中分配我们的块,我们需要想办法泄露它的基地址,从而进一步计算我们想要的覆盖地址。

我们看一看dhcp_opts结构体:

struct dhcp_opt {
int opt, len, flags;
union {
int encap;
unsigned int wildcard_mask;
unsigned char *vendor_class;
} u;
unsigned char *val;
struct dhcp_netid *netid;
struct dhcp_opt *next;
};

我们写入配置文件的选项数据将被以这个结构体形式的链表进行存储。

这个链表用于为客户端提供DHCP响应选项。其中最重要的一个字段是”len”,它表示存储在”val”字段中的数据长度。如果我们可以在dhcp_opt实例之前分配缓冲区,我们就可以覆盖这里的”len”字段,从而通过向dhcp服务器请求选项来获取这片内存。

问题是我们只能将数据写入当前迭代中分配的缓冲区。这就意味着在默认情况下,在它之后不会有块被分配,所以我们无法进一步进行覆写。但我们可以通过合理运用libc bins来改变这个行为。如果要释放的块足够小的话,分配器不会将它返回到普通块池,而是将它存储到一个bin中。当程序请求分配一个相同大小的块时,分配器会直接从bin中返回该块,否则块将从堆顶部被分配。

于是我们有如下想法:

1.首先我们需要分配一个小块(Chunk2),然后将它释放,此时该块将被存储到相应的bin中。

2.然后我们需要分配一个不同大小的块(Chunk3),这样便不会获取bin中存储的与chunk2大小一样的块。

3.此时,如果我们分配一个与Chunk2相同大小的新块(Chunk4),那么将从bin中返回这个块并且此时分配器将返回与我们当时分配Chunk2时相同的内存地址。

那么现在我们就可以在现有的dhcp_opt实例之前分配块来对”len”字段进行覆盖。之后,如果我们向dnsmasq请求DHCP选项,我们将收到存储在”val”字段内容之后的数据。但是由于DHCP数据包长度限制,我们只能泄露0x80字节的数据。此外,如果我们现在试图去泄露数据,并不能获得什么有用的信息。我们最想获得的是来自”dhcp_opt”结构体的指针,但是在现有条件下我们甚至还无法计算堆的基地址,因为我们不知道数据从堆段开始的偏移量。最好的办法就是在泄露之前把相关数据放在这里。

在前面说过,被释放的堆块被放在了bins中。实际上有几种不同类型的bins,其中的一些(tcache,fastbin)将空闲块存储在单链表中,其中每个空闲块指向下一个空闲块。其他的一些bins(samll bin,large bin,unsorted bin)将数据存储在双向链表中。与单链表不同的是,双向链表的头和尾将存储指向位于libc内存段中的main_arena中相应的bin结构的指针。这就意味着如果我们在要泄露的数据之后分配符合small、large或unsroted bin条件的chunk,并且这个chunk将是链表的第一个或是最后一个元素,我们将泄露bin结构地址,从而计算出libc的基地址。

在上图中,最后一个块是bin中空闲块中双向链表中唯一的空闲块,因此它的”fd”和”bk”字段都指向位于

main_arena中的bin结构。因此,通过更改”dhcp_opt 2”中的长度字段可以从”data 2”获取额外数据,其中包含”fd”和”bk”字段的地址。

这样一来我们便可以泄露libc的基地址,从而绕过ASLR缓解机制,下面尝试进行代码执行。

 

07 Exploitation attempt 1.

众所周知,有很多技术可以对缓冲区溢出进行利用,但是考虑到我们对块分配和有效载荷长度的限制。我决定使用尽可能简单的攻击方案。在我看来,利用堆溢出最简单的方法就是攻击tcache。

Tcache是一种相对较新的分配机制,它是在glibc 2.26中被引入。Tcache是一组bins缓存,它可以减少内存分配时的开销,当请求内存分配时,分配器会首先从Tcache中寻找是否有可用块。被释放的块被存储在一个单链表中,其中每个空闲块都指向同一bin中的下一个可用空闲块。如图是tcache空闲块的样子:

它与分配的块一样,拥有大小和标志字段,但是数据的前8个字节中存储了一个指向来自同一bin的下一个空闲块的指针。如果没有更多的空闲块,此指针将被设置为NULL。

所以我们需要做的就是分配可以在空闲块之前溢出的数据块,然后覆写它的next指针指向任意地址。

之后,我们将分配两个适合同一个tcache bin的块,第一个将被分配用以替换Chunk 1,第二个将被分配到地址0xdeadbeef。下一步就是利用这个地址,这将使得分配器返回处在free_hook的数据块而不是0xdeadbeef的数据块。由于前面已经泄露了libc的基地址,这个地址可以很容易的地计算出来。同时我们也可以计算出”system”函数的地址,然后用计算得到的”system地址”覆盖“free_hook”,这样一来,每次释放块时将调用”system”函数。在这里,我们可以在包含OS命令的配置文件中放置一个无效行,以便应用程序读取它,确定该行无效后释放缓冲区,从而调用”system”函数导致远程代码执行。

但当我尝试利用漏洞时我本地OpenStack部署运行在最新的Ubuntu实例上,而目标运行在使用libc 2.17的CentOS Linux上。前面介绍了,tcache在2.26版本才被引入。所以我们需要另辟蹊径。

 

08 Exploitation attempt 2.

为了保持攻击方式的简单与直接,所以这一次尝试攻击fastbin。Fastbin空闲块也存储在单链表中,但这里的关键区别在于分配器在分配之前要检查bin的索引(根据块大小计算)。所以如果尝试去分配一个块来代替”free_hook”,我们将会失败,因为将被分配的块的位置看起来并不像一个真正的内存块,这将无法通过检查。但是有一个方法可以绕过它,我们先来看看”memalign_hook”。
0x7ffff7dd3ae0 <__memalign_hook>: 0x00007ffff7ab6420 0x00007ffff7ab63c0
0x7ffff7dd3af0 <__malloc_hook>: 0x0000000000000000 0x0000000000000000

它是默认被设置的,并且所在位置靠近”malloc_hook”。尽管它依旧看起来不像一个内存块,但是观察当前位置偏移一个小的偏移量的位置。

现在它可以被视为0x70大小的bin的块头并且能够通过分配器的检查。从字面上来讲,我们使用函数地址中的0x7F字节作为内存块的长度字段。此外,”malloc_hook”就在他的旁边。我们只需要将下一个空闲块地址设置在这里,这样我们就可以覆盖”malloc_hook”。

但与”free_hook”不同的是,我们不能将其覆盖为”system”函数,因为malloc的参数是分配器请求的字节数。所以这里我们需要通过ROP链实行进一步利用。根据对malloc调用时的调试,r12与r15寄存器包含要被解析的配置文件中的一行,因此我们需要在libc中寻找gadget(因为我们知道它的基地址)来使用这些寄存器。我成功找到了如下gadget链:

12fd48:movrax,qwordptr[r15+0x60];movrdi,rbx;callqwordptr[rax+0x20];
133d79:movrdi,r12;callqwordptr[rax+0x28];

我们需要覆盖malloc_hook内容如下:

malloc_hook: gadget_12fd48 gadget_133d79
malloc_hook+0x16: system

这样一来,当malloc_hook被调用时,”gadget_12fd48”将被执行,它将读取指向当前正在解析的配置文件行偏移0x60处的指针。这里我们需要放置一个”malloc_hook+0x8”的地址,从而控制执行流程到”gadget_133d79”,它将以当前解析行内容作为参数条用”system”函数。

综上所述,整个利用过程如下:

1.分配和释放两个0x70大小的chunks,这样他们将适用于0x70的bin。

2.其次我们需要使用malloc_hook之前的伪造块的地址溢出覆盖第二个释放块的前向指针。

3.然后我们需要分配两个相同大小的块。第二个将被分配到hooks区域。

4.下面我们可以用我们准备好的gadget chain覆写”malloc_hook”位置,它们在下一次内存分配时将会被调用。

5.最后一步就是解析包含0x60偏移处指针的行,这将触发分配并执行gadget链。

但是,对于利用的最后一步有一个问题——目标OpenStack部署运行于Python2.7环境下。我们只能指定一个unicode字符串作为选项值,并且所有这些值会被转换为string。在这种情况下,python2.7仅仅支持值的内容包含0x00-0x7F范围内的字节,否则将会抛出异常。这就意味着我们只能使用0x00-0x7F范围内的字节。我们也不能使用字节0x20和0x0A。因为它们会破坏配置文件的语法。但是对于最后一步,我们需要在配置文件中放置一个指向我们内存的指针,该文件显然可以是任意内容。在这里我们可以使用ASLR来帮助我们。

我们需要确保malloc_hook+0x8-0x20的地址的内容仅处于0x00-0x7F字节范围内。我们可以思考一下什么样的地址值能够达到这个要求。地址的高24位内容始终为0x00007F,符合限制要求;地址低12位的值取决于libc版本,幸运的是我检查的每个libc版本都有一个符合限制要求的”malloc_hook”偏移量。剩余的28位在每次应用程序启动时由ASLR随机化产生。另一个幸运的地方是,OpenStack设计是故障安全的,所以当dnsmasq崩溃时它会自动重启。这就意味着我们可以泄露libc基地址,计算malloc_hook+0x8-0x20的地址,如果地址内容不满足我们的限制要求,我们也可以使dnsmasq崩溃重启。当你拥有一个溢出漏洞时可以有很多方法去使应用程序崩溃,这不成问题。在OpenStack重启dnsmasq之后会为malloc_hook+0x8-0x20生成一个新的地址,所以在获得一个有效地址前我们可以多次重新启动这一切。

 

09 总结

在不同条件下的成功利用之后,负责任的披露时机已经到来。最终的利用使用了两个不同的漏洞——第一个是OpenStack Neutron组件中的漏洞,第二个是dnsmasq中的缓冲区溢出漏洞,所以这两个漏洞都应该被报告。

在这一点上,我意识到从master分支构建的dnsmasq服务器不存在这个缓冲区溢出漏洞。通过调查,我找到了一个修复该漏洞的提交,提交意见表示:”parse_hex的输入从来都不是不可信任的数据,所以没有安全问题”。这就是为什么这个修复没有被引入到Linux发行版中,也是为什么即使在最新的Ubuntu中我仍然能够利用它的原因。

该漏洞已经被报告给OpenStack bug tracker并由他们的团队进行修复。每个受支持的OpenStack版本的补丁也已经发布。

(完)