こんにちは。株式会社 Flatt Security セキュリティエンジニアの志賀( @Ga_ryo_ ) です。
本記事では、最近公開されたCVE-2020-15702の技術的な解説をしていきたいと思います。本脆弱性は、自分が発見し、Zero Day Initiativeを経由してベンダーに報告しました。本記事は、脆弱性の危険性を通知する目的ではなく、あくまで技術的観点での学びを共有する事を目的としています。
読む前に
解説に関して誤りや疑問点があれば、個人宛に連絡をいただけると幸いです。また、本記事中におけるコードは基本的に2.20.11-0ubuntu27でのapportのソースコードを参照しています。
- https://git.launchpad.net/ubuntu/+source/apport/plain/data/apport?h=applied/ubuntu/focal-devel&id=02a1fd19eafeae3f8e98f1461c9bcea850f0c419
- http://archive.ubuntu.com/ubuntu/pool/main/a/apport/apport_2.20.11-0ubuntu27.tar.gz
書いたPoCの概要は解説しますが、実際のコードは掲載しません。ご理解ください。
概要
apportのPIDの扱いにおけるRace Conditionの脆弱性。この脆弱性を利用することで、特定の条件下で権限昇格を行うことが可能です。前提条件
- Ubuntu上での任意のコード実行権限
- logrotateなど、後に説明する特定の条件を満たすプロセスが特権で実行される
が必要です。2.の条件として特権であることを記載しており、また記事中でもそれを前提に解説しますが、必須条件ではありません。この脆弱性の悪用では、ターゲットとなるプロセスの権限を奪うことが出来るので、権限が低い別のプロセスをターゲットとした場合、その低い権限を掌握することになります。
影響
rootへの権限昇格。
apportとは
https://wiki.ubuntu.com/Apport
apportとは、Ubuntuに標準で実装されているクラッシュレポートの機能です。ユーザーランドのプロセスがSEGVなどでクラッシュした際に、後の解析等のために良い感じのレポートを吐き出してくれます。
garyo@garyo:~/sandbox$ sleep 100 &
[1] 13048
garyo@garyo:~/sandbox$ kill -SIGSEGV 13048
garyo@garyo:~/sandbox$ head -n 20 /var/crash/_usr_bin_sleep.1000.crash
ProblemType: Crash
Architecture: amd64
Date: Tue Aug 25 10:33:06 2020
DistroRelease: Ubuntu 20.04
ExecutablePath: /usr/bin/sleep
ExecutableTimestamp: 1567679920
ProcCmdline: sleep 100
ProcCwd: /home/garyo/sandbox
ProcEnviron:
SHELL=/bin/bash
LANG=en_US.UTF-8
TERM=xterm-256color
XDG_RUNTIME_DIR=
PATH=(custom, no user)
ProcMaps:
564cdc98d000-564cdc98f000 r--p 00000000 08:02 1051009 /usr/bin/sleep
564cdc98f000-564cdc993000 r-xp 00002000 08:02 1051009 /usr/bin/sleep
564cdc993000-564cdc995000 r--p 00006000 08:02 1051009 /usr/bin/sleep
564cdc996000-564cdc997000 r--p 00008000 08:02 1051009 /usr/bin/sleep
564cdc997000-564cdc998000 rw-p 00009000 08:02 1051009 /usr/bin/sleep
Linuxにおいて、プロセスがクラッシュした場合には通常はcoreファイルが吐き出されます。ただし、/proc/sys/kernel/core_patternの1byte目を”|”(パイプ)にしておくとカーネルはusermodehelper(カーネルからユーザーランドプロセスを呼び出す機能)を用いてプロセスを立ち上げ、立ち上げたプロセスの標準入力に紐づいたパイプにコアダンプの内容を書き出すようになります。
試しにUbuntu 20.04における上記のカーネルパラメータを見ると以下のようになっています。
garyo@garyo:~/sandbox$ cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport %p %s %c %d %P %E
第二引数以降のフォーマット文字列は、Linuxカーネルのdo_coredump()関数から呼び出される
format_corename()関数を読むと分かります。ここでは、取り敢えずクラッシュしたプロセスのPIDが渡されるという事だけ分かっていれば良いです。
脆弱性解説
さて、apportのPID関連のRace Conditionと聞いてピンと来た方も多いでしょう。類似の脆弱性は過去にも見つかっており、実際にPoCでも下記のブログを参考にしました。
- https://securitylab.github.com/research/ubuntu-apport-CVE-2019-15790
- https://securitylab.github.com/research/ubuntu-apport-CVE-2019-7307
- https://www.exploit-db.com/exploits/37088
この中でも、CVE-2019-15790の攻撃解説は非常に参考になりました。脆弱性関連のところをざっくりと説明すると、当時のapportは以下のようなフローになっていました。
- クラッシュしたプロセス(以下、A)の情報を引数に取ってkernelから起動される
- 引数にとったプロセスAのPIDを元にprocfsからuid/gid/cwdを取得する
drop_privileges()
関数によって実uid/gidをプロセスAのuid/gidへ変更して権限を落とす- mapsなどのその他の情報をPIDを元にprocfsから取得する
- プロセスAが読み込める権限でレポートが書き出される
CVE-2019-15790のPoCでは、3のタイミングで権限が落ちるのでSIGSTOPシグナルをapport自体に送ることで、apportの実行を止めています。実はこの間にプロセスA(意図的にクラッシュさせた一般ユーザー権限のプロセス)にSIGKILLを送信する事で、プロセスAを終了させることが出来ます*1。プロセスをいくつも立ち上げていればPIDはそのうち再利用される(最大値まで使用するとまた0から空いているPIDが再利用され始める)ため、4の実行前にapportが参照するはずのPIDが指すプロセスを別の特権プロセスに差し替えることができます。するとapportは特権プロセスの/proc/[PID]/mapsを参照してレポートに含んでしまうため、特権プロセスのASLR bypass等に使えてしまうという脆弱性でした。
*1: そもそも、Linux kernelは恐らくクラッシュレポート機能が立ち上がった後に該当プロセスが生きていることを一切保証していません。現実的にはクラッシュレポート機能が立ち上がったときにはプロセスが生きていますが、これはクラッシュレポート機能の終了を待っているからではありません(do_coredump内でusermodehelperを実行するときには、UMH_WAIT_EXECという引数が用いられていて、これはexecが完了することを待ちますがプロセスが終了することは待ちません)。では何故プロセスが生き残っているかというと、通常コアダンプ情報はサイズが大きいため、一度にパイプに書き込み切れず、書き込みがブロックしてしまうからです。クラッシュレポート機能が標準入力を読み込むと終了を待たずに即座にprocfsから消えることを確認できます。
そして、今回の脆弱性はまさに注釈の部分にありました。その前に、まずは先の脆弱性に対する対策を見てみます。
- global pidstat, real_uid, real_gid, cwd
+ global pidstat, real_uid, real_gid, cwd, proc_pid_fd
+
+ proc_pid_fd = os.open('/proc/%s' % pid, os.O_RDONLY | os.O_PATH | os.O_DIRECTORY)
- pidstat = os.stat('/proc/%s/stat' % pid)
+ pidstat = os.stat('stat', dir_fd=proc_pid_fd)
重要なのは、proc_pid_fdという変数です。パッチ以前にはapportは都度PIDを文字列として連結してパス名指定で/proc/[PID]/{stat/cwd/maps}などを開いていました。そのため、/proc/[PID]が同名のディレクトリに差し替わっても差し替わった後のデータを見てしまっていました。このパッチ以降では、まず/proc/[PID]というディレクトリをopenし、procfsからデータを取得する際にはopenしたディレクトリのfdを用いてopenatを用いるようになりました。これによって、ディレクトリごと差し替えられた場合には新しいディレクトリを参照しないようになりました。以下にopenatを用いることでディレクトリの差し替えが不可能になることの検証コードを貼っておきます。
garyo@garyo:~/sandbox$ cat test.py
#!/usr/bin/python3
import os, tempfile
d=tempfile.mkdtemp()
dirfd=os.open(d, os.O_RDONLY|os.O_DIRECTORY|os.O_PATH)
os.open(d+"/bbb", os.O_RDWR|os.O_CREAT)
print("Open {0}".format(os.open(d+"/bbb", os.O_RDONLY)))
print("OpenAt {0}".format(os.open("bbb", os.O_RDONLY, dir_fd=dirfd)))
os.remove(d+"/bbb")
os.rmdir(d)
os.mkdir(d)#create again instead of pid recycle
os.open(d+"/ccc", os.O_RDWR|os.O_CREAT)
print("Open {0}".format(os.open(d+"/ccc", os.O_RDONLY)))
print("OpenAt {0}".format(os.open("ccc", os.O_RDONLY, dir_fd=dirfd)))
garyo@garyo:~/sandbox$ python3 test.py
Open 5
OpenAt 6
Open 8
Traceback (most recent call last):
File "test.py", line 13, in
print("OpenAt {0}".format(os.open("ccc", os.O_RDONLY, dir_fd=dirfd)))
FileNotFoundError: [Errno 2] No such file or directory: 'ccc'
このパッチによって、2~4の間にいくらSIGSTOPでapportの実行を止めようとも、そもそも/proc/[PID]のディレクトリが変更されると情報の取得に失敗するため、差し替えられたプロセスの情報を取得することはなくなりました。
しかし、先ほど述べたように、引数として渡されたPIDのプロセスはapportが立ち上がった後にずっと生きている保証は一切ありません。これはつまり、apportが立ち上がった瞬間にすでにPIDが指す対象のプロセスが別のプロセスである可能性があることを意味します。これが今回の脆弱性の指摘です(実際に差し替える手法はPoC概要にて解説します)。
さて、先の脆弱性ではレポートの権限を自分のuidにしたまま特権プロセスのメモリマップ等をレポートに含めることが出来るのでアドレスリークに使えるという話でした。しかし今回はどうでしょうか。今回の指摘ではそもそも立ち上がった瞬間にプロセスが差し変わるという話なので、権限を落とす段階で参照するuidも特権プロセスのものとなってしまい、一般ユーザーではレポートの参照権限がありません。これではリークには使えませんが、実はapportにはもう1つ重要な機能として、coreファイルをそのまま書き出す機能があります。kernelから送られてきたコアダンプ情報を、そのプロセスが稼働していたworking directoryにファイル保存する機能です(クラッシュレポート機能を通さない通常のcoreファイル吐き出しと同じ)。これを利用することで、自前のプロセスのコアダンプ情報をcoreファイルとして、特権プロセスのcwdに書き込むことが出来ます。これが今回の脆弱性の悪用方法の重要な部分になりますが、ここから実際に権限昇格をする方法はPoC概要にて解説します。
PoC概要
今回のPoCを書く上で重要になってくるのは大きく分けて2点です。
- PID再利用のタイミング調整
- 特権プロセスのcwdにcoreファイルを出力したときに権限昇格をする方法
PID再利用のタイミング調整
まず重要になってくるのがPID再利用のタイミング調整です。現在必要なのはSIGSEGVを送信してapportを呼び出しつつ、procfsを読み取るまでの間にSIGKILL+特権プロセスの起動でPIDを重複させることです。現実的に短時間でPIDを一周させることは難しいですが、SIGSEGVの送信タイミングはこちらで任意に指定できるため、PIDが一周する直前までプロセスの立ち上げと終了を繰り返してからSIGSEGVを送信すれば、時間がかかる問題は解決できます。
しかし、タイミングの問題が残っています。例えばSIGSEGVを送信してから1秒待ってからSIGKILLを送信するような実装では、apport自体がprocfsを読み取るどころか、処理を完了させて終了してしまいます。逆にSIGKILL送信のタイミングが早すぎてもうまく動きません。ほぼ同時にシグナルを送信してしまうと、do_coredump()関数がそもそも呼び出されなくなってしまうからです。以下で説明します。
Linux kernelにおいて受信したシグナルを取り扱うのは、対象プロセスがカーネルモードからユーザーモードへ移行するタイミングです。この時、get_signal()関数によって受信した各シグナルに応じた操作を行うことになります。例えばSIGSEGV等のクリティカルなシグナルを受け取っていれば以下のようにdo_coredump()関数が呼び出され、コアダンプが出力されます(パイプが設定されていればクラッシュレポート機能)。
if (sig_kernel_coredump(signr)) {
if (print_fatal_signals)
print_fatal_signal(ksig->info.si_signo);
proc_coredump_connector(current);
/*
* If it was able to dump core, this kills all
* other threads in the group and synchronizes with
* their demise. If we lost the race with another
* thread getting here, it set group_exit_code
* first and our do_group_exit call below will use
* that value and ignore the one we pass it.
*/
do_coredump(&ksig->info);
}
このget_signal()関数呼び出し時にSIGSEGVとSIGKILLが両方とも受信したシグナルとして保持されていると、終了すべきプロセスであるとして、以下の条件分岐によってdo_coredump()関数が呼び出されなくなってしまいます。これが、シグナル送信が早すぎるとうまく動かない理由です。
/* Has this task already been marked for death? */
if (signal_group_exit(signal)) {
ksig->info.si_signo = signr = SIGKILL;
sigdelset(¤t->pending.signal, SIGKILL);
trace_signal_deliver(SIGKILL, SEND_SIG_NOINFO,
&sighand->action[SIGKILL - 1]);
recalc_sigpending();
goto fatal;
}
つまり、少なくともほぼ同時に送ってはいけないが、遅すぎてもapportが起動して先にprocfsを読み取ってしまうのでタイミング調整が難しい状態にあります。そこで、私はapportに実装されているlock機能を利用することにしました。apportは起動時に、すでに他のapportが起動していないかどうかを/var/run/apport.lock
というファイルによって確認します。そして、このチェック機能(check_lock()関数)は、先ほど説明したproc_pid_fd
を取得する前に呼び出されます。つまり、他のapportプロセスを立ち上げておくことで、do_coredump()関数は呼び出されているが、apportはprocfsを参照していない状態で実行を止めることができるのです。
ただし、この状態では当然他のapportプロセスが終了したタイミングで、即座に実行が再開してしまいます。ここで私は他の脆弱性(CVE-2020-11936)を発見したときに利用したdbusの機能を思い出しました。is_closing_session()という関数では、DBUS_SESSION_BUS_ADDRESS
という環境変数をこちらで指定することで、自前で用意したTCPサーバー等に対してリクエストが送信されるようになります。
def is_closing_session(uid):
'''Check if pid is in a closing user session.
During that, crashes are common as the session D-BUS and X.org are going
away, etc. These crash reports are mostly noise, so should be ignored.
'''
with open('environ', 'rb', opener=proc_pid_opener) as e:
env = e.read().split(b'\0')
for e in env:
if e.startswith(b'DBUS_SESSION_BUS_ADDRESS='):
dbus_addr = e.split(b'=', 1)[1].decode()
break
else:
error_log('is_closing_session(): no DBUS_SESSION_BUS_ADDRESS in environment')
return False
orig_uid = os.geteuid()
os.setresuid(-1, os.getuid(), -1)
try:
gdbus = subprocess.Popen(['/usr/bin/gdbus', 'call', '-e', '-d',
'org.gnome.SessionManager', '-o', '/org/gnome/SessionManager', '-m',
'org.gnome.SessionManager.IsSessionRunning'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env={'DBUS_SESSION_BUS_ADDRESS': dbus_addr})
(out, err) = gdbus.communicate()
ここで、TCPサーバーがレスポンスを返却しないとどうなるでしょうか。gdbusコマンドは終了せず、当然gdbus.communicate()
という行がreturnせずに実行が中断します。つまり、/var/run/apport.lockのロックを保持したままプログラムの実行が止まっている状態になります。
まとめると以下のようなフローで対象PIDに対するapportの実行を止め、PIDの再利用を安定して待つことが出来るという話になります。
- 環境変数DBUS_SESSION_BUS_ADDRESSにtcp:host=127.0.0.1,port=8888のような値を設定
- 指定したポートにTCP サーバーを立ち上げておく
- プロセスAを立ち上げてSIGSEGVでapportを呼び出す
- TCPサーバーにgdbusからAUTHリクエストが来るが、止めておく
- プロセスBを立ち上げてSIGSEGVでapportを呼び出す
- 3.で呼び出されたapportが終了していないため、check_lock()関数で止まる
- プロセスBをSIGKILLで強制的に終了させる
- プロセスBのPIDと重複するように何らかの方法で特権プロセスを立ち上げる
- TCPサーバーから適当なレスポンスを返す
- プロセスAのためのapportが終了する
- lockが解放され、プロセスBのためのapportの実行が再開される(この時点でプロセスBのPIDは再利用されている)
特権プロセスのcwdにcoreファイルを出力したときに権限昇格をする方法
さて、先ほどまでの解説で、どのようにPIDを再利用するかを説明しました。しかし再利用して特権を取得する方法をまだ記載していません。これには、以下のexploitを参考にしました。
まず現状を整理すると、非特権プロセスでも起動可能な or 起動タイミングが予測できる特権プロセスのcwdに対して、用意したプロセスのコアダンプを出力することが出来るという状態です。ということはターゲットとなるプロセスは必然的に、cwdからファイルを読み込む機能があり、そのファイルを利用するとexploit可能なプロセスということになります。この条件を満たすプロセスを探すのには少し苦労しました。ファイルにコマンドを指定することが可能なソフトウェア(cronなど)は多くありますが、わざわざchdir()を呼び出してcwdを変更するようなプロセスがなかなか見つからなかったからです。しかし最終的にはlogrotateがその条件を満たすソフトウェアであることが分かりました。
logrotateは、以下のように今回のexploitにピッタリでした。
- 一般に特定の時間に必ず実行される(タイミングが予測できる)
- 一般に高い権限で実行される
- /etc/logrotate.d/内のファイルを読み込む前にchdir()する
- ファイル名に依存せず、/etc/logrotate.d/内の全てのファイルを参照する
- ファイルフォーマットは厳密に見ずに不正な文字はskipして読み進めるため、異常なバイナリファイルの中に正常な設定の文字列さえ入っていれば良い
3に関しては先ほど言った通りですが、5もかなり重要です。コアダンプファイルはメモリ状態を出力するため、ある程度こちらで操作可能ですが、完全には操作できないため、一般的にはただの異常なバイナリファイルとして扱われてしまうからです。
5の条件のおかげで、以下のような文字列を定義したプログラムをクラッシュさせることでtouch /tmp/exploited
の部分が実行される設定ファイルを/etc/logrotate.d/内に作成出来るようになりました。
char payload[] = "\n/tmp/pwn.log{\n su root root\n daily\n size=0\n firstaction\n touch /tmp/exploited;\n endscript\n}\n";
その状態で、再度logrotateが実行されると実際に入力したコマンドが実行されます。
余談
余談ですが、ここまで説明した内容でも実は実際に攻撃しようと思うと完璧ではありません。というのも、chdir()が呼び出されてから設定ファイルをパースし終えて元のディレクトリに戻るまでの時間が短すぎるからです。その短い時間にpythonのプログラムをある程度実行しないといけないため、非常に厳しいタイミング調整が必要になってしまいます。
この問題に対しては、(あくまでPoCであるため)最終的には理論的に攻撃ができていれば良いので、/etc/logrotate.d/内にファイルサイズの大きい無意味なconfigファイルを作ることで対処しました。設定ファイルが大きければ大きいほどパースに時間がかかるため、cwdが変更されている時間を長くすることができます。この状態でchdir()をinotifyで検知すると/etc/logrotate.d/にcoreファイルが書きだされ、次のlogrotateの実行時に権限昇格が可能なことを確認しました。
以下に、実行した結果のイメージを貼っておきます。(logrotateは手動で起動しています)
修正
ベンダーから以下のパッチが出ているため、apportパッケージを更新することを推奨します。
http://launchpadlibrarian.net/491870223/apport_2.20.11-0ubuntu27.4_2.20.11-0ubuntu27.6.diff.gz
まとめ
今回はUbuntuのクラッシュレポート機能を用いた権限昇格について解説しました。次回も公開可能になれば他の脆弱性の解説記事を上げようと思いますのでよろしくお願いします。
参考
https://securitylab.github.com/research/ubuntu-apport-CVE-2019-15790
https://www.exploit-db.com/exploits/37088
https://scan.netsecurity.ne.jp/article/2015/06/15/36624.html