はじめに
CVE-2019-1162 とは、Google Project Zero の Tavis Ormandy 氏が見つけ、2019 年 8 月の Patch Tuesday で修正がリリースされた脆弱性です。非管理者ユーザーから SYSTEM 権限まで昇格して、メモリへの書き込み (Arbitrary Memory Write) とコードの実行 (Remote Code Execution) が可能です。 危険ですね。コードは全て公開されているので、手元で試すのは簡単です。
CVE - CVE-2019-1162
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-1162
Project Zero: Down the Rabbit-Hole...
https://googleprojectzero.blogspot.com/2019/08/down-rabbit-hole.html
ブログを読んでいたら面白そうだったので、自分で手を動かして理解してみることにしました。
CTF プロトコルの発見
Tavis Ormandy 氏の物語は、特定の String Message (0xC000-FFFF の範囲にある Window Message。このとき見つかったのは 0xc046 と 0xc047。) は Low IL プロセスから Medium IL プロセスに送信可能であることに気づいたところから始まります。String Message 0xc046 を登録していたのは MSCTF.dll であり、ここで彼は何か感じるものがあったらしくこのように書いています。まあここまでは私でも来れます。
I decided it would be worth it to spend a couple of weeks reverse engineering CTF to understand the security properties.
CTF を解析した結果分かったのは、CTF サーバーとも言える ctfmon.exe という High IL で動いているプロセスがデスクトップ セッション毎に ALPC ポートを開いていて、そのポートに対していい感じの ALPC メッセージを送ると、サーバーから任意の CTF クライアントに対して String Message が送られる仕組みになっているらしい、ということです。この仕組みによって、ChangeWindowMessageFilterEx を介さずに Low から Medium/High IL プロセスへメッセージを送ることができることが分かりました。
そもそも ALPC 関連の API も公開されていなかったような気がしますが、彼は CTF が使っている ALPC メッセージのパラメーターの構造とその意味を解析することに成功しています。具体的にどうやって解析したのかまでは書かれていません。私がやるなら MSCTF!CCtfClientPort::ProcessMessage あたりの関数を頑張って読む、とかでしょうか。IDA Pro があれば簡単にできる・・・のだろうか。CTF プロトコルを解析しろ、というタスクが降ってくれば頑張れるような気もしますが、ちょっと興味を持っただけだと途中ですぐ諦めてしまうだろうなぁ。2 週間どころか、半年ぐらいは余裕でかかりそうです。
CTF プロトコルでは、クライアント側からターゲットのスレッド ID と、メッセージの種類、そのパラメーターを送ることができます。例えば、1 つのメッセージでターゲット スレッド内で COM オブジェクトを作成し、次のメッセージで、作成したオブジェクトに対して任意のメソッドを任意のパラメーターを指定して実行することができます。そして衝撃なのは、その時に権限のチェックが一切存在しないことです。
Firstly, there is no access control whatsoever!
Any application, any user - even sandboxed processes - can connect to any CTF session. Clients are expected to report their thread id, process id and HWND, but there is no authentication involved and you can simply lie.
Secondly, there is nothing stopping you pretending to be a CTF service and getting other applications - even privileged applications - to connect to you.
Even when working as intended, CTF could allow escaping from sandboxes and escalating privileges. This convinced me that my time would be well spent reverse engineering the protocol.
この動き、以前書いた WNF の動きにかなり近いですね。
Subscribe a notification from WNF - Qiita
https://qiita.com/msmania/items/aaba5f3bddec10c245c8
BlackHat で発表した時点で Alex Ionescu 氏は、WNF の存在と、Fuzzer を使って幾つかのプロセスをクラッシュさせる方法を見つけたぐらいでした。CTF プロトコルについても、ここまでの時点でかなりの成果を挙げていると思うのですが、Ormandy 氏はさらに踏み込んで調査します。
MSCTF!CTipProxy::Reconvert による Remote Code Execution
1 つのパラメーターをマーシャリングして適当なメソッドを呼び出すと、ターゲット内でそのメソッドが呼ばれたときのパラメーター近傍のメモリの構造は以下のようになります。ちなみにパラメーターの数を増やすと、構造が微妙に変わります。
ブログの中では、各メソッドを呼び出して例外が発生したときのコールスタックから、MSCTF!CTipProxy::Reconvert を使うとパラメーターとジャンプ先の値を制御できる、という結論に達しています。コールスタックなどを一個一個目視して判断したのだと思いますが、このへんの地味な作業は経験が物を言うんでしょうね。私はたぶんヒント無しだと無理。
MSCTF!CTipProxy::Reconvert を呼び出すと、MSCTF!CTipProxy::FindContextPtr 経由で indirect call を実行します。この indirect call のジャンプ先が poi(buffer + 40) になるので、ここから任意のアドレスを実行できます。さらに、call の時点で rcx は buffer - 0xb0 のアドレスを保持しているので、多少の制限はありますがパラメーターも制御できます。
ただし、ptr [MSCTF!_guard_dispatch_icall_fptr] は ntdll!LdrpDispatchUserCallTarget を経由するので、CFG のバイパスは必要です。彼に言わせると "In practice, CFG is a very weak mitigation. Ignoring the many known limitations, even the simplest Windows programs have hundreds of thousands of whitelisted indirect branch targets." らしいですが。
Indirect call までのアセンブリとデータの流れは以下の通りです。
MSCTF!CTipProxy::Reconvert:
00007ffc`7560c5f0 48895c2408 mov qword ptr [rsp+8],rbx
00007ffc`7560c5f5 55 push rbp
00007ffc`7560c5f6 56 push rsi
00007ffc`7560c5f7 57 push rdi
00007ffc`7560c5f8 488bec mov rbp,rsp
00007ffc`7560c5fb 4883ec60 sub rsp,60h
00007ffc`7560c5ff 488bfa mov rdi,rdx <---- rdx = buffer-b0
poi(rdx) = buffer-80
poi(rdx-38) = buffer+10
00007ffc`7560c602 488bd9 mov rbx,rcx
00007ffc`7560c605 4885d2 test rdx,rdx
00007ffc`7560c608 750a jne MSCTF!CTipProxy::Reconvert+0x24 (00007ffc`7560c614)
MSCTF!CTipProxy::Reconvert+0x1a:
00007ffc`7560c60a bb03400080 mov ebx,80004003h
00007ffc`7560c60f e992000000 jmp MSCTF!CTipProxy::Reconvert+0xb6 (00007ffc`7560c6a6)
MSCTF!CTipProxy::Reconvert+0x24:
00007ffc`7560c614 4883c1b0 add rcx,0FFFFFFFFFFFFFFB0h
00007ffc`7560c618 e8878cffff call MSCTF!CTipProxy::FindContextPtr (00007ffc`756052a4)
MSCTF!CTipProxy::FindContextPtr:
00007ffc`756052a4 4053 push rbx
00007ffc`756052a6 4883ec20 sub rsp,20h
00007ffc`756052aa 488bd9 mov rbx,rcx
00007ffc`756052ad 4c8bc2 mov r8,rdx <----
00007ffc`756052b0 33c9 xor ecx,ecx
00007ffc`756052b2 48894c2438 mov qword ptr [rsp+38h],rcx
00007ffc`756052b7 4885d2 test rdx,rdx
00007ffc`756052ba 7438 je MSCTF!CTipProxy::FindContextPtr+0x50 (00007ffc`756052f4)
MSCTF!CTipProxy::FindContextPtr+0x18:
00007ffc`756052bc 488b02 mov rax,qword ptr [rdx] <---- rax = buffer-80
00007ffc`756052bf 498bc8 mov rcx,r8 <---- rcx = buffer-b0
poi(rcx-38) = buffer+10
00007ffc`756052c2 488d542438 lea rdx,[rsp+38h]
00007ffc`756052c7 488b80c0000000 mov rax,qword ptr [rax+0C0h] <---- rax = poi(buffer+40)
00007ffc`756052ce ff15cc670500 call qword ptr [MSCTF!_guard_dispatch_icall_fptr (00007ffc`7565baa0)]
msvcrt!_init_time による Arbitrary Memory Write
次のステップとして、RCE までの連鎖を発生させるデータ構造を作ります。そのためには任意のアドレスに書き込みができる必要があり、前述の buffer+40 経由で最初に実行するコードとして msvcrt!_init_time を使います。どうやって msvcrt!_init_time を見つけたのかという話ですが、CFG のホワイトリストを地道にチェックしただけのようです。MSCTF!CTipProxy::Reconvert を見つける作業はスクリプトを使って多少自動化していたようですが、このフェーズは完全に手作業っぽいです。
MSCTF!CTipProxy::Reconver の indirect call からmsvcrt!_init_time に飛んできた時点で、rcx は buffer-0xb0 のアドレスを保持しています。アセンブリを辿ると、一回の msvcrt!_init_time で buffer+a8 の 1 バイトをデクリメントできることが分かります。条件として、buffer-90 の 1 バイト領域が 0 である必要があります。したがって、近傍が全て 0 で埋まっている領域を見つけておけば、デクリメントを繰り返すことで任意の値にメモリを書き換えることができます。このような領域は多数ありますが、ここでは kernel32.dll のデータ領域 (.data セクション) のオフセット +0x1008 のところを使います。
msvcrt!_init_time:
00007ffc`74ff1d30 48895c2408 mov qword ptr [rsp+8],rbx
00007ffc`74ff1d35 48896c2410 mov qword ptr [rsp+10h],rbp
00007ffc`74ff1d3a 57 push rdi
00007ffc`74ff1d3b 4883ec20 sub rsp,20h
00007ffc`74ff1d3f 83792000 cmp dword ptr [rcx+20h],0 <--- check poi(buffer-b0+20)
00007ffc`74ff1d43 488d2d86e00500 lea rbp,[msvcrt!_lc_time_c (00007ffc`7504fdd0)]
00007ffc`74ff1d4a 488bf9 mov rdi,rcx <--- rdi = buffer-b0
00007ffc`74ff1d4d 744b je msvcrt!_init_time+0x6a (00007ffc`74ff1d9a) --> jump 74ff1d9a
msvcrt!_init_time+0x6a:
00007ffc`74ff1d9a 488bdd mov rbx,rbp
msvcrt!_init_time+0x6d:
00007ffc`74ff1d9d 488b8758010000 mov rax,qword ptr [rdi+158h] <---- rax = poi(buffer-b0+158)
= poi(buffer+a8)
00007ffc`74ff1da4 483bc5 cmp rax,rbp
00007ffc`74ff1da7 7407 je msvcrt!_init_time+0x80 (00007ffc`74ff1db0)
msvcrt!_init_time+0x79:
00007ffc`74ff1da9 f0ff8860010000 lock dec dword ptr [rax+160h] <---- decrement poi(buffer+a8)+160
ALPC メッセージに渡すバッファーの +40 に msvcrt!_init_time、+a8 にデクリメントしたいアドレス - 0x160 をセットして MSCTF!CTipProxy::Reconvert を呼ぶだけです。
combase!CStdProxyBuffer_CF_AddRef/MSCTF!CCompartmentEventSink::OnChange によるパラメーター乗っ取り
メモリが自由に書き換えられるようになりました。ひとまずのゴールは、LoadLibrary にジャンプさせて任意の DLL をロードすることです。既に任意のアドレスを実行できるようになっているので LoadLibrary を実行することはできますが、MSCTF!CTipProxy::Reconvert の indirect call では、パラメーターを完全に乗っ取ることができていません。
どうやって見つけたのか謎ですが、combase!CStdProxyBuffer_CF_AddRef と MSCTF!CCompartmentEventSink::OnChange を 2 回ずつ連鎖させることで、最終段階の indirect call ではジャンプ先と第一パラメーターを完全に乗っ取ることができます。それぞれの連鎖におけるステップは以下の通りです。どうやってこんなのに気づくのか意味不明ですね。アクロバティックすぎる。
combase!CStdProxyBuffer_CF_AddRef:
00007ffc`75fae370 488b49c8 mov rcx,qword ptr [rcx-38h] <---- rcx = buffer+10
00007ffc`75fae374 488b01 mov rax,qword ptr [rcx] <---- rax = object
00007ffc`75fae377 488b4008 mov rax,qword ptr [rax+8] <---- rax = poi(object+8) = Func2
00007ffc`75fae37b 48ff250e5c0700 jmp qword ptr [combase!__guard_dispatch_icall_fptr (00007ffc`76023f90)]
--> MSCTF!CCompartmentEventSink::OnChange
MSCTF!CCompartmentEventSink::OnChange:
00007ffc`75623010 488b4130 mov rax,qword ptr [rcx+30h] <---- rax = poi(buffer+10+30) = Func1
00007ffc`75623014 488b4938 mov rcx,qword ptr [rcx+38h] <---- rcx = poi(buffer+10+38) = object+38
00007ffc`75623018 48ff25818a0300 jmp qword ptr [MSCTF!_guard_dispatch_icall_fptr (00007ffc`7565baa0)]
--> combase!CStdProxyBuffer_CF_AddRef
combase!CStdProxyBuffer_CF_AddRef:
00007ffc`75fae370 488b49c8 mov rcx,qword ptr [rcx-38h] <---- rcx = poi(object+38-38) = object
00007ffc`75fae374 488b01 mov rax,qword ptr [rcx] <---- rax = object
00007ffc`75fae377 488b4008 mov rax,qword ptr [rax+8] <---- rax = poi(object+8) = Func2
00007ffc`75fae37b 48ff250e5c0700 jmp qword ptr [combase!__guard_dispatch_icall_fptr (00007ffc`76023f90)]
--> MSCTF!CCompartmentEventSink::OnChange
MSCTF!CCompartmentEventSink::OnChange:
00007ffc`75623010 488b4130 mov rax,qword ptr [rcx+30h] <---- rax = poi(object+30) = Func3
00007ffc`75623014 488b4938 mov rcx,qword ptr [rcx+38h] <---- rcx = poi(object+38)
00007ffc`75623018 48ff25818a0300 jmp qword ptr [MSCTF!_guard_dispatch_icall_fptr (00007ffc`7565baa0)]
--> LoadLibraryA
ここから逆算すると必要なデータ構造が分かるので、前述した msvcrt!_init_time によって 1 バイトずつちまちまとデクリメントをすることを繰り返して作っておきます。必要なデータ構造は以下の通りです。
おわりに
久々に exploit を追いかけてみました。
Ormandy 氏はこの脆弱性用に ctftool という簡易スクリプト エンジンを作っており、その README に DLL Injection までの仕組みが紹介されています。どう動くかは書かれていますが、combase!CStdProxyBuffer_CF_AddRef などをどうやって見つけたかは書いてないんですよね。そこが気になります。なお本記事は、ctftool に含まれる ctf-exploit-common-win10.ctf をデバッグしながら書きました。
taviso/ctftool: Interactive CTF Exploration Tool
https://github.com/taviso/ctftool
8 月の修正でどのように直したかは見ていませんが、Microsoft の Advisory で MSCTF については触れられていないので、ALPC 側を直したんでしょうかね。8 月のパッチは必ず当てておきましょう。
CVE-2019-1162 | Windows ALPC Elevation of Privilege Vulnerability
https://portal.msrc.microsoft.com/en-US/security-guidance/advisory/CVE-2019-1162
WNF でも、頑張って探せば RCE できる気にさせられます。