CVE-2019-19726 OpenBSD dynamic loader 本地提权漏洞

CVE-2019-19726 OpenBSD dynamic loader Local Privilege Escalation Vulnerability

0x01. 漏洞介绍

CVE-2019-19726 是 OpenBSD dynamic loader 在清理 LD_LIBRARY_PATH 环境变量时存在的一个本地提权漏洞,由 Qualys Research Team 发现。该漏洞获得 2020 年 Pwnie Awards Best Privilege Escalation Bug 提名。漏洞补丁可参考 libexec/ld.so/loader.c

0x02. 漏洞分析

2.1 setrlimit / RLIMIT_DATA

int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);

The getrlimit() and setrlimit() system calls get and set resource limits respectively. Each resource has an associated soft and hard limit, as defined by the rlimit structure:

struct rlimit {
rlim_t rlim_cur; /* Soft limit */
rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
};

The soft limit is the value that the kernel enforces for the corresponding resource. The hard limit acts as a ceiling for the soft limit: an unprivileged process may only set its soft limit to a value in the range from 0 up to the hard limit, and (irreversibly) lower its hard limit. A privileged process (under Linux: one with the CAP_SYS_RESOURCE capability) may make arbitrary changes to either limit value.

The value RLIM_INFINITY denotes no limit on a resource (both in the structure returned by getrlimit() and in the structure passed to setrlimit()).

简单来说,进程可以通过 setrlimit 来限制自身的资源使用上限:

  • 对于普通进程而言,soft limit 只能位于区间 [0, hard limit],而 hard limit 则只能进行下调操作
  • 对于特权进程而言,soft limit 可以随意设置
  • RLIM_INFINITY 表示没有任何限制

第一个参数 resource 表示资源类型,RLIMIT_DATA 表示内存资源。

RLIMIT_DATA

The maximum size of the process’s data segment (initialized data, uninitialized data, and heap). This limit affects calls to brk(2) and sbrk(2), which fail with the error ENOMEM upon encountering the soft limit of this resource.

测试代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/time.h>
#include <sys/resource.h>

void show_rlimit() {
struct rlimit rl;
getrlimit(RLIMIT_DATA, &rl);
printf("cur=%lu, max=%lu\n", rl.rlim_cur, rl.rlim_max);
}

void try_malloc(size_t size) {
printf("malloc %lu bytes: ", size);
void *p = malloc(size);
if (p == NULL) {
printf("%s\n", strerror(errno));
} else {
printf("Succeeded\n");
free(p);
}
}

int main(int argc, char **argv) {
const unsigned long val = 1024 * 1024;
const struct rlimit rl = {val, val};
show_rlimit();
setrlimit(RLIMIT_DATA, &rl);
show_rlimit();

try_malloc(1024);
try_malloc(val);

return 0;
}

测试结果:

$ ./a.out
cur=18446744073709551615, max=18446744073709551615
cur=1048576, max=1048576
malloc 1024 bytes: Succeeded
malloc 1048576 bytes: Cannot allocate memory

2.2 ARG_MAX

ARG_MAX 定义于 limits.h 头文件中,表示程序命令行参数(包含环境变量参数)的最大大小。

ARG_MAX

Maximum length of argument to the exec functions including environment data.
Minimum Acceptable Value: {_POSIX_ARG_MAX}

_POSIX_ARG_MAX

Maximum length of argument to the exec functions including environment data.
Value: 4096

2.3 CVE-2019-19726

对于 SUID-root 程序,链接器 ld.so 需要去除危险的环境变量(比如 LD_LIBRARY_PATH 等),以防止通过环境变量来实现提权。

比如,glibc 中的 _dl_non_dynamic_init 函数负责清理此类危险的环境变量:

void
_dl_non_dynamic_init (void)
{
// ......
if (__libc_enable_secure)
{
static const char unsecure_envvars[] =
UNSECURE_ENVVARS
#ifdef EXTRA_UNSECURE_ENVVARS
EXTRA_UNSECURE_ENVVARS
#endif
;
const char *cp = unsecure_envvars;
while (cp < unsecure_envvars + sizeof (unsecure_envvars))
{
__unsetenv (cp);
cp = (const char *) __rawmemchr (cp, '\0') + 1;
}
#if !HAVE_TUNABLES
if (__access ("/etc/suid-debug", F_OK) != 0)
__unsetenv ("MALLOC_CHECK_");
#endif
}
// ......
}

UNSECURE_ENVVARS 的定义如下:

