一个Unix内核级别漏洞(一)

翻译原创稿件,prison整理翻译,首发ichunqiu,原地址:http://lsd-pl.net/kernelvuln.pdf

这是一篇关于Unix内核级别漏洞的paper,由某团队发布在一次黑客挑战上,内容很有深度,但有条理,翔实的为读者说明了该漏洞可能造成的影响,以及漏洞利用的过程。另外因为是paper形式,所以比较严谨,值得研究。难度系数:四颗星,本文较长,耐心看完的额都学到了本事!

1.引言

2.ldt x86 bug

   2.1  问题描述

   2.2  Solaris2.7 2.8 x86

           2.2.1 安装call gate

           2.2.2 跳转到一个新的call gate

           2.2.3 内核上的代码执行

第一章引言

这是一个关于内核级别漏洞以及其对操作系统安全性能的潜在影响的技术论文。在5th ArgusHacking Challenge(一个黑客技能挑战大赛,以下简称挑战) 中,这一主题将会被放在非常具体的环境中讨论,以验证概念代码的可行性。所以,这篇论文可以看做是由两部分组成的,因为它主要介绍了两个看似独立的问题,在一个特定的条件下,这两个问题又有一定的关联性。第一个部分描述了操作系统存在的内核级别漏洞应用技术,第二部分包含了ldt内核级别漏洞的在实际开发中的EXP,这在挑战中成功的被验证了。

在开始的时候,我们想要澄清一些我们在挑战之后的交流中提到过的一些问题,在这个在特定条件下已经被证明成功的攻击方法中,我们在UNIX 内核中使用了USER LDT 验证漏洞,该漏洞在2001年1月17日的NetBSD 安全报告2001-2002中已经发布(见下文13)。根据这个原始的参考,在x86平台上的NetBSD操作系统内核中,存在一个对系统调用参数进行验证的漏洞。通过对这一漏洞的巧妙利用,可以使每个具有本地或者远程访问系统的用户进行内核中的任意内存访问,通过这种方式绕过系统保护(见13)。正如报告中说的那样,以下操作系统受到影响:

- NetBSD, versions 1.4.x and 1.5,

- OpenBSD/x86 由于直接从NetBSD中集成代码,所以其同样脆皮(但是,在USER LDT默认的情况下是不可用的),

-Solaris/x86好了那么一丢丢,在同一机制的不同事件中脆一点

因此,我们希望可以消除大家对这个漏洞的所有疑惑和模糊不清的地方,在挑战中使用的漏洞还没有被我们发现,因此应该把他的信息发送给坏蛋,这个漏洞没有引起别人的注意,没有引起讨论,我们也没有见到任何有人exp代码,甚至没有人尝试过利用这个漏洞,但是你懂得,这并不意味着这些代码不存在,可能只是他们比较菜。

这对我们来说是个非常大的惊喜,因为这个特殊的漏洞从一开始就非常好玩,因为它直接位于内核级啊。所以,如果能够对这样一个漏洞熟练利用,尝试各种姿势就有可能在操作系统中获得绝对的主权而不是被其配件的乱七八糟的详细配置压在身下。另外,我们认为这样做的话对绕过操作系统内核实现各种OS增强系统有潜在的用处。后来我们证明有些时候梦想还是要有的,万一实现了呢。

在NetBSD 报告发布后不就,我们对其漏洞进行了一些初步的分析,特别注意了它在Sokaris x86操作系统环境中的利用。在这些实验中,我们可以安装call gate到本地进程描述符表(LDT),并将控制权转移到内核空间中的一个地址,从而使系统崩溃。这说明Solaris 7中确实存在这个漏洞,但由于其他原因,我们曾经暂停了这个项目的分析。

在四月份的时候我们又开始了分析,就在开始之前的前一天我们发现了关于挑战的信息。在挑战中,我们在由Pitbull Foundation 3.0产品增强的操作系统中验证了存在的漏洞,我们还成功的编写了exp,允许我们在这个系统中获得管理员权限(uid=0)。

在挑战之后,我们继续对这种漏洞以及利用方式进行了进一步研究,这也让我们找出了NetBSD和OpenBSD的exp。此外,我们在SCO的OpenServer和Unixware上发现了这个漏洞(已经有效的利用方法)。通过分析项目,我们发现了SCO OpenServer内核中非常不同的漏洞,两相比较并深入分析后,我们成功的设计了这种漏洞的利用方式,并在此基础上讨论该漏洞。


