Posted at

パス名のスゲカエ

More than 1 year has passed since last update.


はじめに

とある実行ファイル (バイナリファイル) があったとします。この実行ファイルのソースコードは入手できません。この実行ファイルを起動すると /etc/passwd ファイルにアクセスすることがわかっています。この /etc/passwd ファイルへのアクセスをフックして、例えば /var/tmp/oops/etc/passwd へアクセスさせようというのが、上記タイトル「パス名のスゲカエ」の趣旨です。

ソースコードが利用可能であっても再コンパイルできない場合や再コンパイルしたくない場合もあると思います。実行ファイルがシェル・スクリプトなどで簡単に書き換え可能だけれども、そのスクリプトの数が膨大で書き換える気にならない場合もあると思います。

今回、このスゲカエの実現に 2 つの方法を試してみました。以下に紹介します。スーパーユーザーの特権や suid バイナリが利用できない環境を想定しています。


LD_PRELOAD

最初に思いついたのは LD_PRELOAD で指定したオブジェクトにより libc ライブラリの呼び出しをフックする方法です。以下にサンプルプログラム fakepath.c を示します。このサンプルプログラムは動作を理解することを前提に簡略化して記述してあります。特にエラー処理などは省略しています。


fakepath.c

#define _GNU_SOURCE

#include <dlfcn.h>
#include <string.h>

static int (*sys_open)(const char *path, int flags) = NULL;

int open(const char *path, int flags)
{
if (!sys_open)
*(void **)(&sys_open) = dlsym(RTLD_NEXT, "open");

if (strncmp(path, "/var/tmp/", 9) == 0)
path += 4;

return (*sys_open)(path, flags);
}


fakepath.c では read() システムコールをフックしてその引数を調べます。第 1 引数 (path) が "/var/tmp/" で始まっていたら、ポインタを進めてて "/tmp" で始まっているかのように見せかけます。お分かりかと思いますが、この逆 ("/tmp/" で始まっていたら "/var/tmp" で始まっているように見せかける) を処理するためには、パス名のバッファーを用意する必要があります。若干手間がかかります。

コンパイルと実験手順を以下に示します。

$ gcc -fPIC -shared -o fakepath.so fakepath.c -dl

$ echo "This file is /tmp/test-file" > /tmp/test-file
$ echo "This file is /var/tmp/test-file" > /var/tmp/test-file
$ cat /var/tmp/test-file
This file is /var/tmp/test-file
$ LD_PRELOAD=./fakepath.so cat /var/tmp/test-file
This file is /tmp/test-file

LD_PRELOAD を指定した方では /var/tmp/test-file へアクセスしているはずなのに、実際には /tmp/test-file へアクセスしていることがわかります。パス名のスゲカエに成功していることがわかりますね。もちろんこの例では、たまたま cat コマンドに特定の引数を与えた所、うまくいったにすぎません。

上記のように特定のシステムコールのパス名をスゲカエるのは簡単に可能です。しかし、パス名を扱うシステムコール全てに対応するのは、該当するシステムコールの数が多く非常に手間がかかります。このような処理をまとめて処理するようなライブラリがないか探してはみましたが、発見できませんでした。


Mount Namespace

Linux では Mount Namespace (マウント名前空間) というものが利用できます。このような説明でよいのか不安ではありますが、マウント名前空間を切り替えたプロセスは、それまで利用していたファイルシステムのマウント構成と異なる構成を取ることが出来ます。切り替えた構成は、そのプロセス一代限りではなく、その子孫のプロセスにも引き継がれます。

しかし、新しいマウント名前空間を利用するためには特権 (CAP_SYS_ADMIN ケーパビリティ) が必要です。 clone(2) システムコールのオンラインマニュアル の CLONE_NEWNS の項にその記載があります。

では、特権の利用できない環境では新しいマウント名前空間は利用できないということでしょうか? そうでもないようです。CLONE_NEWNS は CLONE_NEWUSER と一緒に用いることにより特権がなくとも利用可能となっています。 user_namespaces(7) のオンラインマニュアル の「Interaction of user namespaces and other types of namespaces」にその説明があります。

ここでは、CLONE_NEWUSER と CLONE_NEWNS でパス名のスゲカエが出来ないか試みてみます。

以下にサンプルプログラム fakemount.c を示します。このサンプルプログラムは動作を理解することを前提に簡略化して記述してあります。特にエラー処理などは省略しています。


fakemount.c

#define _GNU_SOURCE

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sched.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

#define STACK_SIZE (1024 * 1024)
static char stack[STACK_SIZE];

static void writeproc(const char *pathfmt, int pid, const char *format, int id)
{
char path[32], buf[32];
int fd;

sprintf(path, pathfmt, pid);
sprintf(buf, format, id, id);

fd = open(path, O_WRONLY | O_TRUNC);
write(fd, buf, strlen(buf));
close(fd);
}

static int child(void *arg)
{
char **argv = arg;

sleep(1); /* Wait for the parent to finish writing to the /proc files */

while (strcmp(argv[0], "--") != 0) {
mount(argv[0], argv[1], NULL, MS_BIND, NULL);
argv += 2;
}
argv++;

execvp(argv[0], argv);
return 254;
}