/* Environment variable to be removed for SUID programs.  The names are
all stuffed in a single string which means they have to be terminated
with a '\0' explicitly. */
#define UNSECURE_ENVVARS \
"GCONV_PATH\0" \
"GETCONF_DIR\0" \
GLIBC_TUNABLES_ENVVAR \
"HOSTALIASES\0" \
"LD_AUDIT\0" \
"LD_DEBUG\0" \
"LD_DEBUG_OUTPUT\0" \
"LD_DYNAMIC_WEAK\0" \
"LD_HWCAP_MASK\0" \
"LD_LIBRARY_PATH\0" \
"LD_ORIGIN_PATH\0" \
"LD_PRELOAD\0" \
"LD_PROFILE\0" \
"LD_SHOW_AUXV\0" \
"LD_USE_LOAD_BIAS\0" \
"LOCALDOMAIN\0" \
"LOCPATH\0" \
"MALLOC_TRACE\0" \
"NIS_PATH\0" \
"NLSPATH\0" \
"RESOLV_HOST_CONF\0" \
"RES_OPTIONS\0" \
"TMPDIR\0" \
"TZDIR\0"

FreeBSD 相关处理代码如下(参考 src/libexec/ld.so/loader.c):

/*
* grab interesting environment variables, zap bad env vars if
* issetugid, and set the exported environ and __progname variables
*/
void
_dl_setup_env(const char *argv0, char **envp)
{
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));
_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 (_dl_libpath) {
_dl_free_path(_dl_libpath);
_dl_libpath = NULL;
_dl_unsetenv("LD_LIBRARY_PATH", envp);
}
if (_dl_preload) {
_dl_preload = NULL;
_dl_unsetenv("LD_PRELOAD", envp);
}
// ......
}
// ......
}

LD_LIBRARY_PATH 的处理有点特殊,额外调用了 _dl_split_path 函数,对应的代码如下(参考 src/libexec/ld.so/path.c):

char **
_dl_split_path(const char *searchpath)
{
int pos = 0;
int count = 1;
const char *pp, *p_begin;
char **retval;

if (searchpath == NULL)
return (NULL);

/* Count ':' or ';' in searchpath */
pp = searchpath;
while (*pp) {
if (*pp == ':' || *pp == ';')
count++;
pp++;
}

/* one more for NULL entry */
count++;

retval = _dl_reallocarray(NULL, count, sizeof(*retval));
if (retval == NULL)
return (NULL);
// ......
}

这里调用 _dl_reallocarray 分配内存,如果分配失败,_dl_split_path 将返回 NULL

回到 _dl_setup_env 函数,可以发现当 _dl_split_path 返回 NULL 时,环境变量 LD_LIBRARY_PATH 将不会被清理,此时可以通过 so 动态库加载劫持来实现 root 提权。

_dl_libpath = _dl_split_path(_dl_getenv("LD_LIBRARY_PATH", envp));
// ......
_dl_trust = !_dl_issetugid();
if (!_dl_trust) { /* Zap paths if s[ug]id... */
if (_dl_libpath) {
_dl_free_path(_dl_libpath);
_dl_libpath = NULL;
_dl_unsetenv("LD_LIBRARY_PATH", envp);
}

0x03. 漏洞利用

原作者挑选了 /usr/bin/chpass 来实现漏洞利用,其 main 函数做了以下操作:

  1. 调用 setuid(0)
  2. 调用 pw_initRLIMIT_DATA 重置成了 RLIM_INFINITY
  3. 调用 pw_mkdb 通过 vfork / execv 执行 /usr/sbin/pwd_mkdb,其中 execv 会继承环境变量

/usr/sbin/pwd_mkdb 执行时,会重新触发 ld.so 中的逻辑,但由于此时已经没有了 RLIMIT_DATA 资源限制,以下代码会成功执行:

_dl_libpath = _dl_split_path(_dl_getenv("LD_LIBRARY_PATH", envp));

而由于 /usr/sbin/pwd_mkdb 并不是 SUID-root 程序,LD_LIBRARY_PATH 不会被清理,因此最终可以通过 so 动态库加载劫持来实现 root 提权。

另外需要注意的是,对 /usr/bin/chpass 而言,由于 _dl_libpathNULL,所以 LD_LIBRARY_PATHchpass 本身是不会起作用的。

综上,应该只有极少数刚好满足条件的 SUID-root 程序,才可以完成这个提权漏洞的利用。

0x04. References

  1. Local Privilege Escalation in OpenBSD’s dynamic loader (CVE-2019-19726) / Wayback Machine
  2. https://pwnies.com/qualys-security-advisory-team-2/
  3. https://github.com/openbsd/src/commit/eee3c75f9abd5ea51e066dd0fe6b1efa470e4d0c
  4. https://linux.die.net/man/2/setrlimit
  5. https://pubs.opengroup.org/onlinepubs/009695399/basedefs/limits.h.html
  6. https://codebrowser.dev/glibc/glibc/elf/dl-support.c.html
  7. https://codebrowser.dev/glibc/glibc/sysdeps/generic/unsecvars.h.html
请作者喝杯咖啡☕