第二章ldt x86 bug

本文的这一部分主要是在基于intel x86的操作系统中出现的ldt漏洞。在第一部分(2.1)中,我们将详细介绍由Intel微处理器提供的保护机制的选择组件,同时在安全模式下运行,然后在2.2中,我们将在Solaris x86操作系统中详细介绍ldt漏洞利用的姿势。我们会特别强调这一点(敲黑板,划重点),因为我们想要阐明我们的思维方式,并一步一步介绍这一特殊exp的利用过程。这一节应该看作是对不同OS平台上exp的基础和参考。

2.1问题描述  在安全模式下,Intelx86提供了一种安全机制,限制对某些字符段或者内存页的访问,基于特权级别(从0到3,0最吊)。操作系统一般情况下把这种机制用来保护他们最重要的代码和数据。其主要的目的是将操作系统内核放在比包含用户应用程序权限更高的级别上。因此,由处理器提供的这种机制应该可以防止应用程序访问操作系统代码和数据,而不受其他约束。

在安全模式下操作时,所有对内存的访问都通过全局(GDT)或本地(LDT)描述符表。这些表包含段描述符的条目,它提供(在其他)段的基本访问地址、访问权限(Descriptor Privilege Level-DPL)和实际的类型(见2.1)。首先,在访问程序中的数据或者代码段之前,必须将适当的选择器加载到数据、堆栈或代码段寄存器中。此时,当加载段选择器时,通过将运行进程的CPL(当前权限级别)与给定的段选择器的RPL(请求的权限级别)和段描述符的DPL进行比较来执行权限检查。由于有这样的验证机制,所以不可能访问到数据或者越权操作(第11章,第二章,系统架构概览,第2-3页)

为了在访问具有不同权限级别的代码段是提供足够的控制级别,处理器使用了特殊的描述符集,成为gate描述符。

这类描述符有四种主要类型:与任务管理链接的task  gates ,为异常处理和调用/中断gates提供接口,通常用于为访问较低级别的用户应用程序提供权限更高的保护级别。

图片1.png

Call gate 描述符可能留在本地或全局描述符表中,他们是下图所示格式的结构。

图片2.png

要访问的代码段由call gate描述符的段选择器字段表示。补偿字段包含代码段中的入口点,这是要调用的特定过程的第一个指令。DPL字段表示通过特定的门户访问代码过程所需的权限级别。P标志表示call gate描述符是否有效。因为在不同的权限级别访问过程得情况下会发生堆栈切换,因此引出了额外的机制以方便传递给调用过程的参数。这种机制基于这样一种操作:处理器可以自动地将指定的参数数量(存储在gate描述符的参数计数器字段中)从调用者的堆栈过程复制到新方法中。

为了调用call gate ,必须使用长指针来调用lcall或ljmp命令,这个指针应该由具有已定的call gate和偏移量的段选择器组成,而处理器实际上一般是无视偏移量的。在执行这样的指令处理器时,使用指定的段选择器(从长指针中提取)来定位LDT或GDT表中的call gate描述符。然后,它使用来自call gate的段选择器来定位目标代码段的适当段描述符。此时,调用过程的入口点地址是由从获得的代码段描述符和来自call gate描述符的偏移量组成的。如果call gate将控制权转移到更有权限的代码段,则处理器会自动切换到相应的堆栈并复制指定数量的参数。

基于x86处理器(特别是Unix家族)的操作系统使用Intel体系结构童工的安全机制来保护其核心部分,即系统内核。因此,操作系统内核的代码和数据位于权限最高的级别(ring0),而用户的应用程序运行在最低级别(ring3)。内存保护机制可以有效地防止用户的应用程序访问内核或是在同一处理器上执行的其他任务的代码和数据。每个这样的任务都有自己的LDT表,其中包含了自己的代码和数据段的描述符。GDT表包含的主要是操作系统内核所使用的描述符其他任务无法访问该描述符。为了实现用户的应用程序和系统内核之间的通信,使用了一个call and/or 中断 gate机制。这种机制可以通过向LDT(或LDT中的中断gate)添加一些特殊的call gate描述符,从而在ring0下调用系统函数,这还可以从用户级访问(DPL = 3)。注意,LDT、GDT和LDT表本身位于权限最低(CPL = 3)中的不可访问部分。

