
Qualys セキュリティアドバイザリ
Baron Samedit: Sudo におけるヒープベースのバッファオーバーフロー (CVE-2021-3156)
概要 分析 悪用 謝辞 タイムライン
私たちは Sudo にヒープベースのバッファオーバーフローを発見しました (https://www.sudo.ws/)。この脆弱性は次のとおりです:
認証なしで、任意のローカルユーザー(通常ユーザーおよびシステムユーザー、 sudoers と非 sudoers)によって悪用可能です(つまり、攻撃者は ユーザーのパスワードを知る必要はありません);
2011年7月(コミット 8255ed69)に導入され、デフォルト設定において、 すべてのレガシーバージョン 1.8.2 から 1.8.31p2 と、 すべての安定版 1.9.0 から 1.9.5p1 に影響します。
私たちはこの脆弱性に対する3つの異なるエクスプロイトを開発し、 Ubuntu 20.04 (Sudo 1.8.31)、Debian 10 (Sudo 1.8.27)、 Fedora 33 (Sudo 1.9.2) で完全な root 権限を取得しました。他のオペレーティングシステムや ディストリビューションでも悪用可能である可能性が高いです。
Sudo が "shell" モード(shell -c command)でコマンドを実行するために実行される場合:
-s オプション(Sudo の MODE_SHELL フラグを設定する)による方法;
または -i オプション(Sudo の MODE_SHELL と MODE_LOGIN_SHELL フラグを設定する)による方法;
その場合、Sudo の main() の先頭で parse_args() は argv を書き換えます(行 609-617)。 すべてのコマンドライン引数を連結し(行 587-595)、すべてのメタ文字をバックスラッシュでエスケープします(行 590-591):
その後、sudoers_policy_main() 内の set_cmnd() は、コマンドライン引数を ヒープベースのバッファ "user_args" に連結し(行 864-871)、 メタ文字のエスケープを解除します(行 866-867)。これは "sudoers の マッチングとロギングのため" です:
残念ながら、コマンドライン引数が単一のバックスラッシュ文字で終わる場合、次のようになります:
行 866 では、"from[0]" はバックスラッシュ文字であり、"from[1]" は 引数のヌル終端文字です(つまり、スペース文字ではありません);
行 867 では、"from" がインクリメントされ、ヌル終端文字を指します;
行 868 では、ヌル終端文字が "user_args" バッファにコピーされ、 "from" が再度インクリメントされ、ヌル終端文字の後の最初の文字を指します (つまり、引数の境界外です);
行 865-869 の "while" ループは、境界外の文字を読み取って "user_args" バッファにコピーします。
言い換えると、set_cmnd() は、ヒープベースのバッファオーバーフローに対して脆弱です。 "user_args" バッファにコピーされる境界外の文字が、 そのサイズ(行 852-853 で計算)に含まれていないためです。
しかし理論上は、コマンドライン引数が単一のバックスラッシュ文字で終わることはありません: MODE_SHELL または MODE_LOGIN_SHELL が設定されている場合(行 858、 これは脆弱なコードに到達するための必要条件)、MODE_SHELL が設定され(行 571)、 parse_args() はすでにバックスラッシュを含むすべてのメタ文字をエスケープしています (つまり、すべての単一バックスラッシュを2番目のバックスラッシュでエスケープしています)。
しかし実際には、set_cmnd() 内の脆弱なコードと parse_args() 内のエスケープコードは、 わずかに異なる条件で囲まれています:
一方:
では、私たちの疑問は次のとおりです: MODE_SHELL と MODE_EDIT または MODE_CHECK のいずれかを設定し(脆弱なコードに到達するため)、 デフォルトの MODE_RUN を設定しない(エスケープコードを回避するため)ことは可能でしょうか?
答えは、どうやら「いいえ」のようです: MODE_EDIT(-e オプション、行 361)または MODE_CHECK(-l オプション、行 423 および 519)を設定すると、 parse_args() は "valid_flags" から MODE_SHELL を削除し(行 363 および 424)、 MODE_SHELL のような無効なフラグを指定するとエラーで終了します(行 532-533):
しかし、私たちは抜け穴を見つけました: "sudo" の代わりに "sudoedit" として Sudo を実行すると、 parse_args() は自動的に MODE_EDIT を設定しますが(行 270)、 "valid_flags" をリセットせず、"valid_flags" にはデフォルトで MODE_SHELL が含まれます(行 127 および 249):
したがって、"sudoedit -s" を実行すると、MODE_EDIT と MODE_SHELL の両方を設定し(ただし MODE_RUN は設定しない)、 エスケープコードを回避し、脆弱なコードに到達し、単一のバックスラッシュ文字で終わるコマンドライン引数を介して ヒープベースのバッファ "user_args" をオーバーフローさせます:
perl -e 'print "A" x 65536'
malloc(): corrupted top size
Aborted (core dumped)攻撃者の観点から見ると、このバッファオーバーフローは理想的です:
私たちはオーバーフローさせる "user_args" バッファのサイズを制御します(連結されたコマンドライン引数のサイズ、行 852-854);
オーバーフロー自体のサイズと内容を独立して制御します(最後のコマンドライン引数の直後に最初の環境変数が続きます。これらは行 852-853 のサイズ計算には含まれません);
オーバーフローさせるバッファにヌルバイトを書き込むことさえできます(単一のバックスラッシュで終わるすべてのコマンドライン引数または環境変数は、"user_args" にヌルバイトを書き込みます、行 866-868)。
たとえば、amd64 Linux では、次のコマンドは 24 バイトの "user_args" バッファ(32 バイトのヒープチャンク)を割り当て、 次のチャンクの size フィールドを "A=a\0B=b\0" (0x00623d4200613d41)、 fd フィールドを "C=c\0D=d\0" (0x00643d4400633d43)、 bk フィールドを "E=e\0F=f\0" (0x00663d4600653d45) で上書きします:
--|--------+--------+--------+--------|--------+--------+--------+--------+-- | | |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.| --|--------+--------+--------+--------|--------+--------+--------+--------+-- size <---- user_args buffer ----> size fd bk
Sudo は main() 関数の先頭でローカライズ関数を呼び出すため:
そして、翻訳文字列を(gettext() 関数と _() マクロを通じて) フォーマット文字列関数に渡します。例:
私たちは当初、https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ の halfdog の魅力的な手法を再利用し、Sudo のヒープベースのバッファオーバーフローを フォーマット文字列エクスプロイトに変換したいと考えていました。より正確には:
行 154 の setlocale() で、複数の LC 環境変数(LC_CTYPE、LC_MESSAGES、LC_TIME など)を malloc() して free() し、 Sudo のヒープの先頭に小さな穴を作成します(解放された fast または tcache チャンク);
行 155 の bindtextdomain() は struct binding を malloc() します。これには、".mo" カタログファイル、つまり翻訳文字列を含むディレクトリの名前を指す dirname ポインタが含まれます;
set_cmnd() では、"user_args" バッファを Sudo のヒープの先頭にある穴の1つに malloc() し、このバッファをオーバーフローさせて、struct binding の dirname ポインタを上書きします;
行 301(例)で、gettext()(_() マクロを通じて)は、上書きされた dirname から私たち自身の翻訳文字列をロードします。つまり、sudo_printf() に渡されるフォーマット文字列を制御します。
この初期手法を実装するために、gdb 内で Sudo を実行し、"user_args" バッファをオーバーフローさせ、次のパラメータをランダムに選択する 初歩的なブルートフォーサーを作成しました:
Sudo に渡す LC 環境変数とその長さ("C.UTF-8" ロケールを使用し、ランダムな "@modifier" を追加します);
オーバーフローさせる "user_args" バッファのサイズ;
オーバーフロー自体のサイズ;
Sudo の認証コードを経由するか(-A または -n オプション)経由しないか(-u #realuid オプション)。
残念ながら、この初期手法は失敗しました。私たちのブルートフォーサーは struct binding の dirname ポインタを上書きできました:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6e0dde1ea9 in __dcigettext (domainname=domainname@entry=0x7f6e0d9cc020 "sudoers", msgid1=msgid1@entry=0x7f6e0d9cc014 "user NOT in sudoers", msgid2=msgid2@entry=0x0, plural=plural@entry=0, n=n@entry=0, category=5) at dcigettext.c:619
=> 0x7f6e0dde1ea9 <__dcigettext+1257>: cmpb $0x2f,(%rax)
しかし、LC_MESSAGES は常にデフォルトの "C" ロケール("C.UTF-8" ではない)であり、 gettext() での文字列翻訳が無効になります(つまり、gettext() はオリジナルのフォーマット文字列を返し、私たち自身のものは返しません)。
しかし幸運なことに、私たちのブルートフォーサーは数十のユニークな Sudo クラッシュと gdb バックトレースを生成しました。その中で3つが私たちの注意を引き、最終的にその3つすべてを悪用しました。
私たちの注意を引いた最初のクラッシュは次のとおりです:
Program received signal SIGSEGV, Segmentation fault.
0x000056291a25d502 in process_hooks_getenv (name=name@entry=0x7f4a6d7dc046 "SYSTEMD_BYPASS_USERDB", value=value@entry=0x7ffc595cc240) at ../../src/hooks.c:108
=> 0x56291a25d502 <process_hooks_getenv+82>: callq *0x8(%rbx)
rbx 0x56291c1df2b0 94734565372592
信じられないことに、Sudo の関数 process_hooks_getenv() は(行 108 で)クラッシュしました。 これは、関数ポインタである getenv_fn(ヒープベースの struct sudo_hook_entry のメンバ)を直接上書きしたためです:
この struct sudo_hook_entry の上書きを悪用するために、次の点に注目します:
getenv_fn への呼び出し(行 108)は、execve() への呼び出しと互換性があります:
. name ("SYSTEMD_BYPASS_USERDB") は execve() の pathname 引数と互換性があります;
. &val(NULL ポインタへのポインタ)は execve() の argv と互換性があります;
. hook->closure(NULL ポインタ)は execve() の envp と互換性があります;
関数ポインタ getenv_fn(共有ライブラリ sudoers.so 内の関数 sudoers_hook_getenv() を指す)を部分的に上書きすることで ASLR を無効化できます。幸いなことに、sudoers.so の先頭には execve()(または execv())への呼び出しが含まれています:
したがって、次の戦略を採用します:
私たちはこの最初のエクスプロイトを Ubuntu 20.04 で正常にテストしました。
私たちの注意を引いた2番目のクラッシュは次のとおりです:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344
=> 0x7f6bf9c294ee <nss_load_library+46>: cmpq $0x0,0x8(%rbx)
glibc の関数 nss_load_library() は(行 344 で)クラッシュしました。 これは、ヒープベースの struct service_user のメンバであるポインタ "library" を上書きしたためです:
この struct service_user の上書きを任意コード実行に簡単に変換できます:
ni->library を NULL ポインタで上書きし、行 330-342 のブロックに入り、行 344 でのクラッシュを回避して、行 344-359 のブロックに入ります;
ni->name(文字の配列、初期値は "systemd")を "X/X" で上書きします;
行 353-357 は共有ライブラリ "libnss_X/X.so.2" の名前を構築します("libnss_systemd.so.2" の代わりに);- at line 359 では、現在の作業ディレクトリから独自の共有ライブラリ「libnss_X/X.so.2」を読み込み、root として _init() コンストラクタを実行します。
この 2 番目のエクスプロイトは、Ubuntu 20.04、Debian 10、Fedora 33 で正常にテストしました。
3 番目のエクスプロイトは、Sudo のクラッシュから派生したものではなく、何気ない観察から得られたものです。ブルートフォース中に、Sudo が現在の作業ディレクトリに多数の新しいディレクトリ(AAAAAA、AAAAAAAAA など)を作成しました。これらのディレクトリはすべて root が所有し、各ディレクトリには自分のユーザー名にちなんだ小さなファイルが 1 つだけ含まれています。それが Sudo のタイムスタンプファイルです。つまり、Sudo のタイムスタンプディレクトリの名前である def_timestampdir を上書きしていたのです。
def_timestampdir を、まだ存在しないディレクトリの名前で上書きすると、Sudo の ts_mkdirs() に対して競争を仕掛け、任意のファイルへのシンボリックリンクを作成し、以下の操作を行うことができます。
3a/ この任意のファイルをユーザー root およびグループ root に chown() する。
3b/ この任意のファイルを root として開く(または作成する)か、そこに struct timestamp_entry を書き込む。
私たちは 3a/ を完全な root 権限に変換することはできませんでした(たとえば、自分の SUID バイナリを root に chown() すると、カーネルが自動的にバイナリの SUID ビットを削除します)。読者の皆様で、この問題の解決策を見つけた方は、公開の oss-security メーリングリストに投稿してください。
最終的に、私たちは 3b/ を完全な root 権限に変換できましたが、最初は 2 つの問題に直面しました。
Sudo の timestamp_open() は、シンボリックリンクが指すファイルが起動時間より古い場合、任意のシンボリックリンクを削除します。この最初の問題は、非常に古いタイムスタンプファイル(Unix エポックのもの)を作成し、timestamp_open() がそれを削除するまで待ち、timestamp_open() との競争を仕掛けて最終的な任意のシンボリックリンクを作成することで解決できました。
任意のファイルに書き込まれる struct timestamp_entry の内容を制御することはできません。私たちが知る限り、制御できるのは 3 バイト(プロセス ID または struct timespec)だけで、この 3 バイトの書き込みを完全な root 権限に変換することはできませんでした。読者の皆様で、この問題の解決策を見つけた方は、公開の oss-security メーリングリストに投稿してください。
しかし、Sudo の timestamp_lock() にある軽微なバグを悪用することで、2 番目の問題を回避できました。ts_mkdirs() と timestamp_open() の 2 つの競争に勝ち、任意のシンボリックリンクが /etc/passwd を指すようにすると、このファイルは root として開かれ、次のようになります。
644 行目で、/etc/passwd の最初の 0x38 バイト("root❌0:0:...")がスタック上の struct timestamp_entry である entry に読み込まれます。
652 行目で、entry.type は TS_LOCKEXCL ではなく 0x783a(":x")です。
657 行目と 318 行目で、スタック上の entry から entry->size バイトが /etc/passwd に書き込まれますが、entry->size は実際には sizeof(struct timestamp_entry) ではなく 0x746f("ot")です。
その結果、Sudo のスタックの内容全体(コマンドライン引数と環境変数を含む)を /etc/passwd に書き込むことになります。つまり、/etc/passwd に任意のユーザーを注入し、完全な root 権限を獲得します。この 3 番目のエクスプロイトは、Ubuntu 20.04 で正常にテストしました。
注: timestamp_lock() のこの軽微なバグは、2020 年 1 月にコミット 586b418a で修正されましたが、この修正はレガシーバージョンにはバックポートされませんでした。
Todd C. Miller 氏のプロフェッショナリズム、迅速な対応、そして私たちの報告書の細部にまで行き届いた注意に感謝します。また、distros@openwall のメンバーにも感謝します。
2021-01-13: アドバイザリを Todd.Miller@sudo に送付。
2021-01-19: アドバイザリとパッチを distros@openwall に送付。
2021-01-26: 調整済みリリース日(UTC 18:00)。