int main(int argc, char *argv[])
{
int pid, status;

pid = clone(child, stack + STACK_SIZE, CLONE_NEWUSER | CLONE_NEWNS | SIGCHLD, ++argv);

writeproc("/proc/%d/setgroups", pid, "deny", 0);
writeproc("/proc/%d/gid_map", pid, "%d %d 1\n", getgid());
writeproc("/proc/%d/uid_map", pid, "%d %d 1\n", getuid());

waitpid(pid, &status, 0);
if (WIFEXITED(status))
return WEXITSTATUS(status);
return 255;
}


上記 fakemount.c のソースコードを簡単に説明します。

clone() で子プロセスを生成するにあたり CLONE_NEWUSER と CLONE_NEWNS を指定しています。生成された子プロセスは新しいユーザー名前空間と新しマウント名前空間を持ちます。

親プロセス側の説明を先にします。親プロセス側では /proc ファイルシステムへの書き込みを行いユーザー ID (グループ ID) のマッピングを定義します。最後に waitpid() で子プロセスの終了を待ちます。子プロセスの終了を待ち、その終了ステータスを取り込むためには、clone() に SIGCHLD を指定する必要があります。

次に、子プロセス側の説明です。まず、親プロセス側で行われる /proc ファイルシステムへの書き込みが終わるのを待ちます。pipe() などで作成したパイプを使用して同期を取る例が見受けられますが、このソースコードでは単純に sleep(1) することにします。

CLONE_NEWUSER を指定して生成された子プロセスは、そのユーザー名前空間で「complete set of capabilities」を持つことが出来ます。 user_namespaces(7) のオンラインマニュアル の「Capabilities」にその記述があります。この時点で通常特権が必要な mount() を実行します。コマンドライン引数に "--" が現れるまでマウントの処理行います。

最後に execvp() で "--" 以降に指定されたコマンドを起動します。exec() するとケーパビリティの再計算が行われ、持っていた「complete set of capabilities」を失います。

コンパイルと実験手順を以下に示します。

$ gcc -o fakemount fakemount.c

$ ./fakemount $HOME /mnt -- ls -ld /mnt/Desktop
drwxr-xr-x 5 myname mygroup 4096 2018-07-30 19:56 /mnt/Desktop
$ id
uid=1001(myname) gid=1001(mygroup) groups=1001(mygroup),4(adm),20(dialout),24(cdrom),27(sudo),30(dip),46(plugdev),105(fuse),108(lpadmin),126(wireshark)
$ ./fakemount -- id
uid=1001(myname) gid=1001(mygroup) groups=1001(mygroup),65534(nogroup)
$ ./fakemount /etc /mnt -- ls -l /mnt/passwd /mnt/shadow
-rw-r--r-- 1 nobody nogroup 2208 2018-04-23 10:12 /mnt/passwd
-rw-r----- 1 nobody nogroup 1358 2018-04-23 10:12 /mnt/shadow

実験結果を順に説明します。

最初の fakemount は自分のホームディレクトリを /mnt にマウントしています。$HOME/Desktop ディレクトリが存在するので、マウントが成功していれば /mnt/Desktop ディレクトリが確認できるはずです。

次の 2 つはユーザーとグループの ID を調べています。id コマンドを実行することにより、現在の ID を確認することが出来ます。fakemount から起動した id では、これまで持っていた数多くの補助グループ ID がなくなり mygroup と nogroup だけになっています。

最後の 2 例は /etc を /mnt にマウントしています。/mnt/passwd と /mnt/shadow が存在することから、マウントは成功しているようです。しかしながら、これらファイルのオーナーとグループが nobody, nogroup になってしまっています。

上記は /proc/[pid]/uid_map に書き込んだユーザー ID のマッピング定義が反映されています。/proc/[pid]/gid_map に書き込んだグループ ID についても同様です (以下省略します)。

/proc/[pid]/uid_map には "1001 1001 1" が書き込まれています。これは、子プロセスのユーザー名前空間のユーザ ID 1001 を、親プロセスのユーザー名前空間のユーザー ID 1001 に対応させるという意味になります。それ以外のユーザー ID はオーバーフローユーザー ID (デフォルトでは 65534 = nobody) にマッピングされます。

/proc/[pid]/uid_map には "0 0 65534" というマッピングを定義することも出来ます。これは ユーザー ID 0 から連続する 65534 個のユーザー ID をマッピングするという定義になります。しかし、このマッピング定義を書き込むためには、書き込み側に CAP_SETUID ケーパビリティが必要になります (特権が必要)。書き込み処理を行うプロセスは親プロセスでなくても構いません。細かい条件はありますが、親プロセスと同じユーザー名前空間を持っていることが必要です。

名前空間の話ばかりでパス名のスゲカエが見えてきていないかもしれません。最後に示すコマンド例でパス名のスゲカエが可能なことをわかりやすく示したいと思います。

fakemount.c の中で用いている bind mount はディレクトリに対してだけでなくファイルに対しても行うことが出来ます。ここでは /etc/passwd ファイルへのアクセスを /var/tmp/oops/etc/passwd にスゲカエた例を実行しています。まともな /etc/passwd にアクセスできない状態ではユーザー名やグループ名の解決が出来ないので ls コマンドには -n オプションを付加してユーザー ID (グループ ID) を数値表示するようにしています。

$ mkdir -p /var/tmp/oops/etc

$ touch /var/tmp/oops/etc/passwd
$ /fakemount /var/tmp/oops/etc/passwd /etc/passwd -- ls -ln /etc/passwd
-rw-r--r-- 1 1001 1001 0 2018-07-31 21:37 /etc/passwd