另外,一些操作系统提供了一种机制,可以将数据段描述符添加到LDT中去,同时还提供了从用户模式可以实现的call gate目标代码段的描述符。通过应用程序调用的特殊系统调用实现这种功能,并在其名称中添加适当的条目到其LDT表中。

在本文的这一部分中描述的内核级漏洞,实现于这样一个系统调用中一些函数的脆皮版本禁用了使用DPL添加到LDT描述符的任何方式,但是在call gate的情况下,call gate所指向的代码段的DPL被漏掉了。因此,存在一种可能添加到LDT的call gate描述符,其中有一个DPL=0的目标段(例如内核代码段),可以从用户模式(DPL=3和选择器RPL=3)访问。对于用户模式进程(CPL=3),安装这样的call gate及其利用可能的,并且不需要特殊的操作系统权限(uid!=0)。因此,系统中的任何进程都可以将魔爪伸向到操作系统内核中的任意地址,这就造成了潜在的重大安全风险。

2.2 Solaris 2.7 2.8 x86

我们将会在在Solaris操作系统上对ldt漏洞的各种姿势深入剖析,这黑客也主要攻击这一块。为了增加这一节的可读性,它分为四个小节,分别引用了call gate描述符的安装(2.2.1),跳过新创建的call gate(2.2.2),在内核堆栈上执行代码(2.2.3),最后增加了进程权限(2.2.4)。

2.2.1 安装 call gate 描述符

在Solaris x86平台上,这个漏洞存在于sysi86()系统调用例程的中(见1)。为了安装附加的call gate,必须使用SI86DSCR参数和指针来调用这个系统函数,以适当地填充ssd。

图片3.png

在操作系统内核中处理这个系统调用的代码段,将ssd结构的字段映射到适当的call gate描述符结构,如下列:

图片4.png

在这个结构中,访问位的设置(gd。gd acc0007)表示创建的call gate的描述符类型(GATE_386CALL 等同于0x0c和0000 1100的二进制文件)。该描述符也被标记为有效且可访问的用户模式(GATE_UACC=0xe0和1110 0000二进制文件,设置P=1和DPL=3)。参数计数器(未使用二进制0 . .3 gd.gd)包含了从调用过程堆栈复制到新一组的参数数量(4字节),在堆栈开关的情况下(在本例中是0)。段选择器(gd.gd选择器)指定在call gate期间访问的代码段。在本例中,它是默认的内核代码段(KCSSEL等于0×158)。偏移量字段(gd.gd off015和gd.gd off1631)包含代码段0×12345678(源代码:11,第4章:保护,第4-17页)的入口点。

这个参数被传递到sysi86()系统调用的sel,包含要创建的新call gate 描述符段的选择器。例如s.sel 选择器字段可以加载0×44的值(二进制:01000100)。这个值有两个(从0)位Tl=1(标指示符),这意味着该选择其应该被添加到当前进程的LDT中。位0和1指定请求选择器的权限级别(本例中是RPL=0)。从3到5的为包含了描述表中的偏移量。在这个特殊的情境下,它等同于(0×44-3)=8.在调用sysyd86()系统call例程时,将新的call gate描述符添加到位置8(LDT的源代码的一个特定进程的LDT中。C程序将作为本文这一部分的猪脚。

图片5.png

系统调用执行的结果是,创建了一个给定进程的LDT的新副本。默认情况下所有新创建的(派生的)进程都遵循标准LDT定义,这可以看做是使用nm实用程序的操作系统内核的默认值。

图片6.png

LDT表的新创建副本包含sysi86()系统调用call所引入的修改。它可以从给定进程的进程结构中使用p ldt指针来定位。

图片7.png

图片8.png

为了验证是否已经成功地安装了已定义call gate描述符,adb(见2)实用程序可以用来显示出我们先前挂起的LDT表的第8位。为了实现这一点,需要使用proc结构的内核地址,而获得它的最简单方法是通过使用ps命令(见2)使用-l选项。

图片9.png

此外,还需要proc结构中的p ldt字段的偏移量。要找到这个偏移量,可以使用adbgen实用程序(见3)。该工具是adb工具集的一部分,旨在找出系统结构中的偏移量。

图片10.png

因此,来自proc结构的p ldt字段的相对偏移量等于1744字节。要打印定义call gate描述符的完整信息,可以使用一个特殊的adb宏程序。

图片11.png

在adb的帮助下,可以打印这一过程的9个LDT表的条目。还可以获得关于索引8的描述符的详细信息。

图片13.png

综上所述call gate描述符已经成功地添加到描述表中。

2.2.2 通过新call gate跳转

现在,新的call gate已经被添加到LDT,下一步将是调用它。为了实现这一目的,需要通过0×44选择器(被处理器无视的一个调用指令的偏移参数)来调用一个远的call指令。

图片14.png

在执行此指令时,处理器将使用指定的段选择器来定位先前写入到LDT的call gate描述符。然后,它将使用来自call gate描述符的段选择器来定位目标代码段的段描述符(在本例中是KCSEL)。在此之后,处理器将把来自代码段描述符的基址和来自call gate描述符的偏移量组合在一起,形成目标过程入口点的地址。因为这个call gate将控制权转移给更有权限的代码段(DPL=0),处理器会自动切换到堆栈,以使其为ring0。

在这一点上来看,可以预先准备好通过call gate进行跳转的完整程序。因此这个程序应该将可以在ldt中添加一个远调用指令操作码的表。c和getchar()行应该替换为这个表的合适调用,就好像它是一个c语言函数一样

图片15.png

结果显示,获取getuid()例程的当前内存位置(adr=0xfe8a5188) ,并且可以在拟定的ldt.c代码中使用。然而,使用这样的call gate偏移地址的ldt程序的执行将会失败,因为处理器将无法处理以下指令:

图片16.png

由于寄存器%gs等于0,而doeas不包含有效的段选择器,所以将会出现异常。操作系统内核假定段寄存器%g存储KGSSEL=0x1b0选择器,它指向当前处理器的cpu结构(使用相对的0地址):

图片17.png

这里,cpu结构最重要的字段是指向当前正在执行的线程的线程id  t结构的指针(cpu线程)。使用这个指针,内核可以很容易地找到与实际执行的线程相关的结构,通过访问当前内核线程结构的指针。在getuid()例程中,有三个指针被使用,它们分别是指向cpu t、kthread t和cred  t结构的指针。

图片18.png

因此,跳转到处理与进程/线程相关的数据的过程是不可能的,因为它们总是(至少在使用像系统调用这样的高级接口时)使用%gs寄存器。显然,可以跳转到例程,在系统调用执行之前适当地加载%gs寄存器(USER SCALL或USER ALTSCALL调用gates)。然而,事实证明这没个卵用,因为执行任何未经授权的操作,必须在对给定进程的特权进行验证的例程之外进行跳转(例如在setuid()中)。可以绕过这种限制,如果在执行新添加的调用gate之前,可以在第3圈中加载KGSEL选择器值的%gs段寄存器。然而,这特么根本不可能,除非你是我。因为在加载段寄存器时,处理器也会执行对权限的验证。因此,这样的尝试最终将以一般的保护异常告终

当然,%gs寄存器可以加载用户模式选择器(例如USER DS=0x1f)。但是在这种情况下(假设输入数据正确地拟定了cpu t、kthread t和cred t结构),内核例程将在用户的数据上运行而不是内部内核结构。另一个问题是如何精确地跳转到给定的内核过程的特定部分。因此,除了查找过程的地址外,还必须通过某种方式获得某种特定指令的偏移量(例如,可以通过对存储在/kerne/genunix文件中的内核二进制文件进行分析)。

2.2.3 在内核堆栈上执行代码

上面的方法都太TM难了最简单、最灵活的解决方案是设计一种方法来执行一些恰如其分的已定的代码,而不是跳到操作系统内核中的现有代码。为,有必要找到一种方法,在操作系统内核的可执行空间中放置几个字节的代码。这类代码的实际目的是利用一些机器语言指令执行定义操作。代码也应该能够在KCSSEL选择器中找到定的内核结构的地址

重点来了,在Intel架构的情况下,这个任务可能相当简单,因为这些处理器不允许将内存页设置为不可执行的。因此,从微处理器的角度来看,可以执行位于内核内存页上的指令,这些指令被标记为代码和数据。因此,不需要找到修改或扩展内核内存池的技术。对于普通的(uid!=0)系统用户来说,这样的事情是不可能的(假设系统的正常运行)

在内核空间中放置数据也是一项相当简单的任务。这是因为通过执行各种系统调用的方式,数据被自动放置在内核内存中。然而,更困难的任务是找到这些数据被放置的实际地址,因为它们通常被复制到内核堆内存中。因此,这些地址大多数是随机的,普通用户的权限很难定位到这些地址,而普通用户不可能通过/dev/kmem设备检查内核内存。

在我们对这个问题的研究中,我们发现了一些在已知位置放置数据的未解锁姿势。为了利用这个特定的ldt漏洞,选择了处理器提供的标准机制。使用它之后,就可以将指定的数据量(在4个字中)从执行给定的call gate(ring3)的进程堆栈复制到目标代码段级别的堆栈(在本例中是ring0)。要指示处理器复制n 4个字节(其中n在0..31)到内核堆栈,即s的值。应该将要复制的acc2设置为4个字节.

图片19.png
 

然后,可以修改汇编代码(asmcode),用用户数据块(0×11、0 x11、…)的地址加载%esp寄存器,并通过一个长调用指令调用一个陷阱(真不是我偷懒,它真叫陷阱)

图片20.png

为了通过创建的call gate执行跳转之后检查内核堆栈内容,可以使用交互式内核调试器kadb(见3)。要在Solaris x86机器上启用kadb,必须使系统在引导提示符上加载调试器。

图片21.png

如果内核调试器在系统中是运行中的,那么在系统操作的任何时候都可以很容易地召唤它。然后,在进入getuid()系统调用时,可以在kadb中设置一个断点,因此,在调用完call gate之后,我们可以停止调用堆栈检查:

图片22.png

可以注意到,来自用户堆栈的数据被复制到内核堆栈中。要找到这些数据的地址,就必须找到给定进程的内核堆栈(显然是动态分配的)。这可以通过获得给定进程的%esp寄存器(来自gregs结构)的值来实现,而它在ring0(或在ring3)中运行。可以通过调用getcontext()系统调用(参见1)来获得此值

图片23.png

为了验证修改的ldt的执行之前的%esp的值。必须获得调用getcontext()例程的指令的真实地址。要获得这个地址实用程序(见2),可以使用它来停止getcontext()调用中的进程。然后,可以应用pstack utili-ty(见2)来查找停止的ldt进程的%eip寄存器的当前值(请注意,pstack打印setcontext(),因为在内核中只有f可以设置g的内容)。

图片24.png

在接下来的步骤中,adb实用程序可以使用7(长度o lcall指令)和一个ldt程序的新实例来使用所获得的地址。在打印寄存器之后,程序应该继续。

图片26.png

在这一点上,可以很容易地注意到打印堆栈指针%esp(来自于ring0)与%kesp(也从ring0)相同。但是有更多的信息可以通过使用kadb来获得.

图片27.png

因此,堆栈中也有两个不同的值:0 x0000000e和0×00000006,后面是

图片28.png

为了获得在call gate执行期间数据将被复制到栈上的详细位置,应该添加12个字节以获得%esp值(两个虚拟值+1个参数为286)和4(保存%eip)+4(保存%cs)。新的值将指向%esp,假设n 4个字节将被复制到堆栈中。要获得复制数据的起始地址,应该从这个值中减去它的大小(n 4)。

图片29.png

但是,还有一个问题必须解决:如何成功地从call gate例程返回到用户模式?此时,复制的数据将临时填充一个nop(0×90)指令和一个长返回指令(lret 0×10)(见10)。在这种情况下,lret指令将弹出一个段选择器和返回代码段(用户CS)的返回指令指针。然后它将从堆栈中弹出16个字节,并增加%esp寄存器的值。由于内部权限inter-privilege-level级别的远返回,%ss和%esp寄存器将被加载存储在堆栈上的值,并将返回到调用过程堆栈(源:11,第4章,从调用过程返回,第4-24页)。

图片30.png

图片31.png

现在,在执行了ldt程序之后,系统不会崩溃。

图片32.png

从内核的角度来看,它是酱婶儿的

图片33.png

结果,这个方法已经被开发出来,用于将任何汇编代码(不超过31个字节)复制到内核内存中,跳到这个代码中,然后安全地返回到ring3,而不会导致系统崩溃或进程崩溃。

原文地址:https://www.cnblogs.com/ichunqiu/p/7365948.html